package max import ( "bytes" "context" "crypto/subtle" "encoding/json" "fmt" "io" "log/slog" "mime/multipart" "net/http" "strconv" "strings" "sync" "time" "github.com/chenhg5/cc-connect/core" ) func init() { core.RegisterPlatform("max", New) } const ( defaultAPIBase = "https://platform-api.max.ru" // pollTimeout — long-poll timeout sent to MAX (seconds). API allows 0–90, // dev docs default = 30. Using 30 balances responsiveness and load. pollTimeout = 30 // httpTimeout caps the HTTP client wait. Must be much larger than // pollTimeout, otherwise transient MAX backend lag pushes header arrival // past the deadline and the client cancels the long-poll, triggering a // retry storm. httpTimeout = 90 * time.Second initialReconnectBackoff = time.Second maxReconnectBackoff = 30 * time.Second stableConnectionWindow = 10 * time.Second typingInterval = 4 * time.Second maxAttachmentBytes = 25 * 1024 * 1024 // 25 MiB cap per downloaded attachment attachmentDownloadTO = 60 * time.Second attachmentUploadTO = 5 * time.Minute // attachmentReadyDelay is the pause between CDN upload and POST /messages. // Without it MAX may reject the message with "attachment.not.ready" while // it is still indexing the freshly uploaded blob. attachmentReadyDelay = 600 * time.Millisecond attachmentReadyRetries = 4 ) // replyContext carries the information needed to send a reply. type replyContext struct { chatID string messageID string // populated from incoming message, used only by UpdateMessage } // Platform implements core.Platform for the MAX messenger bot API. type Platform struct { token string apiBase string allowFrom string // Webhook mode: if webhookURL is set, the platform registers a // subscription with MAX, listens on webhookListen for incoming updates // and DOES NOT run the long-poll loop. Required by MAX from 2026-05-11 // (long-polling is being throttled to 2 RPS). webhookURL string webhookListen string webhookPath string webhookSecret string resubscribeInterval time.Duration mu sync.RWMutex handler core.MessageHandler ctx context.Context cancel context.CancelFunc stopping bool client *http.Client // general API calls — httpTimeout uploadClient *http.Client // CDN uploads — attachmentUploadTO (overrides short client Timeout) dedup core.MessageDedup webServer *http.Server } // New creates a MAX platform from config options. // // [[projects.platforms]] // type = "max" // [projects.platforms.options] // token = "" // allow_from = "," # optional, "*" or empty = all // api_base = "https://platform-api.max.ru" # optional override // webhook_url = "https://your.domain/webhook" # optional; switches // # platform to webhook mode // webhook_listen = ":8080" # optional, default ":8080" // webhook_path = "/webhook" # optional, default "/webhook"; // # must match the path in webhook_url // webhook_secret = "" # optional; if set, sent to MAX // # so MAX includes it in the // # X-Max-Bot-Api-Secret header // # of every webhook POST (?s= also // # accepted for manual testing) // webhook_resubscribe_interval = "5m" # optional, default 5m; cc-connect // # periodically re-POSTs the // # subscription because MAX has been // # observed to silently drop it func New(opts map[string]any) (core.Platform, error) { token, _ := opts["token"].(string) if token == "" { return nil, fmt.Errorf("max: token is required") } apiBase, _ := opts["api_base"].(string) if apiBase == "" { apiBase = defaultAPIBase } allowFrom, _ := opts["allow_from"].(string) core.CheckAllowFrom("max", allowFrom) webhookURL, _ := opts["webhook_url"].(string) webhookListen, _ := opts["webhook_listen"].(string) webhookPath, _ := opts["webhook_path"].(string) webhookSecret, _ := opts["webhook_secret"].(string) if webhookURL != "" && webhookListen == "" { webhookListen = ":8080" } if webhookPath == "" { webhookPath = "/webhook" } else if !strings.HasPrefix(webhookPath, "/") { webhookPath = "/" + webhookPath } resubscribeInterval := 5 * time.Minute if raw, ok := opts["webhook_resubscribe_interval"].(string); ok && raw != "" { d, err := time.ParseDuration(raw) if err != nil { return nil, fmt.Errorf("max: webhook_resubscribe_interval: %w", err) } resubscribeInterval = d } return &Platform{ token: token, apiBase: apiBase, allowFrom: allowFrom, webhookURL: webhookURL, webhookListen: webhookListen, webhookPath: webhookPath, webhookSecret: webhookSecret, resubscribeInterval: resubscribeInterval, client: &http.Client{Timeout: httpTimeout}, uploadClient: &http.Client{Timeout: attachmentUploadTO}, }, nil } func (p *Platform) Name() string { return "max" } func (p *Platform) Start(handler core.MessageHandler) error { p.mu.Lock() defer p.mu.Unlock() if p.stopping { return fmt.Errorf("max: platform stopped") } p.handler = handler ctx, cancel := context.WithCancel(context.Background()) p.ctx = ctx p.cancel = cancel // Verify token at startup if name, id, err := p.getMe(ctx); err != nil { slog.Warn("max: could not verify bot token", "error", err) } else { slog.Info("max: connected", "bot", name, "id", id) } if p.webhookURL != "" { if err := p.startWebhook(ctx); err != nil { cancel() return fmt.Errorf("max: start webhook: %w", err) } return nil } go p.pollLoop(ctx) return nil } func (p *Platform) Stop() error { p.mu.Lock() srv := p.webServer url := p.webhookURL p.stopping = true if p.cancel != nil { p.cancel() } p.mu.Unlock() if srv != nil { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = srv.Shutdown(shutdownCtx) } if url != "" { // Best-effort unsubscribe so MAX doesn't keep delivering events to a // dead URL. Failures here are not fatal — service is shutting down. unsubCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := p.unsubscribe(unsubCtx, url); err != nil { slog.Warn("max: unsubscribe webhook failed", "url", url, "err", err) } } return nil } // startWebhook registers a webhook subscription with MAX and brings up an // HTTP server on webhookListen so MAX can POST updates to webhookURL. // Called from Start() when webhook_url is configured. Long-polling is NOT // started in webhook mode — the two are mutually exclusive (MAX delivers // each update to one transport). func (p *Platform) startWebhook(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc(p.webhookPath, p.webhookHandler) srv := &http.Server{ Addr: p.webhookListen, Handler: mux, ReadHeaderTimeout: 10 * time.Second, } // Caller (Start) already holds p.mu, so assign directly — re-locking // a non-reentrant sync.RWMutex would deadlock. p.webServer = srv go func() { slog.Info("max: webhook listening", "addr", p.webhookListen, "path", p.webhookPath, "url", p.webhookURL) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("max: webhook listener stopped", "err", err) } }() if err := p.subscribe(ctx, p.webhookURL); err != nil { return fmt.Errorf("subscribe: %w", err) } slog.Info("max: webhook subscribed", "url", p.webhookURL) // MAX has been observed to silently drop the webhook subscription // server-side without any delivery error. The documented 8h failure // window does not match the observed cadence (drops every 25–60min), // so we periodically re-POST the subscription. MAX overwrites the // existing registration in-place, so re-subscribing is idempotent. if p.resubscribeInterval > 0 { go p.resubscribeLoop(ctx) } return nil } func (p *Platform) resubscribeLoop(ctx context.Context) { t := time.NewTicker(p.resubscribeInterval) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: rsCtx, cancel := context.WithTimeout(ctx, 30*time.Second) if err := p.subscribe(rsCtx, p.webhookURL); err != nil { slog.Warn("max: periodic re-subscribe failed", "err", err) } else { slog.Debug("max: periodic re-subscribe ok") } cancel() } } } // webhookCtx returns the parent context for an asynchronous webhook handler // goroutine. While the platform is running, it returns the same context that // Stop() cancels via p.cancel so in-flight handler work short-circuits on // shutdown. Before Start (or after a no-op New), it falls back to Background // so unit tests calling webhookHandler directly still get a usable ctx. func (p *Platform) webhookCtx() context.Context { p.mu.RLock() defer p.mu.RUnlock() if p.ctx != nil { return p.ctx } return context.Background() } // webhookHandler accepts a POST from MAX with a single update and routes it // through the same handleUpdate path used by long-polling. func (p *Platform) webhookHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } if p.webhookSecret != "" { // MAX sends the secret in X-Max-Bot-Api-Secret on every webhook POST // when the subscription was created with a "secret" field. // ?s= query is accepted as a fallback for manual curl testing. got := r.Header.Get("X-Max-Bot-Api-Secret") if got == "" { got = r.URL.Query().Get("s") } if subtle.ConstantTimeCompare([]byte(got), []byte(p.webhookSecret)) != 1 { slog.Warn("max: webhook secret mismatch", "remote", r.RemoteAddr) w.WriteHeader(http.StatusUnauthorized) return } } defer r.Body.Close() body, err := io.ReadAll(io.LimitReader(r.Body, 4*1024*1024)) if err != nil { slog.Warn("max: webhook read body", "err", err) w.WriteHeader(http.StatusBadRequest) return } var upd maxUpdate if err := json.Unmarshal(body, &upd); err != nil { slog.Warn("max: webhook unmarshal", "err", err, "body", string(body[:min(len(body), 256)])) w.WriteHeader(http.StatusBadRequest) return } // MAX expects a fast 200 — process the update asynchronously so we // never let agent latency back-pressure the delivery side. go func() { p.handleUpdate(p.webhookCtx(), &upd) }() w.WriteHeader(http.StatusOK) } // subscribe registers a webhook URL with MAX. The MAX bot API supports // only one webhook per bot — if an old URL is registered, MAX overwrites // it on a successful subscribe, so no explicit cleanup is required. func (p *Platform) subscribe(ctx context.Context, url string) error { payload := map[string]any{ "url": url, "update_types": []string{"message_created", "message_callback"}, } if p.webhookSecret != "" { payload["secret"] = p.webhookSecret } body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiBase+"/subscriptions", bytes.NewReader(body)) if err != nil { return err } p.setAuth(req) req.Header.Set("Content-Type", "application/json") resp, err := p.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return fmt.Errorf("HTTP %d: %s", resp.StatusCode, respBody) } return nil } // unsubscribe removes the webhook registration. Only used during Stop(). func (p *Platform) unsubscribe(ctx context.Context, url string) error { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, p.apiBase+"/subscriptions", nil) if err != nil { return err } p.setAuth(req) q := req.URL.Query() q.Set("url", url) req.URL.RawQuery = q.Encode() resp, err := p.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return fmt.Errorf("HTTP %d: %s", resp.StatusCode, respBody) } return nil } func min(a, b int) int { if a < b { return a } return b } // --- Sending --- func (p *Platform) Reply(ctx context.Context, replyCtx any, content string) error { return p.sendText(ctx, replyCtx, content, nil) } func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error { return p.sendText(ctx, replyCtx, content, nil) } // SendWithButtons implements core.InlineButtonSender — sends message with callback buttons. func (p *Platform) SendWithButtons(ctx context.Context, replyCtx any, content string, buttons [][]core.ButtonOption) error { maxButtons := make([][]maxButton, 0, len(buttons)) for _, row := range buttons { maxRow := make([]maxButton, 0, len(row)) for _, btn := range row { maxRow = append(maxRow, maxButton{ Type: "callback", Text: btn.Text, Payload: btn.Data, }) } maxButtons = append(maxButtons, maxRow) } return p.sendText(ctx, replyCtx, content, maxButtons) } // SendImage implements core.ImageSender. func (p *Platform) SendImage(ctx context.Context, replyCtx any, img core.ImageAttachment) error { rctx, ok := replyCtx.(replyContext) if !ok { return fmt.Errorf("max: unexpected replyCtx type %T", replyCtx) } token, err := p.uploadAttachment(ctx, "image", img.Data, img.FileName) if err != nil { return fmt.Errorf("max: upload image: %w", err) } body := &maxSendBody{ Attachments: []maxOutAttachment{{ Type: "image", Payload: maxTokenPayload{Token: token}, }}, } return p.postMessage(ctx, rctx.chatID, body) } // SendFile implements core.FileSender. MAX routes images uploaded via the file // endpoint as type="file" in the message, so we honor the declared kind: if the // mime says image/*, we upload as image so the recipient sees a proper image // preview instead of a generic file card. func (p *Platform) SendFile(ctx context.Context, replyCtx any, file core.FileAttachment) error { rctx, ok := replyCtx.(replyContext) if !ok { return fmt.Errorf("max: unexpected replyCtx type %T", replyCtx) } kind := "file" attType := "file" if strings.HasPrefix(file.MimeType, "image/") { kind = "image" attType = "image" } else if strings.HasPrefix(file.MimeType, "video/") { kind = "video" attType = "video" } else if strings.HasPrefix(file.MimeType, "audio/") { kind = "audio" attType = "audio" } token, err := p.uploadAttachment(ctx, kind, file.Data, file.FileName) if err != nil { return fmt.Errorf("max: upload file: %w", err) } body := &maxSendBody{ Attachments: []maxOutAttachment{{ Type: attType, Payload: maxTokenPayload{Token: token}, }}, } return p.postMessage(ctx, rctx.chatID, body) } // SendAudio implements core.AudioSender — uploads a voice/audio blob and sends // it as a native MAX audio attachment. Used by the TTS pipeline to reply in // voice when [tts] is enabled in config. func (p *Platform) SendAudio(ctx context.Context, replyCtx any, audio []byte, format string) error { rctx, ok := replyCtx.(replyContext) if !ok { return fmt.Errorf("max: unexpected replyCtx type %T", replyCtx) } if format == "" { format = "mp3" } token, err := p.uploadAttachment(ctx, "audio", audio, "voice."+format) if err != nil { return fmt.Errorf("max: upload audio: %w", err) } body := &maxSendBody{ Attachments: []maxOutAttachment{{ Type: "audio", Payload: maxTokenPayload{Token: token}, }}, } return p.postMessage(ctx, rctx.chatID, body) } // UpdateMessage implements core.MessageUpdater via PUT /messages?message_id=. func (p *Platform) UpdateMessage(ctx context.Context, replyCtx any, content string) error { rctx, ok := replyCtx.(replyContext) if !ok { return fmt.Errorf("max: unexpected replyCtx type %T", replyCtx) } if rctx.messageID == "" { return fmt.Errorf("max: update message: no message id in reply context") } body := maxSendBody{Text: content, Format: "markdown"} data, err := json.Marshal(body) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.apiBase+"/messages", bytes.NewReader(data)) if err != nil { return err } p.setAuth(req) q := req.URL.Query() q.Set("message_id", rctx.messageID) req.URL.RawQuery = q.Encode() req.Header.Set("Content-Type", "application/json") resp, err := p.client.Do(req) if err != nil { return fmt.Errorf("max: edit message: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return fmt.Errorf("max: edit message: HTTP %d: %s", resp.StatusCode, respBody) } return nil } // uploadAttachment performs the two-step MAX upload: request an upload URL from // /uploads?type=, then POST the binary as multipart/form-data field "data" // to that URL. Returns the token to embed in a subsequent /messages attachment. func (p *Platform) uploadAttachment(ctx context.Context, kind string, data []byte, filename string) (string, error) { if len(data) == 0 { return "", fmt.Errorf("empty attachment data") } // Use a 5-minute context AND a dedicated http.Client with a matching Timeout. // p.client has a 35 s Timeout which fires independently of the context deadline // and would abort large CDN uploads before the context expires. uploadCtx, cancel := context.WithTimeout(ctx, attachmentUploadTO) defer cancel() urlReq, err := http.NewRequestWithContext(uploadCtx, http.MethodPost, p.apiBase+"/uploads", nil) if err != nil { return "", err } p.setAuth(urlReq) q := urlReq.URL.Query() q.Set("type", kind) urlReq.URL.RawQuery = q.Encode() urlResp, err := p.uploadClient.Do(urlReq) if err != nil { return "", fmt.Errorf("request upload url: %w", err) } defer urlResp.Body.Close() if urlResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(urlResp.Body, 512)) return "", fmt.Errorf("upload url: HTTP %d: %s", urlResp.StatusCode, body) } var urlInfo struct { URL string `json:"url"` Token string `json:"token"` } if err := json.NewDecoder(urlResp.Body).Decode(&urlInfo); err != nil { return "", fmt.Errorf("decode upload url: %w", err) } if urlInfo.URL == "" { return "", fmt.Errorf("upload url: empty url in response") } if filename == "" { filename = defaultFilename(kind) } var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, err := mw.CreateFormFile("data", filename) if err != nil { return "", err } if _, err := fw.Write(data); err != nil { return "", err } if err := mw.Close(); err != nil { return "", err } cdnReq, err := http.NewRequestWithContext(uploadCtx, http.MethodPost, urlInfo.URL, &buf) if err != nil { return "", err } p.setAuth(cdnReq) cdnReq.Header.Set("Content-Type", mw.FormDataContentType()) cdnResp, err := p.uploadClient.Do(cdnReq) if err != nil { return "", fmt.Errorf("cdn upload: %w", err) } defer cdnResp.Body.Close() if cdnResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(cdnResp.Body, 512)) return "", fmt.Errorf("cdn upload: HTTP %d: %s", cdnResp.StatusCode, body) } cdnBody, err := io.ReadAll(io.LimitReader(cdnResp.Body, 64*1024)) if err != nil { return "", fmt.Errorf("read cdn response: %w", err) } // MAX CDN uses different response shapes per attachment kind: // image: {"photos": {"": {"token": "..."}}} // file: {"token": "..."} // video/audio: "1" (XML) — the real token is already in urlInfo.Token if token := extractCDNToken(kind, cdnBody); token != "" { return token, nil } if urlInfo.Token != "" { return urlInfo.Token, nil } return "", fmt.Errorf("cdn upload: no token in response: %s", cdnBody) } // extractCDNToken parses the token out of a MAX CDN upload response. Returns // "" if not found; the caller is expected to fall back to urlInfo.Token. func extractCDNToken(kind string, body []byte) string { switch kind { case "image": var resp struct { Photos map[string]struct { Token string `json:"token"` } `json:"photos"` } if err := json.Unmarshal(body, &resp); err == nil { for _, ph := range resp.Photos { if ph.Token != "" { return ph.Token } } } case "video", "audio": // CDN returns XML for video/audio; token lives in urlInfo.Token. Nothing to extract here. default: // file var resp struct { Token string `json:"token"` } if err := json.Unmarshal(body, &resp); err == nil && resp.Token != "" { return resp.Token } } return "" } func defaultFilename(kind string) string { switch kind { case "image": return "image.png" case "video": return "video.mp4" case "audio": return "audio.mp3" } return "file.bin" } // StartTyping implements core.TypingIndicator — drives the MAX "is typing" // presence indicator via POST /chats/{id}/actions {"action":"typing_on"}. // MAX clears the indicator automatically after ~10s of inactivity, so we // re-arm it on a ticker until the returned cancel func is called. func (p *Platform) StartTyping(ctx context.Context, replyCtx any) (stop func()) { rctx, ok := replyCtx.(replyContext) if !ok || rctx.chatID == "" { return func() {} } tickCtx, cancel := context.WithCancel(ctx) _ = p.sendChatAction(tickCtx, rctx.chatID, "typing_on") go func() { ticker := time.NewTicker(typingInterval) defer ticker.Stop() for { select { case <-tickCtx.Done(): return case <-ticker.C: _ = p.sendChatAction(tickCtx, rctx.chatID, "typing_on") } } }() return cancel } // sendChatAction posts a presence action to MAX (typing_on / mark_seen). // Best-effort: errors are logged at debug level only and never block the // main message-handling flow. func (p *Platform) sendChatAction(ctx context.Context, chatID, action string) error { if chatID == "" || action == "" { return nil } body, err := json.Marshal(map[string]string{"action": action}) if err != nil { return err } url := p.apiBase + "/chats/" + chatID + "/actions" req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return err } p.setAuth(req) req.Header.Set("Content-Type", "application/json") resp, err := p.client.Do(req) if err != nil { slog.Debug("max: chat action failed", "chat", chatID, "action", action, "err", err) return err } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) slog.Debug("max: chat action non-2xx", "chat", chatID, "action", action, "status", resp.StatusCode, "body", string(respBody)) return fmt.Errorf("max: chat action %s: status %d", action, resp.StatusCode) } return nil } // FormattingInstructions implements core.FormattingInstructionProvider. // The engine appends this to the agent system prompt so Claude uses only // MAX-supported markdown syntax. func (p *Platform) FormattingInstructions() string { return `Formatting rules for MAX messenger: - **bold** and _italic_ are supported - Inline code: ` + "`code`" + ` and fenced code blocks (` + "```" + `) are supported - Bullet lists with - or * are supported as plain text - Do NOT use headers (# ## ###) - Do NOT use horizontal rules (--- or ***) - Do NOT use tables - Do NOT use HTML tags Keep responses concise and use plain text where possible.` } // ReconstructReplyCtx implements core.ReplyContextReconstructor. // Session key format: "max:{chatID}" or "max:{chatID}:{userID}". func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) { rest, ok := strings.CutPrefix(sessionKey, "max:") if !ok { return nil, fmt.Errorf("max: cannot reconstruct reply ctx from %q", sessionKey) } chatID, _, _ := strings.Cut(rest, ":") if chatID == "" { return nil, fmt.Errorf("max: cannot reconstruct reply ctx from %q", sessionKey) } return replyContext{chatID: chatID}, nil } // --- MAX API types --- type maxButton struct { Type string `json:"type"` Text string `json:"text"` Payload string `json:"payload"` } // maxOutAttachment is the generic outgoing attachment wrapper used for both // inline_keyboard (with maxKbPayload) and image/file/video/audio (with // maxTokenPayload). type maxOutAttachment struct { Type string `json:"type"` Payload any `json:"payload,omitempty"` } type maxKbPayload struct { Buttons [][]maxButton `json:"buttons"` } type maxTokenPayload struct { Token string `json:"token"` } type maxSendBody struct { Text string `json:"text"` Format string `json:"format,omitempty"` Attachments []maxOutAttachment `json:"attachments,omitempty"` } type maxUpdate struct { UpdateType string `json:"update_type"` Timestamp int64 `json:"timestamp"` // message_created Message *maxMessage `json:"message,omitempty"` // message_callback Callback *maxCallback `json:"callback,omitempty"` } type maxMessage struct { Sender maxUser `json:"sender"` Recipient maxRecipient `json:"recipient"` Timestamp int64 `json:"timestamp"` Body maxBody `json:"body"` // Link is set when the message is a forward or a reply. For forwarded // messages the actual content (text + attachments) lives inside Link.Message, // while Body may be empty. We surface those attachments to the agent. Link *maxLink `json:"link,omitempty"` } // maxLink mirrors the LinkedMessage object from MAX bot API. Type is "forward" // or "reply"; for forwarded messages the inner Message contains the original // text and attachments. type maxLink struct { Type string `json:"type"` Sender maxUser `json:"sender,omitempty"` ChatID int64 `json:"chat_id,omitempty"` Message maxBody `json:"message"` } type maxBody struct { Mid string `json:"mid"` Text string `json:"text"` Attachments []maxAttachmentRaw `json:"attachments,omitempty"` } // maxAttachmentRaw mirrors what MAX API delivers in message_created updates. // Known types: "image", "video", "audio", "file", "sticker", "share". // "image" carries payload.url directly; "video"/"audio" only carry payload.token // and require an extra API round-trip (/videos/{token}, /audios/{token}) to // resolve the actual download URL. type maxAttachmentRaw struct { Type string `json:"type"` Payload maxAttachmentPayld `json:"payload"` Filename string `json:"filename,omitempty"` } type maxAttachmentPayld struct { URL string `json:"url,omitempty"` Token string `json:"token,omitempty"` } type maxUser struct { UserID int64 `json:"user_id"` Name string `json:"name"` } type maxRecipient struct { ChatID int64 `json:"chat_id"` } type maxCallback struct { CallbackID string `json:"callback_id"` Payload string `json:"payload"` User maxUser `json:"user"` Message maxMessage `json:"message"` } type maxUpdatesResponse struct { Updates []maxUpdate `json:"updates"` Marker *int64 `json:"marker"` } // --- Long polling --- func (p *Platform) pollLoop(ctx context.Context) { var marker *int64 backoff := initialReconnectBackoff for { if ctx.Err() != nil { return } startedAt := time.Now() newMarker, err := p.poll(ctx, marker) if ctx.Err() != nil { return } if err != nil { elapsed := time.Since(startedAt) wait := backoff if elapsed >= stableConnectionWindow { wait = initialReconnectBackoff backoff = initialReconnectBackoff } else { backoff *= 2 if backoff > maxReconnectBackoff { backoff = maxReconnectBackoff } } slog.Warn("max: poll error, retrying", "error", err, "backoff", wait) select { case <-ctx.Done(): return case <-time.After(wait): } continue } backoff = initialReconnectBackoff if newMarker != nil { marker = newMarker } } } func (p *Platform) poll(ctx context.Context, marker *int64) (*int64, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.apiBase+"/updates", nil) if err != nil { return nil, err } p.setAuth(req) q := req.URL.Query() q.Set("timeout", strconv.Itoa(pollTimeout)) q.Set("limit", "20") q.Set("types", "message_created,message_callback") if marker != nil { q.Set("marker", strconv.FormatInt(*marker, 10)) } req.URL.RawQuery = q.Encode() resp, err := p.client.Do(req) if err != nil { return nil, fmt.Errorf("poll request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, fmt.Errorf("poll: HTTP %d: %s", resp.StatusCode, body) } var result maxUpdatesResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("poll decode: %w", err) } for i := range result.Updates { p.handleUpdate(ctx, &result.Updates[i]) } return result.Marker, nil } func (p *Platform) handleUpdate(ctx context.Context, upd *maxUpdate) { switch upd.UpdateType { case "message_created": if upd.Message != nil { p.handleMessage(ctx, upd.Message) } case "message_callback": if upd.Callback != nil { p.handleCallback(ctx, upd.Callback) } } } func (p *Platform) handleMessage(ctx context.Context, msg *maxMessage) { msgTime := time.UnixMilli(msg.Timestamp) if core.IsOldMessage(msgTime) { slog.Debug("max: ignoring old message after restart", "date", msgTime) return } if p.dedup.IsDuplicate(msg.Body.Mid) { slog.Debug("max: duplicate message ignored", "message_id", msg.Body.Mid) return } text := msg.Body.Text atts := msg.Body.Attachments // Forwarded message: text and attachments live inside link.message. // We merge them into the visible payload so the agent sees the file. // For replies (link.type == "reply") we keep the user's own text/atts // untouched — the quoted message is just context. if msg.Link != nil && msg.Link.Type == "forward" { if text == "" { text = msg.Link.Message.Text } else if msg.Link.Message.Text != "" { text = text + "\n\n[forwarded] " + msg.Link.Message.Text } if len(msg.Link.Message.Attachments) > 0 { atts = append(atts, msg.Link.Message.Attachments...) } } if text == "" && len(atts) == 0 { return } userID := strconv.FormatInt(msg.Sender.UserID, 10) if !core.AllowList(p.allowFrom, userID) { slog.Debug("max: message from unauthorized user", "user_id", userID) return } chatID := strconv.FormatInt(msg.Recipient.ChatID, 10) sessionKey := fmt.Sprintf("max:%s:%s", chatID, userID) rctx := replyContext{chatID: chatID, messageID: msg.Body.Mid} // Acknowledge the message so the user gets a "read" tick in MAX. // Fire-and-forget — must never block the routing flow. go func() { _ = p.sendChatAction(ctx, chatID, "mark_seen") }() images, files, audio := p.fetchAttachments(ctx, atts) if text == "" && audio == nil && (len(images) > 0 || len(files) > 0) { switch { case len(images) > 0 && len(files) == 0: text = "Please look at the attached image." case len(files) > 0 && len(images) == 0: text = "Please look at the attached file." default: text = "Please look at the attached files." } } slog.Debug("max: message received", "user", msg.Sender.Name, "chat", chatID, "text_len", len(text), "images", len(images), "files", len(files), "has_audio", audio != nil) handler := p.getHandler() if handler == nil { return } handler(p, &core.Message{ SessionKey: sessionKey, Platform: "max", MessageID: msg.Body.Mid, UserID: userID, UserName: msg.Sender.Name, Content: text, Images: images, Files: files, Audio: audio, ReplyCtx: rctx, }) } // fetchAttachments downloads every supported attachment from a MAX message // and splits them into images, files, and at most one audio blob. Audio is // returned separately so the core engine can route it through the speech // transcription pipeline instead of exposing a raw .mp3 to the agent. // Unsupported types (sticker, share, contact) are silently dropped. func (p *Platform) fetchAttachments(ctx context.Context, atts []maxAttachmentRaw) ([]core.ImageAttachment, []core.FileAttachment, *core.AudioAttachment) { if len(atts) == 0 { return nil, nil, nil } var images []core.ImageAttachment var files []core.FileAttachment var audio *core.AudioAttachment for _, a := range atts { switch a.Type { case "image": data, mime, err := p.downloadAttachment(ctx, a.Payload.URL) if err != nil { slog.Warn("max: image download failed", "error", err) continue } if mime == "" || mime == "application/octet-stream" { mime = sniffImageMime(data) } images = append(images, core.ImageAttachment{ MimeType: mime, Data: data, FileName: a.Filename, }) case "file": url := a.Payload.URL if url == "" { slog.Warn("max: file attachment without payload.url, skipping", "filename", a.Filename) continue } data, mime, err := p.downloadAttachment(ctx, url) if err != nil { slog.Warn("max: file download failed", "error", err, "filename", a.Filename) continue } effectiveMime := mime if effectiveMime == "" || effectiveMime == "application/octet-stream" { if sniffed := sniffImageMime(data); sniffed != "application/octet-stream" { effectiveMime = sniffed } } if strings.HasPrefix(effectiveMime, "image/") { images = append(images, core.ImageAttachment{ MimeType: effectiveMime, Data: data, FileName: a.Filename, }) continue } if strings.HasPrefix(effectiveMime, "audio/") && audio == nil { audio = &core.AudioAttachment{ MimeType: effectiveMime, Data: data, Format: audioFormatFromMime(effectiveMime, a.Filename), } continue } if effectiveMime == "" { effectiveMime = "application/octet-stream" } files = append(files, core.FileAttachment{ MimeType: effectiveMime, Data: data, FileName: a.Filename, }) case "audio": url := a.Payload.URL if url == "" { url, _, _ = p.resolveMediaURL(ctx, a.Type, a.Payload.Token) } if url == "" { slog.Warn("max: audio has no download URL") continue } data, mime, err := p.downloadAttachment(ctx, url) if err != nil { slog.Warn("max: audio download failed", "error", err) continue } if audio == nil { audio = &core.AudioAttachment{ MimeType: mime, Data: data, Format: audioFormatFromMime(mime, a.Filename), } } case "video": url := a.Payload.URL if url == "" { url, _, _ = p.resolveMediaURL(ctx, a.Type, a.Payload.Token) } if url == "" { slog.Warn("max: video has no download URL") continue } data, mime, err := p.downloadAttachment(ctx, url) if err != nil { slog.Warn("max: video download failed", "error", err) continue } fname := a.Filename files = append(files, core.FileAttachment{ MimeType: mime, Data: data, FileName: fname, }) default: slog.Debug("max: skipping unsupported attachment type", "type", a.Type) } } return images, files, audio } // audioFormatFromMime derives the short format hint ("ogg", "mp3", "m4a", …) // expected by core.AudioAttachment.Format. MAX voice messages are typically // ogg/opus; audio files uploaded via paperclip can be anything. func audioFormatFromMime(mime, filename string) string { if mime != "" { if i := strings.Index(mime, "/"); i >= 0 { sub := mime[i+1:] switch sub { case "mpeg": return "mp3" case "mp4", "x-m4a": return "m4a" case "ogg", "webm", "wav": return sub } if sub != "" { return sub } } } if filename != "" { if i := strings.LastIndex(filename, "."); i >= 0 && i < len(filename)-1 { return strings.ToLower(filename[i+1:]) } } return "ogg" } // downloadAttachment GETs an arbitrary URL (typically a pre-signed CDN link // from MAX), capping the response at maxAttachmentBytes. The URLs MAX serves // for image/file payloads are already authenticated, so no bot token is // attached to the request. func (p *Platform) downloadAttachment(ctx context.Context, url string) ([]byte, string, error) { if url == "" { return nil, "", fmt.Errorf("empty url") } dlCtx, cancel := context.WithTimeout(ctx, attachmentDownloadTO) defer cancel() req, err := http.NewRequestWithContext(dlCtx, http.MethodGet, url, nil) if err != nil { return nil, "", err } resp, err := p.client.Do(req) if err != nil { return nil, "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode) } data, err := io.ReadAll(io.LimitReader(resp.Body, maxAttachmentBytes+1)) if err != nil { return nil, "", err } if len(data) > maxAttachmentBytes { return nil, "", fmt.Errorf("attachment exceeds %d bytes", maxAttachmentBytes) } return data, resp.Header.Get("Content-Type"), nil } // resolveMediaURL asks MAX for the playable/downloadable URL of a video or // audio attachment. MAX delivers only an opaque token in the message payload // and exposes /videos/{token} and /audios/{token} for resolution. func (p *Platform) resolveMediaURL(ctx context.Context, kind, token string) (string, string, error) { if token == "" { return "", "", fmt.Errorf("empty token") } endpoint := "/videos/" if kind == "audio" { endpoint = "/audios/" } req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.apiBase+endpoint+token, nil) if err != nil { return "", "", err } p.setAuth(req) resp, err := p.client.Do(req) if err != nil { return "", "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, body) } var info struct { URL string `json:"url"` Files struct { MP4 struct { URL string `json:"url"` } `json:"mp4"` } `json:"files"` Filename string `json:"filename"` } if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { return "", "", err } url := info.URL if url == "" { url = info.Files.MP4.URL } return url, info.Filename, nil } // sniffImageMime is a tiny fallback when the CDN returned no Content-Type. func sniffImageMime(data []byte) string { if len(data) >= 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G' { return "image/png" } if len(data) >= 2 && data[0] == 0xFF && data[1] == 0xD8 { return "image/jpeg" } if len(data) >= 4 && string(data[:4]) == "GIF8" { return "image/gif" } if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" { return "image/webp" } return "application/octet-stream" } func (p *Platform) handleCallback(ctx context.Context, cb *maxCallback) { if p.dedup.IsDuplicate(cb.CallbackID) { slog.Debug("max: duplicate callback ignored", "callback_id", cb.CallbackID) return } userID := strconv.FormatInt(cb.User.UserID, 10) if !core.AllowList(p.allowFrom, userID) { slog.Debug("max: callback from unauthorized user", "user_id", userID) return } chatID := strconv.FormatInt(cb.Message.Recipient.ChatID, 10) sessionKey := fmt.Sprintf("max:%s:%s", chatID, userID) rctx := replyContext{chatID: chatID, messageID: cb.Message.Body.Mid} slog.Debug("max: callback received", "user", cb.User.Name, "payload", cb.Payload) handler := p.getHandler() if handler == nil { return } handler(p, &core.Message{ SessionKey: sessionKey, Platform: "max", MessageID: cb.CallbackID, UserID: userID, UserName: cb.User.Name, Content: cb.Payload, ReplyCtx: rctx, }) } // --- HTTP helpers --- // normalizeLineBreaks converts single newlines to markdown hard breaks // (two trailing spaces + \n). MAX markdown parser renders a bare \n as // literal `'n` on the client; CommonMark spec treats a single \n as just // whitespace, so we explicitly mark intended line breaks. Paragraph breaks // (consecutive newlines) are preserved, and fenced code blocks are left // untouched so code indentation stays intact. func normalizeLineBreaks(s string) string { if !strings.Contains(s, "\n") { return s } lines := strings.Split(s, "\n") var sb strings.Builder sb.Grow(len(s) + len(lines)*2) inCodeBlock := false for i, line := range lines { isFence := strings.HasPrefix(strings.TrimSpace(line), "```") if isFence { inCodeBlock = !inCodeBlock } sb.WriteString(line) if i < len(lines)-1 { next := lines[i+1] // Do not add hard break when: inside code block, on a fence // line, empty line, empty next line, or already has trailing // double-space (hard break) / backslash (escaped break). if !inCodeBlock && !isFence && line != "" && next != "" && !strings.HasSuffix(line, " ") && !strings.HasSuffix(line, `\`) { sb.WriteString(" ") } sb.WriteByte('\n') } } return sb.String() } func (p *Platform) sendText(ctx context.Context, replyCtx any, content string, buttons [][]maxButton) error { rctx, ok := replyCtx.(replyContext) if !ok { return fmt.Errorf("max: unexpected replyCtx type %T", replyCtx) } var kbAttachments []maxOutAttachment if len(buttons) > 0 { kbAttachments = []maxOutAttachment{{ Type: "inline_keyboard", Payload: maxKbPayload{Buttons: buttons}, }} } content = normalizeLineBreaks(content) // MAX API caps body around 4000 bytes; 1500 runes ≈ 3000 bytes of Cyrillic UTF-8 // (or 1500 bytes of ASCII), staying safely under the limit for any script. const maxLen = 1500 chunks := splitMessage(content, maxLen) for i, chunk := range chunks { chunkBody := maxSendBody{Text: chunk, Format: "markdown"} if i == len(chunks)-1 && len(kbAttachments) > 0 { chunkBody.Attachments = kbAttachments } if err := p.postMessage(ctx, rctx.chatID, &chunkBody); err != nil { return err } if len(chunks) > 1 && i < len(chunks)-1 { time.Sleep(300 * time.Millisecond) } } return nil } // postMessage sends one /messages request. It is the single HTTP call used by // sendText, SendImage, SendFile — kept separate so retry/backoff for // "attachment.not.ready" lives in one place. func (p *Platform) postMessage(ctx context.Context, chatID string, body *maxSendBody) error { data, err := json.Marshal(body) if err != nil { return err } backoff := attachmentReadyDelay for attempt := 0; attempt <= attachmentReadyRetries; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiBase+"/messages", bytes.NewReader(data)) if err != nil { return err } p.setAuth(req) q := req.URL.Query() q.Set("chat_id", chatID) req.URL.RawQuery = q.Encode() req.Header.Set("Content-Type", "application/json") resp, err := p.client.Do(req) if err != nil { return fmt.Errorf("max: send message: %w", err) } respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) resp.Body.Close() if resp.StatusCode == http.StatusOK { return nil } if isAttachmentNotReady(respBody) && attempt < attachmentReadyRetries { slog.Debug("max: attachment not ready, retrying", "attempt", attempt+1, "backoff", backoff) select { case <-ctx.Done(): return ctx.Err() case <-time.After(backoff): } backoff *= 2 continue } slog.Warn("max: send message failed", "status", resp.StatusCode, "chat", chatID, "body", string(respBody)) return fmt.Errorf("max: send message: HTTP %d: %s", resp.StatusCode, respBody) } return fmt.Errorf("max: send message: attachment not ready after %d retries", attachmentReadyRetries) } func isAttachmentNotReady(body []byte) bool { return bytes.Contains(body, []byte("attachment.not.ready")) || bytes.Contains(body, []byte("not.ready")) } func (p *Platform) getMe(ctx context.Context) (name string, id int64, err error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.apiBase+"/me", nil) if err != nil { return "", 0, err } p.setAuth(req) resp, err := p.client.Do(req) if err != nil { return "", 0, err } defer resp.Body.Close() var info struct { Name string `json:"name"` UserID int64 `json:"user_id"` } if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { return "", 0, err } return info.Name, info.UserID, nil } func (p *Platform) getHandler() core.MessageHandler { p.mu.RLock() defer p.mu.RUnlock() return p.handler } // setAuth adds the Authorization header with the bot token. func (p *Platform) setAuth(req *http.Request) { req.Header.Set("Authorization", p.token) } // splitMessage chunks long text under maxLen Unicode code points (runes). // Counting in runes (not bytes) is critical for non-ASCII content like // Cyrillic, where each character is 2 bytes in UTF-8: a byte-based cut // can split a multi-byte character mid-sequence and leave the next chunk // with a malformed leading byte that the MAX server may reject. // // Cut preference: // 1. paragraph break (consecutive \n\n) — keeps logical blocks together // 2. single newline // 3. word boundary (space) // 4. exact maxLen — rune-safe by construction // // minCut prevents tiny chunks if a low-position newline is encountered. func splitMessage(text string, maxLen int) []string { runes := []rune(text) if len(runes) <= maxLen { return []string{text} } var chunks []string for len(runes) > 0 { if len(runes) <= maxLen { chunks = append(chunks, string(runes)) break } cut := maxLen minCut := maxLen / 4 // 1. paragraph break for i := maxLen - 1; i > minCut; i-- { if runes[i] == '\n' && i+1 < len(runes) && runes[i+1] == '\n' { cut = i break } } // 2. single newline if cut == maxLen { for i := maxLen - 1; i > minCut; i-- { if runes[i] == '\n' { cut = i break } } } // 3. word boundary if cut == maxLen { for i := maxLen - 1; i > maxLen/2; i-- { if runes[i] == ' ' { cut = i break } } } // 4. fall through: cut at maxLen (rune-safe, never splits a code point) chunks = append(chunks, string(runes[:cut])) runes = runes[cut:] // trim leading whitespace on next chunk for len(runes) > 0 && (runes[0] == '\n' || runes[0] == ' ') { runes = runes[1:] } } return chunks } // Compile-time interface compliance assertions. var ( _ core.Platform = (*Platform)(nil) _ core.ImageSender = (*Platform)(nil) _ core.FileSender = (*Platform)(nil) _ core.AudioSender = (*Platform)(nil) _ core.InlineButtonSender = (*Platform)(nil) _ core.MessageUpdater = (*Platform)(nil) _ core.TypingIndicator = (*Platform)(nil) _ core.FormattingInstructionProvider = (*Platform)(nil) _ core.ReplyContextReconstructor = (*Platform)(nil) )