package notify import ( "context" "encoding/json" "errors" "fmt" "net/url" "strings" ) // telegramTextLimit 是 Telegram sendMessage 的 text 字段上限(字符数)。 const telegramTextLimit = 4096 // telegramChannel 实现 Telegram Bot API。 // // 平台特性: // - 鉴权全部在 URL path 里(/bot/sendMessage),无需加签。 // - 用 HTML 解析模式而不是 MarkdownV2:MarkdownV2 要求转义 `_*[]()~`>#+-=|{}.!` // 共 18 个字符,漏一个就整条消息被拒;HTML 只需转义 & < > 三个。 // - 业务错误同样藏在 HTTP 200 里,靠 ok 字段判断。 type telegramChannel struct{} func (telegramChannel) Kind() string { return KindTelegram } // Telegram 单聊约 1 条/秒、群组 20 条/分钟。取保守值。 func (telegramChannel) DefaultRatePerMin() int { return 20 } // Bot Token 是完整凭据;chat_id 只是收件人,不算秘密(拿到它没有 Token 也发不了消息)。 func (telegramChannel) SecretKeys() []string { return []string{"bot_token"} } func (telegramChannel) Validate(cfg map[string]any) error { if cfgString(cfg, "bot_token") == "" { return errors.New("缺少 Bot Token") } if cfgString(cfg, "chat_id") == "" { return errors.New("缺少 Chat ID") } if base := cfgString(cfg, "base_url"); base != "" { if err := validateHTTPURL(base); err != nil { return fmt.Errorf("API 地址无效: %w", err) } } return nil } func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message) error { if err := c.Validate(cfg); err != nil { return Permanent(err) } endpoint, err := telegramEndpoint(cfg) if err != nil { return Permanent(err) } payload := map[string]any{ "chat_id": cfgString(cfg, "chat_id"), "text": telegramHTML(m), "parse_mode": "HTML", "disable_web_page_preview": false, } raw, err := doJSON(ctx, "POST", endpoint, nil, payload) if err != nil { return err } var res struct { OK bool `json:"ok"` ErrorCode int `json:"error_code"` Description string `json:"description"` } if err := json.Unmarshal(raw, &res); err != nil { return fmt.Errorf("解析 Telegram 响应失败: %w (%s)", err, snippet(raw)) } if res.OK { return nil } // 429 是限流,退避后重试有效;其余(400 参数错、401 token 错、403 被拉黑、 // 404 chat 不存在)都是配置问题,重试不会自愈。 if res.ErrorCode == 429 { return fmt.Errorf("Telegram 限流: %s", res.Description) } return Permanent(fmt.Errorf("Telegram 返回错误 %d: %s", res.ErrorCode, res.Description)) } // telegramEndpoint 拼出 sendMessage 地址。base_url 留空时用官方 API, // 非空时用于自建 Bot API 反代(国内网络下的常见需求)。 func telegramEndpoint(cfg map[string]any) (string, error) { base := cfgString(cfg, "base_url") if base == "" { base = "https://api.telegram.org" } base = strings.TrimSuffix(base, "/") token := cfgString(cfg, "bot_token") raw := base + "/bot" + token + "/sendMessage" u, err := url.Parse(raw) if err != nil { // 不透传 err:地址里含 Bot Token,且此时连 addr 都不该回显。 return "", fmt.Errorf("拼接 API 地址失败(API 地址:%s)", redactRequestTarget(base)) } return u.String(), nil } // telegramHTML 渲染 HTML 正文。 func telegramHTML(m Message) string { var b strings.Builder b.WriteString("" + telegramEscape(markdownTitle(m)) + "\n") if m.Batch { for i, it := range m.Items { var line string if a := assetLine(it.Assets, maxAssetsShown); a != "" { line = fmt.Sprintf("%d. %s · %s — %s", i+1, SeverityLabel(it.Severity), it.Title(), a) } else { line = fmt.Sprintf("%d. %s · %s", i+1, SeverityLabel(it.Severity), it.Title()) } b.WriteString("\n" + telegramEscape(line)) } if m.HomeURL != "" { b.WriteString(fmt.Sprintf("\n\n在平台中查看全部", telegramEscapeAttr(m.HomeURL))) } return TruncateHTML(b.String(), telegramTextLimit) } if len(m.Items) == 0 { return b.String() } it := m.Items[0] if it.IsStatusChange() { b.WriteString(fmt.Sprintf("\n状态变更:%s → %s", telegramEscape(StatusLabel(it.FromStatus)), telegramEscape(StatusLabel(it.ToStatus)))) } if it.VulnClass != "" && it.VulnClass != it.Title() { b.WriteString("\n类型:" + telegramEscape(it.VulnClass)) } if a := assetLine(it.Assets, maxAssetsShown); a != "" { b.WriteString("\n资产:" + telegramEscape(a)) } if s := OneLine(it.Summary, maxSummaryRunes); s != "" { b.WriteString("\n摘要:" + telegramEscape(s)) } if it.DetailURL != "" { b.WriteString(fmt.Sprintf("\n\n查看详情", telegramEscapeAttr(it.DetailURL))) } return TruncateHTML(b.String(), telegramTextLimit) } // telegramEscape 转义 HTML 文本内容。 // Telegram 只认这三种实体,转义后 & 之类的已有实体会被二次转义——这正是 // 期望行为:我们要显示的是原始字符,不是让用户注入 HTML。 func telegramEscape(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") return s } // telegramEscapeAttr 转义 HTML 属性值。在文本转义之外还要处理引号—— // URL 里带引号会提前闭合 href 属性,把后面的内容变成注入点。 func telegramEscapeAttr(s string) string { s = telegramEscape(s) s = strings.ReplaceAll(s, "\"", """) return s }