信封路径(非 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 承担分类。 每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
240 lines
8.3 KiB
Go
240 lines
8.3 KiB
Go
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)
|
||
}
|