package output import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" "github.com/ytshih/stonks/internal/config" "github.com/ytshih/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) 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 two // separate Discord embeds (always both, even when one or both lists are // empty, so "nothing to report" is stated explicitly rather than omitted). func buildRecommendationEmbeds(recs []report.Recommendation) []map[string]any { var buyLines, sellLines []string for _, r := range recs { line := fmt.Sprintf("**%s** — %s", r.Symbol, r.Reason) if r.Action == "sell" { sellLines = append(sellLines, line) } else { buyLines = append(buyLines, line) } } return []map[string]any{ recommendationEmbed("🟢 Buy", colorBuy, buyLines, "No buy recommendations today."), recommendationEmbed("🔴 Sell", colorSell, sellLines, "No sell recommendations today."), } } func recommendationEmbed(title string, color int, lines []string, emptyText string) map[string]any { description := emptyText if len(lines) > 0 { description = truncateForDescription(strings.Join(lines, "\n")) } return map[string]any{ "title": title, "color": color, "description": description, } } 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 }