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/… 地址),因此与 // 下面的传输层分支同形处理:只留来源,地址由 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/),而 // *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) }