1539 lines
46 KiB
Go
1539 lines
46 KiB
Go
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 = "<bot-token>"
|
||
// allow_from = "<user_id>,<user_id>" # 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 = "<random-string>" # 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=<kind>, 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": {"<photo_id>": {"token": "..."}}}
|
||
// file: {"token": "..."}
|
||
// video/audio: "<retval>1</retval>" (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)
|
||
)
|