mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 09:56:47 +08:00
* feat(telegram): send and receive photos, videos and files Telegram was text-only in both directions: dispatch dropped every non-text update with an "unsupported" notice, the outbound subscriber only knew sendMessage/editMessageText, and the channel was never declared to DeclareChannelFileDelivery, so agents were told they cannot attach files. Inbound: photos, documents, video, video notes, animations, audio and voice notes become chat attachments through the engine's MediaResolver seam (getFile, file host download, object storage, intent-ledger row before the PUT, as Slack does). The caption is the message text and a placeholder marker leads the sender's own text; the engine swaps it for the attachment link once the bind commits. Quoted and recent group context still go in front of it. Stickers and other non-file kinds keep the unsupported notice. Outbound: files the agent bound to its reply are sent into the chat as their own messages once the text settles (sendPhoto for common images up to 10 MB, sendVideo/sendAudio for mp4 and common audio, sendDocument for the rest up to 50 MB). A photo Telegram refuses to process is resent as a document. Both halves hang off the same store != nil branch that declares file delivery, so the agent is promised the hop only where it exists. Compared with the WeCom reference this drops relay routing, the three-state delivery classification, per-reply metrics and the pending/admitted counters: Telegram outbound is stateless HTTP and the delivery lease already guarantees a single sender per turn. Co-Authored-By: Claude Fable 5.1 <[email protected]> * fix(telegram): address media review — deliver files once, admit before spawning, keep the notice without storage Review on #8727 raised five points; each lands here with a test. 1. A file-only reply could be delivered twice. closeTurn returned true whether this call ended the turn, a deeper attempt owned it, or it was settled already, and the empty-reply branch delivered files on all three. closeTurn now reports closedNow / closedAlready / closeHeld, and files go out only on closedNow. Regression tests: a replayed chat:done on another replica sends the files once; a completion an automatic retry has superseded sends nothing and the retry's answer still lands. 2. Admission sat behind the goroutine and the lookup. The attachment lookup now runs on the terminal worker, so a reply with nothing bound costs one indexed read and no goroutine; a delivery is spawned only for files known to exist, under a slot claimed first (non-blocking, four slots). Every slot busy sheds the reply with the notice instead of parking it. 3. Rebased on main over MUL-7585: recent group context, /new isolation and the privacy rule are kept; unaddressed group media is buffered and never a turn, as before; the media placeholder leads the sender's own text with quoted and recent context still in front. 4. Without object storage the member got no signal. The polling loop now knows whether the deployment stores media (ChannelDeps.AcceptsMedia, set from the same store != nil branch as the resolver) and keeps the old unsupported notice when it does not. A fetch that fails — over the 20 MB bot download limit, or a failed download — tells the sender so, instead of leaving the agent a placeholder nothing stands behind. 5. The failure notice claimed more than the code knew: a lost response may mean Telegram accepted the file. It now says delivery could not be confirmed and that the file stays attached in Multica, which holds either way. Co-Authored-By: Claude Fable 5.1 <[email protected]> * fix(telegram): offset the media placeholder past prepended context; report a failed attachment lookup Second review round on #8727. 1. With quoted or recent group context ahead of the sender's text, the MediaRef carried only the placeholder, so the engine replaced occurrence 0: a member who typed "[Image]" in the window received the sender's file and the sender's own marker stayed bare. inboundMedia now carries PlaceholderIndex — the enrichers only prepend, so it is the count of the marker ahead of the sender's segment, literals included (the DingTalk rule) — and the resolver sets it as InlineIndex. Regression tests cover the recent-context and the quoted-message paths. 2. A failed ListAttachmentsByChatMessage dropped the files silently while the text already on screen referred to them. The read has no side effects, so it is retried three times 250 ms apart; if it still fails the member gets a notice worded for what is known — whether the reply had files could not be checked — never the one that presumes a file existed. Co-Authored-By: Claude Fable 5.1 <[email protected]> --------- Co-authored-by: Claude Fable 5.1 <[email protected]>
433 lines
15 KiB
Go
433 lines
15 KiB
Go
package telegram
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/textproto"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// This file is the minimal Telegram Bot API surface the adapter needs:
|
|
// getMe (install-time validation), getUpdates (inbound long polling),
|
|
// sendMessage / editMessageText (outbound + streaming), sendChatAction
|
|
// (typing indicator). It is a thin hand-rolled client over net/http — the
|
|
// Bot API is plain JSON-over-HTTPS, so no SDK dependency is warranted.
|
|
|
|
// defaultAPIBase is the production Bot API host. Tests point apiBase at an
|
|
// httptest server.
|
|
const defaultAPIBase = "https://api.telegram.org"
|
|
|
|
// longPollTimeoutSecs is the getUpdates server-side hold. 50s stays under
|
|
// common 60s proxy/LB idle timeouts while keeping the request count low.
|
|
const longPollTimeoutSecs = 50
|
|
|
|
// ErrConflict surfaces a 409 from getUpdates: the same bot token is being
|
|
// polled by another consumer (a second Multica instance, another worktree, or
|
|
// a foreign process). The polling loop treats it as fatal for the attempt and
|
|
// reports a precise message so the operator sees "bot polled elsewhere"
|
|
// instead of a silent message tug-of-war.
|
|
var ErrConflict = errors.New("telegram: bot is already being polled by another instance (409 conflict)")
|
|
|
|
// requestError deliberately omits the request URL from Error(). Bot API URLs
|
|
// contain the bot token, while net/http transport errors can include that URL.
|
|
// Unwrap preserves cancellation and transport classification without making
|
|
// the credential loggable through the outer error string.
|
|
type requestError struct {
|
|
method string
|
|
cause error
|
|
}
|
|
|
|
func (e *requestError) Error() string { return fmt.Sprintf("telegram: %s request failed", e.method) }
|
|
|
|
func (e *requestError) Unwrap() error { return e.cause }
|
|
|
|
// apiError is a non-OK Bot API response.
|
|
type apiError struct {
|
|
Code int
|
|
Description string
|
|
// RetryAfter is Telegram's mandated backoff (seconds) on a 429.
|
|
RetryAfter int
|
|
}
|
|
|
|
func (e *apiError) Error() string {
|
|
return fmt.Sprintf("telegram api: %d %s", e.Code, e.Description)
|
|
}
|
|
|
|
// isRetryAfter reports the 429 backoff, if this error carries one.
|
|
func retryAfter(err error) (time.Duration, bool) {
|
|
var ae *apiError
|
|
if errors.As(err, &ae) && ae.Code == http.StatusTooManyRequests {
|
|
secs := ae.RetryAfter
|
|
if secs <= 0 {
|
|
secs = 1
|
|
}
|
|
return time.Duration(secs) * time.Second, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// botAPI is one bot's API client.
|
|
type botAPI struct {
|
|
base string // API host, no trailing slash
|
|
token string
|
|
client *http.Client
|
|
}
|
|
|
|
func newBotAPI(base, token string, client *http.Client) *botAPI {
|
|
if base == "" {
|
|
base = defaultAPIBase
|
|
}
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 65 * time.Second}
|
|
}
|
|
return &botAPI{base: base, token: token, client: client}
|
|
}
|
|
|
|
// envelope is the Bot API response wrapper.
|
|
type envelope struct {
|
|
OK bool `json:"ok"`
|
|
Result json.RawMessage `json:"result"`
|
|
ErrorCode int `json:"error_code"`
|
|
Description string `json:"description"`
|
|
Parameters *struct {
|
|
RetryAfter int `json:"retry_after"`
|
|
} `json:"parameters"`
|
|
}
|
|
|
|
// call POSTs one Bot API method and decodes the result into out (skipped when
|
|
// out is nil). API-level failures return *apiError; a 409 on getUpdates is
|
|
// mapped by the caller.
|
|
func (a *botAPI) call(ctx context.Context, method string, params any, out any) error {
|
|
var body io.Reader
|
|
if params != nil {
|
|
buf, err := json.Marshal(params)
|
|
if err != nil {
|
|
return fmt.Errorf("telegram: encode %s params: %w", method, err)
|
|
}
|
|
body = bytes.NewReader(buf)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.base+"/bot"+a.token+"/"+method, body)
|
|
if err != nil {
|
|
return fmt.Errorf("telegram: build %s request: %w", method, err)
|
|
}
|
|
if params != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
return a.do(req, method, out)
|
|
}
|
|
|
|
// do performs one prepared Bot API request and decodes the envelope.
|
|
func (a *botAPI) do(req *http.Request, method string, out any) error {
|
|
resp, err := a.client.Do(req)
|
|
if err != nil {
|
|
return &requestError{method: method, cause: err}
|
|
}
|
|
defer resp.Body.Close()
|
|
var env envelope
|
|
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
|
|
return fmt.Errorf("telegram: decode %s response (http %d): %w", method, resp.StatusCode, err)
|
|
}
|
|
if !env.OK {
|
|
ae := &apiError{Code: env.ErrorCode, Description: env.Description}
|
|
if env.Parameters != nil {
|
|
ae.RetryAfter = env.Parameters.RetryAfter
|
|
}
|
|
return ae
|
|
}
|
|
if out != nil {
|
|
if err := json.Unmarshal(env.Result, out); err != nil {
|
|
return fmt.Errorf("telegram: decode %s result: %w", method, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// User is the Bot API User object (getMe result / message sender).
|
|
type User struct {
|
|
ID int64 `json:"id"`
|
|
IsBot bool `json:"is_bot"`
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name,omitempty"`
|
|
Username string `json:"username,omitempty"`
|
|
}
|
|
|
|
// Chat is the Bot API Chat object.
|
|
type Chat struct {
|
|
ID int64 `json:"id"`
|
|
Type string `json:"type"` // "private", "group", "supergroup", "channel"
|
|
}
|
|
|
|
// Message is the subset of the Bot API Message object the adapter reads.
|
|
type Message struct {
|
|
MessageID int64 `json:"message_id"`
|
|
From *User `json:"from"`
|
|
Chat Chat `json:"chat"`
|
|
Date int64 `json:"date"`
|
|
Text string `json:"text"`
|
|
Entities []MessageEntity `json:"entities,omitempty"`
|
|
Caption string `json:"caption,omitempty"`
|
|
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
|
|
ReplyToMessage *Message `json:"reply_to_message,omitempty"`
|
|
MessageThreadID int64 `json:"message_thread_id,omitempty"`
|
|
IsTopicMessage bool `json:"is_topic_message,omitempty"`
|
|
// A message carries at most one of the media below (an album arrives as
|
|
// one message per item). Photo lists the available sizes, smallest first.
|
|
Photo []PhotoSize `json:"photo,omitempty"`
|
|
Document *FileRef `json:"document,omitempty"`
|
|
Video *FileRef `json:"video,omitempty"`
|
|
VideoNote *FileRef `json:"video_note,omitempty"`
|
|
Animation *FileRef `json:"animation,omitempty"`
|
|
Audio *FileRef `json:"audio,omitempty"`
|
|
Voice *FileRef `json:"voice,omitempty"`
|
|
Sticker *struct{} `json:"sticker,omitempty"`
|
|
}
|
|
|
|
// PhotoSize is one rendition of a photo message.
|
|
type PhotoSize struct {
|
|
FileID string `json:"file_id"`
|
|
FileUniqueID string `json:"file_unique_id"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
FileSize int64 `json:"file_size,omitempty"`
|
|
}
|
|
|
|
// FileRef is the common shape of Document / Video / Animation / Audio / Voice:
|
|
// the fields getFile needs plus the display metadata the attachment row keeps.
|
|
// Kinds without a file_name simply leave it empty.
|
|
type FileRef struct {
|
|
FileID string `json:"file_id"`
|
|
FileUniqueID string `json:"file_unique_id"`
|
|
FileName string `json:"file_name,omitempty"`
|
|
MimeType string `json:"mime_type,omitempty"`
|
|
FileSize int64 `json:"file_size,omitempty"`
|
|
}
|
|
|
|
// File is the getFile result. FilePath is relative to the file download host.
|
|
type File struct {
|
|
FileID string `json:"file_id"`
|
|
FileSize int64 `json:"file_size,omitempty"`
|
|
FilePath string `json:"file_path,omitempty"`
|
|
}
|
|
|
|
// maxBotDownloadBytes is Telegram's cap on what a bot may download via getFile
|
|
// (20 MB). Larger media is refused by Telegram itself, so the resolver refuses
|
|
// it up front without spending an intent row on it.
|
|
const maxBotDownloadBytes = 20 << 20
|
|
|
|
// GetFile resolves a file_id to a download path.
|
|
func (a *botAPI) GetFile(ctx context.Context, fileID string) (File, error) {
|
|
var f File
|
|
err := a.call(ctx, "getFile", struct {
|
|
FileID string `json:"file_id"`
|
|
}{FileID: fileID}, &f)
|
|
return f, err
|
|
}
|
|
|
|
// DownloadFile fetches a file the getFile call located. The download host
|
|
// puts the bot token in the URL, so failures are reported without it.
|
|
func (a *botAPI) DownloadFile(ctx context.Context, filePath string, maxBytes int64) ([]byte, string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.base+"/file/bot"+a.token+"/"+strings.TrimPrefix(filePath, "/"), nil)
|
|
if err != nil {
|
|
return nil, "", errors.New("telegram: build file download request failed")
|
|
}
|
|
resp, err := a.client.Do(req)
|
|
if err != nil {
|
|
return nil, "", &requestError{method: "downloadFile", cause: err}
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, "", fmt.Errorf("telegram: file download: http %d", resp.StatusCode)
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("telegram: read file download: %w", err)
|
|
}
|
|
if int64(len(data)) > maxBytes {
|
|
return nil, "", fmt.Errorf("telegram: file exceeds the %d byte download limit", maxBytes)
|
|
}
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if semi := strings.IndexByte(contentType, ';'); semi >= 0 {
|
|
contentType = strings.TrimSpace(contentType[:semi])
|
|
}
|
|
return data, contentType, nil
|
|
}
|
|
|
|
// sendMediaParams addresses one outbound file. Field is the multipart part the
|
|
// Bot API method expects ("photo" for sendPhoto, "document" for sendDocument,
|
|
// …); the method name is derived from it.
|
|
type sendMediaParams struct {
|
|
ChatID int64
|
|
MessageThreadID int64
|
|
Field string
|
|
Filename string
|
|
ContentType string
|
|
Data []byte
|
|
}
|
|
|
|
// SendMedia uploads one file as its own message via sendPhoto / sendDocument /
|
|
// sendVideo / sendAudio — the multipart counterpart of SendMessage.
|
|
func (a *botAPI) SendMedia(ctx context.Context, p sendMediaParams) (Message, error) {
|
|
var body bytes.Buffer
|
|
mw := multipart.NewWriter(&body)
|
|
_ = mw.WriteField("chat_id", strconv.FormatInt(p.ChatID, 10))
|
|
if p.MessageThreadID != 0 {
|
|
_ = mw.WriteField("message_thread_id", strconv.FormatInt(p.MessageThreadID, 10))
|
|
}
|
|
header := textproto.MIMEHeader{}
|
|
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, p.Field, p.Filename))
|
|
if p.ContentType != "" {
|
|
header.Set("Content-Type", p.ContentType)
|
|
}
|
|
part, err := mw.CreatePart(header)
|
|
if err != nil {
|
|
return Message{}, fmt.Errorf("telegram: encode media upload: %w", err)
|
|
}
|
|
if _, err := part.Write(p.Data); err != nil {
|
|
return Message{}, fmt.Errorf("telegram: encode media upload: %w", err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
return Message{}, fmt.Errorf("telegram: encode media upload: %w", err)
|
|
}
|
|
method := "send" + strings.ToUpper(p.Field[:1]) + p.Field[1:]
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.base+"/bot"+a.token+"/"+method, &body)
|
|
if err != nil {
|
|
return Message{}, fmt.Errorf("telegram: build %s request: %w", method, err)
|
|
}
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
var m Message
|
|
return m, a.do(req, method, &m)
|
|
}
|
|
|
|
// MessageEntity is the Bot API's structured annotation for mentions,
|
|
// commands, links, and formatting. Offset and Length are UTF-16 code units,
|
|
// not UTF-8 byte offsets or Go rune indexes.
|
|
type MessageEntity struct {
|
|
Type string `json:"type"`
|
|
Offset int `json:"offset"`
|
|
Length int `json:"length"`
|
|
}
|
|
|
|
// Update is the getUpdates envelope entry. Only new messages are consumed;
|
|
// edits, channel posts, and callback queries are ignored in v1.
|
|
type Update struct {
|
|
UpdateID int64 `json:"update_id"`
|
|
Message *Message `json:"message"`
|
|
}
|
|
|
|
// GetMe validates the token and returns the bot's own identity.
|
|
func (a *botAPI) GetMe(ctx context.Context) (User, error) {
|
|
var u User
|
|
err := a.call(ctx, "getMe", nil, &u)
|
|
return u, err
|
|
}
|
|
|
|
// WebhookInfo is the subset of the Bot API webhook status needed before
|
|
// starting a long-polling installation.
|
|
type WebhookInfo struct {
|
|
URL string `json:"url"`
|
|
PendingUpdateCount int `json:"pending_update_count"`
|
|
}
|
|
|
|
// GetWebhookInfo detects a webhook that would make getUpdates unavailable.
|
|
func (a *botAPI) GetWebhookInfo(ctx context.Context) (WebhookInfo, error) {
|
|
var info WebhookInfo
|
|
err := a.call(ctx, "getWebhookInfo", nil, &info)
|
|
return info, err
|
|
}
|
|
|
|
type getUpdatesParams struct {
|
|
Offset int64 `json:"offset,omitempty"`
|
|
Timeout int `json:"timeout"`
|
|
AllowedUpdates []string `json:"allowed_updates"`
|
|
}
|
|
|
|
// GetUpdates long-polls for new updates. A 409 is translated to ErrConflict.
|
|
func (a *botAPI) GetUpdates(ctx context.Context, offset int64) ([]Update, error) {
|
|
var updates []Update
|
|
err := a.call(ctx, "getUpdates", getUpdatesParams{
|
|
Offset: offset,
|
|
Timeout: longPollTimeoutSecs,
|
|
// Restrict the stream to plain messages: fewer wakeups, and edits /
|
|
// reactions / channel posts never enter the pipeline.
|
|
AllowedUpdates: []string{"message"},
|
|
}, &updates)
|
|
if err != nil {
|
|
var ae *apiError
|
|
if errors.As(err, &ae) && ae.Code == http.StatusConflict {
|
|
return nil, ErrConflict
|
|
}
|
|
return nil, err
|
|
}
|
|
return updates, nil
|
|
}
|
|
|
|
type sendMessageParams struct {
|
|
ChatID int64 `json:"chat_id"`
|
|
Text string `json:"text"`
|
|
ParseMode string `json:"parse_mode,omitempty"`
|
|
MessageThreadID int64 `json:"message_thread_id,omitempty"`
|
|
ReplyParameters *replyParameters `json:"reply_parameters,omitempty"`
|
|
}
|
|
|
|
// replyParameters is Telegram's current quote-reply shape. The legacy
|
|
// reply_to_message_id field is no longer part of sendMessage.
|
|
type replyParameters struct {
|
|
MessageID int64 `json:"message_id"`
|
|
AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"`
|
|
}
|
|
|
|
// SendMessage posts a message. HTML parse mode is used by callers that send
|
|
// converted markdown; plain calls leave parseMode empty.
|
|
func (a *botAPI) SendMessage(ctx context.Context, p sendMessageParams) (Message, error) {
|
|
var m Message
|
|
err := a.call(ctx, "sendMessage", p, &m)
|
|
return m, err
|
|
}
|
|
|
|
// sendMessageWithRetryAfter honors Telegram's explicit 429 backoff once. It
|
|
// deliberately does not retry transport or other API errors because a lost
|
|
// response can mean Telegram already accepted the message.
|
|
func sendMessageWithRetryAfter(ctx context.Context, a *botAPI, p sendMessageParams) (Message, error) {
|
|
m, err := a.SendMessage(ctx, p)
|
|
if wait, ok := retryAfter(err); ok {
|
|
if !sleepCtx(ctx, wait) {
|
|
return Message{}, ctx.Err()
|
|
}
|
|
return a.SendMessage(ctx, p)
|
|
}
|
|
return m, err
|
|
}
|
|
|
|
type editMessageTextParams struct {
|
|
ChatID int64 `json:"chat_id"`
|
|
MessageID int64 `json:"message_id"`
|
|
Text string `json:"text"`
|
|
ParseMode string `json:"parse_mode,omitempty"`
|
|
}
|
|
|
|
// EditMessageText replaces a previously sent message's text — the streaming
|
|
// primitive. "message is not modified" errors are swallowed by the caller.
|
|
func (a *botAPI) EditMessageText(ctx context.Context, p editMessageTextParams) error {
|
|
return a.call(ctx, "editMessageText", p, nil)
|
|
}
|
|
|
|
// SendChatAction shows the native "typing…" indicator for ~5s in the same
|
|
// forum topic as the triggering message, when applicable.
|
|
func (a *botAPI) SendChatAction(ctx context.Context, chatID, messageThreadID int64) error {
|
|
return a.call(ctx, "sendChatAction", struct {
|
|
ChatID int64 `json:"chat_id"`
|
|
Action string `json:"action"`
|
|
MessageThreadID int64 `json:"message_thread_id,omitempty"`
|
|
}{
|
|
ChatID: chatID, Action: "typing", MessageThreadID: messageThreadID,
|
|
}, nil)
|
|
}
|