package output import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" "gitea.konchin.com/service/stonks/internal/config" "gitea.konchin.com/service/stonks/internal/report" ) func init() { Register("discord", newDiscordOutput) } // discordMessageLimit is Discord's max character count for a single // webhook message's `content` field. const discordMessageLimit = 2000 // discordParams is the plugin-specific config for the discord output. type discordParams struct { // WebhookEnvVar names the environment variable holding the Discord // webhook URL. Defaults to DISCORD_WEBHOOK_URL. WebhookEnvVar string `yaml:"webhook_env_var"` } // discordOutput posts the report to a Discord incoming webhook, splitting it // into multiple messages if it exceeds Discord's 2000-character limit. type discordOutput struct { webhookURL string client *http.Client } func newDiscordOutput(cfg config.PluginConfig) (Output, error) { params := discordParams{WebhookEnvVar: "DISCORD_WEBHOOK_URL"} if err := cfg.DecodeParams(¶ms); err != nil { return nil, err } url := os.Getenv(params.WebhookEnvVar) if url == "" { return nil, fmt.Errorf("discord: environment variable %q is not set", params.WebhookEnvVar) } return &discordOutput{ webhookURL: url, client: &http.Client{Timeout: 15 * time.Second}, }, nil } func (d *discordOutput) Name() string { return "discord" } func (d *discordOutput) Send(ctx context.Context, rep report.Report) error { for i, chunk := range splitForDiscord(rep.Text, discordMessageLimit) { if err := d.post(ctx, chunk); err != nil { return fmt.Errorf("discord: send chunk %d: %w", i+1, err) } } embeds := buildRecommendationEmbeds(rep.Recommendations) embeds = append(embeds, buildPortfolioEmbed(rep.Portfolio)) if len(embeds) > 0 { if err := d.postEmbeds(ctx, embeds); err != nil { return fmt.Errorf("discord: send recommendation embeds: %w", err) } } return nil } func (d *discordOutput) post(ctx context.Context, content string) error { return d.postJSON(ctx, map[string]any{"content": content}) } func (d *discordOutput) postEmbeds(ctx context.Context, embeds []map[string]any) error { return d.postJSON(ctx, map[string]any{"embeds": embeds}) } func (d *discordOutput) postJSON(ctx context.Context, payload map[string]any) error { body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.webhookURL, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := d.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody)) } return nil } // Discord embed colors (decimal RGB). const ( colorBuy = 0x57F287 // green colorSell = 0xED4245 // red ) // discordDescriptionLimit is Discord's max character count for a single // embed's `description`. const discordDescriptionLimit = 4096 // buildRecommendationEmbeds renders the buy and sell recommendations as // separate Discord embeds, one per non-empty list. If there are no // recommendations at all, it returns nil — an empty buy/sell call gets no // embed of its own, though the portfolio embed (see buildPortfolioEmbed) // is still always sent alongside whatever this returns. func buildRecommendationEmbeds(recs []report.Recommendation) []map[string]any { var buyLines, sellLines []string for _, r := range recs { leverage := r.Leverage if leverage <= 0 { leverage = 1 } line := fmt.Sprintf("**%s** — $%.2f at %.2gx — %s", r.Symbol, r.AmountUSD, leverage, r.Reason) if r.Action == "sell" { sellLines = append(sellLines, line) } else { buyLines = append(buyLines, line) } } var embeds []map[string]any if len(buyLines) > 0 { embeds = append(embeds, recommendationEmbed("🟢 Buy", colorBuy, buyLines)) } if len(sellLines) > 0 { embeds = append(embeds, recommendationEmbed("🔴 Sell", colorSell, sellLines)) } return embeds } // buildPortfolioEmbed renders the paper-trading portfolio's post-trade state // — cash, equity, total profit, this run's executed trades, and any open // positions — computed entirely by internal/portfolio, not Claude. Always // returns an embed (never nil) so the profit update is visible every run. func buildPortfolioEmbed(p report.PortfolioSnapshot) map[string]any { lines := []string{ fmt.Sprintf("Cash: $%.2f — Equity: $%.2f", p.CashUSD, p.EquityUSD), fmt.Sprintf("Total P&L: $%.2f (%.2f%%) since $%.2f start", p.TotalProfitUSD, p.TotalProfitPct, p.InitialBudgetUSD), } if len(p.Trades) == 0 { lines = append(lines, "No trades executed this run.") } else { lines = append(lines, "", "**Trades this run:**") for _, t := range p.Trades { status := "skipped" if t.Executed { status = "executed" } line := fmt.Sprintf("%s %s @ %.4g (%s): %s", strings.ToUpper(t.Action), t.Symbol, t.Price, status, t.Note) lines = append(lines, line) } } if len(p.Positions) > 0 { lines = append(lines, "", "**Open positions:**") for _, pos := range p.Positions { lines = append(lines, fmt.Sprintf("%s %s: %.6g units, entry %.4g, now %.4g, unrealized $%.2f", pos.Symbol, pos.Side, pos.Quantity, pos.EntryPrice, pos.CurrentPrice, pos.UnrealizedPnL)) } } color := colorBuy if p.TotalProfitUSD < 0 { color = colorSell } return map[string]any{ "title": "💰 Portfolio", "color": color, "description": truncateForDescription(strings.Join(lines, "\n")), } } func recommendationEmbed(title string, color int, lines []string) map[string]any { return map[string]any{ "title": title, "color": color, "description": truncateForDescription(strings.Join(lines, "\n")), } } func truncateForDescription(s string) string { if len(s) <= discordDescriptionLimit { return s } return s[:discordDescriptionLimit-3] + "..." } // splitForDiscord breaks text into chunks of at most limit characters, // preferring to split on paragraph/line boundaries so Markdown formatting // stays intact across messages. func splitForDiscord(text string, limit int) []string { if len(text) <= limit { return []string{text} } var chunks []string var current strings.Builder flush := func() { if current.Len() > 0 { chunks = append(chunks, strings.TrimSpace(current.String())) current.Reset() } } for _, line := range strings.Split(text, "\n") { // A single line longer than the limit must be hard-split. for len(line) > limit { if current.Len() > 0 { flush() } chunks = append(chunks, line[:limit]) line = line[limit:] } if current.Len()+len(line)+1 > limit { flush() } current.WriteString(line) current.WriteString("\n") } flush() return chunks }