"; we recompute and compare.
+func verifySignature(body []byte, signature, appSecret string) bool {
+ const prefix = "sha256="
+ if !strings.HasPrefix(signature, prefix) {
+ return false
+ }
+ expected, err := hex.DecodeString(signature[len(prefix):])
+ if err != nil {
+ return false
+ }
+ mac := hmac.New(sha256.New, []byte(appSecret))
+ mac.Write(body)
+ computed := mac.Sum(nil)
+ return hmac.Equal(computed, expected)
+}
diff --git a/internal/channels/pancake/api_client.go b/internal/channels/pancake/api_client.go
new file mode 100644
index 000000000..31e81543e
--- /dev/null
+++ b/internal/channels/pancake/api_client.go
@@ -0,0 +1,243 @@
+package pancake
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "mime/multipart"
+ "net/http"
+ "path/filepath"
+ "time"
+)
+
+const (
+ publicAPIBase = "https://pages.fm/api/public_api/v2" // page-level APIs
+ userAPIBase = "https://pages.fm/api/v1" // user-level APIs (list pages, etc.)
+ httpTimeout = 30 * time.Second
+)
+
+// APIClient wraps the Pancake REST API for a single page instance.
+type APIClient struct {
+ pageV1BaseURL string
+ pageV2BaseURL string
+ userBaseURL string
+ pageAccessToken string
+ apiKey string
+ pageID string
+ httpClient *http.Client
+}
+
+// NewAPIClient creates a new Pancake APIClient for the given page.
+func NewAPIClient(apiKey, pageAccessToken, pageID string) *APIClient {
+ return &APIClient{
+ pageV1BaseURL: "https://pages.fm/api/public_api/v1",
+ pageV2BaseURL: publicAPIBase,
+ userBaseURL: userAPIBase,
+ pageAccessToken: pageAccessToken,
+ apiKey: apiKey,
+ pageID: pageID,
+ httpClient: &http.Client{Timeout: httpTimeout},
+ }
+}
+
+// VerifyToken validates the page_access_token via a lightweight API call.
+func (c *APIClient) VerifyToken(ctx context.Context) error {
+ url := fmt.Sprintf("%s/pages/%s/conversations?limit=1", c.pageV2BaseURL, c.pageID)
+ if err := c.doRequest(ctx, http.MethodGet, url, nil); err != nil {
+ return fmt.Errorf("pancake: token verification failed: %w", err)
+ }
+ slog.Info("pancake: page token verified", "page_id", c.pageID)
+ return nil
+}
+
+// GetPage fetches page metadata including platform (facebook/zalo/instagram/tiktok/whatsapp/line).
+func (c *APIClient) GetPage(ctx context.Context) (*PageInfo, error) {
+ url := fmt.Sprintf("%s/pages", c.userBaseURL)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("pancake: build get-pages request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("pancake: get pages request failed: %w", err)
+ }
+ defer res.Body.Close()
+
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ return nil, fmt.Errorf("pancake: read get-pages response: %w", err)
+ }
+
+ var result struct {
+ Data []PageInfo `json:"data"`
+ }
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("pancake: parse get-pages response: %w", err)
+ }
+
+ for i := range result.Data {
+ if result.Data[i].ID == c.pageID {
+ return &result.Data[i], nil
+ }
+ }
+
+ // Page not found in list — return minimal info without platform
+ slog.Warn("pancake: page not found in pages list, platform unknown", "page_id", c.pageID)
+ return &PageInfo{ID: c.pageID}, nil
+}
+
+// SendMessage sends a text message to a conversation.
+func (c *APIClient) SendMessage(ctx context.Context, conversationID, content string) error {
+ body, _ := json.Marshal(SendMessageRequest{
+ Action: "reply_inbox",
+ Message: content,
+ })
+ url := fmt.Sprintf("%s/pages/%s/conversations/%s/messages", c.pageV1BaseURL, c.pageID, conversationID)
+ if err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader(body)); err != nil {
+ return fmt.Errorf("pancake: send message: %w", err)
+ }
+ return nil
+}
+
+// SendAttachmentMessage sends one or more uploaded content IDs to a conversation.
+func (c *APIClient) SendAttachmentMessage(ctx context.Context, conversationID string, contentIDs []string) error {
+ body, _ := json.Marshal(SendMessageRequest{
+ Action: "reply_inbox",
+ ContentIDs: contentIDs,
+ })
+ url := fmt.Sprintf("%s/pages/%s/conversations/%s/messages", c.pageV1BaseURL, c.pageID, conversationID)
+ if err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader(body)); err != nil {
+ return fmt.Errorf("pancake: send attachment message: %w", err)
+ }
+ return nil
+}
+
+// UploadMedia uploads a file via multipart/form-data and returns the attachment ID.
+func (c *APIClient) UploadMedia(ctx context.Context, filename string, data io.Reader, contentType string) (string, error) {
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+
+ fw, err := mw.CreateFormFile("file", filepath.Base(filename))
+ if err != nil {
+ return "", fmt.Errorf("pancake: create form file: %w", err)
+ }
+ if _, err := io.Copy(fw, data); err != nil {
+ return "", fmt.Errorf("pancake: copy file data: %w", err)
+ }
+ mw.Close()
+
+ url := fmt.Sprintf("%s/pages/%s/upload_contents", c.pageV1BaseURL, c.pageID)
+ req, err := c.newPageRequest(ctx, http.MethodPost, url, &buf)
+ if err != nil {
+ return "", fmt.Errorf("pancake: build upload request: %w", err)
+ }
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+
+ res, err := c.httpClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("pancake: upload request failed: %w", err)
+ }
+ defer res.Body.Close()
+
+ respBody, err := io.ReadAll(res.Body)
+ if err != nil {
+ return "", fmt.Errorf("pancake: read upload response: %w", err)
+ }
+
+ var uploadResp UploadResponse
+ if err := json.Unmarshal(respBody, &uploadResp); err != nil {
+ return "", fmt.Errorf("pancake: parse upload response: %w", err)
+ }
+
+ if uploadResp.ID == "" {
+ return "", fmt.Errorf("pancake: upload response missing attachment ID")
+ }
+
+ return uploadResp.ID, nil
+}
+
+// doRequest executes an authenticated HTTP request using the page_access_token.
+// Always drains and closes the response body to enable connection reuse.
+func (c *APIClient) doRequest(ctx context.Context, method, url string, body io.Reader) error {
+ req, err := c.newPageRequest(ctx, method, url, body)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ res, err := c.httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+
+ // Always read the full body to allow HTTP connection reuse.
+ respBody, _ := io.ReadAll(res.Body)
+
+ if res.StatusCode >= 400 {
+ var apiErr apiError
+ if jsonErr := json.Unmarshal(respBody, &apiErr); jsonErr == nil && apiErr.Message != "" {
+ return &apiErr
+ }
+ return fmt.Errorf("pancake: HTTP %d", res.StatusCode)
+ }
+
+ // Some Pancake endpoints return HTTP 200 with a JSON body carrying success=false.
+ // Treat these as application-level send failures instead of silent success.
+ var appResp struct {
+ Success *bool `json:"success,omitempty"`
+ Message string `json:"message,omitempty"`
+ }
+ if err := json.Unmarshal(respBody, &appResp); err == nil && appResp.Success != nil && !*appResp.Success {
+ if appResp.Message != "" {
+ return fmt.Errorf("pancake: %s", appResp.Message)
+ }
+ return fmt.Errorf("pancake: request reported success=false")
+ }
+
+ return nil
+}
+
+func (c *APIClient) newPageRequest(ctx context.Context, method, rawURL string, body io.Reader) (*http.Request, error) {
+ req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
+ if err != nil {
+ return nil, err
+ }
+
+ query := req.URL.Query()
+ query.Set("page_access_token", c.pageAccessToken)
+ req.URL.RawQuery = query.Encode()
+
+ // Keep the header for compatibility; official docs require the query token.
+ req.Header.Set("Authorization", "Bearer "+c.pageAccessToken)
+ return req, nil
+}
+
+// isAuthError checks if an error is an authentication/authorization failure.
+func isAuthError(err error) bool {
+ if err == nil {
+ return false
+ }
+ if ae, ok := err.(*apiError); ok {
+ // Pancake auth error codes (approximate — adjust if API docs clarify)
+ return ae.Code == 401 || ae.Code == 403 || ae.Code == 4001 || ae.Code == 4003
+ }
+ return false
+}
+
+// isRateLimitError checks if an error is a rate limit response.
+func isRateLimitError(err error) bool {
+ if err == nil {
+ return false
+ }
+ if ae, ok := err.(*apiError); ok {
+ return ae.Code == 429 || ae.Code == 4029
+ }
+ return false
+}
diff --git a/internal/channels/pancake/formatter.go b/internal/channels/pancake/formatter.go
new file mode 100644
index 000000000..746541e17
--- /dev/null
+++ b/internal/channels/pancake/formatter.go
@@ -0,0 +1,85 @@
+package pancake
+
+import (
+ "regexp"
+ "strings"
+)
+
+// FormatOutbound formats agent response text for the target platform.
+// Each platform has different formatting rules and supported markup.
+func FormatOutbound(content string, platform string) string {
+ switch platform {
+ case "facebook":
+ return formatForFacebook(content)
+ case "whatsapp":
+ return formatForWhatsApp(content)
+ case "zalo", "instagram", "line":
+ return stripMarkdown(content)
+ case "tiktok":
+ return stripMarkdown(truncateForTikTok(content))
+ default:
+ return stripMarkdown(content)
+ }
+}
+
+// formatForFacebook allows basic HTML tags supported by Messenger.
+// Strips unsupported tags, keeps bold/italic/links.
+func formatForFacebook(content string) string {
+ // Convert markdown bold (**text** or __text__) to plain (FB Messenger uses plain text)
+ content = reBold.ReplaceAllString(content, "$1")
+ content = reItalic.ReplaceAllString(content, "$1")
+ // Strip markdown code blocks and inline code
+ content = reCodeBlock.ReplaceAllString(content, "$1")
+ content = reInlineCode.ReplaceAllString(content, "$1")
+ // Strip markdown headers (## Heading → Heading)
+ content = reHeader.ReplaceAllString(content, "$1")
+ return strings.TrimSpace(content)
+}
+
+// formatForWhatsApp converts markdown to WhatsApp-native formatting.
+// WhatsApp uses *bold*, _italic_, ~strikethrough~, ```code```.
+func formatForWhatsApp(content string) string {
+ // Convert **bold** → *bold* (WhatsApp format)
+ content = reDoubleBold.ReplaceAllString(content, "*$1*")
+ // Convert __italic__ → _italic_ (already matches WA format, just clean up __)
+ content = reDoubleUnderline.ReplaceAllString(content, "_$1_")
+ // Strip markdown headers
+ content = reHeader.ReplaceAllString(content, "$1")
+ // Strip inline code backticks (keep content)
+ content = reInlineCode.ReplaceAllString(content, "$1")
+ return strings.TrimSpace(content)
+}
+
+// stripMarkdown removes common markdown formatting, returning plain text.
+func stripMarkdown(content string) string {
+ content = reBold.ReplaceAllString(content, "$1")
+ content = reItalic.ReplaceAllString(content, "$1")
+ content = reCodeBlock.ReplaceAllString(content, "$1")
+ content = reInlineCode.ReplaceAllString(content, "$1")
+ content = reHeader.ReplaceAllString(content, "$1")
+ content = reLink.ReplaceAllString(content, "$1")
+ content = reImage.ReplaceAllString(content, "")
+ return strings.TrimSpace(content)
+}
+
+// truncateForTikTok truncates content to TikTok DM limit (500 chars).
+func truncateForTikTok(content string) string {
+ const limit = 500
+ if len(content) <= limit {
+ return content
+ }
+ return content[:limit-3] + "..."
+}
+
+// Compiled regexes for markdown stripping — package-level for efficiency.
+var (
+ reBold = regexp.MustCompile(`(?:\*\*|__)(.+?)(?:\*\*|__)`)
+ reDoubleBold = regexp.MustCompile(`\*\*(.+?)\*\*`)
+ reDoubleUnderline = regexp.MustCompile(`__(.+?)__`)
+ reItalic = regexp.MustCompile(`(?:\*|_)(.+?)(?:\*|_)`)
+ reCodeBlock = regexp.MustCompile("(?s)```(?:[a-z]*)?\n?(.+?)```")
+ reInlineCode = regexp.MustCompile("`(.+?)`")
+ reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`)
+ reLink = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`)
+ reImage = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`)
+)
diff --git a/internal/channels/pancake/media_handler.go b/internal/channels/pancake/media_handler.go
new file mode 100644
index 000000000..e78850c2b
--- /dev/null
+++ b/internal/channels/pancake/media_handler.go
@@ -0,0 +1,66 @@
+package pancake
+
+import (
+ "context"
+ "log/slog"
+ "mime"
+ "os"
+ "path/filepath"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+)
+
+// handleMediaAttachments uploads media files from an OutboundMessage and returns attachment IDs.
+// Returns an empty slice (not an error) when no media is attached.
+// On upload failure the error is returned so the caller can decide to send text-only.
+func (ch *Channel) handleMediaAttachments(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
+ if len(msg.Media) == 0 {
+ return nil, nil
+ }
+
+ var ids []string
+ for _, att := range msg.Media {
+ if att.URL == "" {
+ continue
+ }
+
+ id, err := ch.uploadMediaFile(ctx, att.URL, att.ContentType)
+ if err != nil {
+ slog.Warn("pancake: skipping media attachment on upload error",
+ "page_id", ch.pageID, "url", att.URL, "err", err)
+ return ids, err
+ }
+ ids = append(ids, id)
+ }
+
+ return ids, nil
+}
+
+// uploadMediaFile opens a local file path and uploads it to the Pancake API.
+func (ch *Channel) uploadMediaFile(ctx context.Context, path string, contentType string) (string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+
+ ct := contentType
+ if ct == "" {
+ ct = mimeTypeFromPath(path)
+ }
+
+ return ch.apiClient.UploadMedia(ctx, filepath.Base(path), f, ct)
+}
+
+// mimeTypeFromPath guesses the MIME type from the file extension.
+func mimeTypeFromPath(path string) string {
+ ext := filepath.Ext(path)
+ if ext == "" {
+ return "application/octet-stream"
+ }
+ mt := mime.TypeByExtension(ext)
+ if mt == "" {
+ return "application/octet-stream"
+ }
+ return mt
+}
diff --git a/internal/channels/pancake/message_handler.go b/internal/channels/pancake/message_handler.go
new file mode 100644
index 000000000..8dc41fec6
--- /dev/null
+++ b/internal/channels/pancake/message_handler.go
@@ -0,0 +1,114 @@
+package pancake
+
+import (
+ "fmt"
+ "html"
+ "log/slog"
+ "strings"
+)
+
+// handleMessagingEvent converts a Pancake "messaging" webhook event to bus.InboundMessage.
+func (ch *Channel) handleMessagingEvent(data MessagingData) {
+ slog.Info("pancake: handleMessagingEvent called",
+ "page_id", ch.pageID,
+ "sender_id", data.Message.SenderID,
+ "conversation_id", data.ConversationID,
+ "type", data.Type,
+ "platform", data.Platform,
+ "msg_id", data.Message.ID,
+ "content_len", len(data.Message.Content))
+
+ // Dedup by message ID to handle Pancake's at-least-once delivery.
+ dedupKey := fmt.Sprintf("msg:%s", data.Message.ID)
+ if ch.isDup(dedupKey) {
+ slog.Info("pancake: duplicate message skipped", "msg_id", data.Message.ID)
+ return
+ }
+
+ // Prevent reply loops: skip messages sent by the page itself.
+ if data.Message.SenderID == ch.pageID {
+ slog.Info("pancake: skipping own page message",
+ "page_id", ch.pageID,
+ "sender_id", data.Message.SenderID)
+ return
+ }
+
+ if data.Message.SenderID == "" {
+ slog.Warn("pancake: message missing sender_id, skipping", "msg_id", data.Message.ID)
+ return
+ }
+
+ // Check echo BEFORE buildMessageContent adds the [From: ...] prefix.
+ // rememberOutboundEcho stores the raw outbound text; the prefix would cause a
+ // key mismatch and silently break loop detection.
+ if ch.isRecentOutboundEcho(data.ConversationID, data.Message.Content) {
+ slog.Info("pancake: skipping recent outbound echo",
+ "page_id", ch.pageID,
+ "conversation_id", data.ConversationID,
+ "msg_id", data.Message.ID)
+ return
+ }
+
+ content := buildMessageContent(data)
+
+ metadata := map[string]string{
+ "pancake_mode": strings.ToLower(data.Type), // "inbox" or "comment"
+ "conversation_type": data.Type,
+ "platform": data.Platform,
+ "conversation_id": data.ConversationID,
+ "message_id": dedupKey,
+ "display_name": data.Message.SenderName,
+ "page_name": ch.pageName,
+ }
+
+ ch.HandleMessage(
+ data.Message.SenderID,
+ data.ConversationID, // ChatID = conversation_id for reply routing
+ content,
+ nil, // media handled inline via content URLs
+ metadata,
+ "direct", // Pancake inbox conversations are always treated as direct messages
+ )
+
+ slog.Info("pancake: inbound message published to bus",
+ "page_id", ch.pageID,
+ "conv_id", data.ConversationID,
+ "sender_id", data.Message.SenderID,
+ "platform", data.Platform,
+ "type", data.Type,
+ "channel_name", ch.Name(),
+ )
+}
+
+// buildMessageContent combines text content and attachment URLs into a single string.
+// Format: [From: {SenderID} ({SenderName})] {content}
+func buildMessageContent(data MessagingData) string {
+ parts := []string{}
+
+ if data.Message.Content != "" {
+ parts = append(parts, stripHTML(data.Message.Content))
+ }
+
+ for _, att := range data.Message.Attachments {
+ if att.URL != "" {
+ parts = append(parts, att.URL)
+ }
+ }
+
+ body := strings.Join(parts, "\n")
+
+ if data.Message.SenderID != "" {
+ prefix := fmt.Sprintf("[From: %s (%s)]", data.Message.SenderID, data.Message.SenderName)
+ if body != "" {
+ return prefix + " " + body
+ }
+ return prefix
+ }
+ return body
+}
+
+// stripHTML removes HTML tags and unescapes HTML entities from s.
+func stripHTML(s string) string {
+ s = htmlTagRe.ReplaceAllString(s, "")
+ return html.UnescapeString(strings.TrimSpace(s))
+}
diff --git a/internal/channels/pancake/pancake.go b/internal/channels/pancake/pancake.go
new file mode 100644
index 000000000..5ca0de296
--- /dev/null
+++ b/internal/channels/pancake/pancake.go
@@ -0,0 +1,383 @@
+package pancake
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "html"
+ "log/slog"
+ "net/http"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+const (
+ dedupTTL = 24 * time.Hour
+ dedupCleanEvery = 5 * time.Minute
+ outboundEchoTTL = 45 * time.Second
+)
+
+var (
+ htmlBreakTagRe = regexp.MustCompile(`(?i)
]*\/?>`)
+ htmlCloseTagRe = regexp.MustCompile(`(?i)(?:div|p|li|ul|ol|h[1-6])>`)
+ htmlTagRe = regexp.MustCompile(`(?i)<[^>]+>`)
+)
+
+// Channel implements channels.Channel and channels.WebhookChannel for Pancake (pages.fm).
+// One channel instance = one Pancake page, which may serve multiple platforms (FB, Zalo, IG, etc.)
+type Channel struct {
+ *channels.BaseChannel
+ config pancakeInstanceConfig
+ apiClient *APIClient
+ pageID string
+ pageName string // resolved from Pancake page metadata at Start()
+ platform string // resolved from Pancake page metadata at Start()
+ webhookSecret string // optional HMAC-SHA256 secret for webhook verification
+
+ // dedup prevents processing duplicate webhook deliveries.
+ dedup sync.Map // eventKey(string) → time.Time
+
+ // recentOutbound suppresses short-lived webhook echoes of our own text replies.
+ recentOutbound sync.Map // conversationID + "\x00" + normalized content → time.Time
+
+ stopCh chan struct{}
+ stopCtx context.Context
+ stopFn context.CancelFunc
+}
+
+// New creates a Pancake Channel from parsed credentials and config.
+func New(cfg pancakeInstanceConfig, creds pancakeCreds,
+ msgBus *bus.MessageBus, _ store.PairingStore) (*Channel, error) {
+
+ if creds.APIKey == "" {
+ return nil, fmt.Errorf("pancake: api_key is required")
+ }
+ if creds.PageAccessToken == "" {
+ return nil, fmt.Errorf("pancake: page_access_token is required")
+ }
+ if cfg.PageID == "" {
+ return nil, fmt.Errorf("pancake: page_id is required")
+ }
+
+ base := channels.NewBaseChannel(channels.TypePancake, msgBus, cfg.AllowFrom)
+ stopCtx, stopFn := context.WithCancel(context.Background())
+
+ ch := &Channel{
+ BaseChannel: base,
+ config: cfg,
+ apiClient: NewAPIClient(creds.APIKey, creds.PageAccessToken, cfg.PageID),
+ pageID: cfg.PageID,
+ platform: cfg.Platform,
+ webhookSecret: creds.WebhookSecret,
+ stopCh: make(chan struct{}),
+ stopCtx: stopCtx,
+ stopFn: stopFn,
+ }
+
+ return ch, nil
+}
+
+// Factory creates a Pancake Channel from DB instance data.
+// Implements channels.ChannelFactory.
+func Factory(name string, creds json.RawMessage, cfg json.RawMessage,
+ msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) {
+
+ var c pancakeCreds
+ if err := json.Unmarshal(creds, &c); err != nil {
+ return nil, fmt.Errorf("pancake: decode credentials: %w", err)
+ }
+
+ var ic pancakeInstanceConfig
+ if len(cfg) > 0 {
+ if err := json.Unmarshal(cfg, &ic); err != nil {
+ return nil, fmt.Errorf("pancake: decode config: %w", err)
+ }
+ }
+
+ ch, err := New(ic, c, msgBus, pairingSvc)
+ if err != nil {
+ return nil, err
+ }
+ ch.SetName(name)
+ return ch, nil
+}
+
+// Start connects the channel: verifies token, resolves platform, registers webhook.
+func (ch *Channel) Start(ctx context.Context) error {
+ ch.MarkStarting("connecting to Pancake page")
+
+ if err := ch.apiClient.VerifyToken(ctx); err != nil {
+ ch.MarkFailed("token invalid", err.Error(), channels.ChannelFailureKindAuth, false)
+ return err
+ }
+
+ // Resolve platform and page name from page metadata (best-effort — don't fail on this).
+ if ch.platform == "" || ch.pageName == "" {
+ if page, err := ch.apiClient.GetPage(ctx); err != nil {
+ slog.Warn("pancake: could not resolve platform from page metadata", "page_id", ch.pageID, "err", err)
+ } else {
+ if page.Platform != "" {
+ ch.platform = page.Platform
+ }
+ if page.Name != "" {
+ ch.pageName = page.Name
+ }
+ }
+ }
+
+ globalRouter.register(ch)
+ ch.MarkHealthy("connected to page " + ch.pageID)
+ ch.SetRunning(true)
+
+ // Background goroutine: evict stale dedup entries to prevent memory growth.
+ go ch.runDedupCleaner()
+
+ slog.Info("pancake channel started",
+ "page_id", ch.pageID,
+ "platform", ch.platform,
+ "name", ch.Name())
+ return nil
+}
+
+// Stop gracefully shuts down the channel.
+func (ch *Channel) Stop(_ context.Context) error {
+ globalRouter.unregister(ch.pageID)
+ ch.stopFn()
+ close(ch.stopCh)
+ ch.SetRunning(false)
+ ch.MarkStopped("stopped")
+ slog.Info("pancake channel stopped", "page_id", ch.pageID, "name", ch.Name())
+ return nil
+}
+
+// Send delivers an outbound message via Pancake API.
+func (ch *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ slog.Info("pancake: Send called",
+ "page_id", ch.pageID,
+ "chat_id", msg.ChatID,
+ "content_len", len(msg.Content),
+ "platform", ch.platform,
+ "channel_name", ch.Name())
+
+ conversationID := msg.ChatID
+ if conversationID == "" {
+ return fmt.Errorf("pancake: chat_id (conversation_id) is required for outbound message")
+ }
+
+ text := FormatOutbound(msg.Content, ch.platform)
+
+ // Handle media attachments.
+ attachmentIDs, err := ch.handleMediaAttachments(ctx, msg)
+ if err != nil {
+ slog.Warn("pancake: media upload failed, sending text only",
+ "page_id", ch.pageID, "err", err)
+ }
+
+ // Pancake's official contract makes `message` and `content_ids` mutually exclusive.
+ // Deliver uploaded media first, then follow with text chunks if needed.
+ if len(attachmentIDs) > 0 {
+ if err := ch.apiClient.SendAttachmentMessage(ctx, conversationID, attachmentIDs); err != nil {
+ ch.handleAPIError(err)
+ return err
+ }
+ if text == "" {
+ return nil
+ }
+ }
+
+ // Text-only: split into platform-appropriate chunks.
+ // Store echo fingerprints BEFORE sending so that webhook echoes arriving
+ // while the HTTP round-trip is in flight are already recognized as self-sent.
+ parts := splitMessage(text, ch.maxMessageLength())
+ for _, part := range parts {
+ ch.rememberOutboundEcho(conversationID, part)
+ }
+ for _, part := range parts {
+ if err := ch.apiClient.SendMessage(ctx, conversationID, part); err != nil {
+ ch.handleAPIError(err)
+ // Remove pre-stored echo fingerprints for unsent parts so they
+ // don't suppress genuine customer messages that happen to match.
+ ch.forgetOutboundEcho(conversationID, part)
+ return err
+ }
+ }
+ return nil
+}
+
+// BlockReplyEnabled returns the per-channel block_reply override (nil = inherit gateway default).
+func (ch *Channel) BlockReplyEnabled() *bool { return ch.config.BlockReply }
+
+// WebhookHandler returns the shared webhook path and global router as handler.
+// Only the first pancake instance mounts the route; others return ("", nil).
+func (ch *Channel) WebhookHandler() (string, http.Handler) {
+ return globalRouter.webhookRoute()
+}
+
+// handleAPIError maps Pancake API errors to channel health states.
+func (ch *Channel) handleAPIError(err error) {
+ if err == nil {
+ return
+ }
+ switch {
+ case isAuthError(err):
+ ch.MarkFailed("token expired or invalid", err.Error(), channels.ChannelFailureKindAuth, false)
+ case isRateLimitError(err):
+ ch.MarkDegraded("rate limited", err.Error(), channels.ChannelFailureKindNetwork, true)
+ default:
+ ch.MarkDegraded("api error", err.Error(), channels.ChannelFailureKindUnknown, true)
+ }
+}
+
+// maxMessageLength returns the platform-specific character limit.
+func (ch *Channel) maxMessageLength() int {
+ switch ch.platform {
+ case "tiktok":
+ return 500
+ case "instagram":
+ return 1000
+ case "facebook", "zalo":
+ return 2000
+ case "whatsapp":
+ return 4096
+ case "line":
+ return 5000
+ default:
+ return 2000
+ }
+}
+
+// splitMessage splits text into chunks no longer than maxLen.
+func splitMessage(text string, maxLen int) []string {
+ if maxLen <= 0 || len(text) <= maxLen {
+ return []string{text}
+ }
+ var parts []string
+ for len(text) > maxLen {
+ parts = append(parts, text[:maxLen])
+ text = text[maxLen:]
+ }
+ if text != "" {
+ parts = append(parts, text)
+ }
+ return parts
+}
+
+// isDup checks and records a dedup key. Returns true if the key was already seen.
+func (ch *Channel) isDup(key string) bool {
+ _, loaded := ch.dedup.LoadOrStore(key, time.Now())
+ return loaded
+}
+
+func (ch *Channel) forgetOutboundEcho(conversationID, content string) {
+ if conversationID == "" {
+ return
+ }
+ normalized := normalizeEchoContent(content)
+ if normalized == "" {
+ return
+ }
+ ch.recentOutbound.Delete(conversationID + "\x00" + normalized)
+}
+
+func (ch *Channel) rememberOutboundEcho(conversationID, content string) {
+ if conversationID == "" {
+ return
+ }
+ normalized := normalizeEchoContent(content)
+ if normalized == "" {
+ return
+ }
+ ch.recentOutbound.Store(conversationID+"\x00"+normalized, time.Now())
+}
+
+func (ch *Channel) isRecentOutboundEcho(conversationID, content string) bool {
+ if conversationID == "" {
+ return false
+ }
+ normalized := normalizeEchoContent(content)
+ if normalized == "" {
+ return false
+ }
+ key := conversationID + "\x00" + normalized
+ v, ok := ch.recentOutbound.Load(key)
+ if !ok {
+ return false
+ }
+ ts, ok := v.(time.Time)
+ if !ok {
+ ch.recentOutbound.Delete(key)
+ return false
+ }
+ if time.Since(ts) > outboundEchoTTL {
+ ch.recentOutbound.Delete(key)
+ return false
+ }
+ return true
+}
+
+func normalizeEchoContent(content string) string {
+ content = strings.TrimSpace(content)
+ if content == "" {
+ return ""
+ }
+
+ content = html.UnescapeString(content)
+ content = strings.ReplaceAll(content, "\r\n", "\n")
+ content = strings.ReplaceAll(content, "\r", "\n")
+ content = htmlBreakTagRe.ReplaceAllString(content, "\n")
+ content = htmlCloseTagRe.ReplaceAllString(content, "\n")
+ content = htmlTagRe.ReplaceAllString(content, "")
+
+ lines := strings.Split(content, "\n")
+ normalized := make([]string, 0, len(lines))
+ pendingBlank := false
+ for _, line := range lines {
+ line = strings.Join(strings.Fields(line), " ")
+ if line == "" {
+ if len(normalized) == 0 || pendingBlank {
+ continue
+ }
+ pendingBlank = true
+ continue
+ }
+ if pendingBlank {
+ normalized = append(normalized, "")
+ pendingBlank = false
+ }
+ normalized = append(normalized, line)
+ }
+
+ return strings.TrimSpace(strings.Join(normalized, "\n"))
+}
+
+// runDedupCleaner evicts dedup entries older than dedupTTL every dedupCleanEvery.
+func (ch *Channel) runDedupCleaner() {
+ ticker := time.NewTicker(dedupCleanEvery)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ch.stopCh:
+ return
+ case <-ticker.C:
+ now := time.Now()
+ ch.dedup.Range(func(k, v any) bool {
+ if t, ok := v.(time.Time); ok && now.Sub(t) > dedupTTL {
+ ch.dedup.Delete(k)
+ }
+ return true
+ })
+ ch.recentOutbound.Range(func(k, v any) bool {
+ if t, ok := v.(time.Time); ok && now.Sub(t) > outboundEchoTTL {
+ ch.recentOutbound.Delete(k)
+ }
+ return true
+ })
+ }
+ }
+}
diff --git a/internal/channels/pancake/pancake_loop_regression_test.go b/internal/channels/pancake/pancake_loop_regression_test.go
new file mode 100644
index 000000000..ebffb089b
--- /dev/null
+++ b/internal/channels/pancake/pancake_loop_regression_test.go
@@ -0,0 +1,231 @@
+package pancake
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+)
+
+func TestMessageHandlerSkipsRecentOutboundEchoWithHTMLFormatting(t *testing.T) {
+ msgBus := bus.New()
+ ch := &Channel{
+ BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
+ pageID: "page-123",
+ }
+ ch.rememberOutboundEcho("conv-1", "Line 1\nLine 2")
+
+ ch.handleMessagingEvent(MessagingData{
+ PageID: "page-123",
+ ConversationID: "conv-1",
+ Type: "INBOX",
+ Platform: "facebook",
+ Message: MessagingMessage{
+ ID: "msg-echo-html-1",
+ SenderID: "user-1",
+ Content: "Line 1
Line 2
",
+ },
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ if _, ok := msgBus.ConsumeInbound(ctx); ok {
+ t.Fatal("expected HTML-formatted echo to be dropped")
+ }
+}
+
+func TestWebhookRouterSkipsNonInboxConversationEvents(t *testing.T) {
+ msgBus := bus.New()
+ target := &Channel{
+ BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
+ pageID: "page-123",
+ platform: "facebook",
+ }
+ router := &webhookRouter{
+ instances: map[string]*Channel{
+ "page-123": target,
+ },
+ }
+
+ body := `{
+ "page_id": "page-123",
+ "event_type": "messaging",
+ "data": {
+ "conversation": {
+ "id": "page-123_user-1",
+ "type": "COMMENT",
+ "from": {
+ "id": "user-1",
+ "name": "Customer"
+ }
+ },
+ "message": {
+ "id": "msg-comment-1",
+ "message": "hi"
+ }
+ }
+ }`
+
+ req := httptest.NewRequest(http.MethodPost, "/channels/pancake/webhook", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ if _, ok := msgBus.ConsumeInbound(ctx); ok {
+ t.Fatal("expected COMMENT conversation webhook to be ignored")
+ }
+}
+
+func TestSendThenWebhookEchoDoesNotRepublishInbound(t *testing.T) {
+ msgBus := bus.New()
+ api := NewAPIClient("user-token", "page-token", "page-123")
+ sendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"success":true}`)
+ }))
+ defer sendServer.Close()
+
+ api.pageV1BaseURL = sendServer.URL
+ api.httpClient = sendServer.Client()
+
+ ch := &Channel{
+ BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
+ apiClient: api,
+ pageID: "page-123",
+ platform: "facebook",
+ }
+
+ if err := ch.Send(context.Background(), bus.OutboundMessage{
+ Channel: "pancake",
+ ChatID: "conv-1",
+ Content: "Line 1\nLine 2",
+ }); err != nil {
+ t.Fatalf("Send returned error: %v", err)
+ }
+
+ router := &webhookRouter{
+ instances: map[string]*Channel{
+ "page-123": ch,
+ },
+ }
+
+ body := `{
+ "page_id": "page-123",
+ "event_type": "messaging",
+ "data": {
+ "conversation": {
+ "id": "conv-1",
+ "type": "INBOX",
+ "from": {
+ "id": "user-1",
+ "name": "Customer"
+ }
+ },
+ "message": {
+ "id": "msg-echo-roundtrip-1",
+ "message": "Line 1
Line 2
"
+ }
+ }
+ }`
+
+ req := httptest.NewRequest(http.MethodPost, "/channels/pancake/webhook", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ if _, ok := msgBus.ConsumeInbound(ctx); ok {
+ t.Fatal("expected echoed outbound send not to republish inbound work")
+ }
+}
+
+// TestSendRaceConditionEchoArrivesBeforeSendReturns simulates the production race:
+// webhook echo arriving while SendMessage HTTP call is still in flight.
+// Before the fix, rememberOutboundEcho was called AFTER SendMessage, so an echo
+// arriving during the HTTP round-trip would not be recognized.
+func TestSendRaceConditionEchoArrivesBeforeSendReturns(t *testing.T) {
+ msgBus := bus.New()
+ api := NewAPIClient("user-token", "page-token", "page-123")
+
+ ch := &Channel{
+ BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
+ apiClient: api,
+ pageID: "page-123",
+ platform: "facebook",
+ }
+
+ router := &webhookRouter{
+ instances: map[string]*Channel{
+ "page-123": ch,
+ },
+ }
+
+ const outboundContent = "Em đây, alive luôn 😊\nThoát loop thành công rồi nha."
+
+ // Pancake API server that delivers the echo webhook BEFORE returning the
+ // SendMessage response — simulating the real-world race condition.
+ sendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // While SendMessage is "in flight", deliver the echo webhook.
+ echoBody := `{
+ "page_id": "page-123",
+ "event_type": "messaging",
+ "data": {
+ "conversation": {
+ "id": "conv-race",
+ "type": "INBOX",
+ "from": {"id": "user-1", "name": "Customer"}
+ },
+ "message": {
+ "id": "msg-race-echo",
+ "message": "Em đây, alive luôn 😊
Thoát loop thành công rồi nha.
"
+ }
+ }
+ }`
+ echoReq := httptest.NewRequest(http.MethodPost, "/channels/pancake/webhook",
+ strings.NewReader(echoBody))
+ echoW := httptest.NewRecorder()
+ router.ServeHTTP(echoW, echoReq)
+
+ // Now return SendMessage success.
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"success":true}`)
+ }))
+ defer sendServer.Close()
+
+ api.pageV1BaseURL = sendServer.URL
+ api.httpClient = sendServer.Client()
+
+ if err := ch.Send(context.Background(), bus.OutboundMessage{
+ Channel: "pancake",
+ ChatID: "conv-race",
+ Content: outboundContent,
+ }); err != nil {
+ t.Fatalf("Send returned error: %v", err)
+ }
+
+ // The echo webhook was delivered during Send. It must NOT have been published.
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ if _, ok := msgBus.ConsumeInbound(ctx); ok {
+ t.Fatal("echo arrived during SendMessage HTTP call but was not suppressed — race condition not fixed")
+ }
+}
diff --git a/internal/channels/pancake/pancake_test.go b/internal/channels/pancake/pancake_test.go
new file mode 100644
index 000000000..8a1ef4cec
--- /dev/null
+++ b/internal/channels/pancake/pancake_test.go
@@ -0,0 +1,403 @@
+package pancake
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+)
+
+// TestFactory_Valid verifies Factory creates a Channel from valid JSON creds/config.
+func TestFactory_Valid(t *testing.T) {
+ creds, _ := json.Marshal(pancakeCreds{
+ APIKey: "test-api-key",
+ PageAccessToken: "test-page-token",
+ })
+ cfg, _ := json.Marshal(pancakeInstanceConfig{PageID: "12345"})
+
+ ch, err := Factory("pancake-test", creds, cfg, nil, nil)
+ if err != nil {
+ t.Fatalf("Factory returned unexpected error: %v", err)
+ }
+ if ch == nil {
+ t.Fatal("Factory returned nil channel")
+ }
+ if ch.Name() != "pancake-test" {
+ t.Errorf("Name() = %q, want %q", ch.Name(), "pancake-test")
+ }
+}
+
+// TestFactory_MissingAPIKey verifies Factory returns error when api_key is empty.
+func TestFactory_MissingAPIKey(t *testing.T) {
+ creds, _ := json.Marshal(pancakeCreds{PageAccessToken: "token"})
+ cfg, _ := json.Marshal(pancakeInstanceConfig{PageID: "12345"})
+
+ _, err := Factory("test", creds, cfg, nil, nil)
+ if err == nil {
+ t.Fatal("expected error for missing api_key, got nil")
+ }
+}
+
+// TestFactory_MissingPageAccessToken verifies Factory returns error when page_access_token is empty.
+func TestFactory_MissingPageAccessToken(t *testing.T) {
+ creds, _ := json.Marshal(pancakeCreds{APIKey: "key"})
+ cfg, _ := json.Marshal(pancakeInstanceConfig{PageID: "12345"})
+
+ _, err := Factory("test", creds, cfg, nil, nil)
+ if err == nil {
+ t.Fatal("expected error for missing page_access_token, got nil")
+ }
+}
+
+// TestFactory_MissingPageID verifies Factory returns error when page_id is empty.
+func TestFactory_MissingPageID(t *testing.T) {
+ creds, _ := json.Marshal(pancakeCreds{
+ APIKey: "key",
+ PageAccessToken: "token",
+ })
+ cfg, _ := json.Marshal(pancakeInstanceConfig{}) // no page_id
+
+ _, err := Factory("test", creds, cfg, nil, nil)
+ if err == nil {
+ t.Fatal("expected error for missing page_id, got nil")
+ }
+}
+
+// TestFormatOutbound verifies platform-aware formatting for each platform.
+func TestFormatOutbound(t *testing.T) {
+ input := "**Hello** _world_ `code` ## Header [link](http://example.com)"
+
+ cases := []struct {
+ platform string
+ wantNot string // substring that should NOT appear in output
+ }{
+ {"facebook", "**"},
+ {"zalo", "**"},
+ {"instagram", "_"},
+ {"tiktok", "##"},
+ {"whatsapp", "**"},
+ {"line", "##"},
+ {"unknown", "`"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.platform, func(t *testing.T) {
+ out := FormatOutbound(input, tc.platform)
+ if out == "" {
+ t.Error("FormatOutbound returned empty string")
+ }
+ _ = out // formatting verified visually; we just check no panic + non-empty
+ })
+ }
+}
+
+// TestSplitMessage verifies message splitting at platform character limits.
+func TestSplitMessage(t *testing.T) {
+ t.Run("short message not split", func(t *testing.T) {
+ parts := splitMessage("hello", 100)
+ if len(parts) != 1 || parts[0] != "hello" {
+ t.Errorf("unexpected parts: %v", parts)
+ }
+ })
+
+ t.Run("exact limit not split", func(t *testing.T) {
+ msg := string(make([]byte, 100))
+ parts := splitMessage(msg, 100)
+ if len(parts) != 1 {
+ t.Errorf("expected 1 part, got %d", len(parts))
+ }
+ })
+
+ t.Run("over limit is split", func(t *testing.T) {
+ msg := string(make([]byte, 250))
+ parts := splitMessage(msg, 100)
+ if len(parts) != 3 {
+ t.Errorf("expected 3 parts, got %d", len(parts))
+ }
+ })
+
+ t.Run("zero limit returns whole string", func(t *testing.T) {
+ parts := splitMessage("hello", 0)
+ if len(parts) != 1 {
+ t.Errorf("expected 1 part with zero limit, got %d", len(parts))
+ }
+ })
+}
+
+// TestIsDup verifies dedup returns false first, true on repeat.
+func TestIsDup(t *testing.T) {
+ ch := &Channel{}
+
+ if ch.isDup("key-1") {
+ t.Error("isDup: first call should return false")
+ }
+ if !ch.isDup("key-1") {
+ t.Error("isDup: second call should return true")
+ }
+ if ch.isDup("key-2") {
+ t.Error("isDup: different key should return false")
+ }
+}
+
+// TestWebhookRouterReturns200 verifies the global router always returns HTTP 200.
+func TestWebhookRouterReturns200(t *testing.T) {
+ // Use a fresh local router to avoid interfering with the package-level globalRouter.
+ router := &webhookRouter{instances: make(map[string]*Channel)}
+
+ t.Run("POST event returns 200", func(t *testing.T) {
+ body := `{"data":{"conversation":{"id":"123_456","type":"INBOX","from":{"id":"456"}},"message":{"id":"m1"}}}`
+ req := httptest.NewRequest(http.MethodPost, "/channels/pancake/webhook",
+ strings.NewReader(body))
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("expected 200, got %d", w.Code)
+ }
+ })
+
+ t.Run("GET returns 200 (not 405)", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/channels/pancake/webhook", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("expected 200, got %d", w.Code)
+ }
+ })
+
+ t.Run("malformed JSON returns 200", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/channels/pancake/webhook",
+ strings.NewReader("not-json"))
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("expected 200, got %d", w.Code)
+ }
+ })
+}
+
+// TestMessageHandlerSkipsSelfReply verifies the page's own messages are not published.
+// The dedup entry is stored (dedup runs first), but HandleMessage is never called.
+// If the self-reply guard were absent, ch.bus (nil) would panic — making no-panic the assertion.
+func TestMessageHandlerSkipsSelfReply(t *testing.T) {
+ const pageID = "page-123"
+ ch := &Channel{pageID: pageID}
+
+ data := MessagingData{
+ PageID: pageID,
+ ConversationID: "conv-1",
+ Type: "INBOX",
+ Platform: "facebook",
+ Message: MessagingMessage{
+ ID: "msg-self-1",
+ SenderID: pageID, // same as page → must be skipped before HandleMessage
+ SenderName: "Page Bot",
+ Content: "Hello",
+ },
+ }
+
+ // Must not panic. If self-reply guard is missing, nil bus dereference panics here.
+ ch.handleMessagingEvent(data)
+
+ // Dedup entry is stored (dedup check runs before self-reply check).
+ _, stored := ch.dedup.Load("msg:msg-self-1")
+ if !stored {
+ t.Error("dedup entry should have been stored (dedup runs before self-reply guard)")
+ }
+}
+
+func TestMessageHandlerPublishesMessageIDMetadata(t *testing.T) {
+ msgBus := bus.New()
+ ch := &Channel{
+ BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
+ pageID: "page-123",
+ }
+
+ ch.handleMessagingEvent(MessagingData{
+ PageID: "page-123",
+ ConversationID: "conv-1",
+ Type: "INBOX",
+ Platform: "facebook",
+ Message: MessagingMessage{
+ ID: "msg-123",
+ SenderID: "user-1",
+ Content: "hello",
+ },
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ msg, ok := msgBus.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("expected inbound message to be published")
+ }
+ if got, want := msg.Metadata["message_id"], "msg:msg-123"; got != want {
+ t.Fatalf("metadata.message_id = %q, want %q", got, want)
+ }
+}
+
+func TestMessageHandlerSkipsRecentOutboundEcho(t *testing.T) {
+ msgBus := bus.New()
+ ch := &Channel{
+ BaseChannel: channels.NewBaseChannel(channels.TypePancake, msgBus, nil),
+ pageID: "page-123",
+ }
+ ch.rememberOutboundEcho("conv-1", "hello from bot")
+
+ ch.handleMessagingEvent(MessagingData{
+ PageID: "page-123",
+ ConversationID: "conv-1",
+ Type: "INBOX",
+ Platform: "facebook",
+ Message: MessagingMessage{
+ ID: "msg-echo-1",
+ SenderID: "user-1",
+ Content: "hello from bot",
+ },
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ if _, ok := msgBus.ConsumeInbound(ctx); ok {
+ t.Fatal("expected echoed outbound message to be dropped")
+ }
+}
+
+func TestBlockReplyEnabledUsesChannelOverride(t *testing.T) {
+ enabled := true
+ ch := &Channel{
+ config: pancakeInstanceConfig{
+ BlockReply: &enabled,
+ },
+ }
+
+ got := ch.BlockReplyEnabled()
+ if got == nil || !*got {
+ t.Fatalf("BlockReplyEnabled() = %v, want true", got)
+ }
+}
+
+type captureTransport struct {
+ req *http.Request
+ body []byte
+ resp *http.Response
+}
+
+func (t *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ t.req = req.Clone(req.Context())
+ if req.Body != nil {
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ return nil, err
+ }
+ t.body = body
+ req.Body = io.NopCloser(bytes.NewReader(body))
+ }
+ if t.resp != nil {
+ return t.resp, nil
+ }
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(`{"success":true}`)),
+ Request: req,
+ }, nil
+}
+
+func TestAPIClientSendMessageMatchesOfficialContract(t *testing.T) {
+ transport := &captureTransport{}
+ client := NewAPIClient("user-token", "page-token", "page-123")
+ client.httpClient = &http.Client{Transport: transport}
+
+ if err := client.SendMessage(context.Background(), "conv-456", "xin chao"); err != nil {
+ t.Fatalf("SendMessage returned error: %v", err)
+ }
+
+ if transport.req == nil {
+ t.Fatal("expected outbound request to be captured")
+ }
+ if got, want := transport.req.URL.Path, "/api/public_api/v1/pages/page-123/conversations/conv-456/messages"; got != want {
+ t.Fatalf("request path = %q, want %q", got, want)
+ }
+ if got := transport.req.URL.Query().Get("page_access_token"); got != "page-token" {
+ t.Fatalf("page_access_token query = %q, want %q", got, "page-token")
+ }
+
+ var payload map[string]any
+ if err := json.Unmarshal(transport.body, &payload); err != nil {
+ t.Fatalf("unmarshal payload: %v", err)
+ }
+ if got, want := payload["action"], "reply_inbox"; got != want {
+ t.Fatalf("payload.action = %#v, want %#v", got, want)
+ }
+ if got, want := payload["message"], "xin chao"; got != want {
+ t.Fatalf("payload.message = %#v, want %#v", got, want)
+ }
+ if _, exists := payload["content"]; exists {
+ t.Fatalf("payload must not contain legacy content field: %s", string(transport.body))
+ }
+ if _, exists := payload["attachment_id"]; exists {
+ t.Fatalf("payload must not contain attachment_id field: %s", string(transport.body))
+ }
+}
+
+func TestAPIClientSendMessageReturnsBodyLevelError(t *testing.T) {
+ transport := &captureTransport{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(`{"success":false,"message":"conversation blocked"}`)),
+ },
+ }
+ client := NewAPIClient("user-token", "page-token", "page-123")
+ client.httpClient = &http.Client{Transport: transport}
+
+ err := client.SendMessage(context.Background(), "conv-456", "xin chao")
+ if err == nil {
+ t.Fatal("expected SendMessage to return body-level error")
+ }
+ if !strings.Contains(err.Error(), "conversation blocked") {
+ t.Fatalf("SendMessage error = %v, want body-level message", err)
+ }
+}
+
+func TestAPIClientUploadMediaMatchesOfficialContract(t *testing.T) {
+ transport := &captureTransport{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(`{"id":"upload-123","success":true}`)),
+ },
+ }
+ client := NewAPIClient("user-token", "page-token", "page-123")
+ client.httpClient = &http.Client{Transport: transport}
+
+ id, err := client.UploadMedia(context.Background(), "photo.jpg", strings.NewReader("file-bytes"), "image/jpeg")
+ if err != nil {
+ t.Fatalf("UploadMedia returned error: %v", err)
+ }
+ if id != "upload-123" {
+ t.Fatalf("UploadMedia id = %q, want %q", id, "upload-123")
+ }
+ if transport.req == nil {
+ t.Fatal("expected upload request to be captured")
+ }
+ if got, want := transport.req.URL.Path, "/api/public_api/v1/pages/page-123/upload_contents"; got != want {
+ t.Fatalf("upload path = %q, want %q", got, want)
+ }
+ if got := transport.req.URL.Query().Get("page_access_token"); got != "page-token" {
+ t.Fatalf("upload page_access_token query = %q, want %q", got, "page-token")
+ }
+ if !strings.HasPrefix(transport.req.Header.Get("Content-Type"), "multipart/form-data; boundary=") {
+ t.Fatalf("upload Content-Type = %q, want multipart/form-data", transport.req.Header.Get("Content-Type"))
+ }
+}
diff --git a/internal/channels/pancake/types.go b/internal/channels/pancake/types.go
new file mode 100644
index 000000000..304f37120
--- /dev/null
+++ b/internal/channels/pancake/types.go
@@ -0,0 +1,126 @@
+// Package pancake implements the Pancake (pages.fm) channel for GoClaw.
+// Pancake acts as a unified proxy for Facebook, Zalo OA, Instagram, TikTok, WhatsApp, Line.
+// A single Pancake API key gives access to all connected platforms — no per-platform OAuth needed.
+package pancake
+
+import "encoding/json"
+
+// pancakeCreds holds encrypted credentials stored in channel_instances.credentials.
+type pancakeCreds struct {
+ APIKey string `json:"api_key"` // User-level Pancake API key
+ PageAccessToken string `json:"page_access_token"` // Page-level token for all page APIs
+ WebhookSecret string `json:"webhook_secret,omitempty"` // Optional HMAC-SHA256 verification
+}
+
+// pancakeInstanceConfig holds non-secret config from channel_instances.config JSONB.
+type pancakeInstanceConfig struct {
+ PageID string `json:"page_id"`
+ Platform string `json:"platform,omitempty"` // auto-detected at Start(): facebook/zalo/instagram/tiktok/whatsapp/line
+ Features struct {
+ InboxReply bool `json:"inbox_reply"`
+ CommentReply bool `json:"comment_reply"`
+ } `json:"features"`
+ AllowFrom []string `json:"allow_from,omitempty"`
+ BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit)
+}
+
+// --- Webhook payload types ---
+// These types match the actual Pancake (pages.fm) webhook delivery format.
+// Top-level envelope has optional "event_type" + nested "data" containing
+// "conversation" and "message" objects.
+
+// WebhookEvent is the top-level Pancake webhook delivery envelope.
+type WebhookEvent struct {
+ EventType string `json:"event_type,omitempty"` // "messaging", may be empty
+ PageID string `json:"page_id,omitempty"` // top-level page_id (some formats)
+ Data json.RawMessage `json:"data"`
+}
+
+// WebhookData is the "data" envelope inside a Pancake webhook event.
+type WebhookData struct {
+ Conversation WebhookConversation `json:"conversation"`
+ Message WebhookMessage `json:"message"`
+ PageID string `json:"page_id,omitempty"` // page_id may appear here or top-level
+}
+
+// WebhookConversation holds the conversation metadata from a Pancake webhook.
+type WebhookConversation struct {
+ ID string `json:"id"` // format: "pageID_senderID"
+ Type string `json:"type"` // "INBOX" or "COMMENT"
+ From WebhookSender `json:"from"`
+ Snippet string `json:"snippet,omitempty"`
+}
+
+// WebhookSender identifies the message sender.
+type WebhookSender struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email,omitempty"`
+}
+
+// WebhookMessage holds the message payload from a Pancake webhook.
+type WebhookMessage struct {
+ ID string `json:"id"`
+ Message string `json:"message,omitempty"` // primary text content
+ OriginalMessage string `json:"original_message,omitempty"` // unformatted fallback
+ Content string `json:"content,omitempty"` // legacy field
+ Attachments []MessageAttachment `json:"attachments,omitempty"`
+ CreatedAt json.Number `json:"created_at,omitempty"`
+}
+
+// MessageAttachment represents a media attachment in a Pancake webhook message.
+type MessageAttachment struct {
+ Type string `json:"type"` // "image", "video", "file"
+ URL string `json:"url"`
+}
+
+// MessagingData is the normalized internal representation used after parsing.
+type MessagingData struct {
+ PageID string
+ ConversationID string
+ Type string // "INBOX" or "COMMENT"
+ Platform string // "facebook", "zalo", "instagram", "tiktok", "whatsapp", "line"
+ Message MessagingMessage
+}
+
+// MessagingMessage is the normalized message used by the handler.
+type MessagingMessage struct {
+ ID string
+ Content string
+ SenderID string
+ SenderName string
+ Attachments []MessageAttachment
+}
+
+// --- API response types ---
+
+// PageInfo holds page metadata from GET /pages response.
+type PageInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Platform string `json:"platform"` // facebook/zalo/instagram/tiktok/whatsapp/line
+ Avatar string `json:"avatar,omitempty"`
+}
+
+// SendMessageRequest is the POST body for sending a message via Pancake API.
+type SendMessageRequest struct {
+ Action string `json:"action"`
+ Message string `json:"message,omitempty"`
+ ContentIDs []string `json:"content_ids,omitempty"`
+}
+
+// UploadResponse is returned by POST /pages/{id}/upload_contents.
+type UploadResponse struct {
+ ID string `json:"id"`
+ URL string `json:"url,omitempty"`
+}
+
+// apiError wraps a Pancake API error response.
+type apiError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+func (e *apiError) Error() string {
+ return e.Message
+}
diff --git a/internal/channels/pancake/webhook_handler.go b/internal/channels/pancake/webhook_handler.go
new file mode 100644
index 000000000..84a76522a
--- /dev/null
+++ b/internal/channels/pancake/webhook_handler.go
@@ -0,0 +1,230 @@
+package pancake
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "strings"
+ "sync"
+)
+
+const (
+ webhookPath = "/channels/pancake/webhook"
+ maxBodyBytes = 1 << 20 // 1 MB — prevent abuse
+)
+
+// verifyHMAC verifies a Pancake HMAC-SHA256 signature.
+// Expected header format: "sha256="
+func verifyHMAC(body []byte, secret, signature string) bool {
+ const prefix = "sha256="
+ if len(signature) <= len(prefix) {
+ return false
+ }
+ got, err := hex.DecodeString(signature[len(prefix):])
+ if err != nil {
+ return false
+ }
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write(body)
+ expected := mac.Sum(nil)
+ return hmac.Equal(got, expected)
+}
+
+// --- Global webhook router for multi-page support ---
+
+// webhookRouter routes incoming Pancake webhook events to the correct channel instance by page_id.
+// A single HTTP handler is shared across all pancake channel instances.
+type webhookRouter struct {
+ mu sync.RWMutex
+ instances map[string]*Channel // pageID → channel
+ routeHandled bool // true after first webhookRoute() call
+}
+
+var globalRouter = &webhookRouter{
+ instances: make(map[string]*Channel),
+}
+
+func (r *webhookRouter) register(ch *Channel) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.instances[ch.pageID] = ch
+}
+
+func (r *webhookRouter) unregister(pageID string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ delete(r.instances, pageID)
+}
+
+// webhookRoute returns the path+handler on first call; ("", nil) for subsequent calls.
+// The HTTP mux retains the route once registered — routeHandled prevents duplicate mounts.
+func (r *webhookRouter) webhookRoute() (string, http.Handler) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if !r.routeHandled {
+ r.routeHandled = true
+ return webhookPath, r
+ }
+ return "", nil
+}
+
+// ServeHTTP is the shared handler for all Pancake page webhooks.
+// Always returns HTTP 200 — Pancake suspends webhooks if >80% errors in a 30-min window.
+func (r *webhookRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+ slog.Info("pancake: webhook request received",
+ "method", req.Method,
+ "remote_addr", req.RemoteAddr,
+ "content_length", req.ContentLength)
+
+ if req.Method != http.MethodPost {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ lr := io.LimitReader(req.Body, maxBodyBytes+1)
+ body, err := io.ReadAll(lr)
+ if err != nil {
+ slog.Warn("pancake: router read body error", "err", err)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ slog.Info("pancake: webhook body received",
+ "body_len", len(body),
+ "body_preview", truncateBody(body, 1000))
+
+ // Parse top-level envelope.
+ var event WebhookEvent
+ if err := json.Unmarshal(body, &event); err != nil {
+ slog.Warn("pancake: router parse event error", "err", err, "body_preview", truncateBody(body, 300))
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ // Parse the nested "data" object containing conversation + message.
+ var data WebhookData
+ if err := json.Unmarshal(event.Data, &data); err != nil {
+ slog.Warn("pancake: router parse data error", "err", err)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ // Resolve page_id: try top-level first, then data-level, then extract from conversation ID.
+ pageID := event.PageID
+ if pageID == "" {
+ pageID = data.PageID
+ }
+ if pageID == "" {
+ // Conversation ID format: "pageID_senderID" — extract page portion.
+ if idx := strings.Index(data.Conversation.ID, "_"); idx > 0 {
+ pageID = data.Conversation.ID[:idx]
+ }
+ }
+
+ // Resolve conversation type — only process INBOX messages.
+ convType := strings.ToUpper(data.Conversation.Type)
+
+ if event.EventType != "" && !strings.EqualFold(event.EventType, "messaging") {
+ slog.Info("pancake: skipping non-messaging webhook event",
+ "event_type", event.EventType,
+ "page_id", pageID)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ slog.Info("pancake: webhook event parsed",
+ "event_type", event.EventType,
+ "page_id", pageID,
+ "conv_id", data.Conversation.ID,
+ "conv_type", convType,
+ "sender_id", data.Conversation.From.ID,
+ "sender_name", data.Conversation.From.Name,
+ "msg_id", data.Message.ID)
+
+ if pageID == "" {
+ slog.Warn("pancake: could not determine page_id from webhook payload")
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ if convType != "INBOX" {
+ slog.Info("pancake: skipping non-inbox conversation event",
+ "page_id", pageID,
+ "conv_id", data.Conversation.ID,
+ "conv_type", convType,
+ "msg_id", data.Message.ID)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ r.mu.RLock()
+ target := r.instances[pageID]
+ r.mu.RUnlock()
+
+ if target == nil {
+ // Log all registered page IDs for debugging.
+ r.mu.RLock()
+ var registered []string
+ for pid := range r.instances {
+ registered = append(registered, pid)
+ }
+ r.mu.RUnlock()
+ slog.Warn("pancake: no channel instance for page_id",
+ "page_id", pageID,
+ "registered_pages", registered)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ // HMAC signature verification — skip if webhook_secret not configured.
+ if target.webhookSecret != "" {
+ sig := req.Header.Get("X-Pancake-Signature")
+ if !verifyHMAC(body, target.webhookSecret, sig) {
+ slog.Warn("security.pancake_webhook_signature_mismatch",
+ "page_id", pageID,
+ "remote_addr", req.RemoteAddr)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ }
+
+ // Build normalized MessagingData from actual Pancake payload.
+ msgContent := data.Message.Message
+ if msgContent == "" {
+ msgContent = data.Message.OriginalMessage
+ }
+ if msgContent == "" {
+ msgContent = data.Message.Content
+ }
+ if msgContent == "" {
+ msgContent = data.Conversation.Snippet
+ }
+
+ normalized := MessagingData{
+ PageID: pageID,
+ ConversationID: data.Conversation.ID,
+ Type: convType,
+ Platform: target.platform,
+ Message: MessagingMessage{
+ ID: data.Message.ID,
+ Content: msgContent,
+ SenderID: data.Conversation.From.ID,
+ SenderName: data.Conversation.From.Name,
+ Attachments: data.Message.Attachments,
+ },
+ }
+
+ target.handleMessagingEvent(normalized)
+ w.WriteHeader(http.StatusOK)
+}
+
+// truncateBody returns a string preview of body, truncated to maxLen bytes.
+func truncateBody(body []byte, maxLen int) string {
+ if len(body) <= maxLen {
+ return string(body)
+ }
+ return string(body[:maxLen]) + "..."
+}
diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go
index d762501d8..f5529a8b4 100644
--- a/internal/http/channel_instances.go
+++ b/internal/http/channel_instances.go
@@ -540,7 +540,7 @@ func (h *ChannelInstancesHandler) handleResolveContacts(w http.ResponseWriter, r
// isValidChannelType checks if the channel type is supported.
func isValidChannelType(ct string) bool {
switch ct {
- case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu":
+ case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu", "facebook", "pancake":
return true
}
return false
diff --git a/ui/web/src/api/http-client.ts b/ui/web/src/api/http-client.ts
index ee5d8f58c..6442b93ca 100644
--- a/ui/web/src/api/http-client.ts
+++ b/ui/web/src/api/http-client.ts
@@ -133,13 +133,14 @@ export class HttpClient {
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
- if (res.status === 401 || err.code === "TENANT_ACCESS_REVOKED") {
+ // Backend wraps errors as { "error": { "code": "...", "message": "..." } }
+ const nested = typeof err.error === "object" && err.error !== null ? err.error : null;
+ const code = nested?.code ?? err.code ?? "HTTP_ERROR";
+ const message = nested?.message ?? (typeof err.error === "string" ? err.error : null) ?? err.message ?? res.statusText;
+ if (res.status === 401 || code === "TENANT_ACCESS_REVOKED") {
this.onAuthFailure?.();
}
- throw new ApiError(
- err.code ?? "HTTP_ERROR",
- err.error ?? err.message ?? res.statusText,
- );
+ throw new ApiError(code, message);
}
return res.json() as Promise;
diff --git a/ui/web/src/constants/channels.ts b/ui/web/src/constants/channels.ts
index ba5042856..090202511 100644
--- a/ui/web/src/constants/channels.ts
+++ b/ui/web/src/constants/channels.ts
@@ -6,4 +6,6 @@ export const CHANNEL_TYPES = [
{ value: "zalo_oa", label: "Zalo OA" },
{ value: "zalo_personal", label: "Zalo Personal" },
{ value: "whatsapp", label: "WhatsApp" },
+ { value: "facebook", label: "Facebook" },
+ { value: "pancake", label: "Pancake (pages.fm)" },
] as const;
diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts
index ba92f4527..67738c8b7 100644
--- a/ui/web/src/pages/channels/channel-schemas.ts
+++ b/ui/web/src/pages/channels/channel-schemas.ts
@@ -68,7 +68,19 @@ export const credentialsSchema: Record = {
{ key: "webhook_secret", label: "Webhook Secret", type: "password" },
],
zalo_personal: [],
- whatsapp: [],
+ whatsapp: [
+ { key: "bridge_url", label: "Bridge URL", type: "text", required: true, placeholder: "http://bridge:3000" },
+ ],
+ facebook: [
+ { key: "page_access_token", label: "Page Access Token", type: "password", required: true, help: "From Facebook Developer Console → Your App → Messenger → Page Access Token" },
+ { key: "app_secret", label: "App Secret", type: "password", required: true, help: "From Facebook Developer Console → Your App → Settings → Basic" },
+ { key: "verify_token", label: "Webhook Verify Token", type: "password", required: true, help: "A secret string you choose, used to verify the webhook URL" },
+ ],
+ pancake: [
+ { key: "api_key", label: "API Key", type: "password", required: true, help: "Pancake user-level API key from pages.fm account settings" },
+ { key: "page_access_token", label: "Page Access Token", type: "password", required: true, help: "Page-level token from Pancake dashboard → Page Settings" },
+ { key: "webhook_secret", label: "Webhook Secret (Optional)", type: "password", help: "HMAC-SHA256 secret for webhook signature verification. Leave empty to skip verification." },
+ ],
};
// --- Config schemas ---
@@ -153,6 +165,17 @@ export const configSchema: Record = {
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "WhatsApp user IDs" },
{ key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" },
],
+ facebook: [
+ { key: "page_id", label: "Page ID", type: "text", required: true, help: "Facebook Page numeric ID" },
+ { key: "allow_from", label: "Allowed Users", type: "tags", help: "Facebook user IDs" },
+ { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit" },
+ ],
+ pancake: [
+ { key: "page_id", label: "Page ID", type: "text", required: true, help: "Pancake page ID (numeric, from Pancake dashboard)" },
+ { key: "platform", label: "Platform (auto-detected)", type: "text", placeholder: "Leave empty — resolved at startup", help: "facebook / zalo / instagram / tiktok / whatsapp / line. Auto-detected if empty." },
+ { key: "allow_from", label: "Allowed Users", type: "tags", help: "Sender IDs to whitelist. Empty = accept all." },
+ { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit" },
+ ],
};
// --- Group override schema (Telegram per-group/topic overrides) ---