P2-1 telegram doJSON 的**请求构造**失败分支:http.NewRequestWithContext 解析失败的 err 同样是 *url.Error(里面是整条 /bot<token>/… 地址),而 poll.go 的 long poll failed 会原样落日志。改成与传输层分支同形:trace.HostOf(endpoint) + callCause。 P2-2 qq_obv11 connectClient 的成功 INFO 从 maskAccessTokenQuery 换成 trace.MaskQuery:后者认得 access%5Ftoken / token / api_key / 签名参数 / userinfo, 且保留 EscapedPath(路径可读)与不截断。maskAccessTokenQuery 仍被 sanitizeConnectReason 使用,未成死代码。 P2-3 删掉 maxEnvelopeMessageRunes / maxDescriptionRunes(各 200):trace.RedactText 内部已 truncate 到 maxSummaryRunes=80,这两层永不命中,注释口径与实际不符。改为 直接 return trace.RedactText(压平空白),注释写明 80 rune 由 RedactText 负责。 用例(全部变异打红,逐条验证):telegram 构造分支 token、QQ 八形态凭据矩阵 (含四种 maskAccessTokenQuery 漏掉的形态 + 路径可读反向断言)、两处 80 rune 上限。
250 lines
8.1 KiB
Go
250 lines
8.1 KiB
Go
package telegram
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"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.
|
|
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>/… 地址),因此与
|
|
// 下面的传输层分支同形处理:只留来源,地址由 callCause 剥掉。
|
|
return nil, fmt.Errorf("telegram: build %s request %s: %w",
|
|
method, trace.HostOf(a.endpoint(method)), callCause(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)), callCause(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 {
|
|
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 的回复收敛成错误:**只**取 error_code 与
|
|
// description。响应体其余字段(以及 Telegram 回显的请求参数)一个字都不拼 ——
|
|
// 旧实现把 512B 体原文当 desc 拼进来,等于把上游报文写进调用方的日志。
|
|
//
|
|
// description 是上游自由文本(例如 setWebhook 失败时会回显我们给的地址),因此过
|
|
// trace.RedactText 清洗凭据形状并按 rune 截断后才出镜。
|
|
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))
|
|
}
|
|
if desc := boundedDescription(env.Description); desc != "" {
|
|
parts = append(parts, desc)
|
|
}
|
|
return fmt.Errorf("%s: %w", strings.Join(parts, ": "), ErrBadResponse)
|
|
}
|
|
|
|
// boundedDescription 是 Bot API description 进入错误前的唯一处理:压平空白,再交给
|
|
// trace.RedactText —— 它既清洗凭据形状,也把长度压到 80 rune(超出的带省略号)。
|
|
func boundedDescription(s string) string {
|
|
return trace.RedactText(strings.Join(strings.Fields(s), " "))
|
|
}
|
|
|
|
// callCause 剥掉 net/http 的 *url.Error 外壳:它会把完整地址拼进错误串,而 Bot API
|
|
// 的地址路径里就写着 bot token。剥壳只丢地址,内层哨兵链(context 取消等)原样保留。
|
|
// 与 internal/channel/mediafetch 的 sanitizeErr 同一口径(那份未导出,且在别包里)。
|
|
func callCause(err error) error {
|
|
var uerr *url.Error
|
|
if errors.As(err, &uerr) && uerr.Err != nil {
|
|
return uerr.Err
|
|
}
|
|
return err
|
|
}
|