Files
Cyrene_v2/plugins/channels/telegram/botapi.go
T
AskaEth 996201c4ed fix(httpclient): 信封 message 默认不进错误,渠道上游自由文本一并收口
信封路径(非 2xx + 信封体、2xx + code!=0)此前把上游 message 经 RedactText
拼进 error,而上游会在 message 里回显被拒的请求正文 —— 她的原话因此进了错误
与调用方日志(M49 探针实测:code 1: invalid content: 她刚才说的那句话 ...)。

- envelope message 改为**默认不带**(新 opt-in WithEnvelopeMessage(),与
  trace.UpstreamError 的 message 同一口径);code 与上游 status 保留,
  errors.Is 的 ErrAPI / trace.ErrUpstream 链不变。
- opt-in 下统一走单一清洗 upstreamText:压平空白 + trace.RedactTextLimited,
  上限 40 rune(比 trace 的 128 短:错误里那句话是提示,不是摘要)。
- plugins/channels 同族出口一并收口(扫过全部,命中 4 处):
  telegram description、qq_obv11 msg(错误 + logEchoFailure 日志)、
  weixin_ilink CDN x-error-message;qq 的 status 字段改走 trace.ErrorToken
  令牌守卫。被剥掉的一律由 status / error_code / retcode 承担分类。

每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
2026-10-26 19:28:15 +08:00

240 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package telegram
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// botResponse is the shared envelope Telegram returns for every method: ok plus
// either result or the error description/code.
type botResponse struct {
OK bool `json:"ok"`
Result json.RawMessage `json:"result"`
Description string `json:"description"`
ErrorCode int `json:"error_code"`
}
// maxErrorBodyBytes caps how much of a non-200 reply is read before classifying it.
// Only error_code is taken from it (description is dropped — see badResponseError).
const maxErrorBodyBytes = 512
// doJSON posts a JSON payload to a Bot API method and decodes the envelope. The
// caller is responsible for decoding Result; a non-OK reply is returned as
// ErrBadResponse wrapped with the API description.
func (a *Adapter) doJSON(ctx context.Context, method string, payload any) (*botResponse, error) {
var buf bytes.Buffer
if payload != nil {
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
return nil, fmt.Errorf("telegram: encode %s request: %w", method, err)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(method), &buf)
if err != nil {
// 解析失败的 err 同样是 *url.Error(里面是整条 /bot<token>/… 地址),因此与
// 下面的传输层分支同形处理:只留来源,地址由 trace.URLCause 剥掉。
return nil, fmt.Errorf("telegram: build %s request %s: %w",
method, trace.HostOf(a.endpoint(method)), trace.URLCause(err))
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := a.client.Do(req)
if err != nil {
// 只留来源:Bot API 把 token 写在地址路径里(/bot<token>/<method>),而
// *url.Error 会把整条地址拼进错误串 —— 原样 `%w` 等于把 bot token 写进日志。
return nil, fmt.Errorf("telegram: %s call %s: %w", method, trace.HostOf(a.endpoint(method)), trace.URLCause(err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
return nil, badResponseError(method, resp.StatusCode, body)
}
var out botResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("telegram: decode %s response: %w", method, err)
}
if !out.OK {
// 200 + `ok:false` 也是失败。这一支没有 status 可报(HTTP 恒 200),于是把
// error_code 带上 —— 它是**分类令牌**,不是正文通道,与上面非 200 分支同一口径。
// description 在这里同样**默认不带**:Telegram 会在里面回显我们发过去的参数
// (M50 口径,见 badResponseError)。
if out.ErrorCode != 0 {
return nil, fmt.Errorf("telegram: %s: error_code %d: %w", method, out.ErrorCode, ErrBadResponse)
}
return nil, fmt.Errorf("telegram: %s: %w", method, ErrBadResponse)
}
return &out, nil
}
// getMe verifies the token and returns the bot's username.
func (a *Adapter) getMe(ctx context.Context) (string, error) {
out, err := a.doJSON(ctx, "getMe", nil)
if err != nil {
return "", err
}
var me struct {
Username string `json:"username"`
}
if err := json.Unmarshal(out.Result, &me); err != nil {
return "", fmt.Errorf("telegram: getMe result: %w", err)
}
return me.Username, nil
}
// setWebhook registers the webhook URL and disables getUpdates polling.
func (a *Adapter) setWebhook(ctx context.Context, url string) error {
_, err := a.doJSON(ctx, "setWebhook", map[string]any{"url": url})
return err
}
// getUpdates performs a long-poll for updates at the current offset. It returns
// a list of updates; the caller advances a.offset after acknowledging them.
func (a *Adapter) getUpdates(ctx context.Context) ([]telegramUpdate, error) {
a.mu.RLock()
offset := a.offset
a.mu.RUnlock()
payload := map[string]any{
"offset": offset,
"limit": maxUpdatesPerPoll,
"timeout": longPollTimeoutSec,
}
out, err := a.doJSON(ctx, "getUpdates", payload)
if err != nil {
return nil, err
}
var updates []telegramUpdate
if err := json.Unmarshal(out.Result, &updates); err != nil {
return nil, fmt.Errorf("telegram: getUpdates result: %w", err)
}
if updates == nil {
updates = []telegramUpdate{}
}
return updates, nil
}
// sendMessage sends a text message and returns the new message id.
func (a *Adapter) sendMessage(ctx context.Context, chatID, text string, replyTo int64, parseMode string) (int64, error) {
payload := map[string]any{
"chat_id": chatID,
"text": text,
}
if replyTo != 0 {
payload["reply_to_message_id"] = replyTo
}
if parseMode != "" {
payload["parse_mode"] = parseMode
}
return a.sendPayload(ctx, "sendMessage", payload)
}
// sendPhoto sends an existing photo by file_id and returns the new message id.
func (a *Adapter) sendPhoto(ctx context.Context, chatID, fileID string, replyTo int64, caption string) (int64, error) {
payload := map[string]any{
"chat_id": chatID,
"photo": fileID,
}
if caption != "" {
payload["caption"] = caption
payload["parse_mode"] = "Markdown"
}
if replyTo != 0 {
payload["reply_to_message_id"] = replyTo
}
return a.sendPayload(ctx, "sendPhoto", payload)
}
// sendVoice sends an existing voice clip by file_id and returns the new id.
func (a *Adapter) sendVoice(ctx context.Context, chatID, fileID string, replyTo int64, caption string) (int64, error) {
payload := map[string]any{
"chat_id": chatID,
"voice": fileID,
}
if caption != "" {
payload["caption"] = caption
payload["parse_mode"] = "Markdown"
}
if replyTo != 0 {
payload["reply_to_message_id"] = replyTo
}
return a.sendPayload(ctx, "sendVoice", payload)
}
// sendDocument sends an existing document by file_id and returns the new id.
func (a *Adapter) sendDocument(ctx context.Context, chatID, fileID, fileName string, replyTo int64, caption string) (int64, error) {
payload := map[string]any{
"chat_id": chatID,
"document": fileID,
}
if fileName != "" {
payload["file_name"] = fileName
}
if caption != "" {
payload["caption"] = caption
payload["parse_mode"] = "Markdown"
}
if replyTo != 0 {
payload["reply_to_message_id"] = replyTo
}
return a.sendPayload(ctx, "sendDocument", payload)
}
// sendPayload posts a media/message payload and returns the acknowledged
// message id from the Bot API result.
func (a *Adapter) sendPayload(ctx context.Context, method string, payload map[string]any) (int64, error) {
out, err := a.doJSON(ctx, method, payload)
if err != nil {
return 0, err
}
var res struct {
MessageID int64 `json:"message_id"`
}
if err := json.Unmarshal(out.Result, &res); err != nil {
return 0, fmt.Errorf("telegram: %s result: %w", method, err)
}
return res.MessageID, nil
}
// parseReplyToID converts a reply context identifier to a Telegram message id.
// A non-numeric or empty value yields 0 (no reply).
func parseReplyToID(s string) int64 {
if s == "" {
return 0
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0
}
return n
}
// badResponseError 把一次非 200 的回复收敛成错误:**只**取 status 与 error_code。
// 响应体其余字段(以及 Telegram 回显的请求参数)一个字都不拼 —— 旧实现把 512B 体原文
// 当 desc 拼进来,等于把上游报文写进调用方的日志。
//
// 🔴 description 默认**不带**(M50):它是上游自由文本,Telegram 会回显我们发过去的
// 参数,而那些参数里就有她的话(`Bad Request: can't parse entities: <正文>` 是真实
// 形状)。口径与 `trace.UpstreamError` 的 message 一致 —— 默认安全,要带必须显式
// opt-in;本适配器不开这个开关,因为 status + error_code 已足够分类(400/403/429)。
// 将来若真要带,请回到 httpclient 那条单一清洗口径(`trace` 的唯一打码 + 40 rune,
// 见 internal/channel/adapter/httpclient/upstream_text.go),不要在这里另写截断。
func badResponseError(method string, status int, body []byte) error {
var env botResponse
_ = json.Unmarshal(body, &env)
parts := []string{fmt.Sprintf("telegram: %s: status %d", method, status)}
if env.ErrorCode != 0 {
parts = append(parts, fmt.Sprintf("error_code %d", env.ErrorCode))
}
return fmt.Errorf("%s: %w", strings.Join(parts, ": "), ErrBadResponse)
}