add Telegram alerts: qualified setups, S/R proximity, score drops, daily digest
Closes the action loop — instead of polling the dashboard, the platform pushes actionable signals to Telegram. New hourly 'alerts' job dispatches four toggleable triggers, deduped via a new alert_log table (cooldown-based for qualified/S-R/digest, watermark-based for score deterioration). Admin → Settings gains a Telegram panel (write-only bot token, chat ID, per-trigger toggles, Send Test). Credentials follow DB > env precedence (TELEGRAM_BOT_TOKEN / _CHAT_ID). Backend: alert_service + AlertLog model + migration 005, scheduler job, admin endpoints/schema. Frontend: AlertSettings panel, hooks, api, types. Deploy: run alembic upgrade (new alert_log table). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAlertSettings, useUpdateAlertSettings, useTestAlert } from '../../hooks/useAdmin';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
database: 'configured here',
|
||||
environment: 'from environment (.env)',
|
||||
none: 'not configured',
|
||||
};
|
||||
|
||||
type TriggerKey =
|
||||
| 'qualified_enabled'
|
||||
| 'sr_proximity_enabled'
|
||||
| 'score_drop_enabled'
|
||||
| 'digest_enabled';
|
||||
|
||||
const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [
|
||||
{ key: 'qualified_enabled', label: 'Qualified setups', hint: 'a setup newly clears the activation gate' },
|
||||
{ key: 'sr_proximity_enabled', label: 'Watchlist S/R proximity', hint: 'a watched ticker nears a strong support/resistance' },
|
||||
{ key: 'score_drop_enabled', label: 'Score deterioration', hint: 'a watched ticker’s composite drops sharply' },
|
||||
{ key: 'digest_enabled', label: 'Daily digest', hint: 'one end-of-day summary of qualified setups' },
|
||||
];
|
||||
|
||||
function Toggle({ checked, onChange, label, hint }: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label: string;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
|
||||
/>
|
||||
<span>
|
||||
<span className="text-sm text-gray-200">{label}</span>
|
||||
<span className="block text-[11px] text-gray-500">{hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertSettings() {
|
||||
const { data, isLoading, isError, error } = useAlertSettings();
|
||||
const update = useUpdateAlertSettings();
|
||||
const test = useTestAlert();
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [chatId, setChatId] = useState('');
|
||||
const [botToken, setBotToken] = useState('');
|
||||
const [triggers, setTriggers] = useState<Record<TriggerKey, boolean>>({
|
||||
qualified_enabled: true,
|
||||
sr_proximity_enabled: true,
|
||||
score_drop_enabled: true,
|
||||
digest_enabled: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setEnabled(data.enabled);
|
||||
setChatId(data.telegram_chat_id ?? '');
|
||||
setTriggers({
|
||||
qualified_enabled: data.qualified_enabled,
|
||||
sr_proximity_enabled: data.sr_proximity_enabled,
|
||||
score_drop_enabled: data.score_drop_enabled,
|
||||
digest_enabled: data.digest_enabled,
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) return <SkeletonTable rows={4} cols={2} />;
|
||||
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load alert settings'}</p>;
|
||||
if (!data) return null;
|
||||
|
||||
const onSave = () => {
|
||||
update.mutate({
|
||||
enabled,
|
||||
telegram_chat_id: chatId,
|
||||
...triggers,
|
||||
...(botToken ? { bot_token: botToken } : {}),
|
||||
});
|
||||
setBotToken('');
|
||||
};
|
||||
|
||||
const tokenConfigured = data.bot_token_configured;
|
||||
|
||||
return (
|
||||
<div className="glass p-5 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-200">Telegram Alerts</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Push actionable signals to Telegram so you don’t have to keep checking the dashboard.
|
||||
The dispatcher runs hourly; each trigger respects a cooldown so you’re not spammed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="h-4 w-4 cursor-pointer accent-blue-400"
|
||||
/>
|
||||
<span className="text-sm text-gray-200">Alerts enabled</span>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Bot Token</span>
|
||||
<input
|
||||
type="password"
|
||||
value={botToken}
|
||||
onChange={(e) => setBotToken(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder={tokenConfigured ? '•••••••••• (leave blank to keep current)' : 'Paste bot token from @BotFather…'}
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-500">
|
||||
Status:{' '}
|
||||
<span className={tokenConfigured ? 'text-emerald-400' : 'text-amber-400'}>
|
||||
{SOURCE_LABEL[data.bot_token_source] ?? data.bot_token_source}
|
||||
</span>
|
||||
{' '}· write-only, never displayed
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Chat ID</span>
|
||||
<input
|
||||
type="text"
|
||||
value={chatId}
|
||||
onChange={(e) => setChatId(e.target.value)}
|
||||
placeholder="e.g. 123456789"
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-600">Your numeric chat ID (message @userinfobot to find it).</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-400">Triggers</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{TRIGGERS.map((t) => (
|
||||
<Toggle
|
||||
key={t.key}
|
||||
label={t.label}
|
||||
hint={t.hint}
|
||||
checked={triggers[t.key]}
|
||||
onChange={(v) => setTriggers((prev) => ({ ...prev, [t.key]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button className="btn-primary px-4 py-2 text-sm" onClick={onSave} disabled={update.isPending}>
|
||||
{update.isPending ? 'Saving…' : 'Save Alerts'}
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white disabled:opacity-50"
|
||||
onClick={() => test.mutate()}
|
||||
disabled={test.isPending}
|
||||
>
|
||||
{test.isPending ? 'Sending…' : 'Send Test'}
|
||||
</button>
|
||||
<span className="text-[11px] text-gray-500">Save first, then Send Test to verify the bot reaches you.</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-4 py-2.5 text-[11px] text-gray-500">
|
||||
Setup: 1) message <span className="text-gray-300">@BotFather</span>, send <span className="text-gray-300">/newbot</span>, copy the token.
|
||||
2) send your new bot any message. 3) get your chat ID from <span className="text-gray-300">@userinfobot</span>. Paste both above.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user