- Implemented Citations component to display citation data. - Created WatchlistDrawer and ChatSidebar components for managing watchlists and AI chat functionality. - Integrated API calls for watchlist management and chat interactions. - Updated index.tsx to include new components in the main application layout. - Added API client in lib/api.ts for structured API interactions. - Developed Alerts, Dashboard, Portfolio, Routines, Screener, and Report pages with relevant data fetching and UI components. - Introduced styles in tokens.css for consistent theming across the application. - Configured TypeScript and Vite for project setup and development.
164 lines
5.0 KiB
Go
164 lines
5.0 KiB
Go
package alerts
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Target is one push destination resolved per user at send time.
|
|
type Target struct {
|
|
TelegramToken string
|
|
TelegramChatID string
|
|
DiscordURL string
|
|
}
|
|
|
|
// Notifier delivers alert cards to Telegram + Discord webhooks with context +
|
|
// citations. Missing credentials degrade to a no-op (logged, not fatal) so
|
|
// offline/demo runs never fail on delivery.
|
|
type Notifier struct {
|
|
TelegramToken string
|
|
TelegramChatID string
|
|
DiscordURL string
|
|
http *http.Client
|
|
// Targets, when set, resolves per-owner push destinations. It is consulted
|
|
// on every Send so destination CRUD takes effect immediately.
|
|
Targets func(owner string) []Target
|
|
}
|
|
|
|
// NewNotifier builds a notifier; empty creds mean dry-run mode.
|
|
func NewNotifier(tgToken, tgChat, discordURL string) *Notifier {
|
|
return &Notifier{TelegramToken: tgToken, TelegramChatID: tgChat,
|
|
DiscordURL: discordURL, http: &http.Client{Timeout: 15 * time.Second}}
|
|
}
|
|
|
|
// DryRun reports whether no server-level channel is configured.
|
|
func (n *Notifier) DryRun() bool {
|
|
return n.TelegramToken == "" && n.DiscordURL == ""
|
|
}
|
|
|
|
// DryRunFor reports whether an owner has no push target anywhere: neither
|
|
// the owner's enabled destinations nor the server fallback.
|
|
func (n *Notifier) DryRunFor(owner string) bool {
|
|
return len(n.targetsFor(owner)) == 0
|
|
}
|
|
|
|
// targetsFor resolves push targets: per-owner destinations first, then the
|
|
// server-level env fallback. Owner "" means server fallback only.
|
|
func (n *Notifier) targetsFor(owner string) []Target {
|
|
var out []Target
|
|
if n.Targets != nil && owner != "" {
|
|
out = append(out, n.Targets(owner)...)
|
|
}
|
|
if n.TelegramToken != "" && n.TelegramChatID != "" {
|
|
out = append(out, Target{TelegramToken: n.TelegramToken, TelegramChatID: n.TelegramChatID})
|
|
}
|
|
if n.DiscordURL != "" {
|
|
out = append(out, Target{DiscordURL: n.DiscordURL})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Card is the rendered alert text shared by both channels.
|
|
func Card(f Finding, cites string) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "*%s* — %s\n%s", f.Ticker, f.Rule, f.Message)
|
|
if len(f.Context) > 0 {
|
|
raw, _ := json.Marshal(f.Context)
|
|
fmt.Fprintf(&b, "\n`%s`", string(raw))
|
|
}
|
|
if cites != "" {
|
|
fmt.Fprintf(&b, "\nCitations: %s", cites)
|
|
}
|
|
fmt.Fprintf(&b, "\nDetail: /report/%s", f.Ticker)
|
|
return b.String()
|
|
}
|
|
|
|
// Send delivers one finding to all configured channels (server fallback).
|
|
// Prefer SendTo so delivery fans out to the finding owner's destinations.
|
|
func (n *Notifier) Send(ctx context.Context, f Finding, cites string) error {
|
|
return n.SendTo(ctx, "", f, cites)
|
|
}
|
|
|
|
// SendTo delivers one finding to the owner's enabled destinations plus the
|
|
// server fallback. With no targets anywhere it is record-only (nil).
|
|
func (n *Notifier) SendTo(ctx context.Context, owner string, f Finding, cites string) error {
|
|
targets := n.targetsFor(owner)
|
|
if len(targets) == 0 {
|
|
return nil // recorded in alert_events regardless
|
|
}
|
|
if cites == "" && len(f.Citations) > 0 {
|
|
cc, _ := json.Marshal(f.Citations)
|
|
cites = string(cc)
|
|
}
|
|
text := Card(f, cites)
|
|
var firstErr error
|
|
for _, t := range targets {
|
|
if t.TelegramToken != "" && t.TelegramChatID != "" {
|
|
if err := sendTelegram(ctx, n.http, t.TelegramToken, t.TelegramChatID, text); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
if t.DiscordURL != "" {
|
|
if err := sendDiscord(ctx, n.http, t.DiscordURL, text); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
func (n *Notifier) telegram(ctx context.Context, text string) error {
|
|
return sendTelegram(ctx, n.http, n.TelegramToken, n.TelegramChatID, text)
|
|
}
|
|
|
|
func sendTelegram(ctx context.Context, client *http.Client, token, chatID, text string) error {
|
|
u := "https://api.telegram.org/bot" + token + "/sendMessage"
|
|
body, _ := json.Marshal(map[string]any{
|
|
"chat_id": chatID, "text": text, "parse_mode": "Markdown",
|
|
})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("alerts: telegram: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
return fmt.Errorf("alerts: telegram HTTP %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (n *Notifier) discord(ctx context.Context, text string) error {
|
|
return sendDiscord(ctx, n.http, n.DiscordURL, text)
|
|
}
|
|
|
|
func sendDiscord(ctx context.Context, client *http.Client, webhookURL, text string) error {
|
|
if len(text) > 1900 {
|
|
text = text[:1900] + "…"
|
|
}
|
|
body, _ := json.Marshal(map[string]any{"content": text})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("alerts: discord: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
return fmt.Errorf("alerts: discord HTTP %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|