diff --git a/CLAUDE.md b/CLAUDE.md index f6306fb2ce..207500cf95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,7 @@ ui/desktop/ Wails v2 desktop app (React frontend + embedded ga - **Config:** JSON5 at `GOCLAW_CONFIG` env. Secrets in `.env.local` or env vars, never in config.json - **Security:** Rate limiting, input guard (detection-only), CORS, shell deny patterns, SSRF protection, path traversal prevention, AES-256-GCM encryption. All security logs: `slog.Warn("security.*")` - **Telegram formatting:** LLM output → `SanitizeAssistantContent()` → `markdownToTelegramHTML()` → `chunkHTML()` → `sendHTML()`. Tables rendered as ASCII in `
` tags
+- **Reply-to plumbing:** `metadata["reply_to_message_id"]` is the cross-channel reply key. Stamped on group inbound (telegram, pancake comments) by default; opted-in per channel for DMs via `channels.DMQuoteChannel` (currently `zalo_oa` only). Channel `Send` translates the key to platform-specific payload — Telegram `ReplyParameters`, Zalo OA `message.quote_message_id`, Pancake `comment_id`. First chunk only; media sends drop quote. Zalo OA silently retries without quote on `FamilyPayload` errors (expired/deleted source).
- **i18n:** Web UI uses `i18next` with namespace-split locale files in `ui/web/src/i18n/locales/{lang}/`. Backend uses `internal/i18n` message catalog with `i18n.T(locale, key, args...)`. Locale propagated via `store.WithLocale(ctx)` — WS `connect` param `locale`, HTTP `Accept-Language` header. Supported: en (default), vi, zh. New user-facing strings: add key to `internal/i18n/keys.go`, add translations to all 3 catalog files. New UI strings: add key to all 3 locale dirs. Bootstrap templates (SOUL.md, etc.) stay English-only (LLM consumption).
## Running
diff --git a/cmd/gateway.go b/cmd/gateway.go
index 0ebb2a899c..909e5362a6 100644
--- a/cmd/gateway.go
+++ b/cmd/gateway.go
@@ -27,7 +27,8 @@ import (
slackchannel "github.com/nextlevelbuilder/goclaw/internal/channels/slack"
"github.com/nextlevelbuilder/goclaw/internal/channels/telegram"
"github.com/nextlevelbuilder/goclaw/internal/channels/whatsapp"
- "github.com/nextlevelbuilder/goclaw/internal/channels/zalo"
+ zalobot "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/bot"
+ zalooa "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/oa"
zalopersonal "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/edition"
@@ -460,7 +461,8 @@ func runGateway() {
instanceLoader.RegisterFactory(channels.TypeTelegram, telegram.FactoryWithStoresAndAudio(pgStores.Agents, pgStores.ConfigPermissions, pgStores.Teams, pgStores.SubagentTasks, pgStores.PendingMessages, audioMgr))
instanceLoader.RegisterFactory(channels.TypeDiscord, discord.FactoryWithStoresAndAudio(pgStores.Agents, pgStores.ConfigPermissions, pgStores.PendingMessages, audioMgr))
instanceLoader.RegisterFactory(channels.TypeFeishu, feishu.FactoryWithPendingStoreAndAudio(pgStores.PendingMessages, audioMgr))
- instanceLoader.RegisterFactory(channels.TypeZaloOA, zalo.Factory)
+ instanceLoader.RegisterFactory(channels.TypeZaloBot, zalobot.Factory)
+ instanceLoader.RegisterFactory(channels.TypeZaloOA, zalooa.Factory(pgStores.ChannelInstances))
instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages))
instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.FactoryWithDBAudio(pgStores.DB, pgStores.PendingMessages, "pgx", audioMgr, pgStores.BuiltinTools))
instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages))
diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go
index df2840a3f3..5aec813840 100644
--- a/cmd/gateway_channels_setup.go
+++ b/cmd/gateway_channels_setup.go
@@ -17,7 +17,7 @@ import (
slackchannel "github.com/nextlevelbuilder/goclaw/internal/channels/slack"
"github.com/nextlevelbuilder/goclaw/internal/channels/telegram"
"github.com/nextlevelbuilder/goclaw/internal/channels/whatsapp"
- "github.com/nextlevelbuilder/goclaw/internal/channels/zalo"
+ zalobot "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/bot"
zalopersonal "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/zalomethods"
"github.com/nextlevelbuilder/goclaw/internal/config"
@@ -86,12 +86,12 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms
if cfg.Channels.Zalo.Enabled {
if cfg.Channels.Zalo.Token == "" {
- recordMissingConfig(channels.TypeZaloOA, "Set channels.zalo.token in config.")
- } else if z, err := zalo.New(cfg.Channels.Zalo, msgBus, pgStores.Pairing); err != nil {
- channelMgr.RecordFailure(channels.TypeZaloOA, "", err)
+ recordMissingConfig(channels.TypeZaloBot, "Set channels.zalo.token in config.")
+ } else if z, err := zalobot.New(cfg.Channels.Zalo, msgBus, pgStores.Pairing); err != nil {
+ channelMgr.RecordFailure(channels.TypeZaloBot, "", err)
slog.Error("failed to initialize zalo channel", "error", err)
} else {
- channelMgr.RegisterChannel(channels.TypeZaloOA, z)
+ channelMgr.RegisterChannel(channels.TypeZaloBot, z)
slog.Info("zalo channel enabled (config)")
}
}
@@ -152,6 +152,8 @@ func wireChannelRPCMethods(server *gateway.Server, pgStores *store.Stores, chann
// Register channel instances WS RPC methods
if pgStores.ChannelInstances != nil {
methods.NewChannelInstancesMethods(pgStores.ChannelInstances, pgStores.Agents, msgBus, msgBus).Register(server.Router())
+ methods.NewZaloOAMethods(pgStores.ChannelInstances, msgBus).Register(server.Router())
+ methods.NewZaloWebhookMethods(pgStores.ChannelInstances).Register(server.Router())
zalomethods.NewQRMethods(pgStores.ChannelInstances, msgBus).Register(server.Router())
zalomethods.NewContactsMethods(pgStores.ChannelInstances).Register(server.Router())
whatsapp.NewQRMethods(pgStores.ChannelInstances, channelMgr).Register(server.Router())
diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go
index 59c7e6e850..bb2ab91723 100644
--- a/cmd/gateway_consumer_normal.go
+++ b/cmd/gateway_consumer_normal.go
@@ -215,12 +215,7 @@ func processNormalMessage(
// Build outbound metadata for reply-to + thread routing BEFORE RegisterRun
// so block.reply handler can use it for routing intermediate messages.
- outMeta := channels.CopyFinalRoutingMeta(msg.Metadata)
- if isGroup {
- if mid := msg.Metadata["message_id"]; mid != "" {
- outMeta["reply_to_message_id"] = mid
- }
- }
+ outMeta := buildOutboundReplyMeta(msg.Metadata, msg.Channel, isGroup, deps.ChannelMgr)
// Register run with channel manager for streaming/reaction event forwarding.
// Use localKey (composite key with topic suffix) so streaming/reaction events
@@ -529,3 +524,21 @@ func processNormalMessage(
}
}(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, msg.Content, outMeta, blockReply, ptd, msg.TenantID, agentLoop.UUID(), agentLoop.OtherConfig())
}
+
+// buildOutboundReplyMeta clones routing metadata and stamps reply_to_message_id
+// on group inbounds (always) and DM inbounds for channels that opt into the
+// DMQuoteChannel capability.
+func buildOutboundReplyMeta(in map[string]string, channelName string, isGroup bool, mgr *channels.Manager) map[string]string {
+ out := channels.CopyFinalRoutingMeta(in)
+ mid := in["message_id"]
+ if mid == "" {
+ return out
+ }
+ switch {
+ case isGroup:
+ out["reply_to_message_id"] = mid
+ case mgr != nil && mgr.QuoteInboundOnDM(channelName):
+ out["reply_to_message_id"] = mid
+ }
+ return out
+}
diff --git a/cmd/gateway_consumer_reply_meta_test.go b/cmd/gateway_consumer_reply_meta_test.go
new file mode 100644
index 0000000000..70b55af8e0
--- /dev/null
+++ b/cmd/gateway_consumer_reply_meta_test.go
@@ -0,0 +1,84 @@
+package cmd
+
+import (
+ "context"
+ "testing"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+)
+
+// quoteOptInChannel implements channels.Channel + channels.DMQuoteChannel.
+type quoteOptInChannel struct{ name string }
+
+func (q *quoteOptInChannel) Name() string { return q.name }
+func (q *quoteOptInChannel) Type() string { return q.name }
+func (q *quoteOptInChannel) Start(ctx context.Context) error { return nil }
+func (q *quoteOptInChannel) Stop(ctx context.Context) error { return nil }
+func (q *quoteOptInChannel) Send(ctx context.Context, _ bus.OutboundMessage) error { return nil }
+func (q *quoteOptInChannel) IsRunning() bool { return true }
+func (q *quoteOptInChannel) IsAllowed(_ string) bool { return true }
+func (q *quoteOptInChannel) QuoteInboundOnDM() bool { return true }
+
+// plainChannel implements only Channel.
+type plainChannel struct{ name string }
+
+func (p *plainChannel) Name() string { return p.name }
+func (p *plainChannel) Type() string { return p.name }
+func (p *plainChannel) Start(ctx context.Context) error { return nil }
+func (p *plainChannel) Stop(ctx context.Context) error { return nil }
+func (p *plainChannel) Send(ctx context.Context, _ bus.OutboundMessage) error { return nil }
+func (p *plainChannel) IsRunning() bool { return true }
+func (p *plainChannel) IsAllowed(_ string) bool { return true }
+
+func TestBuildOutboundReplyMeta_DMOptedIn(t *testing.T) {
+ t.Parallel()
+ mgr := channels.NewManager(bus.New())
+ mgr.RegisterChannel("zalo_oa", "eOptInChannel{name: "zalo_oa"})
+
+ out := buildOutboundReplyMeta(map[string]string{"message_id": "mid-1"}, "zalo_oa", false, mgr)
+ if out["reply_to_message_id"] != "mid-1" {
+ t.Errorf("reply_to_message_id = %q, want mid-1", out["reply_to_message_id"])
+ }
+}
+
+func TestBuildOutboundReplyMeta_DMNotOptedIn(t *testing.T) {
+ t.Parallel()
+ mgr := channels.NewManager(bus.New())
+ mgr.RegisterChannel("telegram", &plainChannel{name: "telegram"})
+
+ out := buildOutboundReplyMeta(map[string]string{"message_id": "mid-1"}, "telegram", false, mgr)
+ if _, ok := out["reply_to_message_id"]; ok {
+ t.Errorf("reply_to_message_id must not be stamped on telegram DM, got out=%v", out)
+ }
+}
+
+func TestBuildOutboundReplyMeta_GroupAlwaysStamps(t *testing.T) {
+ t.Parallel()
+ mgr := channels.NewManager(bus.New())
+ mgr.RegisterChannel("telegram", &plainChannel{name: "telegram"})
+
+ out := buildOutboundReplyMeta(map[string]string{"message_id": "mid-2"}, "telegram", true, mgr)
+ if out["reply_to_message_id"] != "mid-2" {
+ t.Errorf("group must stamp regardless of capability; got %q", out["reply_to_message_id"])
+ }
+}
+
+func TestBuildOutboundReplyMeta_NoMessageID(t *testing.T) {
+ t.Parallel()
+ mgr := channels.NewManager(bus.New())
+ mgr.RegisterChannel("zalo_oa", "eOptInChannel{name: "zalo_oa"})
+
+ out := buildOutboundReplyMeta(map[string]string{}, "zalo_oa", false, mgr)
+ if _, ok := out["reply_to_message_id"]; ok {
+ t.Errorf("missing message_id must not produce a quote; got out=%v", out)
+ }
+}
+
+func TestBuildOutboundReplyMeta_NilManager(t *testing.T) {
+ t.Parallel()
+ out := buildOutboundReplyMeta(map[string]string{"message_id": "x"}, "anything", false, nil)
+ if _, ok := out["reply_to_message_id"]; ok {
+ t.Errorf("nil manager DM must not stamp; got out=%v", out)
+ }
+}
diff --git a/cmd/gateway_errors.go b/cmd/gateway_errors.go
index 29cd363972..87826cd637 100644
--- a/cmd/gateway_errors.go
+++ b/cmd/gateway_errors.go
@@ -94,6 +94,7 @@ func isExternalChannel(channelType string) bool {
channels.TypeDiscord,
channels.TypeFeishu,
channels.TypeWhatsApp,
+ channels.TypeZaloBot,
channels.TypeZaloOA,
channels.TypeZaloPersonal,
channels.TypePancake,
diff --git a/cmd/gateway_errors_test.go b/cmd/gateway_errors_test.go
index 6efcf48456..a39df12c61 100644
--- a/cmd/gateway_errors_test.go
+++ b/cmd/gateway_errors_test.go
@@ -24,6 +24,7 @@ func TestIsExternalChannel(t *testing.T) {
{"discord", channels.TypeDiscord, true},
{"feishu", channels.TypeFeishu, true},
{"whatsapp", channels.TypeWhatsApp, true},
+ {"zalo_bot", channels.TypeZaloBot, true},
{"zalo_oa", channels.TypeZaloOA, true},
{"zalo_personal", channels.TypeZaloPersonal, true},
{"pancake", channels.TypePancake, true},
diff --git a/cmd/gateway_lifecycle.go b/cmd/gateway_lifecycle.go
index bc6c4277b0..5af001b9bb 100644
--- a/cmd/gateway_lifecycle.go
+++ b/cmd/gateway_lifecycle.go
@@ -3,6 +3,7 @@ package cmd
import (
"context"
"log/slog"
+ "net/http"
"os"
"strings"
"time"
@@ -10,6 +11,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/channels"
+ zalocommon "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/heartbeat"
@@ -207,10 +209,26 @@ func (d *gatewayDeps) runLifecycle(
// Mount channel webhook handlers on the main mux (e.g. Feishu /feishu/events).
// This allows webhook-based channels to share the main server port.
+ zaloPrefixMounted := false
for _, route := range d.channelMgr.WebhookHandlers() {
mux.Handle(route.Path, route.Handler)
slog.Info("webhook route mounted on gateway", "path", route.Path)
+ if route.Path == zalocommon.WebhookPathPrefix {
+ zaloPrefixMounted = true
+ }
+ }
+ // Always mount the Zalo webhook prefix so wizard-created channels added
+ // after boot are reachable without restart. The shared router 404s
+ // unknown slugs, so an unmounted-yet-registered channel never silently
+ // drops. Bare /channels/zalo/webhook always returns 404 to avoid the
+ // http.ServeMux 301 redirect leaking the prefix path.
+ if !zaloPrefixMounted {
+ mux.Handle(zalocommon.WebhookPathPrefix, zalocommon.SharedRouter())
+ slog.Info("webhook route mounted on gateway", "path", zalocommon.WebhookPathPrefix, "source", "shared_router_default")
}
+ mux.HandleFunc(zalocommon.WebhookPathBare, func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, "not found", http.StatusNotFound)
+ })
tsCleanup := initTailscale(ctx, d.cfg, mux)
if tsCleanup != nil {
diff --git a/docker-compose.selfservice.yml b/docker-compose.selfservice.yml
index be9d4c2651..d20a996f36 100644
--- a/docker-compose.selfservice.yml
+++ b/docker-compose.selfservice.yml
@@ -9,6 +9,12 @@
# # or: make up WITH_WEB_NGINX=1
#
# Dashboard via nginx: http://localhost:3000
+#
+# NOTE: nginx.conf uses a static `upstream goclaw_backend` block, which
+# resolves the backend hostname only once at config load. K8s with a stable
+# Service ClusterIP is fine. In docker-compose, if the backend container is
+# recreated and gets a new IP, restart the UI container to refresh the
+# resolution: `docker compose restart goclaw-ui`.
services:
goclaw-ui:
diff --git a/docker-compose.yml b/docker-compose.yml
index aed3e2a87b..361f855423 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -47,8 +47,10 @@ services:
- GOCLAW_GATEWAY_TOKEN=${GOCLAW_GATEWAY_TOKEN:-}
- GOCLAW_ENCRYPTION_KEY=${GOCLAW_ENCRYPTION_KEY:-}
- GOCLAW_SKILLS_DIR=/app/data/skills
+ - GOCLAW_AUTO_UPGRADE=${GOCLAW_AUTO_UPGRADE:-true}
# Debug
- GOCLAW_TRACE_VERBOSE=${GOCLAW_TRACE_VERBOSE:-0}
+ - GOCLAW_LOG_LEVEL=${GOCLAW_LOG_LEVEL:-info}
volumes:
- goclaw-data:/app/data
- goclaw-workspace:/app/workspace
diff --git a/docs/00-architecture-overview.md b/docs/00-architecture-overview.md
index 8f46c4cb73..189ec6243d 100644
--- a/docs/00-architecture-overview.md
+++ b/docs/00-architecture-overview.md
@@ -14,6 +14,7 @@ flowchart TD
TG[Telegram]
DC[Discord]
FS[Feishu / Lark]
+ ZB[Zalo Bot]
ZL[Zalo OA]
ZLP[Zalo Personal]
WA[WhatsApp]
@@ -77,7 +78,7 @@ flowchart TD
WS --> WSS
HTTP --> HTTPS
- TG & DC & FS & ZL & ZLP & WA & SL --> CM
+ TG & DC & FS & ZB & ZL & ZLP & WA & SL --> CM
WSS --> MR
HTTPS --> MR
@@ -113,7 +114,7 @@ flowchart TD
| `internal/bootstrap/` | System prompt files (AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, BOOTSTRAP.md) + seeding + truncation |
| `internal/config/` | Config loading (JSON5) + env var overlay |
| `internal/skills/` | SKILL.md loader (5-tier hierarchy) + BM25 search + hot-reload via fsnotify |
-| `internal/channels/` | Channel manager + adapters: Telegram (forum topics, STT, bot commands), Feishu/Lark (streaming cards, media), Zalo OA, Zalo Personal, Discord, WhatsApp, Slack |
+| `internal/channels/` | Channel manager + adapters: Telegram (forum topics, STT, bot commands), Feishu/Lark (streaming cards, media), Zalo Bot (static-token), Zalo OA (OAuth), Zalo Personal, Discord, WhatsApp, Slack |
| `internal/mcp/` | MCP server bridge (stdio, SSE, streamable-HTTP transports) |
| `internal/scheduler/` | Lane-based concurrency control (main, subagent, cron, team lanes) with per-session serialization. Per-edition rate limits (`MaxSubagentConcurrent`, `MaxSubagentDepth`) with tenant-scoped concurrency |
| `internal/memory/` | Memory system (pgvector hybrid search) |
diff --git a/docs/05-channels-messaging.md b/docs/05-channels-messaging.md
index cb0f3808d0..f9e5f3ecc9 100644
--- a/docs/05-channels-messaging.md
+++ b/docs/05-channels-messaging.md
@@ -13,6 +13,7 @@ flowchart LR
DC["Discord"]
SL["Slack"]
FS["Feishu/Lark"]
+ ZB["Zalo Bot"]
ZL["Zalo OA"]
ZLP["Zalo Personal"]
WA["WhatsApp"]
@@ -90,7 +91,8 @@ Every channel must implement the base interface:
| `StreamingChannel` | Real-time streaming updates | Telegram, Slack |
| `WebhookChannel` | Webhook HTTP handler mounting | Facebook, Feishu/Lark, Pancake |
| `ReactionChannel` | Status reactions on messages | Telegram, Slack, Feishu |
-| `BlockReplyChannel` | Override gateway block_reply setting | Discord, Feishu/Lark, Pancake, Slack, Zalo OA, Zalo Personal |
+| `BlockReplyChannel` | Override gateway block_reply setting | Discord, Feishu/Lark, Pancake, Slack, Zalo Bot, Zalo OA, Zalo Personal |
+| `DMQuoteChannel` | Opt into stamping `reply_to_message_id` on DM outbound metadata (group inbounds always stamp it) — channel `Send` translates to platform-specific quote payload (Telegram `ReplyParameters`, Zalo OA `message.quote_message_id`, Pancake `comment_id`) | Zalo OA |
`BaseChannel` provides a shared implementation that all channels embed: allowlist matching, `HandleMessage()`, `CheckPolicy()`, and user ID extraction.
@@ -156,7 +158,7 @@ flowchart TD
| Feature | Telegram | Feishu/Lark | Discord | Slack | WhatsApp | Zalo OA | Zalo Personal |
|---------|----------|-------------|---------|-------|----------|---------|---------------|
-| Connection | Long polling | WS (default) / Webhook | Gateway events | Socket Mode | Direct protocol (in-process) | Long polling | Internal protocol |
+| Connection | Long polling | WS (default) / Webhook | Gateway events | Socket Mode | Direct protocol (in-process) | Long polling (default) or Webhook (operator opt-in) | Internal protocol |
| DM support | Yes | Yes | Yes | Yes | Yes | Yes (DM only) | Yes |
| Group support | Yes (mention gating) | Yes | Yes | Yes (mention gating + thread cache) | Yes | No | Yes |
| Forum/Topics | Yes (per-topic config) | Yes (topic session mode) | -- | -- | -- | -- | -- |
@@ -555,18 +557,187 @@ The WhatsApp channel connects directly to the WhatsApp network via the multi-dev
---
-## 10. Zalo OA
+## 10. Zalo Bot + Zalo OA (two variants)
-The Zalo OA (Official Account) channel connects to the Zalo OA Bot API.
+> **Operator-facing setup guides live on the public docs site:**
+> - [Zalo OA setup (OAuth + webhook)](https://docs.goclaw.sh/channel-zalo-oa)
+> - [Zalo Bot setup (static token)](https://docs.goclaw.sh/channel-zalo-bot)
+>
+> This file documents the *channel-system architecture* — see those guides for end-to-end onboarding.
-### Key Behaviors
+Zalo ships two distinct channel types under the same "Official Account"
+umbrella. GoClaw exposes both; pick based on deployment scale and auth model.
+
+| Variant | Channel type | Auth | When to use |
+|---|---|---|---|
+| **Zalo Bot** | `zalo_bot` | Pre-provisioned static OA access token pasted into the gateway | Dev, small-scale, single-OA setups |
+| **Zalo OA** | `zalo_oa` | OAuth v4 consent flow (user completes consent in Zalo, gateway stores refresh token + auto-refreshes access tokens) | Production, multi-OA, long-running deployments |
+
+Both variants consume the same `/v3.0/oa/message/cs` send endpoint and the
+same message-shape rules (template/media for images+gifs, plain `type=file`
+for files). They differ only in how access tokens are obtained + refreshed.
+
+### Transport modes (both variants)
+
+Both `zalo_bot` and `zalo_oa` support two inbound transports — operator
+picks per instance via `config.transport`:
+
+| Mode | Default | When to choose |
+|---|---|---|
+| `polling` | ✓ default | Gateway has no externally-reachable URL; operator wants single transport with no inbound HTTP exposure |
+| `webhook` | opt-in | Gateway has a public URL operator can register with Zalo dev console; operator prefers push delivery and is willing to manage signing-secret rotation |
+
+**Webhook is single-transport-active.** When `transport: "webhook"` the
+poll loop does NOT run — there is no concurrent fallback. Use the
+`catch_up_on_restart` opt-in (OA only) to backfill messages missed during
+gateway downtime. If the operator sets `transport: "webhook"` and Zalo
+delivery is failing, no polling will retrieve missed messages unless
+`catch_up_on_restart` is also enabled.
+
+### Webhook setup (operator walkthrough)
+
+1. Toggle the instance to `transport: "webhook"`. For OA, also set
+ `webhook_secret_key` (in the credentials blob) to the signing secret
+ from the Zalo developer console — distinct from the OAuth `secret_key`
+ credential, see Common pitfalls below. For Bot, set `webhook_secret`
+ (used as `X-Bot-Api-Secret-Token`).
+2. Reload the channel instance (toggle `enabled` off/on, or restart
+ gateway). The channel registers itself with the shared router under
+ `/channels/zalo/webhook/` and starts accepting POSTs. The slug
+ is derived from the instance name (e.g. `my-oa`) or the explicit
+ `webhook_path` config field.
+3. Call the WS RPC `channels.instances.zalo.webhook_url` with
+ `instance_id`. Response: `{path, instance_id, hint}`. Path is, e.g.,
+ `/channels/zalo/webhook/my-oa` — there is **no** PublicBaseURL field
+ in gateway config, so the RPC returns the path fragment only.
+4. Prepend your gateway's externally-reachable host to the path
+ (e.g., `https://gw.example.com/channels/zalo/webhook/my-oa`) and
+ register that full URL in the Zalo dev console.
+5. Send a test event from the Zalo console; the gateway logs
+ `zalo_oa.webhook.event_received` (or the bot equivalent). If you see
+ `security.zalo_webhook_signature_mismatch`, the secret on the gateway
+ does not match what's configured in Zalo.
+
+### OA polling-window resilience
+
+When `transport: "polling"` the OA channel exposes two operator-tunable
+knobs to reduce silent message loss on bursty OAs:
+
+| Setting | Default | Range | Notes |
+|---|---|---|---|
+| `poll_count` | 10 | [1, 10] | Page size per `listrecentchat` call (Zalo hard-caps at 10; values above return error -210, so anything bigger is silently clamped) |
+| `poll_burndown_max_pages` | 10 | [1, 20] | Max consecutive pages per cycle; set to 1 to disable burn-down |
+| `poll_interval_seconds` | 15 | [5, 120] | Cycle interval |
+
+At default settings the per-cycle ceiling is 10 × 10 = 100 messages.
+Burn-down stops on the first partial page or when
+`poll_burndown_max_pages` is reached (the cap emits
+`zalo_oa.poll.burndown_capped`). These fields are ignored when
+`transport: "webhook"`.
+
+### OA catch-up on restart
+
+`catch_up_on_restart: true` (off by default) fires a single bounded
+`listrecentchat` sweep at Start when the cursor is stale, in a goroutine
+so Start returns within 1s. Useful if you run webhook-only and need
+backfill across gateway restarts. The sweep cancels promptly on Stop via
+the channel's catch-up WaitGroup.
+
+### Zalo Bot — static-token variant
- **DM only**: No group support. Only direct messages are processed
- **Text limit**: 2,000-character maximum per message
-- **Long polling**: Default 30-second timeout, 5-second backoff on errors
+- **Polling**: long-polling against getUpdates (default 30-second timeout, 5-second backoff)
+- **Webhook**: header-token auth (`X-Bot-Api-Secret-Token`), constant-time compare; empty secret is rejected at Start
- **Media**: Image support with 5 MB default limit
- **Default DM policy**: `"pairing"` (requires pairing code)
- **Pairing debounce**: 60-second debounce on pairing instructions
+- **Self-echo filter**: webhook handler drops messages where `from.id == botID` (A8) — Zalo redelivers our own outbound through the same URL otherwise
+
+### Zalo OA — OAuth v4 variant
+
+- **Auth flow**: User provides consent via Zalo OAuth endpoint; `code` query
+ param pasted back into the gateway; gateway exchanges for access + refresh
+ tokens and stores encrypted at rest
+- **Token refresh**: Lazy single-flight; safety ticker preempts near-expiry
+- **Polling**: `/v2.0/oa/listrecentchat` with operator-tunable
+ `poll_count` + `poll_burndown_max_pages` (see "OA polling-window
+ resilience" above)
+- **Webhook**: `X-ZEvent-Signature: hex(SHA256(appID + body + timestamp + secret))`.
+ Signature behavior driven by `webhook_signature_mode`: `strict` (reject
+ mismatch), `log_only` (warn-and-allow — useful for first-deploy spec
+ verification), `disabled` (default — accept unsigned). The default keeps
+ onboarding frictionless before the OA Secret Key is pasted into Credentials;
+ **operators handling production traffic must flip to `strict`** once the
+ secret is configured, otherwise inbound webhooks are not authenticated.
+ Replay window via `webhook_replay_window_seconds` (default 300, clamp
+ [60, 3600])
+- **Self-echo filter**: webhook handler drops events where `sender.id == oa_id` (A8)
+- **Per-endpoint caps**: image 1MB (hard Zalo cap, compress-before-upload
+ attempts downshift), file 5MB (PDF/DOC/DOCX only), gif 5MB
+- **Error-code registry**: centralized in
+ `internal/channels/zalo/oa/errors.go` (access-token-invalid family:
+ 216/-216/401/-401; invalid_grant -118; params-invalid -201; file-size
+ exceeded -210; invalid redirect URI -14003)
+- **Trace mode**: set `GOCLAW_ZALO_OA_TRACE=1` to dump raw Zalo response
+ bodies at Debug level. PII-sensitive — do NOT enable in production
+ without scrubbing review
+
+### Common pitfalls
+
+- **Two secrets on OA**: `creds.secret_key` (OAuth refresh credential)
+ is **distinct** from `creds.webhook_secret_key` (signing key from the
+ dev console webhook panel). Both live in the encrypted credentials
+ blob. Mixing them silently breaks signature verification.
+- **Webhook URL exposes the slug**: this is acceptable — the slug alone
+ gives no access without the matching signature secret. Treat the
+ webhook URL as semi-secret; rotation requires unregister + re-register
+ on the Zalo console.
+- **Operability signals**: watch for `zalo_webhook.handler_error`
+ (handler raised after 200 ack — Zalo's 2s window forces async dispatch),
+ `zalo_webhook.empty_message_id_streak` (extractor returning "" for ≥10
+ events suggests Zalo schema drift), `zalo_oa.poll.burndown_capped`
+ (raise `poll_count` or shorten `poll_interval_seconds`).
+
+### Operator config reference
+
+Polling (default) — Zalo OA:
+
+```json5
+{
+ "transport": "polling",
+ "poll_interval_seconds": 15,
+ "poll_count": 50,
+ "poll_burndown_max_pages": 5
+}
+```
+
+Webhook — Zalo OA (the signing secret lives in credentials, not config):
+
+```json5
+// credentials
+{
+ "app_id": "",
+ "secret_key": "",
+ "webhook_secret_key": ""
+}
+// config
+{
+ "transport": "webhook",
+ "webhook_signature_mode": "strict",
+ "webhook_replay_window_seconds": 300,
+ "catch_up_on_restart": true
+}
+```
+
+Webhook — Zalo Bot (secret is in credentials, not config):
+
+```json5
+// credentials
+{ "token": "", "webhook_secret": "" }
+// config
+{ "transport": "webhook", "dm_policy": "open" }
+```
---
@@ -576,14 +747,14 @@ The Zalo Personal channel provides access to personal Zalo accounts using a reve
### Key Differences from Zalo OA
-| Aspect | Zalo OA | Zalo Personal |
-|--------|---------|---------------|
-| Protocol | Official Bot API | Reverse-engineered (zcago, MIT) |
+| Aspect | Zalo OA / Bot | Zalo Personal |
+|--------|---------------|---------------|
+| Protocol | Official OA API (OAuth v4 or static token) | Reverse-engineered (zcago, MIT) |
| DM support | Yes | Yes |
| Group support | No | Yes |
| Default DM policy | `pairing` | `allowlist` (restrictive) |
| Default group policy | N/A | `allowlist` (restrictive) |
-| Authentication | API credentials | Pre-loaded credentials or QR scan |
+| Authentication | API credentials or OAuth consent | Pre-loaded credentials or QR scan |
| Risk | None | Account may be locked/banned |
### Security Warning
diff --git a/docs/18-http-api.md b/docs/18-http-api.md
index 33047f4cba..b0fe6a7dc9 100644
--- a/docs/18-http-api.md
+++ b/docs/18-http-api.md
@@ -947,7 +947,7 @@ Accepts partial updates. Flag keys are validated against recognized v3 flags.
| `POST` | `/v1/channels/instances/{id}/writers` | Add writer to group |
| `DELETE` | `/v1/channels/instances/{id}/writers/{userId}` | Remove writer |
-**Supported channels:** `telegram`, `discord`, `slack`, `whatsapp`, `zalo_oa`, `zalo_personal`, `feishu`
+**Supported channels:** `telegram`, `discord`, `slack`, `whatsapp`, `zalo_oa`, `zalo_bot`, `zalo_personal`, `feishu`
Credentials are masked in HTTP responses.
diff --git a/internal/agent/systemprompt_sections.go b/internal/agent/systemprompt_sections.go
index 73c26b1a5e..9b10e63f2c 100644
--- a/internal/agent/systemprompt_sections.go
+++ b/internal/agent/systemprompt_sections.go
@@ -445,17 +445,52 @@ func buildRuntimeSection(cfg SystemPromptConfig) []string {
return lines
}
-// buildChannelFormattingHint returns platform-specific formatting guidance.
-// Zalo does not render any markup, so we instruct the model to use plain text.
+// buildChannelFormattingHint returns platform-specific guidance: markdown
+// rendering, per-message length caps, and outbound attachment limits.
func buildChannelFormattingHint(channelType string) []string {
switch channelType {
- case "zalo", "zalo_personal":
+ case "zalo_personal":
return []string{
"## Output Formatting",
"",
"This channel (Zalo) does NOT support any text formatting — no Markdown, no HTML, no bold/italic/code.",
- "Always respond in clean plain text. Do not use **, __, `, ```, #, > or any markup syntax.",
- "For lists use simple dashes or bullets (•). For code, just paste the code as-is without fencing.",
+ "Always respond in clean plain text. Do NOT use **, __, ` (backticks), ```, #, --- (horizontal rule), >, ![]() or any other markup syntax — they appear as literal characters to the user.",
+ "For lists use simple dashes or bullets (•). For code, paste it as-is without fencing. Use blank lines to separate sections, not `---`.",
+ "",
+ }
+ case "zalo_oa":
+ return []string{
+ "## Output Formatting (Zalo Official Account)",
+ "",
+ "Plain text only — Zalo does NOT render Markdown or HTML. The user sees the literal characters of any markup you emit.",
+ "Do NOT use **, __, ` (backticks), ```, #, --- (horizontal rule), >, ![]() or tables. No emphasis syntax of any kind.",
+ "For lists use simple dashes or bullets (•). Separate sections with blank lines, never `---`. For code, paste it raw, no fences.",
+ "",
+ "### Outbound attachment limits (Zalo OA API — non-negotiable, enforced server-side)",
+ "- Documents: PDF, DOC, DOCX only, ≤ 5 MB. NEVER generate xlsx / xls / csv / pptx / txt / zip / json / md — Zalo will silently drop them and the user gets a 'cannot be delivered' fallback instead of your file.",
+ "- Images: JPG or PNG, ≤ 1 MB (auto-compressed to JPEG when larger).",
+ "- GIF: ≤ 5 MB via the dedicated GIF endpoint.",
+ "- Per-message text cap: 2000 characters. Longer replies are auto-split into multiple messages, but try to be concise.",
+ "",
+ "### File-generation rule",
+ "Before calling write_file(deliver=true) or send_file, the artifact MUST be PDF, DOC, or DOCX. If you cannot produce one of those (e.g. a charting library is missing), DO NOT fall back to xlsx — instead summarize the data inline as plain text. Do not claim to have sent a file in any other format; the send will fail silently.",
+ "",
+ }
+ case "zalo_bot":
+ return []string{
+ "## Output Formatting (Zalo Bot)",
+ "",
+ "Plain text only — Zalo does NOT render Markdown or HTML. The user sees the literal characters of any markup you emit.",
+ "Do NOT use **, __, ` (backticks), ```, #, --- (horizontal rule), >, ![]() or tables. No emphasis syntax of any kind.",
+ "For lists use simple dashes or bullets (•). Separate sections with blank lines, never `---`. For code, paste it raw, no fences.",
+ "",
+ "### Outbound attachment limits (Zalo Bot API — non-negotiable)",
+ "- Zalo Bot CANNOT send file attachments of any kind. No PDF, no DOC, no DOCX, no xlsx — file delivery is not supported on this channel. Do not call send_file or write_file(deliver=true) with a local path; the send will hard-fail.",
+ "- Images: only via a publicly reachable HTTP(S) URL (sent inline as a photo). Local image files are not accepted; host the image elsewhere first or skip it.",
+ "- Per-message text cap: 2000 characters. Longer replies are auto-split into multiple messages, but try to be concise.",
+ "",
+ "### File-generation rule",
+ "Never produce a file artifact for delivery on this channel. If the user asks for a report, table, or document, summarize it inline as plain text instead.",
"",
}
default:
diff --git a/internal/channels/channel.go b/internal/channels/channel.go
index e3903f2c4e..b1affc90fc 100644
--- a/internal/channels/channel.go
+++ b/internal/channels/channel.go
@@ -78,6 +78,7 @@ const (
TypeSlack = "slack"
TypeTelegram = "telegram"
TypeWhatsApp = "whatsapp"
+ TypeZaloBot = "zalo_bot"
TypeZaloOA = "zalo_oa"
TypeZaloPersonal = "zalo_personal"
)
@@ -145,6 +146,16 @@ type BlockReplyChannel interface {
BlockReplyEnabled() *bool
}
+// DMQuoteChannel is optionally implemented by channels that want the gateway
+// consumer to stamp reply_to_message_id on DM outbound metadata (the
+// standard group-only behavior is bypassed). The channel's Send path is
+// responsible for translating the metadata into the platform-specific quote
+// payload. Manager.QuoteInboundOnDM releases its RLock before invoking, so
+// implementations need not be lock-free, but should still be cheap.
+type DMQuoteChannel interface {
+ QuoteInboundOnDM() bool
+}
+
// WebhookChannel extends Channel with an HTTP handler that can be mounted
// on the main gateway mux instead of starting a separate HTTP server.
// This allows webhook-based channels (e.g. Feishu/Lark) to share the main
@@ -469,6 +480,17 @@ func (c *BaseChannel) MarkDegraded(summary, detail string, kind ChannelFailureKi
c.setHealth(NewChannelHealth(ChannelHealthStateDegraded, summary, detail, kind, retryable))
}
+// MarkBootstrap records a degraded state that's part of normal setup
+// (not a fault). The bootstrap_state field is locale-independent.
+func (c *BaseChannel) MarkBootstrap(state ChannelBootstrapState, summary, detail string, kind ChannelFailureKind, retryable bool) {
+ if summary == "" {
+ summary = "Setup incomplete"
+ }
+ h := NewChannelHealth(ChannelHealthStateDegraded, summary, detail, kind, retryable)
+ h.BootstrapState = state
+ c.setHealth(h)
+}
+
// MarkFailed records a startup or runtime failure.
func (c *BaseChannel) MarkFailed(summary, detail string, kind ChannelFailureKind, retryable bool) {
if summary == "" {
diff --git a/internal/channels/health.go b/internal/channels/health.go
index 0ad1d97c63..27ae720db4 100644
--- a/internal/channels/health.go
+++ b/internal/channels/health.go
@@ -40,6 +40,15 @@ const (
ChannelRemediationCodeCheckNetwork ChannelRemediationCode = "check_network"
)
+// ChannelBootstrapState classifies a degraded state that is part of normal
+// first-time setup rather than a fault. Locale-independent so UIs can gate
+// bootstrap banners without substring-matching localized summaries.
+type ChannelBootstrapState string
+
+const (
+ ChannelBootstrapAwaitingSecret ChannelBootstrapState = "awaiting_secret"
+)
+
// ChannelRemediationTarget tells the UI which existing surface can help resolve the issue.
type ChannelRemediationTarget string
@@ -75,6 +84,7 @@ type ChannelHealth struct {
LastFailedAt time.Time `json:"last_failed_at"`
LastHealthyAt time.Time `json:"last_healthy_at"`
Remediation *ChannelRemediation `json:"remediation,omitempty"`
+ BootstrapState ChannelBootstrapState `json:"bootstrap_state,omitempty"`
}
// ChannelErrorInfo contains shared error classification output for operators.
diff --git a/internal/channels/instance_loader.go b/internal/channels/instance_loader.go
index df6d677f30..2e5730fa00 100644
--- a/internal/channels/instance_loader.go
+++ b/internal/channels/instance_loader.go
@@ -270,6 +270,12 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns
if base, ok := ch.(interface{ SetTenantID(uuid.UUID) }); ok {
base.SetTenantID(inst.TenantID)
}
+ // Propagate the channel_instances.id row UUID. Used by channels (e.g.
+ // zalo_oa) that need to write back to their own row at runtime —
+ // e.g. token refresh persisting rotated credentials.
+ if base, ok := ch.(interface{ SetInstanceID(uuid.UUID) }); ok {
+ base.SetInstanceID(inst.ID)
+ }
// Propagate tenant_id to pending history for compaction/sweep DB operations.
// Factory creates PendingHistory before SetTenantID is called, so tenantID is uuid.Nil at construction.
if ph, ok := ch.(interface{ SetPendingHistoryTenantID(uuid.UUID) }); ok {
diff --git a/internal/channels/runs.go b/internal/channels/runs.go
index 3c5a0163cb..7000c6a5d0 100644
--- a/internal/channels/runs.go
+++ b/internal/channels/runs.go
@@ -56,3 +56,16 @@ func (m *Manager) ResolveBlockReply(channelName string, globalDefault *bool) boo
}
return globalDefault != nil && *globalDefault
}
+
+// QuoteInboundOnDM reports whether the named channel opts into DM reply-to
+// stamping. Channels that don't implement DMQuoteChannel default to false.
+func (m *Manager) QuoteInboundOnDM(channelName string) bool {
+ m.mu.RLock()
+ ch, exists := m.channels[channelName]
+ m.mu.RUnlock()
+ if !exists {
+ return false
+ }
+ q, ok := ch.(DMQuoteChannel)
+ return ok && q.QuoteInboundOnDM()
+}
diff --git a/internal/channels/runs_test.go b/internal/channels/runs_test.go
new file mode 100644
index 0000000000..3b3c006e22
--- /dev/null
+++ b/internal/channels/runs_test.go
@@ -0,0 +1,76 @@
+package channels
+
+import (
+ "context"
+ "testing"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+)
+
+// fakeQuoteChannel implements Channel + DMQuoteChannel.
+type fakeQuoteChannel struct {
+ name string
+ quote bool
+}
+
+func (f *fakeQuoteChannel) Name() string { return f.name }
+func (f *fakeQuoteChannel) Type() string { return f.name }
+func (f *fakeQuoteChannel) Start(ctx context.Context) error { return nil }
+func (f *fakeQuoteChannel) Stop(ctx context.Context) error { return nil }
+func (f *fakeQuoteChannel) Send(ctx context.Context, _ bus.OutboundMessage) error {
+ return nil
+}
+func (f *fakeQuoteChannel) IsRunning() bool { return true }
+func (f *fakeQuoteChannel) IsAllowed(_ string) bool { return true }
+func (f *fakeQuoteChannel) QuoteInboundOnDM() bool { return f.quote }
+
+// fakePlainChannel implements only Channel — no DMQuoteChannel.
+type fakePlainChannel struct{ name string }
+
+func (f *fakePlainChannel) Name() string { return f.name }
+func (f *fakePlainChannel) Type() string { return f.name }
+func (f *fakePlainChannel) Start(ctx context.Context) error { return nil }
+func (f *fakePlainChannel) Stop(ctx context.Context) error { return nil }
+func (f *fakePlainChannel) Send(ctx context.Context, _ bus.OutboundMessage) error {
+ return nil
+}
+func (f *fakePlainChannel) IsRunning() bool { return true }
+func (f *fakePlainChannel) IsAllowed(_ string) bool { return true }
+
+func TestQuoteInboundOnDM_OptedIn(t *testing.T) {
+ t.Parallel()
+ m := NewManager(bus.New())
+ m.RegisterChannel("zalo_oa", &fakeQuoteChannel{name: "zalo_oa", quote: true})
+
+ if !m.QuoteInboundOnDM("zalo_oa") {
+ t.Fatal("zalo_oa with QuoteInboundOnDM=true should opt in")
+ }
+}
+
+func TestQuoteInboundOnDM_NotImplemented(t *testing.T) {
+ t.Parallel()
+ m := NewManager(bus.New())
+ m.RegisterChannel("telegram", &fakePlainChannel{name: "telegram"})
+
+ if m.QuoteInboundOnDM("telegram") {
+ t.Fatal("telegram does not implement DMQuoteChannel; must return false")
+ }
+}
+
+func TestQuoteInboundOnDM_OptedOut(t *testing.T) {
+ t.Parallel()
+ m := NewManager(bus.New())
+ m.RegisterChannel("opt_out", &fakeQuoteChannel{name: "opt_out", quote: false})
+
+ if m.QuoteInboundOnDM("opt_out") {
+ t.Fatal("channel that returns false must not opt in")
+ }
+}
+
+func TestQuoteInboundOnDM_UnknownChannel(t *testing.T) {
+ t.Parallel()
+ m := NewManager(bus.New())
+ if m.QuoteInboundOnDM("missing") {
+ t.Fatal("unknown channel must return false")
+ }
+}
diff --git a/internal/channels/zalo/bot/api.go b/internal/channels/zalo/bot/api.go
new file mode 100644
index 0000000000..8db43a4860
--- /dev/null
+++ b/internal/channels/zalo/bot/api.go
@@ -0,0 +1,148 @@
+package bot
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// apiBase is the Zalo Bot API root; var so tests can override.
+var apiBase = "https://bot-api.zaloplatforms.com"
+
+func (c *Channel) callAPI(method string, body any) (json.RawMessage, error) {
+ return c.callAPIWith(context.Background(), c.client, method, body)
+}
+
+func (c *Channel) callAPIWith(ctx context.Context, client *http.Client, method string, body any) (json.RawMessage, error) {
+ url := fmt.Sprintf("%s/bot%s/%s", apiBase, c.token, method)
+
+ var reqBody io.Reader
+ if body != nil {
+ data, err := json.Marshal(body)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+ reqBody = bytes.NewReader(data)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", url, reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+ if reqBody != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ // *url.Error embeds the full URL including the bot token; scrub it.
+ return nil, fmt.Errorf("api call %s: %s", method, strings.ReplaceAll(err.Error(), c.token, ""))
+ }
+ defer resp.Body.Close()
+
+ respData, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read response: %w", err)
+ }
+
+ var apiResp zaloAPIResponse
+ if err := json.Unmarshal(respData, &apiResp); err != nil {
+ return nil, fmt.Errorf("unmarshal response: %w", err)
+ }
+
+ if !apiResp.OK {
+ return nil, formatAPIError(apiResp.ErrorCode, apiResp.Description)
+ }
+
+ return apiResp.Result, nil
+}
+
+func (c *Channel) getMe() (*zaloBotInfo, error) {
+ result, err := c.callAPI("getMe", nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var info zaloBotInfo
+ if err := json.Unmarshal(result, &info); err != nil {
+ return nil, fmt.Errorf("unmarshal bot info: %w", err)
+ }
+ return &info, nil
+}
+
+func (c *Channel) deleteWebhook() error {
+ _, err := c.callAPI("deleteWebhook", nil)
+ return err
+}
+
+func (c *Channel) getUpdates(timeout int) ([]zaloUpdate, error) {
+ params := map[string]any{
+ "timeout": timeout,
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second+pollTimeoutHeadroom)
+ defer cancel()
+
+ result, err := c.callAPIWith(ctx, c.pollClient, "getUpdates", params)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(result) > 0 && result[0] == '[' {
+ var batch []zaloUpdate
+ if err := json.Unmarshal(result, &batch); err != nil {
+ return nil, fmt.Errorf("unmarshal updates: %w", err)
+ }
+ out := batch[:0]
+ for _, u := range batch {
+ if u.EventName != "" {
+ out = append(out, u)
+ }
+ }
+ return out, nil
+ }
+ var update zaloUpdate
+ if err := json.Unmarshal(result, &update); err != nil {
+ return nil, fmt.Errorf("unmarshal updates: %w", err)
+ }
+ if update.EventName == "" {
+ return nil, nil
+ }
+ return []zaloUpdate{update}, nil
+}
+
+func (c *Channel) sendMessage(chatID, text string) error {
+ params := map[string]any{
+ "chat_id": chatID,
+ "text": text,
+ }
+
+ _, err := c.callAPI("sendMessage", params)
+ return err
+}
+
+func (c *Channel) sendPhoto(chatID, photoURL, caption string) error {
+ params := map[string]any{
+ "chat_id": chatID,
+ "photo": photoURL,
+ }
+ if caption != "" {
+ params["caption"] = caption
+ }
+
+ _, err := c.callAPI("sendPhoto", params)
+ return err
+}
+
+func (c *Channel) sendChatAction(chatID, action string) error {
+ _, err := c.callAPI("sendChatAction", map[string]any{
+ "chat_id": chatID,
+ "action": action,
+ })
+ return err
+}
diff --git a/internal/channels/zalo/bot/channel.go b/internal/channels/zalo/bot/channel.go
new file mode 100644
index 0000000000..699a2e0445
--- /dev/null
+++ b/internal/channels/zalo/bot/channel.go
@@ -0,0 +1,257 @@
+// Package bot implements the Zalo Bot channel (static-token variant).
+// API: https://bot-api.zaloplatforms.com
+// DM only, text limit 2000 chars, polling + webhook modes.
+package bot
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/typing"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/internal/tools"
+)
+
+const (
+ maxTextLength = 2000
+ defaultMediaMaxMB = 5
+ pairingDebounce = 60 * time.Second
+)
+
+// Channel connects to the Zalo Bot API.
+type Channel struct {
+ *channels.BaseChannel
+ token string
+ dmPolicy string
+ mediaMaxMB int
+ blockReply *bool
+ stopCh chan struct{}
+ client *http.Client
+ pollClient *http.Client
+ mediaClient *http.Client
+
+ transport string // "webhook" (default) | "polling"
+ webhookPath string // slug suffix appended to /channels/zalo/webhook/
+ webhookSecret string // guards X-Bot-Api-Secret-Token in webhook mode
+ botID string // from getMe; used to filter self-echoes
+ instanceID uuid.UUID
+
+ webhookRouter *common.Router
+ resolvedSlug string
+
+ bootstrapDroppedCount atomic.Int64
+
+ pollWG sync.WaitGroup
+ stopOnce sync.Once
+
+ legacyPhotoSentinelWarn sync.Once
+
+ typingCtrls sync.Map
+}
+
+func (c *Channel) SetInstanceID(id uuid.UUID) { c.instanceID = id }
+
+// inBootstrap: webhook with no secret yet — slug registered so Zalo's
+// setWebhook ping returns 200, but events drop until secret is pasted.
+func (c *Channel) inBootstrap() bool {
+ return c.transport == "webhook" && c.webhookSecret == ""
+}
+
+var _ channels.WebhookChannel = (*Channel)(nil)
+
+// WebhookHandler returns (path, handler) on the first caller across the
+// shared router; subsequent calls return ("", nil). Per-instance dispatch
+// uses the slug suffix of the path: /channels/zalo/webhook/.
+func (c *Channel) WebhookHandler() (string, http.Handler) {
+ return common.SharedRouter().MountRoute()
+}
+
+// ResolvedWebhookSlug returns the slug the channel registered with the shared
+// router (empty if not yet started or polling mode).
+func (c *Channel) ResolvedWebhookSlug() string { return c.resolvedSlug }
+
+// New creates a Zalo Bot channel.
+func New(cfg config.ZaloConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (*Channel, error) {
+ if cfg.Token == "" {
+ return nil, fmt.Errorf("zalo token is required")
+ }
+
+ base := channels.NewBaseChannel("zalo", msgBus, cfg.AllowFrom)
+ base.ValidatePolicy(cfg.DMPolicy, "")
+
+ dmPolicy := cfg.DMPolicy
+ if dmPolicy == "" {
+ dmPolicy = "pairing"
+ }
+
+ mediaMax := cfg.MediaMaxMB
+ if mediaMax <= 0 {
+ mediaMax = defaultMediaMaxMB
+ }
+
+ transport := cfg.Transport
+ if transport == "" {
+ transport = "webhook"
+ }
+
+ ch := &Channel{
+ BaseChannel: base,
+ token: cfg.Token,
+ dmPolicy: dmPolicy,
+ mediaMaxMB: mediaMax,
+ blockReply: cfg.BlockReply,
+ stopCh: make(chan struct{}),
+ client: &http.Client{Timeout: 60 * time.Second},
+ pollClient: &http.Client{Timeout: 0},
+ mediaClient: tools.NewSSRFSafeClient(60 * time.Second),
+ transport: transport,
+ webhookPath: cfg.WebhookPath,
+ webhookSecret: cfg.WebhookSecret,
+ webhookRouter: common.SharedRouter(),
+ }
+ ch.SetPairingService(pairingSvc)
+ return ch, nil
+}
+
+// BlockReplyEnabled returns the per-channel block_reply override
+// (nil = inherit gateway default).
+func (c *Channel) BlockReplyEnabled() *bool { return c.blockReply }
+
+// Start begins listening. polling: long-poll getUpdates.
+// webhook: register with common.Router; HandleWebhookEvent dispatches.
+func (c *Channel) Start(ctx context.Context) error {
+ info, err := c.getMe()
+ if err != nil {
+ return fmt.Errorf("zalo getMe failed: %w", err)
+ }
+ c.botID = info.ID
+ slog.Info("zalo bot connected",
+ "bot_id", info.ID, "bot_name", info.Name, "transport", c.transport)
+
+ c.SetRunning(true)
+
+ switch c.transport {
+ case "webhook":
+ slug := c.webhookPath
+ if slug == "" {
+ slug = common.DeriveSlugFromName(c.Name())
+ }
+ if err := c.webhookRouter.RegisterInstance(c.instanceID, c, c.TenantID(), slug); err != nil {
+ c.MarkFailed(
+ "webhook slug invalid",
+ err.Error(),
+ channels.ChannelFailureKindConfig,
+ false,
+ )
+ c.SetRunning(false)
+ return nil
+ }
+ c.resolvedSlug = slug
+
+ if c.inBootstrap() {
+ c.MarkBootstrap(
+ channels.ChannelBootstrapAwaitingSecret,
+ "awaiting webhook secret",
+ "Bot Webhook Secret not yet set. Webhook acks Zalo's setWebhook verification ping (HTTP 200) but drops events. Paste the same secret you registered with setWebhook in Credentials → Webhook Secret to enable X-Bot-Api-Secret-Token verification.",
+ channels.ChannelFailureKindConfig,
+ true,
+ )
+ slog.Info("zalo_bot.webhook.bootstrap_active",
+ "instance_id", c.instanceID, "bot_id", c.botID, "slug", slug)
+ return nil
+ }
+
+ slog.Info("zalo_bot.webhook.registered",
+ "instance_id", c.instanceID, "bot_id", c.botID, "slug", slug)
+ c.MarkHealthy("webhook")
+ case "polling":
+ // getUpdates 400s while a webhook URL is registered; clear it.
+ if err := c.deleteWebhook(); err != nil {
+ slog.Warn("zalo_bot.poll.delete_webhook_failed",
+ "instance_id", c.instanceID, "bot_id", c.botID, "err", err)
+ }
+ c.pollWG.Add(1)
+ go c.pollLoop(ctx)
+ c.MarkHealthy("polling")
+ default:
+ c.MarkFailed(
+ "unknown transport",
+ fmt.Sprintf("zalo_bot: unknown transport %q (expected webhook or polling)", c.transport),
+ channels.ChannelFailureKindConfig,
+ false,
+ )
+ c.SetRunning(false)
+ return nil
+ }
+ return nil
+}
+
+// Stop shuts down the bot; webhook mode unregisters from the shared router.
+func (c *Channel) Stop(_ context.Context) error {
+ slog.Info("stopping zalo bot", "transport", c.transport)
+ if c.transport == "webhook" && c.webhookRouter != nil {
+ c.webhookRouter.UnregisterInstance(c.instanceID)
+ }
+ c.stopOnce.Do(func() { close(c.stopCh) })
+ c.SetRunning(false)
+
+ c.typingCtrls.Range(func(key, val any) bool {
+ if ctrl, ok := val.(*typing.Controller); ok {
+ ctrl.Stop()
+ }
+ c.typingCtrls.Delete(key)
+ return true
+ })
+ c.pollWG.Wait()
+ return nil
+}
+
+// Send delivers an outbound message.
+func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return fmt.Errorf("zalo bot not running")
+ }
+
+ if ctrl, ok := c.typingCtrls.LoadAndDelete(msg.ChatID); ok {
+ ctrl.(*typing.Controller).Stop()
+ }
+
+ // Zalo Bot doesn't render markup.
+ msg.Content = common.StripMarkdown(msg.Content)
+
+ if strings.Contains(msg.Content, "[photo:") {
+ c.legacyPhotoSentinelWarn.Do(func() {
+ slog.Warn("zalo_bot.send.legacy_photo_sentinel_detected",
+ "chat_id", msg.ChatID,
+ "hint", "switch caller to bus.OutboundMessage.Media[]")
+ })
+ }
+
+ if len(msg.Media) == 0 {
+ return c.sendChunkedText(msg.ChatID, msg.Content)
+ }
+ if len(msg.Media) > 1 {
+ slog.Info("zalo_bot.send.extra_media_skipped",
+ "chat_id", msg.ChatID, "extra", len(msg.Media)-1)
+ }
+
+ m := msg.Media[0]
+ if !isHTTPURL(m.URL) {
+ return fmt.Errorf("zalo_bot: local file media not supported; use zalo_oa channel (got %q)", m.URL)
+ }
+ caption := mergeTrailingText(m.Caption, msg.Content)
+ return c.sendPhoto(msg.ChatID, m.URL, caption)
+}
+
diff --git a/internal/channels/zalo/bot/errors.go b/internal/channels/zalo/bot/errors.go
new file mode 100644
index 0000000000..296a65b539
--- /dev/null
+++ b/internal/channels/zalo/bot/errors.go
@@ -0,0 +1,57 @@
+package bot
+
+import (
+ "errors"
+ "fmt"
+)
+
+// Zalo Bot API error codes (HTTP-status-shaped) returned in the response
+// envelope's `error_code` field. Source: docs/zalo-error-codes.md (bot-api
+// section, scraped from https://bot.zapps.me/docs/error-code/).
+//
+// Note on code 403: the Zalo doc labels it "Internal server error", which is
+// inconsistent with HTTP semantics but matches what the API actually returns.
+// We stay faithful to the doc.
+const (
+ codeBotBadRequest = 400
+ codeBotUnauthorized = 401
+ codeBotInternalServerError = 403
+ codeBotNotFound = 404
+ codeBotRequestTimeout = 408
+ codeBotQuotaExceeded = 429
+)
+
+// botCodeHints maps a Zalo Bot error code to a one-sentence English hint
+// that the LLM (or an operator reading the channel error) can act on.
+// Unknown codes return the empty string and the legacy format is kept.
+var botCodeHints = map[int]string{
+ codeBotBadRequest: "Zalo rejected the request as malformed; verify the bot endpoint path, method name, and required parameters.",
+ codeBotUnauthorized: "Zalo bot token is expired or invalid; the operator must regenerate the token before sends will resume.",
+ codeBotInternalServerError: "Zalo returned an internal server error (Zalo labels code 403 this way); retry after a short backoff.",
+ codeBotNotFound: "Zalo could not find the target resource; verify chat_id / message_id / file_id before retrying.",
+ codeBotRequestTimeout: "Zalo took too long to process the request; retry after a short backoff.",
+ codeBotQuotaExceeded: "Zalo bot API rate limit exceeded; back off before retrying.",
+}
+
+// APIError carries the Zalo Bot envelope's error_code so callers can match
+// by errors.As instead of substring-grepping the formatted message.
+type APIError struct {
+ Code int
+ Description string
+}
+
+func (e *APIError) Error() string {
+ if hint, ok := botCodeHints[e.Code]; ok {
+ return fmt.Sprintf("zalo API error %d: %s — %s", e.Code, e.Description, hint)
+ }
+ return fmt.Sprintf("zalo API error %d: %s", e.Code, e.Description)
+}
+
+func formatAPIError(code int, description string) error {
+ return &APIError{Code: code, Description: description}
+}
+
+func isAPIErrCode(err error, code int) bool {
+ var apiErr *APIError
+ return errors.As(err, &apiErr) && apiErr.Code == code
+}
diff --git a/internal/channels/zalo/bot/errors_test.go b/internal/channels/zalo/bot/errors_test.go
new file mode 100644
index 0000000000..8edda6de5b
--- /dev/null
+++ b/internal/channels/zalo/bot/errors_test.go
@@ -0,0 +1,38 @@
+package bot
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestFormatAPIError_KnownCodes(t *testing.T) {
+ tests := []struct {
+ code int
+ descr string
+ mustHave []string // substrings the hint should contain
+ }{
+ {400, "Bad request", []string{"400", "Bad request", "endpoint path"}},
+ {401, "Unauthorized", []string{"401", "Unauthorized", "token"}},
+ {403, "Internal server error", []string{"403", "Internal server error", "retry"}},
+ {404, "Not found", []string{"404", "Not found", "chat_id"}},
+ {408, "Request timeout", []string{"408", "Request timeout", "backoff"}},
+ {429, "Quota exceeded", []string{"429", "Quota exceeded", "rate limit"}},
+ }
+
+ for _, tt := range tests {
+ got := formatAPIError(tt.code, tt.descr).Error()
+ for _, want := range tt.mustHave {
+ if !strings.Contains(got, want) {
+ t.Errorf("formatAPIError(%d, %q) missing %q in %q", tt.code, tt.descr, want, got)
+ }
+ }
+ }
+}
+
+func TestFormatAPIError_UnknownCodeFallsBack(t *testing.T) {
+ got := formatAPIError(999, "weird").Error()
+ want := "zalo API error 999: weird"
+ if got != want {
+ t.Errorf("formatAPIError(999, %q) = %q, want %q (no hint, legacy format)", "weird", got, want)
+ }
+}
diff --git a/internal/channels/zalo/bot/export_test.go b/internal/channels/zalo/bot/export_test.go
new file mode 100644
index 0000000000..12caad7e81
--- /dev/null
+++ b/internal/channels/zalo/bot/export_test.go
@@ -0,0 +1,3 @@
+package bot
+
+func (c *Channel) BootstrapDroppedForTest() int64 { return c.bootstrapDroppedCount.Load() }
diff --git a/internal/channels/zalo/factory.go b/internal/channels/zalo/bot/factory.go
similarity index 71%
rename from internal/channels/zalo/factory.go
rename to internal/channels/zalo/bot/factory.go
index 661afd8548..54d39d33b2 100644
--- a/internal/channels/zalo/factory.go
+++ b/internal/channels/zalo/bot/factory.go
@@ -1,4 +1,4 @@
-package zalo
+package bot
import (
"encoding/json"
@@ -10,22 +10,22 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
-// zaloCreds maps the credentials JSON from the channel_instances table.
type zaloCreds struct {
Token string `json:"token"`
WebhookSecret string `json:"webhook_secret,omitempty"`
}
-// zaloInstanceConfig maps the non-secret config JSONB from the channel_instances table.
type zaloInstanceConfig struct {
- DMPolicy string `json:"dm_policy,omitempty"`
- WebhookURL string `json:"webhook_url,omitempty"`
- MediaMaxMB int `json:"media_max_mb,omitempty"`
- AllowFrom []string `json:"allow_from,omitempty"`
- BlockReply *bool `json:"block_reply,omitempty"`
+ DMPolicy string `json:"dm_policy,omitempty"`
+ Transport string `json:"transport,omitempty"`
+ WebhookPath string `json:"webhook_path,omitempty"`
+ MediaMaxMB int `json:"media_max_mb,omitempty"`
+ AllowFrom []string `json:"allow_from,omitempty"`
+ BlockReply *bool `json:"block_reply,omitempty"`
}
-// Factory creates a Zalo OA channel from DB instance data.
+// Factory creates a Zalo Bot channel from channel_instances data.
+// Webhook-mode channels register with common.SharedRouter() at Start().
func Factory(name string, creds json.RawMessage, cfg json.RawMessage,
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) {
@@ -51,7 +51,8 @@ func Factory(name string, creds json.RawMessage, cfg json.RawMessage,
Token: c.Token,
AllowFrom: ic.AllowFrom,
DMPolicy: ic.DMPolicy,
- WebhookURL: ic.WebhookURL,
+ Transport: ic.Transport,
+ WebhookPath: ic.WebhookPath,
WebhookSecret: c.WebhookSecret,
MediaMaxMB: ic.MediaMaxMB,
BlockReply: ic.BlockReply,
@@ -61,7 +62,6 @@ func Factory(name string, creds json.RawMessage, cfg json.RawMessage,
if err != nil {
return nil, err
}
-
ch.SetName(name)
return ch, nil
}
diff --git a/internal/channels/zalo/factory_test.go b/internal/channels/zalo/bot/factory_test.go
similarity index 97%
rename from internal/channels/zalo/factory_test.go
rename to internal/channels/zalo/bot/factory_test.go
index 5d8e6a4c62..27494127a5 100644
--- a/internal/channels/zalo/factory_test.go
+++ b/internal/channels/zalo/bot/factory_test.go
@@ -1,4 +1,4 @@
-package zalo
+package bot
import (
"encoding/json"
@@ -13,7 +13,7 @@ import (
// Channel when credentials and config JSON are well-formed.
func TestFactory_ValidCredsProducesChannel(t *testing.T) {
creds := []byte(`{"token":"fake-zalo-token","webhook_secret":"hook-sec"}`)
- cfg := []byte(`{"dm_policy":"open","media_max_mb":7,"allow_from":["+84900000000"],"webhook_url":"https://example.test/hook","block_reply":true}`)
+ cfg := []byte(`{"dm_policy":"open","media_max_mb":7,"allow_from":["+84900000000"],"block_reply":true}`)
mb := bus.New()
ch, err := Factory("my-zalo", creds, cfg, mb, nil)
@@ -170,7 +170,6 @@ func TestFactoryConfigWithoutOptionals(t *testing.T) {
func TestZaloInstanceConfigRoundTrip(t *testing.T) {
src := zaloInstanceConfig{
DMPolicy: "pairing",
- WebhookURL: "https://example.test",
MediaMaxMB: 3,
AllowFrom: []string{"user1", "user2"},
}
diff --git a/internal/channels/zalo/bot/policy.go b/internal/channels/zalo/bot/policy.go
new file mode 100644
index 0000000000..af8ccd5627
--- /dev/null
+++ b/internal/channels/zalo/bot/policy.go
@@ -0,0 +1,52 @@
+package bot
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+)
+
+func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool {
+ result := c.CheckDMPolicy(ctx, senderID, c.dmPolicy)
+ switch result {
+ case channels.PolicyAllow:
+ return true
+ case channels.PolicyNeedsPairing:
+ c.sendPairingReply(ctx, senderID, chatID)
+ return false
+ default:
+ slog.Debug("zalo message rejected by policy", "sender_id", senderID, "policy", c.dmPolicy)
+ return false
+ }
+}
+
+func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) {
+ ps := c.PairingService()
+ if ps == nil {
+ return
+ }
+
+ if !c.CanSendPairingNotif(senderID, pairingDebounce) {
+ return
+ }
+
+ code, err := ps.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil)
+ if err != nil {
+ slog.Debug("zalo pairing request failed", "sender_id", senderID, "error", err)
+ return
+ }
+
+ replyText := fmt.Sprintf(
+ "GoClaw: access not configured.\n\nYour Zalo user id: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s",
+ senderID, code, code,
+ )
+
+ if err := c.sendMessage(chatID, replyText); err != nil {
+ slog.Warn("failed to send zalo pairing reply", "error", err)
+ } else {
+ c.MarkPairingNotifSent(senderID)
+ slog.Info("zalo pairing reply sent", "sender_id", senderID, "code", code)
+ }
+}
diff --git a/internal/channels/zalo/bot/poll.go b/internal/channels/zalo/bot/poll.go
new file mode 100644
index 0000000000..e0f35aeb4b
--- /dev/null
+++ b/internal/channels/zalo/bot/poll.go
@@ -0,0 +1,180 @@
+package bot
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/internal/tools"
+)
+
+const (
+ defaultPollTimeout = 30
+ pollErrorBackoff = 5 * time.Second
+ pollTimeoutHeadroom = 7 * time.Second
+)
+
+func (c *Channel) pollLoop(ctx context.Context) {
+ defer c.pollWG.Done()
+ slog.Info("zalo polling loop started")
+
+ for {
+ select {
+ case <-ctx.Done():
+ slog.Info("zalo polling loop stopped (context)")
+ return
+ case <-c.stopCh:
+ slog.Info("zalo polling loop stopped")
+ return
+ default:
+ }
+
+ updates, err := c.getUpdates(defaultPollTimeout)
+ if err != nil {
+ // 408 = long-poll timeout (no updates); not a real error.
+ if !isAPIErrCode(err, codeBotRequestTimeout) {
+ slog.Warn("zalo getUpdates error", "error", err)
+ select {
+ case <-ctx.Done():
+ return
+ case <-c.stopCh:
+ return
+ case <-time.After(pollErrorBackoff):
+ }
+ }
+ continue
+ }
+
+ for _, update := range updates {
+ c.processUpdate(update)
+ }
+ }
+}
+
+func (c *Channel) processUpdate(update zaloUpdate) {
+ // Zalo redelivers our own sends on both webhook and long-poll surfaces.
+ if update.Message != nil && update.Message.From.ID != "" && update.Message.From.ID == c.botID {
+ slog.Debug("zalo_bot.self_echo_filtered",
+ "bot_id", c.botID, "message_id", update.Message.MessageID)
+ return
+ }
+
+ switch update.EventName {
+ case "message.text.received":
+ if update.Message != nil {
+ c.handleTextMessage(update.Message)
+ }
+ case "message.image.received":
+ if update.Message != nil {
+ c.handleImageMessage(update.Message)
+ }
+ default:
+ slog.Debug("zalo unsupported event", "event", update.EventName)
+ }
+}
+
+func (c *Channel) handleTextMessage(msg *zaloMessage) {
+ ctx := context.Background()
+ ctx = store.WithTenantID(ctx, c.TenantID())
+ senderID := msg.From.ID
+ if senderID == "" {
+ slog.Warn("zalo: dropping text message with empty sender ID", "message_id", msg.MessageID)
+ return
+ }
+ chatID := msg.Chat.ID
+ if chatID == "" {
+ chatID = senderID
+ }
+
+ if !c.checkDMPolicy(ctx, senderID, chatID) {
+ return
+ }
+
+ content := msg.Text
+ if content == "" {
+ content = "[empty message]"
+ }
+
+ slog.Debug("zalo text message received",
+ "sender_id", senderID,
+ "chat_id", chatID,
+ "preview", channels.Truncate(content, 50),
+ )
+
+ metadata := common.InboundMeta{
+ MessageID: msg.MessageID,
+ Platform: common.PlatformZaloBot,
+ SenderDisplayName: msg.From.Username,
+ }.ToMap()
+
+ c.startTyping(chatID)
+ c.HandleMessage(senderID, chatID, content, nil, metadata, "direct")
+}
+
+func (c *Channel) handleImageMessage(msg *zaloMessage) {
+ ctx := context.Background()
+ ctx = store.WithTenantID(ctx, c.TenantID())
+ senderID := msg.From.ID
+ if senderID == "" {
+ slog.Warn("zalo: dropping image message with empty sender ID", "message_id", msg.MessageID)
+ return
+ }
+ chatID := msg.Chat.ID
+ if chatID == "" {
+ chatID = senderID
+ }
+
+ if !c.checkDMPolicy(ctx, senderID, chatID) {
+ return
+ }
+
+ content := msg.Caption
+ if content == "" {
+ content = "[image]"
+ }
+
+ // Zalo CDN URLs are auth-restricted/expiring; download to local temp.
+ var media []string
+ var photoURL string
+ switch {
+ case msg.PhotoURL != "":
+ photoURL = msg.PhotoURL
+ case msg.Photo != "":
+ photoURL = msg.Photo
+ }
+
+ if photoURL != "" {
+ if err := tools.CheckSSRF(photoURL); err != nil {
+ slog.Warn("zalo photo blocked by SSRF guard",
+ "photo_url", photoURL, "error", err)
+ } else {
+ localPath, err := c.downloadMedia(photoURL)
+ if err != nil {
+ slog.Warn("zalo photo download failed, passing URL as fallback",
+ "photo_url", photoURL, "error", err)
+ media = []string{photoURL}
+ } else {
+ media = []string{localPath}
+ }
+ }
+ }
+
+ slog.Info("zalo image message received",
+ "sender_id", senderID,
+ "chat_id", chatID,
+ "photo_url", photoURL,
+ "has_media", len(media) > 0,
+ )
+
+ metadata := common.InboundMeta{
+ MessageID: msg.MessageID,
+ Platform: common.PlatformZaloBot,
+ SenderDisplayName: msg.From.Username,
+ }.ToMap()
+
+ c.startTyping(chatID)
+ c.HandleMessage(senderID, chatID, content, media, metadata, "direct")
+}
diff --git a/internal/channels/zalo/bot/send.go b/internal/channels/zalo/bot/send.go
new file mode 100644
index 0000000000..acff5d6928
--- /dev/null
+++ b/internal/channels/zalo/bot/send.go
@@ -0,0 +1,96 @@
+package bot
+
+import (
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+)
+
+const maxMediaBytes = 10 * 1024 * 1024 // 10MB
+
+// isHTTPURL gates sendPhoto inputs — Zalo Bot's sendPhoto only accepts
+// remote URLs.
+func isHTTPURL(u string) bool {
+ return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://")
+}
+
+// mergeTrailingText joins caption + content with a blank line.
+func mergeTrailingText(caption, content string) string {
+ caption = strings.TrimSpace(caption)
+ content = strings.TrimSpace(content)
+ switch {
+ case caption == "" && content == "":
+ return ""
+ case caption == "":
+ return content
+ case content == "":
+ return caption
+ default:
+ return caption + "\n\n" + content
+ }
+}
+
+func (c *Channel) sendChunkedText(chatID, text string) error {
+ for _, chunk := range channels.ChunkMarkdown(text, maxTextLength) {
+ if err := c.sendMessage(chatID, chunk); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// downloadMedia fetches a photo from Zalo's CDN to a local temp file.
+// PhotoURL originates in Zalo's getUpdates JSON (untrusted) — uses the
+// SSRF-safe client to close the DNS-rebind window between CheckSSRF and
+// the actual dial.
+func (c *Channel) downloadMedia(url string) (string, error) {
+ resp, err := c.mediaClient.Get(url)
+ if err != nil {
+ return "", fmt.Errorf("fetch: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("http %d", resp.StatusCode)
+ }
+
+ ext := ".jpg"
+ ct := resp.Header.Get("Content-Type")
+ switch {
+ case strings.Contains(ct, "png"):
+ ext = ".png"
+ case strings.Contains(ct, "gif"):
+ ext = ".gif"
+ case strings.Contains(ct, "webp"):
+ ext = ".webp"
+ }
+
+ f, err := os.CreateTemp("", "goclaw_zalo_*"+ext)
+ if err != nil {
+ return "", fmt.Errorf("create temp: %w", err)
+ }
+ defer f.Close()
+
+ // cap+1 distinguishes fits from truncated; bare LimitReader chops silently.
+ n, err := io.Copy(f, io.LimitReader(resp.Body, maxMediaBytes+1))
+ if err != nil {
+ os.Remove(f.Name())
+ return "", fmt.Errorf("write: %w", err)
+ }
+ if n == 0 {
+ os.Remove(f.Name())
+ return "", fmt.Errorf("empty response")
+ }
+ if n > maxMediaBytes {
+ os.Remove(f.Name())
+ return "", fmt.Errorf("media exceeds %d byte cap", maxMediaBytes)
+ }
+
+ slog.Debug("zalo media downloaded", "path", f.Name(), "size", n)
+ return f.Name(), nil
+}
diff --git a/internal/channels/zalo/bot/types.go b/internal/channels/zalo/bot/types.go
new file mode 100644
index 0000000000..b2154b1555
--- /dev/null
+++ b/internal/channels/zalo/bot/types.go
@@ -0,0 +1,41 @@
+package bot
+
+import "encoding/json"
+
+type zaloAPIResponse struct {
+ OK bool `json:"ok"`
+ Result json.RawMessage `json:"result,omitempty"`
+ ErrorCode int `json:"error_code,omitempty"`
+ Description string `json:"description,omitempty"`
+}
+
+type zaloBotInfo struct {
+ ID string `json:"id"`
+ Name string `json:"display_name"`
+}
+
+type zaloMessage struct {
+ MessageID string `json:"message_id"`
+ Text string `json:"text"`
+ Photo string `json:"photo"`
+ PhotoURL string `json:"photo_url"`
+ Caption string `json:"caption"`
+ From zaloFrom `json:"from"`
+ Chat zaloChat `json:"chat"`
+ Date int64 `json:"date"`
+}
+
+type zaloFrom struct {
+ ID string `json:"id"`
+ Username string `json:"display_name"`
+}
+
+type zaloChat struct {
+ ID string `json:"id"`
+ Type string `json:"chat_type"`
+}
+
+type zaloUpdate struct {
+ EventName string `json:"event_name"`
+ Message *zaloMessage `json:"message,omitempty"`
+}
diff --git a/internal/channels/zalo/bot/typing.go b/internal/channels/zalo/bot/typing.go
new file mode 100644
index 0000000000..c8b7bbeb82
--- /dev/null
+++ b/internal/channels/zalo/bot/typing.go
@@ -0,0 +1,37 @@
+package bot
+
+import (
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels/typing"
+)
+
+// Zalo expires the indicator after ~5s; re-fire under that.
+const (
+ typingKeepalive = 4 * time.Second
+ typingMaxTTL = 60 * time.Second
+)
+
+func (c *Channel) startTyping(chatID string) {
+ if !c.IsRunning() {
+ return
+ }
+ ctrl := typing.New(typing.Options{
+ MaxDuration: typingMaxTTL,
+ KeepaliveInterval: typingKeepalive,
+ StartFn: func() error {
+ return c.sendChatAction(chatID, "typing")
+ },
+ })
+ if prev, ok := c.typingCtrls.Load(chatID); ok {
+ prev.(*typing.Controller).Stop()
+ }
+ c.typingCtrls.Store(chatID, ctrl)
+ // If Stop's Range happened before our Store, ctrl would leak past shutdown.
+ if !c.IsRunning() {
+ c.typingCtrls.Delete(chatID)
+ ctrl.Stop()
+ return
+ }
+ ctrl.Start()
+}
diff --git a/internal/channels/zalo/bot/webhook.go b/internal/channels/zalo/bot/webhook.go
new file mode 100644
index 0000000000..eb8e608425
--- /dev/null
+++ b/internal/channels/zalo/bot/webhook.go
@@ -0,0 +1,121 @@
+package bot
+
+import (
+ "context"
+ "crypto/subtle"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
+)
+
+// HandleWebhookEvent dispatches a webhook push. Zalo posts the raw event
+// directly (event_name + message at the top level); the {ok, result}
+// envelope is only used by polling getUpdates responses. Accept both
+// shapes so a future API change doesn't silently drop traffic.
+func (c *Channel) HandleWebhookEvent(_ context.Context, raw json.RawMessage) error {
+ if c.inBootstrap() {
+ n := c.bootstrapDroppedCount.Add(1)
+ // Cap warn-level at first hit so a guessed slug can't amplify logs.
+ if n == 1 {
+ slog.Warn("zalo_bot.webhook.bootstrap_drop",
+ "instance_id", c.instanceID,
+ "drop_count", n,
+ "hint", "paste Webhook Secret in Credentials tab to enable processing")
+ } else {
+ slog.Debug("zalo_bot.webhook.bootstrap_drop",
+ "instance_id", c.instanceID, "drop_count", n)
+ }
+ return nil
+ }
+
+ payload := raw
+ var wrap zaloAPIResponse
+ if json.Unmarshal(raw, &wrap) == nil {
+ switch {
+ case wrap.OK && len(wrap.Result) > 0:
+ payload = wrap.Result
+ case !wrap.OK && wrap.ErrorCode != 0:
+ slog.Debug("zalo_bot.webhook.envelope_not_ok",
+ "instance_id", c.instanceID, "code", wrap.ErrorCode, "desc", wrap.Description)
+ }
+ }
+
+ var u zaloUpdate
+ if err := json.Unmarshal(payload, &u); err != nil {
+ return fmt.Errorf("zalo_bot.webhook: decode update: %w", err)
+ }
+
+ c.processUpdate(u)
+ return nil
+}
+
+// SignatureVerifier returns a header-token verifier bound to the
+// channel's webhook_secret. Bootstrap returns a no-op so the setWebhook
+// URL-save ping gets 200; events are dropped in HandleWebhookEvent.
+func (c *Channel) SignatureVerifier() common.SignatureVerifier {
+ if c.inBootstrap() {
+ return bootstrapVerifier{}
+ }
+ return botSignatureVerifier{secret: c.webhookSecret}
+}
+
+// MessageIDExtractor reads message_id for router dedup.
+func (c *Channel) MessageIDExtractor() common.MessageIDExtractor {
+ return botMessageIDExtractor{}
+}
+
+// botSignatureVerifier compares X-Bot-Api-Secret-Token in constant time.
+// Empty secret is rejected up front — ConstantTimeCompare returns 1 when
+// both inputs are empty.
+type botSignatureVerifier struct {
+ secret string
+}
+
+func (v botSignatureVerifier) Verify(h http.Header, _ []byte) error {
+ if v.secret == "" {
+ return errors.New("zalo_bot.webhook: secret unset")
+ }
+ got := h.Get("X-Bot-Api-Secret-Token")
+ if got == "" {
+ return errors.New("zalo_bot.webhook: missing X-Bot-Api-Secret-Token")
+ }
+ // Reject length mismatch up front; ConstantTimeCompare's len path
+ // isn't documented as constant-time.
+ if len(got) != len(v.secret) {
+ return common.ErrSignatureMismatch
+ }
+ if subtle.ConstantTimeCompare([]byte(got), []byte(v.secret)) != 1 {
+ return common.ErrSignatureMismatch
+ }
+ return nil
+}
+
+type bootstrapVerifier struct{}
+
+func (bootstrapVerifier) Verify(http.Header, []byte) error { return nil }
+
+type botMessageIDExtractor struct{}
+
+func (botMessageIDExtractor) ExtractMessageID(raw json.RawMessage) string {
+ var probe struct {
+ Result *struct {
+ Message struct {
+ MessageID string `json:"message_id"`
+ } `json:"message"`
+ } `json:"result"`
+ Message struct {
+ MessageID string `json:"message_id"`
+ } `json:"message"`
+ }
+ if err := json.Unmarshal(raw, &probe); err != nil {
+ return ""
+ }
+ if probe.Result != nil && probe.Result.Message.MessageID != "" {
+ return probe.Result.Message.MessageID
+ }
+ return probe.Message.MessageID
+}
diff --git a/internal/channels/zalo/bot/webhook_test.go b/internal/channels/zalo/bot/webhook_test.go
new file mode 100644
index 0000000000..a203d5cab1
--- /dev/null
+++ b/internal/channels/zalo/bot/webhook_test.go
@@ -0,0 +1,220 @@
+package bot
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+)
+
+func newWebhookTestChannel(t *testing.T, secret string) (*Channel, *bus.MessageBus) {
+ t.Helper()
+ mb := bus.New()
+ ch, err := New(config.ZaloConfig{
+ Token: "tok",
+ Transport: "webhook",
+ WebhookSecret: secret,
+ DMPolicy: "open",
+ }, mb, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ ch.botID = "bot-self"
+ return ch, mb
+}
+
+func TestBotSignatureVerifier_RejectsEmptySecret(t *testing.T) {
+ v := botSignatureVerifier{secret: ""}
+ err := v.Verify(http.Header{"X-Bot-Api-Secret-Token": []string{"anything"}}, nil)
+ if err == nil || !strings.Contains(err.Error(), "secret unset") {
+ t.Errorf("err = %v, want secret-unset rejection", err)
+ }
+}
+
+func TestBotSignatureVerifier_RejectsMissingHeader(t *testing.T) {
+ v := botSignatureVerifier{secret: "s3cret"}
+ if err := v.Verify(http.Header{}, nil); err == nil {
+ t.Error("missing header should be rejected")
+ }
+}
+
+func TestBotSignatureVerifier_RejectsWrongSecret(t *testing.T) {
+ v := botSignatureVerifier{secret: "right"}
+ err := v.Verify(http.Header{"X-Bot-Api-Secret-Token": []string{"wrong"}}, nil)
+ if !errors.Is(err, common.ErrSignatureMismatch) {
+ t.Errorf("err = %v, want ErrSignatureMismatch", err)
+ }
+}
+
+func TestBotSignatureVerifier_AcceptsMatchingSecret(t *testing.T) {
+ v := botSignatureVerifier{secret: "s3cret"}
+ if err := v.Verify(http.Header{"X-Bot-Api-Secret-Token": []string{"s3cret"}}, nil); err != nil {
+ t.Errorf("err = %v, want nil", err)
+ }
+}
+
+func TestBotMessageIDExtractor(t *testing.T) {
+ e := botMessageIDExtractor{}
+ got := e.ExtractMessageID(json.RawMessage(`{"ok":true,"result":{"event_name":"x","message":{"message_id":"m123"}}}`))
+ if got != "m123" {
+ t.Errorf("got %q, want m123", got)
+ }
+ if got := e.ExtractMessageID(json.RawMessage(`{"event_name":"x","message":{"message_id":"m123"}}`)); got != "m123" {
+ t.Errorf("unwrapped payload (Zalo webhook shape) should also extract: got %q", got)
+ }
+ if e.ExtractMessageID(json.RawMessage(`{}`)) != "" {
+ t.Error("missing message_id should yield empty string")
+ }
+ if e.ExtractMessageID(json.RawMessage(`not-json`)) != "" {
+ t.Error("invalid JSON should yield empty string, not panic")
+ }
+}
+
+func TestHandleWebhookEvent_DispatchesToBus(t *testing.T) {
+ ch, mb := newWebhookTestChannel(t, "s3cret")
+ payload := `{"ok":true,"result":{"event_name":"message.text.received","message":{"message_id":"m1","text":"hi","from":{"id":"alice"},"chat":{"id":"alice"}}}}`
+ if err := ch.HandleWebhookEvent(context.Background(), json.RawMessage(payload)); err != nil {
+ t.Fatalf("HandleWebhookEvent: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ got, ok := mb.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("no inbound message published within deadline")
+ }
+ if got.Content != "hi" {
+ t.Errorf("content = %q, want hi", got.Content)
+ }
+}
+
+func TestHandleWebhookEvent_FiltersSelfEcho(t *testing.T) {
+ ch, mb := newWebhookTestChannel(t, "s3cret")
+ payload := `{"ok":true,"result":{"event_name":"message.text.received","message":{"message_id":"m1","text":"echo","from":{"id":"bot-self"},"chat":{"id":"someone"}}}}`
+ if err := ch.HandleWebhookEvent(context.Background(), json.RawMessage(payload)); err != nil {
+ t.Fatalf("HandleWebhookEvent: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ if _, ok := mb.ConsumeInbound(ctx); ok {
+ t.Error("self-echo should not have published an inbound message")
+ }
+}
+
+// Fixture verbatim from https://bot.zapps.me/docs/apis/webhook.
+func TestHandleWebhookEvent_DocsSamplePayload(t *testing.T) {
+ ch, mb := newWebhookTestChannel(t, "s3cret")
+ payload := `{
+ "ok": true,
+ "result": {
+ "message": {
+ "from": {"id": "6ede9afa66b88fe6d6a9", "display_name": "Ted", "is_bot": false},
+ "chat": {"id": "6ede9afa66b88fe6d6a9", "chat_type": "PRIVATE"},
+ "text": "Xin chào",
+ "message_id": "2d758cb5e222177a4e35",
+ "date": 1750316131602
+ },
+ "event_name": "message.text.received"
+ }
+ }`
+ if err := ch.HandleWebhookEvent(context.Background(), json.RawMessage(payload)); err != nil {
+ t.Fatalf("HandleWebhookEvent: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ got, ok := mb.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("docs-sample payload did not publish an inbound message")
+ }
+ if got.Content != "Xin chào" {
+ t.Errorf("content = %q, want Xin chào", got.Content)
+ }
+}
+
+func TestHandleWebhookEvent_DropsWhenOkFalse(t *testing.T) {
+ ch, mb := newWebhookTestChannel(t, "s3cret")
+ payload := `{"ok":false,"description":"some error"}`
+ if err := ch.HandleWebhookEvent(context.Background(), json.RawMessage(payload)); err != nil {
+ t.Fatalf("HandleWebhookEvent: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ if _, ok := mb.ConsumeInbound(ctx); ok {
+ t.Error("ok=false envelope should not dispatch")
+ }
+}
+
+func TestHandleWebhookEvent_BadJSONReturnsError(t *testing.T) {
+ ch, _ := newWebhookTestChannel(t, "s3cret")
+ if err := ch.HandleWebhookEvent(context.Background(), json.RawMessage(`not-json`)); err == nil {
+ t.Error("invalid JSON should return error")
+ }
+}
+
+func TestStart_WebhookWithoutSecret_EntersBootstrap(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"ok":true,"result":{"id":"bot-xyz","display_name":"TestBot"}}`))
+ }))
+ defer srv.Close()
+ swapAPIBase(t, srv.URL)
+
+ mb := bus.New()
+ ch, err := New(config.ZaloConfig{
+ Token: "tok",
+ Transport: "webhook",
+ WebhookPath: "bot-bootstrap",
+ // no WebhookSecret
+ }, mb, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ ch.webhookRouter = common.NewRouter()
+ ch.instanceID = uuid.New()
+
+ if err := ch.Start(context.Background()); err != nil {
+ t.Fatalf("Start without webhook_secret should not return error in bootstrap; got %v", err)
+ }
+ if !ch.inBootstrap() {
+ t.Error("channel should report inBootstrap()=true when secret missing")
+ }
+ if ch.ResolvedWebhookSlug() == "" {
+ t.Error("slug should be registered even in bootstrap so setWebhook ping succeeds")
+ }
+}
+
+func TestBootstrap_VerifierAcceptsAnything_HandlerDrops(t *testing.T) {
+ ch, mb := newWebhookTestChannel(t, "")
+ if !ch.inBootstrap() {
+ t.Fatalf("setup expected inBootstrap()=true with empty secret")
+ }
+
+ if err := ch.SignatureVerifier().Verify(http.Header{}, nil); err != nil {
+ t.Errorf("bootstrap verifier should accept missing header; got %v", err)
+ }
+ if err := ch.SignatureVerifier().Verify(http.Header{"X-Bot-Api-Secret-Token": []string{"anything"}}, nil); err != nil {
+ t.Errorf("bootstrap verifier should accept arbitrary token; got %v", err)
+ }
+
+ payload := `{"ok":true,"result":{"event_name":"message.text.received","message":{"message_id":"m1","text":"hi","from":{"id":"alice"},"chat":{"id":"alice"}}}}`
+ if err := ch.HandleWebhookEvent(context.Background(), json.RawMessage(payload)); err != nil {
+ t.Fatalf("HandleWebhookEvent in bootstrap: %v", err)
+ }
+ if got := ch.BootstrapDroppedForTest(); got != 1 {
+ t.Errorf("dropped count = %d, want 1", got)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ if _, ok := mb.ConsumeInbound(ctx); ok {
+ t.Error("bootstrap event should not be dispatched to the bus")
+ }
+}
diff --git a/internal/channels/zalo/zalo_test.go b/internal/channels/zalo/bot/zalo_test.go
similarity index 72%
rename from internal/channels/zalo/zalo_test.go
rename to internal/channels/zalo/bot/zalo_test.go
index 0b0b8b4995..c65406a05e 100644
--- a/internal/channels/zalo/zalo_test.go
+++ b/internal/channels/zalo/bot/zalo_test.go
@@ -1,4 +1,4 @@
-package zalo
+package bot
import (
"bytes"
@@ -336,9 +336,9 @@ func TestSend_PlainTextGoesThroughSendMessage(t *testing.T) {
}
}
-// TestSend_PhotoExtractionRoutesToSendPhoto verifies [photo:URL] is
-// extracted and sent via sendPhoto instead of sendMessage.
-func TestSend_PhotoExtractionRoutesToSendPhoto(t *testing.T) {
+// TestSend_MediaHTTPURLRoutesToSendPhoto verifies a Media[] entry with an
+// http(s) URL routes to the sendPhoto endpoint with merged caption.
+func TestSend_MediaHTTPURLRoutesToSendPhoto(t *testing.T) {
var lastPath string
var lastBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -353,7 +353,11 @@ func TestSend_PhotoExtractionRoutesToSendPhoto(t *testing.T) {
ch := newTestChannel(t, srv.URL)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "user-8",
- Content: "look at this [photo:https://cdn.example/test.jpg] nice pic",
+ Content: "nice pic",
+ Media: []bus.MediaAttachment{{
+ URL: "https://cdn.example/test.jpg",
+ Caption: "look at this",
+ }},
})
if err != nil {
t.Fatalf("Send: %v", err)
@@ -364,6 +368,59 @@ func TestSend_PhotoExtractionRoutesToSendPhoto(t *testing.T) {
if lastBody["photo"] != "https://cdn.example/test.jpg" {
t.Errorf("photo = %v", lastBody["photo"])
}
+ if got := lastBody["caption"]; got != "look at this\n\nnice pic" {
+ t.Errorf("caption = %q, want merged caption+content", got)
+ }
+}
+
+// TestSend_MediaLocalPathRejected verifies the bot rejects local-path media
+// with an actionable error directing operators to the zalo_oa channel.
+func TestSend_MediaLocalPathRejected(t *testing.T) {
+ called := false
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ _, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
+ }))
+ defer srv.Close()
+
+ ch := newTestChannel(t, srv.URL)
+ err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "user-9",
+ Content: "with caption",
+ Media: []bus.MediaAttachment{{URL: "/tmp/local.jpg"}},
+ })
+ if err == nil {
+ t.Fatalf("Send: want error for local-path media, got nil")
+ }
+ if !strings.Contains(err.Error(), "local file media not supported") {
+ t.Errorf("err = %v, want local-path rejection", err)
+ }
+ if called {
+ t.Error("API was called despite local-path rejection")
+ }
+}
+
+// TestSend_NoMediaRoutesToText verifies the absence of Media[] routes to the
+// text-chunking path (sendMessage), preserving back-compat for plain text.
+func TestSend_NoMediaRoutesToText(t *testing.T) {
+ var lastPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ lastPath = r.URL.Path
+ _, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
+ }))
+ defer srv.Close()
+
+ ch := newTestChannel(t, srv.URL)
+ err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "user-10",
+ Content: "plain message",
+ })
+ if err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+ if !strings.HasSuffix(lastPath, "/sendMessage") {
+ t.Errorf("path = %q, want sendMessage", lastPath)
+ }
}
// TestStop_SignalsLoopAndTogglesRunning verifies Stop closes stopCh and
@@ -466,6 +523,7 @@ func TestDownloadMedia_SuccessWritesTempFile(t *testing.T) {
mb := bus.New()
ch, _ := New(config.ZaloConfig{Token: "t"}, mb, nil)
+ ch.mediaClient = ch.client // httptest binds to 127.0.0.1; SSRF-safe client blocks loopback.
path, err := ch.downloadMedia(srv.URL + "/photo")
if err != nil {
t.Fatalf("downloadMedia: %v", err)
@@ -492,6 +550,7 @@ func TestDownloadMedia_HTTPErrorReturnsError(t *testing.T) {
defer srv.Close()
ch, _ := New(config.ZaloConfig{Token: "t"}, bus.New(), nil)
+ ch.mediaClient = ch.client
if _, err := ch.downloadMedia(srv.URL); err == nil {
t.Fatal("expected error on 404, got nil")
}
@@ -507,11 +566,30 @@ func TestDownloadMedia_EmptyResponseReturnsError(t *testing.T) {
defer srv.Close()
ch, _ := New(config.ZaloConfig{Token: "t"}, bus.New(), nil)
+ ch.mediaClient = ch.client
if _, err := ch.downloadMedia(srv.URL); err == nil {
t.Fatal("expected empty-response error, got nil")
}
}
+// TestDownloadMedia_OversizeReturnsError verifies the cap is enforced rather
+// than silently truncating (regression: bare LimitReader chops oversize media).
+func TestDownloadMedia_OversizeReturnsError(t *testing.T) {
+ // Stream cap+1 bytes so io.Copy reads past the cap and triggers the guard.
+ const oversize = 10*1024*1024 + 1
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/jpeg")
+ _, _ = w.Write(bytes.Repeat([]byte("x"), oversize))
+ }))
+ defer srv.Close()
+
+ ch, _ := New(config.ZaloConfig{Token: "t"}, bus.New(), nil)
+ ch.mediaClient = ch.client
+ if _, err := ch.downloadMedia(srv.URL); err == nil {
+ t.Fatal("expected oversize error, got nil")
+ }
+}
+
// TestDownloadMedia_FallbackJPEGExtension verifies an unknown content-type
// defaults to .jpg extension.
func TestDownloadMedia_FallbackJPEGExtension(t *testing.T) {
@@ -522,6 +600,7 @@ func TestDownloadMedia_FallbackJPEGExtension(t *testing.T) {
defer srv.Close()
ch, _ := New(config.ZaloConfig{Token: "t"}, bus.New(), nil)
+ ch.mediaClient = ch.client
path, err := ch.downloadMedia(srv.URL)
if err != nil {
t.Fatalf("downloadMedia: %v", err)
@@ -552,3 +631,125 @@ func TestZaloAPIResponse_Roundtrip(t *testing.T) {
t.Error("OK field lost in round-trip")
}
}
+
+func TestSendChatAction_PostsBodyWithParams(t *testing.T) {
+ var gotPath string
+ var gotBody map[string]any
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ raw, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(raw, &gotBody)
+ _, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
+ }))
+ defer srv.Close()
+
+ ch := newTestChannel(t, srv.URL)
+ if err := ch.sendChatAction("chat-1", "typing"); err != nil {
+ t.Fatalf("sendChatAction: %v", err)
+ }
+ if gotPath != "/bott/sendChatAction" {
+ t.Errorf("path = %q, want /bott/sendChatAction", gotPath)
+ }
+ if gotBody["chat_id"] != "chat-1" {
+ t.Errorf("chat_id = %v, want chat-1", gotBody["chat_id"])
+ }
+ if gotBody["action"] != "typing" {
+ t.Errorf("action = %v, want typing", gotBody["action"])
+ }
+}
+
+func TestStartTyping_FiresAndStoresController(t *testing.T) {
+ var calls int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&calls, 1)
+ _, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
+ }))
+ defer srv.Close()
+
+ ch := newTestChannel(t, srv.URL)
+ ch.startTyping("chat-1")
+
+ // Allow the initial fire to land.
+ deadline := time.Now().Add(500 * time.Millisecond)
+ for time.Now().Before(deadline) && atomic.LoadInt32(&calls) == 0 {
+ time.Sleep(10 * time.Millisecond)
+ }
+ if got := atomic.LoadInt32(&calls); got < 1 {
+ t.Errorf("sendChatAction calls = %d, want ≥1", got)
+ }
+ if _, ok := ch.typingCtrls.Load("chat-1"); !ok {
+ t.Error("typingCtrls missing entry for chat-1")
+ }
+ _ = ch.Stop(context.Background())
+}
+
+func TestStartTyping_NoOpWhenNotRunning(t *testing.T) {
+ var calls int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&calls, 1)
+ _, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
+ }))
+ defer srv.Close()
+
+ swapAPIBase(t, srv.URL)
+ ch, err := New(config.ZaloConfig{Token: "t", DMPolicy: "open"}, bus.New(), nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ ch.startTyping("chat-1")
+
+ time.Sleep(50 * time.Millisecond)
+ if got := atomic.LoadInt32(&calls); got != 0 {
+ t.Errorf("sendChatAction calls = %d, want 0 (channel not running)", got)
+ }
+ if _, ok := ch.typingCtrls.Load("chat-1"); ok {
+ t.Error("typingCtrls should be empty when channel not running")
+ }
+}
+
+func TestSend_StopsTypingController(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
+ }))
+ defer srv.Close()
+
+ ch := newTestChannel(t, srv.URL)
+ ch.startTyping("chat-1")
+ if _, ok := ch.typingCtrls.Load("chat-1"); !ok {
+ t.Fatal("precondition: typing controller not stored")
+ }
+
+ if err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "chat-1",
+ Content: "hi",
+ }); err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+ if _, ok := ch.typingCtrls.Load("chat-1"); ok {
+ t.Error("typingCtrls entry should be cleared after Send")
+ }
+}
+
+func TestStop_DrainsTypingControllers(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
+ }))
+ defer srv.Close()
+
+ ch := newTestChannel(t, srv.URL)
+ ch.startTyping("chat-1")
+ ch.startTyping("chat-2")
+
+ if err := ch.Stop(context.Background()); err != nil {
+ t.Fatalf("Stop: %v", err)
+ }
+
+ count := 0
+ ch.typingCtrls.Range(func(_, _ any) bool {
+ count++
+ return true
+ })
+ if count != 0 {
+ t.Errorf("typingCtrls residual entries = %d, want 0", count)
+ }
+}
diff --git a/internal/channels/zalo/common/dedup.go b/internal/channels/zalo/common/dedup.go
new file mode 100644
index 0000000000..80b6dbae74
--- /dev/null
+++ b/internal/channels/zalo/common/dedup.go
@@ -0,0 +1,143 @@
+package common
+
+import (
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// Dedup is a TTL cache of (instanceID, messageID) pairs with global and
+// per-instance caps. Eviction on cap-hit removes the oldest entry — not
+// strict LRU, since access doesn't refresh ordering. The per-instance cap
+// prevents a single noisy tenant from monopolizing the global slot count.
+type Dedup struct {
+ mu sync.Mutex
+ ttl time.Duration
+ maxGlobal int
+ maxPerInstance int
+ now func() time.Time
+
+ entries map[string]dedupEntry
+ perInst map[uuid.UUID]int
+}
+
+type dedupEntry struct {
+ addedAt time.Time
+ instanceID uuid.UUID
+}
+
+// NewDedup returns a Dedup with TTL and global cap. Per-instance cap is
+// derived as max(maxGlobal/4, 1) so tenants can't starve each other.
+func NewDedup(ttl time.Duration, maxGlobal int) *Dedup {
+ perInst := maxGlobal / 4
+ if perInst < 1 {
+ perInst = 1
+ }
+ return &Dedup{
+ ttl: ttl,
+ maxGlobal: maxGlobal,
+ maxPerInstance: perInst,
+ now: time.Now,
+ entries: make(map[string]dedupEntry),
+ perInst: make(map[uuid.UUID]int),
+ }
+}
+
+// SeenOrAdd records the (instanceID, messageID) pair and reports whether
+// it was already seen within TTL. Empty messageID is not-seen and not recorded.
+func (d *Dedup) SeenOrAdd(instanceID uuid.UUID, messageID string) bool {
+ if messageID == "" {
+ return false
+ }
+ key := instanceID.String() + "|" + messageID
+
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ now := d.now()
+ if e, ok := d.entries[key]; ok && now.Sub(e.addedAt) < d.ttl {
+ return true
+ }
+
+ // Sweep only at-cap; TTL check above prevents stale false-positives meanwhile.
+ if len(d.entries) >= d.maxGlobal || d.perInst[instanceID] >= d.maxPerInstance {
+ d.evictExpired(now)
+ if d.perInst[instanceID] >= d.maxPerInstance {
+ d.evictOldestForInstance(instanceID)
+ }
+ if len(d.entries) >= d.maxGlobal {
+ d.evictOldestGlobal()
+ }
+ }
+
+ if _, exists := d.entries[key]; !exists {
+ d.perInst[instanceID]++
+ }
+ d.entries[key] = dedupEntry{addedAt: now, instanceID: instanceID}
+ return false
+}
+
+// Len reports the current entry count (live + not-yet-pruned).
+func (d *Dedup) Len() int {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ return len(d.entries)
+}
+
+func (d *Dedup) evictExpired(now time.Time) {
+ for k, e := range d.entries {
+ if now.Sub(e.addedAt) >= d.ttl {
+ d.deleteKey(k)
+ }
+ }
+}
+
+func (d *Dedup) evictOldestGlobal() {
+ var oldestKey string
+ var oldestTime time.Time
+ first := true
+ for k, e := range d.entries {
+ if first || e.addedAt.Before(oldestTime) {
+ oldestKey = k
+ oldestTime = e.addedAt
+ first = false
+ }
+ }
+ if !first {
+ d.deleteKey(oldestKey)
+ }
+}
+
+func (d *Dedup) evictOldestForInstance(id uuid.UUID) {
+ var oldestKey string
+ var oldestTime time.Time
+ first := true
+ for k, e := range d.entries {
+ if e.instanceID != id {
+ continue
+ }
+ if first || e.addedAt.Before(oldestTime) {
+ oldestKey = k
+ oldestTime = e.addedAt
+ first = false
+ }
+ }
+ if !first {
+ d.deleteKey(oldestKey)
+ }
+}
+
+func (d *Dedup) deleteKey(k string) {
+ e, ok := d.entries[k]
+ if !ok {
+ return
+ }
+ delete(d.entries, k)
+ if d.perInst[e.instanceID] > 0 {
+ d.perInst[e.instanceID]--
+ if d.perInst[e.instanceID] == 0 {
+ delete(d.perInst, e.instanceID)
+ }
+ }
+}
diff --git a/internal/channels/zalo/common/dedup_test.go b/internal/channels/zalo/common/dedup_test.go
new file mode 100644
index 0000000000..f6753f690d
--- /dev/null
+++ b/internal/channels/zalo/common/dedup_test.go
@@ -0,0 +1,128 @@
+package common
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// fakeClock returns advancing deterministic timestamps so tests don't sleep.
+type fakeClock struct{ t time.Time }
+
+func (c *fakeClock) now() time.Time { return c.t }
+
+func (c *fakeClock) advance(d time.Duration) { c.t = c.t.Add(d) }
+
+func newDedupWithClock(ttl time.Duration, maxGlobal int) (*Dedup, *fakeClock) {
+ clk := &fakeClock{t: time.Unix(0, 0)}
+ d := NewDedup(ttl, maxGlobal)
+ d.now = clk.now
+ return d, clk
+}
+
+func TestDedup_FirstAddNotSeen(t *testing.T) {
+ d, _ := newDedupWithClock(time.Minute, 100)
+ id := uuid.New()
+ if d.SeenOrAdd(id, "m1") {
+ t.Error("first SeenOrAdd should report not-seen")
+ }
+}
+
+func TestDedup_DuplicateWithinTTLSeen(t *testing.T) {
+ d, _ := newDedupWithClock(time.Minute, 100)
+ id := uuid.New()
+ d.SeenOrAdd(id, "m1")
+ if !d.SeenOrAdd(id, "m1") {
+ t.Error("second SeenOrAdd within TTL should report seen")
+ }
+}
+
+func TestDedup_ExpiryRecyclesEntry(t *testing.T) {
+ d, clk := newDedupWithClock(10*time.Millisecond, 100)
+ id := uuid.New()
+ d.SeenOrAdd(id, "m1")
+ clk.advance(20 * time.Millisecond)
+ if d.SeenOrAdd(id, "m1") {
+ t.Error("entry should be expired and treated as not-seen")
+ }
+}
+
+func TestDedup_InstanceScopeIsolation(t *testing.T) {
+ d, _ := newDedupWithClock(time.Minute, 100)
+ a, b := uuid.New(), uuid.New()
+ d.SeenOrAdd(a, "m1")
+ if d.SeenOrAdd(b, "m1") {
+ t.Error("same messageID under different instanceID should not collide")
+ }
+}
+
+func TestDedup_GlobalCapEvictsOldest(t *testing.T) {
+ // maxGlobal=12 keeps per-instance cap at 3 (maxGlobal/4) so this exercises
+ // global eviction without colliding with the per-instance cap.
+ d, clk := newDedupWithClock(time.Minute, 12)
+ a, b, c, e := uuid.New(), uuid.New(), uuid.New(), uuid.New()
+ for _, id := range []uuid.UUID{a, b, c, e} {
+ for _, m := range []string{"m1", "m2", "m3"} {
+ d.SeenOrAdd(id, m)
+ clk.advance(time.Millisecond)
+ }
+ }
+ if d.Len() != 12 {
+ t.Fatalf("len = %d, want 12", d.Len())
+ }
+ // One more entry forces global eviction of the oldest (a, m1).
+ d.SeenOrAdd(uuid.New(), "x")
+ if d.Len() != 12 {
+ t.Errorf("len after eviction = %d, want 12", d.Len())
+ }
+ if d.SeenOrAdd(a, "m1") {
+ t.Error("oldest entry should have been evicted")
+ }
+}
+
+func TestDedup_PerInstanceCapEvictsOldestForThatInstance(t *testing.T) {
+ d, clk := newDedupWithClock(time.Minute, 16) // perInstance=4
+ a, b := uuid.New(), uuid.New()
+ for _, m := range []string{"m1", "m2", "m3", "m4"} {
+ d.SeenOrAdd(a, m)
+ clk.advance(time.Millisecond)
+ }
+ d.SeenOrAdd(b, "z1") // unrelated tenant
+ clk.advance(time.Millisecond)
+
+ // Adding 5th entry for `a` evicts `a`'s oldest (m1) only.
+ d.SeenOrAdd(a, "m5")
+ if d.SeenOrAdd(b, "z1") == false {
+ t.Error("instance b's entry should still be present after a's eviction")
+ }
+ if d.SeenOrAdd(a, "m1") {
+ t.Error("a/m1 should have been evicted as oldest for instance a")
+ }
+}
+
+func TestDedup_EmptyMessageIDNotRecorded(t *testing.T) {
+ d, _ := newDedupWithClock(time.Minute, 100)
+ id := uuid.New()
+ if d.SeenOrAdd(id, "") {
+ t.Error("empty messageID should never report seen")
+ }
+ if d.Len() != 0 {
+ t.Error("empty messageID should not be recorded")
+ }
+}
+
+func TestDedup_ConcurrentAccessRaceClean(t *testing.T) {
+ d := NewDedup(time.Minute, 1000)
+ id := uuid.New()
+ var wg sync.WaitGroup
+ for i := range 50 {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ d.SeenOrAdd(id, "m1")
+ }(i)
+ }
+ wg.Wait()
+}
diff --git a/internal/channels/zalo/common/image_compress.go b/internal/channels/zalo/common/image_compress.go
new file mode 100644
index 0000000000..f644023fbb
--- /dev/null
+++ b/internal/channels/zalo/common/image_compress.go
@@ -0,0 +1,190 @@
+package common
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/color"
+ "image/draw"
+ "image/jpeg"
+ "image/png"
+ "log/slog"
+
+ "github.com/disintegration/imaging"
+ _ "golang.org/x/image/webp" // register WebP decoder
+)
+
+// CompressImage shrinks oversized images under maxBytes for any Zalo upload
+// endpoint that caps payload size (OA /v2.0/oa/upload/image: 1MB jpg/png).
+// Bot uploads photos by URL — the URL is typically obtained from the same
+// OA upload endpoint, so Bot inherits the cap transitively.
+//
+// Transparent inputs first try PNG (lossless), then fall back to a
+// white-flattened JPEG so a noisy alpha image doesn't fail the send.
+
+var (
+ jpegQualityLadder = []int{85, 75, 65, 55, 45, 35}
+ maxSideLadder = []int{1600, 1200, 900, 600}
+)
+
+// Bounds the RGBA buffer image.Decode allocates so a small payload with
+// huge dimensions can't pin GB of memory.
+const maxDecodePixels = 25_000_000
+
+func CompressImage(data []byte, originalMIME string, maxBytes int) ([]byte, string, error) {
+ if len(data) <= maxBytes {
+ return data, originalMIME, nil
+ }
+
+ cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
+ if err != nil {
+ return nil, "", fmt.Errorf("zalo: decode image header: %w", err)
+ }
+ if int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
+ return nil, "", fmt.Errorf("zalo: image dimensions %dx%d exceed %d pixel cap",
+ cfg.Width, cfg.Height, maxDecodePixels)
+ }
+
+ // AutoOrientation applies EXIF rotation so phone photos arrive upright
+ // after we strip EXIF on re-encode.
+ img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
+ if err != nil {
+ return nil, "", fmt.Errorf("zalo: decode image for compression: %w", err)
+ }
+
+ if hasTransparency(img, originalMIME) {
+ if out, ok := encodePNGLadder(img, maxBytes); ok {
+ slog.Info("zalo.image.compressed",
+ "orig_bytes", len(data), "orig_mime", originalMIME,
+ "new_bytes", len(out), "out_mime", "image/png", "transparent", true)
+ return out, "image/png", nil
+ }
+ // PNG can't fit — flatten onto white so the message still ships.
+ img = flattenOnWhite(img)
+ }
+
+ out, side, q, err := encodeJPEGLadder(img, maxBytes)
+ if err != nil {
+ return nil, "", err
+ }
+ if out != nil {
+ slog.Info("zalo.image.compressed",
+ "orig_bytes", len(data), "orig_mime", originalMIME,
+ "new_bytes", len(out), "out_mime", "image/jpeg",
+ "side", side, "quality", q)
+ return out, "image/jpeg", nil
+ }
+ b := img.Bounds()
+ return nil, "", fmt.Errorf("zalo: image cannot fit under %d bytes (%dx%d original %d bytes)",
+ maxBytes, b.Dx(), b.Dy(), len(data))
+}
+
+func hasTransparency(img image.Image, originalMIME string) bool {
+ if originalMIME == "image/jpeg" {
+ return false
+ }
+ switch im := img.(type) {
+ case *image.RGBA:
+ for i := 3; i < len(im.Pix); i += 4 {
+ if im.Pix[i] != 0xff {
+ return true
+ }
+ }
+ return false
+ case *image.NRGBA:
+ for i := 3; i < len(im.Pix); i += 4 {
+ if im.Pix[i] != 0xff {
+ return true
+ }
+ }
+ return false
+ case *image.RGBA64:
+ // 16-bit alpha at byte offsets 6..7 of each 8-byte pixel.
+ for i := 6; i+1 < len(im.Pix); i += 8 {
+ if im.Pix[i] != 0xff || im.Pix[i+1] != 0xff {
+ return true
+ }
+ }
+ return false
+ case *image.NRGBA64:
+ for i := 6; i+1 < len(im.Pix); i += 8 {
+ if im.Pix[i] != 0xff || im.Pix[i+1] != 0xff {
+ return true
+ }
+ }
+ return false
+ case *image.Paletted:
+ for _, c := range im.Palette {
+ if _, _, _, a := c.RGBA(); a < 0xffff {
+ return true
+ }
+ }
+ return false
+ }
+ switch img.ColorModel() {
+ case color.YCbCrModel, color.GrayModel, color.Gray16Model, color.CMYKModel:
+ return false
+ }
+ b := img.Bounds()
+ for y := b.Min.Y; y < b.Max.Y; y++ {
+ for x := b.Min.X; x < b.Max.X; x++ {
+ if _, _, _, a := img.At(x, y).RGBA(); a < 0xffff {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// PNG has no quality knob, so only dimensions can shrink the output.
+// Returns ok=false when the smallest tried side still overflows.
+func encodePNGLadder(img image.Image, maxBytes int) ([]byte, bool) {
+ bounds := img.Bounds()
+ origW, origH := bounds.Dx(), bounds.Dy()
+ enc := png.Encoder{CompressionLevel: png.BestCompression}
+ for _, side := range maxSideLadder {
+ scaled := img
+ if origW > side || origH > side {
+ scaled = imaging.Fit(img, side, side, imaging.Lanczos)
+ }
+ var buf bytes.Buffer
+ if err := enc.Encode(&buf, scaled); err != nil {
+ continue
+ }
+ if buf.Len() <= maxBytes {
+ return buf.Bytes(), true
+ }
+ }
+ return nil, false
+}
+
+// Returns nil bytes with nil error when the ladder is exhausted without
+// fitting — so callers can distinguish "didn't fit" from "encode broke".
+func encodeJPEGLadder(img image.Image, maxBytes int) ([]byte, int, int, error) {
+ bounds := img.Bounds()
+ origW, origH := bounds.Dx(), bounds.Dy()
+ for _, side := range maxSideLadder {
+ scaled := img
+ if origW > side || origH > side {
+ scaled = imaging.Fit(img, side, side, imaging.Lanczos)
+ }
+ for _, q := range jpegQualityLadder {
+ var buf bytes.Buffer
+ if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: q}); err != nil {
+ return nil, 0, 0, fmt.Errorf("zalo: jpeg encode (side=%d q=%d): %w", side, q, err)
+ }
+ if buf.Len() <= maxBytes {
+ return buf.Bytes(), side, q, nil
+ }
+ }
+ }
+ return nil, 0, 0, nil
+}
+
+func flattenOnWhite(img image.Image) *image.RGBA {
+ b := img.Bounds()
+ out := image.NewRGBA(b)
+ draw.Draw(out, b, &image.Uniform{C: color.White}, image.Point{}, draw.Src)
+ draw.Draw(out, b, img, b.Min, draw.Over)
+ return out
+}
diff --git a/internal/channels/zalo/common/image_compress_test.go b/internal/channels/zalo/common/image_compress_test.go
new file mode 100644
index 0000000000..70df5f168b
--- /dev/null
+++ b/internal/channels/zalo/common/image_compress_test.go
@@ -0,0 +1,147 @@
+package common
+
+import (
+ "bytes"
+ "image"
+ "image/color"
+ "image/png"
+ "math/rand/v2"
+ "testing"
+)
+
+// synthesizePNG encodes a PNG of the given dimensions. Solid for passthrough
+// tests; pseudo-random noise for shrink-over-cap tests so DEFLATE can't
+// collapse the output, producing a realistic multi-MB payload.
+func synthesizePNG(t *testing.T, w, h int, noisy bool) []byte {
+ t.Helper()
+ img := image.NewRGBA(image.Rect(0, 0, w, h))
+ if noisy {
+ r := rand.New(rand.NewPCG(42, 42))
+ for y := range h {
+ for x := range w {
+ img.Set(x, y, color.RGBA{uint8(r.UintN(256)), uint8(r.UintN(256)), uint8(r.UintN(256)), 255})
+ }
+ }
+ } else {
+ for y := range h {
+ for x := range w {
+ img.Set(x, y, color.RGBA{uint8(x), uint8(y), uint8((x + y) % 256), 255})
+ }
+ }
+ }
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ t.Fatalf("synthesize png: %v", err)
+ }
+ return buf.Bytes()
+}
+
+func TestCompressImage_UnderCapIsPassthrough(t *testing.T) {
+ t.Parallel()
+ data := synthesizePNG(t, 100, 100, false)
+ cap := 1 << 20
+ out, mt, err := CompressImage(data, "image/png", cap)
+ if err != nil {
+ t.Fatalf("compress: %v", err)
+ }
+ if !bytes.Equal(out, data) {
+ t.Errorf("expected passthrough when under cap, got re-encoded bytes")
+ }
+ if mt != "image/png" {
+ t.Errorf("mime = %q, want image/png (unchanged)", mt)
+ }
+}
+
+func TestCompressImage_ShrinksOverCap(t *testing.T) {
+ t.Parallel()
+ data := synthesizePNG(t, 1500, 1500, true)
+ cap := 1 << 20
+ if len(data) <= cap {
+ t.Fatalf("synthesized PNG is only %d bytes; expected >1MB", len(data))
+ }
+
+ out, mt, err := CompressImage(data, "image/png", cap)
+ if err != nil {
+ t.Fatalf("compress: %v", err)
+ }
+ if len(out) > cap {
+ t.Errorf("compressed size %d still exceeds cap %d", len(out), cap)
+ }
+ if mt != "image/jpeg" {
+ t.Errorf("mime = %q, want image/jpeg after compression", mt)
+ }
+}
+
+func TestCompressImage_InvalidDataReturnsError(t *testing.T) {
+ t.Parallel()
+ // cap smaller than payload so we reach decode instead of passthrough.
+ garbage := []byte("not an image, and definitely not bytes the image package can decode.")
+ _, _, err := CompressImage(garbage, "image/png", 10)
+ if err == nil {
+ t.Fatal("expected decode error on garbage bytes")
+ }
+}
+
+// synthesizeTransparentNoisyPNG fills RGBA with random color AND random alpha
+// so DEFLATE can't shrink it and hasTransparency must detect alpha < 0xff.
+func synthesizeTransparentNoisyPNG(t *testing.T, w, h int) []byte {
+ t.Helper()
+ img := image.NewNRGBA(image.Rect(0, 0, w, h))
+ r := rand.New(rand.NewPCG(7, 7))
+ for y := range h {
+ for x := range w {
+ img.Set(x, y, color.NRGBA{
+ uint8(r.UintN(256)), uint8(r.UintN(256)),
+ uint8(r.UintN(256)), uint8(r.UintN(200)) + 50, // 50..249, never fully opaque
+ })
+ }
+ }
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ t.Fatalf("synthesize transparent png: %v", err)
+ }
+ return buf.Bytes()
+}
+
+func TestCompressImage_TransparentFallsBackToJPEG(t *testing.T) {
+ t.Parallel()
+ data := synthesizeTransparentNoisyPNG(t, 800, 800)
+ cap := 200 * 1024 // too tight for noisy PNG, comfortable for JPEG
+
+ out, mt, err := CompressImage(data, "image/png", cap)
+ if err != nil {
+ t.Fatalf("compress: %v", err)
+ }
+ if len(out) > cap {
+ t.Errorf("compressed size %d still exceeds cap %d", len(out), cap)
+ }
+ if mt != "image/jpeg" {
+ t.Errorf("mime = %q, want image/jpeg after white-flatten fallback", mt)
+ }
+}
+
+func TestHasTransparency_JPEGShortCircuit(t *testing.T) {
+ t.Parallel()
+ img := image.NewRGBA(image.Rect(0, 0, 10, 10))
+ for i := range img.Pix {
+ img.Pix[i] = 0xff
+ }
+ if hasTransparency(img, "image/jpeg") {
+ t.Error("hasTransparency should short-circuit on image/jpeg")
+ }
+}
+
+func TestHasTransparency_DetectsAlphaInNRGBA(t *testing.T) {
+ t.Parallel()
+ img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
+ for i := range img.Pix {
+ img.Pix[i] = 0xff
+ }
+ if hasTransparency(img, "image/png") {
+ t.Error("fully opaque NRGBA should not report transparency")
+ }
+ img.Pix[3] = 0x80
+ if !hasTransparency(img, "image/png") {
+ t.Error("expected to detect alpha=0x80 pixel")
+ }
+}
diff --git a/internal/channels/zalo/common/inbound.go b/internal/channels/zalo/common/inbound.go
new file mode 100644
index 0000000000..237f3160ca
--- /dev/null
+++ b/internal/channels/zalo/common/inbound.go
@@ -0,0 +1,28 @@
+package common
+
+// Platform values for inbound message metadata.
+const (
+ PlatformZaloBot = "zalo_bot"
+ PlatformZaloOA = "zalo_oa"
+)
+
+// InboundMeta is the per-message metadata both bot and oa publish.
+type InboundMeta struct {
+ MessageID string
+ Platform string
+ SenderDisplayName string
+}
+
+// ToMap returns the shape BaseChannel.HandleMessage expects.
+func (m InboundMeta) ToMap() map[string]string {
+ out := map[string]string{
+ "platform": m.Platform,
+ }
+ if m.MessageID != "" {
+ out["message_id"] = m.MessageID
+ }
+ if m.SenderDisplayName != "" {
+ out["sender_display_name"] = m.SenderDisplayName
+ }
+ return out
+}
diff --git a/internal/channels/zalo/common/inbound_test.go b/internal/channels/zalo/common/inbound_test.go
new file mode 100644
index 0000000000..dc2da57af4
--- /dev/null
+++ b/internal/channels/zalo/common/inbound_test.go
@@ -0,0 +1,39 @@
+package common
+
+import "testing"
+
+func TestInboundMeta_ToMap_AllFields(t *testing.T) {
+ m := InboundMeta{
+ MessageID: "abc",
+ Platform: PlatformZaloOA,
+ SenderDisplayName: "Alice",
+ }
+ got := m.ToMap()
+ want := map[string]string{
+ "message_id": "abc",
+ "platform": "zalo_oa",
+ "sender_display_name": "Alice",
+ }
+ if len(got) != len(want) {
+ t.Fatalf("len = %d, want %d", len(got), len(want))
+ }
+ for k, v := range want {
+ if got[k] != v {
+ t.Errorf("got[%q] = %q, want %q", k, got[k], v)
+ }
+ }
+}
+
+func TestInboundMeta_ToMap_OmitsEmptyOptionals(t *testing.T) {
+ m := InboundMeta{Platform: PlatformZaloBot}
+ got := m.ToMap()
+ if _, ok := got["message_id"]; ok {
+ t.Error("empty MessageID should be omitted")
+ }
+ if _, ok := got["sender_display_name"]; ok {
+ t.Error("empty SenderDisplayName should be omitted")
+ }
+ if got["platform"] != "zalo_bot" {
+ t.Errorf("platform = %q, want zalo_bot", got["platform"])
+ }
+}
diff --git a/internal/channels/zalo/format.go b/internal/channels/zalo/common/markdown.go
similarity index 69%
rename from internal/channels/zalo/format.go
rename to internal/channels/zalo/common/markdown.go
index fbdcb2949b..b28d35ee73 100644
--- a/internal/channels/zalo/format.go
+++ b/internal/channels/zalo/common/markdown.go
@@ -1,53 +1,32 @@
-package zalo
+// Package common holds shared building blocks used by all Zalo channel
+// flavors (zalo_bot, zalo_oa, zalo_personal).
+package common
import (
"regexp"
"strings"
)
-// StripMarkdown removes markdown formatting artifacts from text, producing
-// clean plain text suitable for Zalo which does not support any markup.
+// StripMarkdown returns plain text with markdown artifacts removed —
+// Zalo does not support any markup rendering.
func StripMarkdown(text string) string {
if text == "" {
return text
}
- // 1. Strip fenced code blocks — keep content, remove ``` delimiters
text = reFencedCode.ReplaceAllString(text, "$1")
-
- // 2. Strip inline code backticks
text = reInlineCode.ReplaceAllString(text, "$1")
-
- // 3. Strip images  — remove entirely
text = reImage.ReplaceAllString(text, "")
-
- // 4. Strip links [text](url) → text (url)
text = reLink.ReplaceAllString(text, "$1 ($2)")
-
- // 5. Strip bold+italic (***text*** or ___text___)
text = reBoldItalicStar.ReplaceAllString(text, "$1")
text = reBoldItalicUnder.ReplaceAllString(text, "$1")
-
- // 6. Strip bold (**text** or __text__)
text = reBoldStar.ReplaceAllString(text, "$1")
- text = reBoldUnder.ReplaceAllString(text, "$1")
-
- // 7. Strip strikethrough ~~text~~
+ text = reBoldUnder.ReplaceAllStringFunc(text, stripBoldUnder)
text = reStrikethrough.ReplaceAllString(text, "$1")
-
- // 8. Strip headers (lines starting with #)
text = reHeader.ReplaceAllString(text, "$1")
-
- // 9. Strip horizontal rules
text = reHorizontalRule.ReplaceAllString(text, "")
-
- // 10. Strip blockquotes
text = reBlockquote.ReplaceAllString(text, "$1")
-
- // 11. Replace bullet markers with •
text = reBullet.ReplaceAllString(text, "${1}• ")
-
- // Clean up excessive blank lines (3+ → 2)
text = reExcessiveNewlines.ReplaceAllString(text, "\n\n")
return strings.TrimSpace(text)
@@ -69,4 +48,16 @@ var (
reBullet = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`)
reExcessiveNewlines = regexp.MustCompile(`\n{3,}`)
+
+ reIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
)
+
+// stripBoldUnder strips __bold__ but preserves identifier-shaped content like
+// __init__ / __name__ where the underscores are part of the token, not markup.
+func stripBoldUnder(match string) string {
+ inner := match[2 : len(match)-2]
+ if reIdentifier.MatchString(inner) {
+ return match
+ }
+ return inner
+}
diff --git a/internal/channels/zalo/format_test.go b/internal/channels/zalo/common/markdown_test.go
similarity index 85%
rename from internal/channels/zalo/format_test.go
rename to internal/channels/zalo/common/markdown_test.go
index ea3935c8ef..9274ad2b3e 100644
--- a/internal/channels/zalo/format_test.go
+++ b/internal/channels/zalo/common/markdown_test.go
@@ -1,4 +1,4 @@
-package zalo
+package common
import "testing"
@@ -13,7 +13,10 @@ func TestStripMarkdown(t *testing.T) {
// Bold & italic
{"bold stars", "this is **bold** text", "this is bold text"},
- {"bold underscores", "this is __bold__ text", "this is bold text"},
+ {"bold underscores multiword", "say __hello world__ now", "say hello world now"},
+ {"bold underscores with punct", "this is __very, bold__ text", "this is very, bold text"},
+ {"python dunder preserved", "use __init__ method", "use __init__ method"},
+ {"python dunder name", "the __name__ var", "the __name__ var"},
{"bold+italic stars", "***important***", "important"},
{"strikethrough", "this is ~~deleted~~ text", "this is deleted text"},
diff --git a/internal/channels/zalo/common/shared.go b/internal/channels/zalo/common/shared.go
new file mode 100644
index 0000000000..7b354d86ae
--- /dev/null
+++ b/internal/channels/zalo/common/shared.go
@@ -0,0 +1,15 @@
+package common
+
+// WebhookPathPrefix is the single mount point for both Zalo channel flavors.
+// Per-instance dispatch reads the slug suffix (e.g. "/channels/zalo/webhook/my-oa").
+// The trailing slash makes ServeMux treat this as a prefix match.
+const WebhookPathPrefix = "/channels/zalo/webhook/"
+
+// WebhookPathBare is the no-slash form. Mount an explicit 404 handler here so
+// http.ServeMux doesn't auto-301 to WebhookPathPrefix.
+const WebhookPathBare = "/channels/zalo/webhook"
+
+var sharedRouter = NewRouter()
+
+// SharedRouter returns the process-global router.
+func SharedRouter() *Router { return sharedRouter }
diff --git a/internal/channels/zalo/common/shared_test.go b/internal/channels/zalo/common/shared_test.go
new file mode 100644
index 0000000000..483e816c0d
--- /dev/null
+++ b/internal/channels/zalo/common/shared_test.go
@@ -0,0 +1,74 @@
+package common
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/google/uuid"
+)
+
+func TestSharedRouter_Singleton(t *testing.T) {
+ a := SharedRouter()
+ b := SharedRouter()
+ if a != b {
+ t.Fatalf("SharedRouter must return identical *Router across calls")
+ }
+}
+
+func TestMountRoute_FirstCallReturnsPath(t *testing.T) {
+ r := NewRouter()
+ path, h := r.MountRoute()
+ if path != WebhookPathPrefix || h != r {
+ t.Fatalf("first MountRoute = (%q, %v), want (%q, router)", path, h, WebhookPathPrefix)
+ }
+}
+
+func TestMountRoute_SecondCallReturnsEmpty(t *testing.T) {
+ r := NewRouter()
+ _, _ = r.MountRoute()
+ path, h := r.MountRoute()
+ if path != "" || h != nil {
+ t.Fatalf("second MountRoute = (%q, %v), want (\"\", nil)", path, h)
+ }
+}
+
+func TestMountRoute_ConcurrentSafety(t *testing.T) {
+ r := NewRouter()
+ var wg sync.WaitGroup
+ var mu sync.Mutex
+ pathClaims := 0
+ for range 100 {
+ wg.Go(func() {
+ path, _ := r.MountRoute()
+ if path != "" {
+ mu.Lock()
+ pathClaims++
+ mu.Unlock()
+ }
+ })
+ }
+ wg.Wait()
+ if pathClaims != 1 {
+ t.Fatalf("expected exactly 1 path claim under concurrent calls, got %d", pathClaims)
+ }
+}
+
+// TestMountRoute_StickyAcrossUnregister proves routeHandled does NOT reset
+// when instances unregister. Re-mounting the same path on http.ServeMux
+// panics, so this invariant is load-bearing for instance_loader.Reload.
+func TestMountRoute_StickyAcrossUnregister(t *testing.T) {
+ r := NewRouter()
+ instID := uuid.New()
+ handler := newFakeHandler()
+
+ if err := r.RegisterInstance(instID, handler, uuid.Nil, "sticky"); err != nil {
+ t.Fatalf("RegisterInstance: %v", err)
+ }
+ _, _ = r.MountRoute()
+ r.UnregisterInstance(instID)
+
+ path, _ := r.MountRoute()
+ if path != "" {
+ t.Fatalf("MountRoute must stay sticky after unregister; got %q", path)
+ }
+}
diff --git a/internal/channels/zalo/common/slug.go b/internal/channels/zalo/common/slug.go
new file mode 100644
index 0000000000..37de92246b
--- /dev/null
+++ b/internal/channels/zalo/common/slug.go
@@ -0,0 +1,62 @@
+package common
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// MaxSlugLen mirrors the RFC-1035-ish 63-char DNS label limit so the slug
+// is safe to embed in any future host or path segment.
+const MaxSlugLen = 63
+
+// ReservedSlugs are URL paths the gateway may want for operational endpoints.
+// Reject these at registration to keep the namespace open for future use.
+var ReservedSlugs = map[string]struct{}{
+ "zalo": {},
+ "webhook": {},
+ "_health": {},
+ "_metrics": {},
+}
+
+var (
+ // Both ends alphanumeric so validator matches what DeriveSlugFromName trims.
+ slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$`)
+ nonAlphanumRE = regexp.MustCompile(`[^a-z0-9]+`)
+ collapseHyphens = regexp.MustCompile(`-+`)
+)
+
+// ErrSlugInvalid is returned by ValidateSlug for any failed check.
+var ErrSlugInvalid = errors.New("zalo_common: invalid slug")
+
+// ValidateSlug enforces ^[a-z0-9][a-z0-9-]{1,62}$ and rejects ReservedSlugs.
+func ValidateSlug(s string) error {
+ if s == "" {
+ return fmt.Errorf("%w: empty", ErrSlugInvalid)
+ }
+ if len(s) > MaxSlugLen {
+ return fmt.Errorf("%w: %d chars exceeds max %d", ErrSlugInvalid, len(s), MaxSlugLen)
+ }
+ if !slugRE.MatchString(s) {
+ return fmt.Errorf("%w: %q must match ^[a-z0-9][a-z0-9-]{1,62}$", ErrSlugInvalid, s)
+ }
+ if _, reserved := ReservedSlugs[s]; reserved {
+ return fmt.Errorf("%w: %q is reserved", ErrSlugInvalid, s)
+ }
+ return nil
+}
+
+// DeriveSlugFromName produces a stable URL-safe slug from a channel name.
+// Lowercase, replace runs of non-alphanumerics with single hyphen,
+// trim leading/trailing hyphens, clamp to MaxSlugLen.
+func DeriveSlugFromName(name string) string {
+ s := strings.ToLower(name)
+ s = nonAlphanumRE.ReplaceAllString(s, "-")
+ s = collapseHyphens.ReplaceAllString(s, "-")
+ s = strings.Trim(s, "-")
+ if len(s) > MaxSlugLen {
+ s = strings.TrimRight(s[:MaxSlugLen], "-")
+ }
+ return s
+}
diff --git a/internal/channels/zalo/common/slug_test.go b/internal/channels/zalo/common/slug_test.go
new file mode 100644
index 0000000000..c7420a413f
--- /dev/null
+++ b/internal/channels/zalo/common/slug_test.go
@@ -0,0 +1,89 @@
+package common
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestValidateSlug(t *testing.T) {
+ t.Parallel()
+ long63 := "a" + strings.Repeat("b", 62)
+ long64 := "a" + strings.Repeat("b", 63)
+ cases := []struct {
+ name string
+ in string
+ wantErr bool
+ }{
+ {"valid simple", "my-oa", false},
+ {"valid digit-prefix", "oa1", false},
+ {"valid hyphenated", "a-b-c", false},
+ {"valid 63 chars", long63, false},
+ {"empty", "", true},
+ {"too long", long64, true},
+ {"uppercase", "My-OA", true},
+ {"leading hyphen", "-leading", true},
+ {"trailing hyphen", "trailing-", true},
+ {"single char", "a", true},
+ {"slash", "with/slash", true},
+ {"dot", "with.dot", true},
+ {"space", "with space", true},
+ {"underscore", "with_underscore", true},
+ {"reserved zalo", "zalo", true},
+ {"reserved webhook", "webhook", true},
+ {"reserved _health", "_health", true},
+ {"reserved _metrics", "_metrics", true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := ValidateSlug(tc.in)
+ if (err != nil) != tc.wantErr {
+ t.Errorf("ValidateSlug(%q) err = %v, wantErr=%v", tc.in, err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestDeriveSlugFromName(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ in string
+ want string
+ }{
+ {"My OA", "my-oa"},
+ {"Customer Support OA #1", "customer-support-oa-1"},
+ {"Hello!!!World", "hello-world"},
+ {" spaced ", "spaced"},
+ {"---hyphens---", "hyphens"},
+ {"UPPER", "upper"},
+ {"a__b", "a-b"},
+ {"a b", "a-b"},
+ {"123abc", "123abc"},
+ {"", ""},
+ {"!!!", ""},
+ }
+ for _, tc := range cases {
+ t.Run(tc.in, func(t *testing.T) {
+ if got := DeriveSlugFromName(tc.in); got != tc.want {
+ t.Errorf("DeriveSlugFromName(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestDeriveSlugFromName_ClampsTo63(t *testing.T) {
+ t.Parallel()
+ in := strings.Repeat("a", 100)
+ got := DeriveSlugFromName(in)
+ if len(got) > 63 {
+ t.Errorf("DeriveSlugFromName clamped len = %d, want <= 63", len(got))
+ }
+}
+
+func TestReservedSlugs_AllRejected(t *testing.T) {
+ t.Parallel()
+ for slug := range ReservedSlugs {
+ if err := ValidateSlug(slug); err == nil {
+ t.Errorf("ValidateSlug(%q) should reject reserved slug", slug)
+ }
+ }
+}
diff --git a/internal/channels/zalo/common/webhook_router.go b/internal/channels/zalo/common/webhook_router.go
new file mode 100644
index 0000000000..8aec1cb7d6
--- /dev/null
+++ b/internal/channels/zalo/common/webhook_router.go
@@ -0,0 +1,313 @@
+package common
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/safego"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// Router dispatches webhook POSTs to registered Zalo channel instances by
+// path-suffix slug. Channels register at Start() and unregister at Stop();
+// the process-global router (shared.go) is mounted once on the mux via
+// MountRoute() at the WebhookPathPrefix prefix.
+type Router struct {
+ mu sync.RWMutex
+ instances map[uuid.UUID]*registeredInstance
+ slugToInstance map[string]uuid.UUID
+ instanceToSlug map[uuid.UUID]string
+ dedup *Dedup
+ rateLimiter *channels.WebhookRateLimiter
+ maxBodySize int64
+
+ routeMu sync.Mutex
+ routeHandled bool
+}
+
+// MountRoute returns (WebhookPathPrefix, r) on the first call across the shared
+// router and ("", nil) afterwards. Sticky across instance_loader.Reload
+// because http.ServeMux would panic on re-mount.
+func (r *Router) MountRoute() (string, http.Handler) {
+ r.routeMu.Lock()
+ defer r.routeMu.Unlock()
+ if !r.routeHandled {
+ r.routeHandled = true
+ return WebhookPathPrefix, r
+ }
+ return "", nil
+}
+
+// emptyIDStreakWarnThreshold catches schema drift where the extractor
+// silently disables dedup by always returning empty.
+const emptyIDStreakWarnThreshold = 10
+
+type registeredInstance struct {
+ handler WebhookHandler
+ tenantID uuid.UUID
+
+ ctx context.Context
+ cancel context.CancelFunc
+
+ dispatchWG sync.WaitGroup
+
+ // emptyIDStreak counts consecutive empty extractor returns; resets on
+ // any non-empty extraction.
+ emptyIDStreak atomic.Int64
+}
+
+// WebhookHandler is the per-instance contract the router invokes after
+// rate limit / signature / dedup checks pass.
+type WebhookHandler interface {
+ HandleWebhookEvent(ctx context.Context, raw json.RawMessage) error
+ SignatureVerifier() SignatureVerifier
+ MessageIDExtractor() MessageIDExtractor
+}
+
+// SignatureVerifier validates per-request authenticity.
+type SignatureVerifier interface {
+ Verify(headers http.Header, body []byte) error
+}
+
+// MessageIDExtractor pulls the dedup id; "" disables dedup for the event.
+type MessageIDExtractor interface {
+ ExtractMessageID(raw json.RawMessage) string
+}
+
+// ErrSignatureMismatch is the canonical signature-mismatch error; the
+// router maps it to 401.
+var ErrSignatureMismatch = errors.New("zalo_common: webhook signature mismatch")
+
+// ErrSlugCollision is returned by RegisterInstance when two channels claim
+// the same slug. Caller should MarkFailed with kind=config.
+var ErrSlugCollision = errors.New("zalo_common: webhook slug already in use")
+
+const (
+ defaultDedupTTL = 5 * time.Minute
+ defaultDedupMax = 1000
+ defaultMaxBodyBytes = 1 * 1024 * 1024
+)
+
+// NewRouter returns a router with default dedup and rate-limit params.
+func NewRouter() *Router {
+ return &Router{
+ instances: make(map[uuid.UUID]*registeredInstance),
+ slugToInstance: make(map[string]uuid.UUID),
+ instanceToSlug: make(map[uuid.UUID]string),
+ dedup: NewDedup(defaultDedupTTL, defaultDedupMax),
+ rateLimiter: channels.NewWebhookRateLimiter(),
+ maxBodySize: defaultMaxBodyBytes,
+ }
+}
+
+// RegisterInstance enrolls a channel for routing under the given slug.
+// Returns ErrSlugInvalid for malformed slugs, ErrSlugCollision when
+// another channel already owns the slug. The per-instance ctx is cancelled
+// by UnregisterInstance so dispatch goroutines bail promptly.
+func (r *Router) RegisterInstance(id uuid.UUID, h WebhookHandler, tenantID uuid.UUID, slug string) error {
+ if id == uuid.Nil {
+ return fmt.Errorf("zalo_common: register requires non-nil instance id")
+ }
+ if err := ValidateSlug(slug); err != nil {
+ return err
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ if tenantID != uuid.Nil {
+ ctx = store.WithTenantID(ctx, tenantID)
+ }
+ inst := ®isteredInstance{
+ handler: h,
+ tenantID: tenantID,
+ ctx: ctx,
+ cancel: cancel,
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if existing, ok := r.slugToInstance[slug]; ok && existing != id {
+ cancel()
+ return fmt.Errorf("%w: slug %q already registered", ErrSlugCollision, slug)
+ }
+ // Re-register under same id: clear old slug mapping if it changed.
+ if oldSlug, ok := r.instanceToSlug[id]; ok && oldSlug != slug {
+ delete(r.slugToInstance, oldSlug)
+ }
+ r.instances[id] = inst
+ r.slugToInstance[slug] = id
+ r.instanceToSlug[id] = slug
+ return nil
+}
+
+// unregisterDrainTimeout bounds Stop()/Reload() so a slow handler can't hang shutdown.
+const unregisterDrainTimeout = 5 * time.Second
+
+// UnregisterInstance removes the channel, cancels its dispatch ctx, and
+// drains in-flight dispatch goroutines (bounded). Idempotent.
+func (r *Router) UnregisterInstance(id uuid.UUID) {
+ r.mu.Lock()
+ inst, ok := r.instances[id]
+ delete(r.instances, id)
+ if slug, hasSlug := r.instanceToSlug[id]; hasSlug {
+ delete(r.slugToInstance, slug)
+ delete(r.instanceToSlug, id)
+ }
+ r.mu.Unlock()
+ if !ok {
+ return
+ }
+ if inst.cancel != nil {
+ inst.cancel()
+ }
+ done := make(chan struct{})
+ go func() {
+ inst.dispatchWG.Wait()
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(unregisterDrainTimeout):
+ slog.Warn("zalo_webhook.unregister_drain_timeout",
+ "instance_id", id, "timeout", unregisterDrainTimeout)
+ }
+}
+
+func (r *Router) lookupBySlug(slug string) (uuid.UUID, *registeredInstance, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ id, ok := r.slugToInstance[slug]
+ if !ok {
+ return uuid.Nil, nil, false
+ }
+ inst, ok := r.instances[id]
+ return id, inst, ok
+}
+
+// reserveDispatchSlot does lookup + dispatchWG.Add(1) atomically under RLock.
+// UnregisterInstance takes the write lock before Wait, so this prevents the
+// "WaitGroup reused before previous Wait returned" race during reload.
+func (r *Router) reserveDispatchSlot(slug string) (uuid.UUID, *registeredInstance, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ id, ok := r.slugToInstance[slug]
+ if !ok {
+ return uuid.Nil, nil, false
+ }
+ inst, ok := r.instances[id]
+ if !ok {
+ return uuid.Nil, nil, false
+ }
+ inst.dispatchWG.Add(1)
+ return id, inst, true
+}
+
+// ServeHTTP returns 200 once dispatch reaches the handler — Zalo retries
+// hard on non-2xx, so handler errors are logged, not surfaced. Pre-dispatch
+// failures (auth, rate limit, parse) return 4xx for operator visibility.
+func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+ if req.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ suffix := strings.TrimPrefix(req.URL.Path, WebhookPathPrefix)
+ // Reject empty suffix and any nested path / traversal attempt.
+ if suffix == "" || strings.Contains(suffix, "/") {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ if err := ValidateSlug(suffix); err != nil {
+ // Path doesn't conform to slug grammar — treat as not found.
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+
+ instanceID, inst, ok := r.lookupBySlug(suffix)
+ if !ok {
+ http.Error(w, "unknown instance", http.StatusNotFound)
+ return
+ }
+
+ body, err := io.ReadAll(io.LimitReader(req.Body, r.maxBodySize))
+ if err != nil {
+ http.Error(w, "read error", http.StatusBadRequest)
+ return
+ }
+
+ if err := inst.handler.SignatureVerifier().Verify(req.Header, body); err != nil {
+ slog.Warn("security.zalo_webhook_signature_mismatch",
+ "instance_id", instanceID,
+ "slug", suffix,
+ "remote", req.RemoteAddr,
+ "err", err)
+ http.Error(w, "signature mismatch", http.StatusUnauthorized)
+ return
+ }
+
+ // Limit AFTER signature verify so a guessed slug can't burn the bucket
+ // for legitimate Zalo deliveries. HMAC verify is cheap (~µs).
+ if !r.rateLimiter.Allow(instanceID.String()) {
+ http.Error(w, "rate limited", http.StatusTooManyRequests)
+ return
+ }
+
+ mid := inst.handler.MessageIDExtractor().ExtractMessageID(body)
+ if mid == "" {
+ // Warn-and-reset at threshold so silent schema drift doesn't go
+ // unnoticed; throttles to one warn per threshold-event window.
+ n := inst.emptyIDStreak.Add(1)
+ if n >= emptyIDStreakWarnThreshold {
+ inst.emptyIDStreak.Store(0)
+ slog.Warn("zalo_webhook.empty_message_id_streak",
+ "count", n,
+ "instance_id", instanceID,
+ "hint", "extractor may need update for schema drift")
+ }
+ } else {
+ inst.emptyIDStreak.Store(0)
+ }
+
+ resolvedID, resolvedInst, ok := r.reserveDispatchSlot(suffix)
+ if !ok || resolvedID != instanceID || resolvedInst != inst {
+ // Reload swapped the registration between Verify and reserveDispatchSlot.
+ slog.Debug("zalo_webhook.reload_race_dropped",
+ "slug", suffix,
+ "verified_instance_id", instanceID,
+ "resolved_instance_id", resolvedID,
+ "resolved_ok", ok)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ // Admit to dedup AFTER the dispatch slot is reserved so reload-dropped
+ // requests don't waste TTL slots keyed by a stale instanceID.
+ if mid != "" && r.dedup.SeenOrAdd(instanceID, mid) {
+ inst.dispatchWG.Done()
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ go r.dispatch(instanceID, inst, body)
+ w.WriteHeader(http.StatusOK)
+}
+
+// dispatch runs the handler in a goroutine so the HTTP ack isn't blocked
+// (Zalo expects ack within ~2s). Panics are recovered and logged.
+func (r *Router) dispatch(instanceID uuid.UUID, inst *registeredInstance, body []byte) {
+ defer inst.dispatchWG.Done()
+ defer safego.Recover(nil, "instance_id", instanceID, "tenant_id", inst.tenantID)
+ if err := inst.handler.HandleWebhookEvent(inst.ctx, body); err != nil {
+ slog.Error("zalo_webhook.handler_error",
+ "instance_id", instanceID,
+ "tenant_id", inst.tenantID,
+ "err", err)
+ }
+}
diff --git a/internal/channels/zalo/common/webhook_router_test.go b/internal/channels/zalo/common/webhook_router_test.go
new file mode 100644
index 0000000000..0f5089112f
--- /dev/null
+++ b/internal/channels/zalo/common/webhook_router_test.go
@@ -0,0 +1,425 @@
+package common
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type fakeHandler struct {
+ mu sync.Mutex
+ dispatched atomic.Int32
+ lastBody json.RawMessage
+ verifyErr error
+ extractedID string
+ handlerErr error
+ panicMsg string
+ doneCh chan struct{}
+}
+
+func newFakeHandler() *fakeHandler {
+ return &fakeHandler{doneCh: make(chan struct{}, 16)}
+}
+
+func (f *fakeHandler) HandleWebhookEvent(_ context.Context, raw json.RawMessage) error {
+ f.mu.Lock()
+ f.lastBody = raw
+ f.mu.Unlock()
+ f.dispatched.Add(1)
+ defer func() { f.doneCh <- struct{}{} }()
+ if f.panicMsg != "" {
+ panic(f.panicMsg)
+ }
+ return f.handlerErr
+}
+
+func (f *fakeHandler) SignatureVerifier() SignatureVerifier { return staticVerifier{err: f.verifyErr} }
+func (f *fakeHandler) MessageIDExtractor() MessageIDExtractor {
+ return staticExtractor{id: f.extractedID}
+}
+
+type staticVerifier struct{ err error }
+
+func (v staticVerifier) Verify(_ http.Header, _ []byte) error { return v.err }
+
+type staticExtractor struct{ id string }
+
+func (e staticExtractor) ExtractMessageID(_ json.RawMessage) string { return e.id }
+
+func waitForDispatch(t *testing.T, h *fakeHandler) {
+ t.Helper()
+ select {
+ case <-h.doneCh:
+ case <-time.After(time.Second):
+ t.Fatalf("handler not dispatched")
+ }
+}
+
+const testSlug = "test-slug"
+
+// newTestServer registers a single instance under testSlug and returns the
+// router, instance UUID, fake handler, and an httptest server mounted at
+// the WebhookPathPrefix prefix so paths look identical to production.
+func newTestServer(t *testing.T) (*Router, uuid.UUID, *fakeHandler, *httptest.Server) {
+ t.Helper()
+ r := NewRouter()
+ id := uuid.New()
+ h := newFakeHandler()
+ if err := r.RegisterInstance(id, h, uuid.New(), testSlug); err != nil {
+ t.Fatalf("RegisterInstance: %v", err)
+ }
+ mux := http.NewServeMux()
+ mux.Handle(WebhookPathPrefix, r)
+ return r, id, h, httptest.NewServer(mux)
+}
+
+func postSlug(srv *httptest.Server, slug, body string) *http.Response {
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+WebhookPathPrefix+slug, strings.NewReader(body))
+ resp, _ := srv.Client().Do(req)
+ return resp
+}
+
+func TestRouter_RejectsNonPOST(t *testing.T) {
+ _, _, _, srv := newTestServer(t)
+ defer srv.Close()
+ resp, _ := srv.Client().Get(srv.URL + WebhookPathPrefix + testSlug)
+ if resp.StatusCode != http.StatusMethodNotAllowed {
+ t.Errorf("status = %d, want 405", resp.StatusCode)
+ }
+}
+
+func TestRouter_404UnknownSlug(t *testing.T) {
+ _, _, _, srv := newTestServer(t)
+ defer srv.Close()
+ resp := postSlug(srv, "no-such-slug", "{}")
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", resp.StatusCode)
+ }
+}
+
+func TestRouter_RejectsEmptySuffix(t *testing.T) {
+ _, _, _, srv := newTestServer(t)
+ defer srv.Close()
+ // POST exactly to the prefix (no slug) — should 404.
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+WebhookPathPrefix, strings.NewReader("{}"))
+ resp, _ := srv.Client().Do(req)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", resp.StatusCode)
+ }
+}
+
+func TestRouter_RejectsPathTraversal(t *testing.T) {
+ _, _, _, srv := newTestServer(t)
+ defer srv.Close()
+ resp := postSlug(srv, testSlug+"/extra", "{}")
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("status = %d, want 404 (nested path)", resp.StatusCode)
+ }
+}
+
+func TestRouter_401OnSignatureMismatch(t *testing.T) {
+ _, _, h, srv := newTestServer(t)
+ defer srv.Close()
+ h.verifyErr = ErrSignatureMismatch
+ resp := postSlug(srv, testSlug, "{}")
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("status = %d, want 401", resp.StatusCode)
+ }
+ if h.dispatched.Load() != 0 {
+ t.Error("handler invoked despite signature mismatch")
+ }
+}
+
+func TestRouter_200OnValidEventDispatches(t *testing.T) {
+ _, _, h, srv := newTestServer(t)
+ defer srv.Close()
+ resp := postSlug(srv, testSlug, `{"x":1}`)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status = %d, want 200", resp.StatusCode)
+ }
+ waitForDispatch(t, h)
+ if h.dispatched.Load() != 1 {
+ t.Errorf("dispatched = %d, want 1", h.dispatched.Load())
+ }
+}
+
+func TestRouter_DedupShortCircuit(t *testing.T) {
+ _, _, h, srv := newTestServer(t)
+ defer srv.Close()
+ h.extractedID = "evt-1"
+ postSlug(srv, testSlug, `{}`)
+ waitForDispatch(t, h)
+
+ resp := postSlug(srv, testSlug, `{}`)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status = %d, want 200", resp.StatusCode)
+ }
+ // Give the goroutine a beat — it should NOT have been dispatched.
+ time.Sleep(50 * time.Millisecond)
+ if h.dispatched.Load() != 1 {
+ t.Errorf("dispatched = %d, want 1 (deduped)", h.dispatched.Load())
+ }
+}
+
+func TestRouter_PanicInHandlerRecovered(t *testing.T) {
+ _, _, h, srv := newTestServer(t)
+ defer srv.Close()
+ h.panicMsg = "boom"
+ resp := postSlug(srv, testSlug, `{}`)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status = %d, want 200", resp.StatusCode)
+ }
+}
+
+func TestRouter_RateLimitReturns429(t *testing.T) {
+ _, _, _, srv := newTestServer(t)
+ defer srv.Close()
+ for range 30 {
+ _ = postSlug(srv, testSlug, `{}`)
+ }
+ resp := postSlug(srv, testSlug, `{}`)
+ if resp.StatusCode != http.StatusTooManyRequests {
+ t.Errorf("status = %d, want 429", resp.StatusCode)
+ }
+}
+
+func TestRouter_UnregisterRemovesInstance(t *testing.T) {
+ r, id, _, srv := newTestServer(t)
+ defer srv.Close()
+ r.UnregisterInstance(id)
+ resp := postSlug(srv, testSlug, `{}`)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("status = %d, want 404 after unregister", resp.StatusCode)
+ }
+}
+
+func TestRouter_UnregisterClearsBothMaps(t *testing.T) {
+ r := NewRouter()
+ id := uuid.New()
+ if err := r.RegisterInstance(id, newFakeHandler(), uuid.New(), "abc"); err != nil {
+ t.Fatalf("RegisterInstance: %v", err)
+ }
+ r.UnregisterInstance(id)
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ if _, ok := r.instances[id]; ok {
+ t.Error("instances map still has entry")
+ }
+ if _, ok := r.slugToInstance["abc"]; ok {
+ t.Error("slugToInstance map still has entry")
+ }
+ if _, ok := r.instanceToSlug[id]; ok {
+ t.Error("instanceToSlug map still has entry")
+ }
+}
+
+func TestRouter_RegisterInstance_RejectsInvalidSlug(t *testing.T) {
+ r := NewRouter()
+ if err := r.RegisterInstance(uuid.New(), newFakeHandler(), uuid.New(), "Bad-Slug"); err == nil {
+ t.Error("uppercase slug should be rejected")
+ }
+}
+
+func TestRouter_RegisterInstance_RejectsCollision(t *testing.T) {
+ r := NewRouter()
+ if err := r.RegisterInstance(uuid.New(), newFakeHandler(), uuid.New(), "shared"); err != nil {
+ t.Fatalf("first register: %v", err)
+ }
+ err := r.RegisterInstance(uuid.New(), newFakeHandler(), uuid.New(), "shared")
+ if !errors.Is(err, ErrSlugCollision) {
+ t.Errorf("second register err = %v, want ErrSlugCollision", err)
+ }
+}
+
+func TestRouter_RegisterInstance_SameIDIdempotent(t *testing.T) {
+ r := NewRouter()
+ id := uuid.New()
+ if err := r.RegisterInstance(id, newFakeHandler(), uuid.New(), "first"); err != nil {
+ t.Fatalf("first register: %v", err)
+ }
+ // Re-register same id under a new slug — should swap, not collide.
+ if err := r.RegisterInstance(id, newFakeHandler(), uuid.New(), "second"); err != nil {
+ t.Fatalf("re-register: %v", err)
+ }
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ if _, stale := r.slugToInstance["first"]; stale {
+ t.Error("old slug mapping not cleared on re-register")
+ }
+ if got, ok := r.slugToInstance["second"]; !ok || got != id {
+ t.Error("new slug mapping missing")
+ }
+}
+
+func TestRouter_NoSingletonPerTestIsolation(t *testing.T) {
+ a := NewRouter()
+ b := NewRouter()
+ id := uuid.New()
+ if err := a.RegisterInstance(id, newFakeHandler(), uuid.New(), "iso"); err != nil {
+ t.Fatalf("RegisterInstance: %v", err)
+ }
+ if _, _, ok := b.lookupBySlug("iso"); ok {
+ t.Error("router b should not see router a's registrations")
+ }
+}
+
+// recordingHandler captures slog records emitted during a test so we can
+// assert on warn-level events without depending on log output formatting.
+type recordingHandler struct {
+ mu sync.Mutex
+ records []slog.Record
+}
+
+func (h *recordingHandler) Enabled(_ context.Context, _ slog.Level) bool { return true }
+func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.records = append(h.records, r.Clone())
+ return nil
+}
+func (h *recordingHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
+func (h *recordingHandler) WithGroup(_ string) slog.Handler { return h }
+
+func (h *recordingHandler) countWarn(msgPrefix string) int {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ n := 0
+ for _, r := range h.records {
+ if r.Level >= slog.LevelWarn && strings.HasPrefix(r.Message, msgPrefix) {
+ n++
+ }
+ }
+ return n
+}
+
+func swapDefaultLogger(t *testing.T) *recordingHandler {
+ t.Helper()
+ rec := &recordingHandler{}
+ old := slog.Default()
+ slog.SetDefault(slog.New(rec))
+ t.Cleanup(func() { slog.SetDefault(old) })
+ return rec
+}
+
+// R3-2: persistent empty ExtractMessageID emits exactly one warn at the
+// streak threshold (N=10) and resets so the next 10 trigger another warn.
+func TestRouter_EmptyIDStreak_WarnsAtThreshold(t *testing.T) {
+ rec := swapDefaultLogger(t)
+ _, _, h, srv := newTestServer(t)
+ defer srv.Close()
+ h.extractedID = "" // every event yields no message_id
+
+ for range 9 {
+ _ = postSlug(srv, testSlug, `{}`)
+ waitForDispatch(t, h)
+ }
+ if got := rec.countWarn("zalo_webhook.empty_message_id_streak"); got != 0 {
+ t.Fatalf("warn count after 9 = %d, want 0", got)
+ }
+ _ = postSlug(srv, testSlug, `{}`)
+ waitForDispatch(t, h)
+ if got := rec.countWarn("zalo_webhook.empty_message_id_streak"); got != 1 {
+ t.Fatalf("warn count after 10 = %d, want 1", got)
+ }
+ _ = postSlug(srv, testSlug, `{}`)
+ waitForDispatch(t, h)
+ if got := rec.countWarn("zalo_webhook.empty_message_id_streak"); got != 1 {
+ t.Fatalf("warn count after 11 = %d, want 1 (counter reset)", got)
+ }
+}
+
+func TestRouter_EmptyIDStreak_ResetsOnNonEmpty(t *testing.T) {
+ rec := swapDefaultLogger(t)
+ r := NewRouter()
+ id := uuid.New()
+ h := newFakeHandler()
+ if err := r.RegisterInstance(id, h, uuid.New(), testSlug); err != nil {
+ t.Fatalf("RegisterInstance: %v", err)
+ }
+ mux := http.NewServeMux()
+ mux.Handle(WebhookPathPrefix, r)
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ h.extractedID = ""
+ for range 5 {
+ _ = postSlug(srv, testSlug, `{}`)
+ waitForDispatch(t, h)
+ }
+ h.extractedID = "non-empty-1"
+ _ = postSlug(srv, testSlug, `{}`)
+ waitForDispatch(t, h)
+
+ h.extractedID = ""
+ for range 9 {
+ _ = postSlug(srv, testSlug, `{}`)
+ waitForDispatch(t, h)
+ }
+ if got := rec.countWarn("zalo_webhook.empty_message_id_streak"); got != 0 {
+ t.Fatalf("warn count = %d, want 0 (streak should have been reset by non-empty event)", got)
+ }
+}
+
+// R3-3: Unregister cancels the in-flight handler's ctx so it returns fast.
+func TestRouter_UnregisterCancelsInFlightDispatch(t *testing.T) {
+ r := NewRouter()
+ id := uuid.New()
+ started := make(chan struct{})
+ finished := make(chan error, 1)
+ blockingHandler := &ctxBlockingHandler{started: started, finished: finished}
+ if err := r.RegisterInstance(id, blockingHandler, uuid.New(), testSlug); err != nil {
+ t.Fatalf("RegisterInstance: %v", err)
+ }
+ mux := http.NewServeMux()
+ mux.Handle(WebhookPathPrefix, r)
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ resp := postSlug(srv, testSlug, `{}`)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ select {
+ case <-started:
+ case <-time.After(time.Second):
+ t.Fatal("handler did not start")
+ }
+
+ r.UnregisterInstance(id)
+
+ select {
+ case err := <-finished:
+ if !errors.Is(err, context.Canceled) {
+ t.Errorf("handler returned err = %v, want context.Canceled", err)
+ }
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("handler did not exit within 100ms after Unregister")
+ }
+}
+
+type ctxBlockingHandler struct {
+ started chan struct{}
+ finished chan error
+}
+
+func (b *ctxBlockingHandler) HandleWebhookEvent(ctx context.Context, _ json.RawMessage) error {
+ close(b.started)
+ <-ctx.Done()
+ b.finished <- ctx.Err()
+ return ctx.Err()
+}
+
+func (b *ctxBlockingHandler) SignatureVerifier() SignatureVerifier { return staticVerifier{} }
+func (b *ctxBlockingHandler) MessageIDExtractor() MessageIDExtractor { return staticExtractor{id: ""} }
+
+var _ = errors.New
diff --git a/internal/channels/zalo/oa/api.go b/internal/channels/zalo/oa/api.go
new file mode 100644
index 0000000000..f58687d530
--- /dev/null
+++ b/internal/channels/zalo/oa/api.go
@@ -0,0 +1,260 @@
+package oa
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "time"
+)
+
+// traceEnvVar=1 dumps raw Zalo response bodies via slog.Debug. Bodies
+// contain PII (display names, IDs, message text) — do not enable in
+// production without scrubbing review.
+const traceEnvVar = "GOCLAW_ZALO_OA_TRACE"
+
+var traceEnabled = os.Getenv(traceEnvVar) == "1"
+
+const traceBodyMaxBytes = 256
+
+func truncateForTrace(b []byte) string {
+ if len(b) <= traceBodyMaxBytes {
+ return string(b)
+ }
+ return string(b[:traceBodyMaxBytes]) + "…(truncated)"
+}
+
+// uploadTimeout accommodates multi-MB multipart uploads over slow mobile carriers.
+const uploadTimeout = 60 * time.Second
+
+// Client wraps Zalo's OAuth + OpenAPI hosts.
+type Client struct {
+ http *http.Client
+ oauthBase string
+ apiBase string
+}
+
+// NewClient returns a Client. Bounded idle-connection lifetime avoids
+// stale connections that cause "awaiting headers" timeouts on Zalo's hosts.
+func NewClient(timeout time.Duration) *Client {
+ if timeout <= 0 {
+ timeout = 30 * time.Second
+ }
+ transport := &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ MaxIdleConns: 10,
+ MaxIdleConnsPerHost: 4,
+ IdleConnTimeout: 60 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ExpectContinueTimeout: 1 * time.Second,
+ ForceAttemptHTTP2: true,
+ }
+ return &Client{
+ http: &http.Client{Timeout: timeout, Transport: transport},
+ oauthBase: defaultOAuthBase,
+ apiBase: defaultAPIBase,
+ }
+}
+
+// ErrRateLimit signals HTTP 429; callers should back off.
+var ErrRateLimit = errors.New("zalo_oa: rate limited")
+
+// APIError is Zalo's non-zero error envelope.
+type APIError struct {
+ Code int `json:"error"`
+ Message string `json:"message"`
+}
+
+func (e *APIError) Error() string {
+ if hint := Classify(e.Code).LLMHint; hint != "" {
+ return fmt.Sprintf("zalo api error %d: %s — %s", e.Code, e.Message, hint)
+ }
+ return fmt.Sprintf("zalo api error %d: %s", e.Code, e.Message)
+}
+
+// Info returns the catalog classification for this error. Unknown codes
+// return CodeInfo{Family: FamilyUnknown}.
+func (e *APIError) Info() CodeInfo {
+ if e == nil {
+ return CodeInfo{}
+ }
+ return Classify(e.Code)
+}
+
+// isAuth reports whether the error is an invalid/expired access_token at
+// the OpenAPI layer (refresh-token death is classifyRefreshError's job).
+// Codes in errors.go; substring fallback for doc drift.
+func (e *APIError) isAuth() bool {
+ if e == nil {
+ return false
+ }
+ if isAccessTokenInvalid(e.Code) {
+ return true
+ }
+ msg := strings.ToLower(e.Message)
+ return strings.Contains(msg, "access_token") && (strings.Contains(msg, "invalid") || strings.Contains(msg, "expired"))
+}
+
+// apiGet sends GET apiBase+path. access_token rides in the HEADER (the
+// query-param form returns 404 on live OpenAPI endpoints). 429 → ErrRateLimit.
+func (c *Client) apiGet(ctx context.Context, path string, query url.Values, accessToken string) (json.RawMessage, error) {
+ if accessToken == "" {
+ return nil, fmt.Errorf("zalo_oa: empty access_token for %s", path)
+ }
+ u := c.apiBase + path
+ if len(query) > 0 {
+ u += "?" + query.Encode()
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ if err != nil {
+ return nil, fmt.Errorf("build request %s: %w", path, err)
+ }
+ req.Header.Set("access_token", accessToken)
+ return c.do(req, path)
+}
+
+// apiPost POSTs application/json. access_token in HEADER. Errors expose
+// path only — never full URL.
+func (c *Client) apiPost(ctx context.Context, path string, body any, accessToken string) (json.RawMessage, error) {
+ if accessToken == "" {
+ return nil, fmt.Errorf("zalo_oa: empty access_token for %s", path)
+ }
+ jsonBody, err := json.Marshal(body)
+ if err != nil {
+ return nil, fmt.Errorf("marshal body: %w", err)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiBase+path, bytes.NewReader(jsonBody))
+ if err != nil {
+ return nil, fmt.Errorf("build request %s: %w", path, err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("access_token", accessToken)
+ return c.do(req, path)
+}
+
+// apiPostMultipart uploads a single file as multipart/form-data.
+func (c *Client) apiPostMultipart(ctx context.Context, path string, fileFieldName, fileName string, fileBytes []byte, fields map[string]string, accessToken string) (json.RawMessage, error) {
+ if accessToken == "" {
+ return nil, fmt.Errorf("zalo_oa: empty access_token for %s", path)
+ }
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+
+ for k, v := range fields {
+ if err := mw.WriteField(k, v); err != nil {
+ return nil, fmt.Errorf("write field %s: %w", k, err)
+ }
+ }
+ part, err := mw.CreateFormFile(fileFieldName, fileName)
+ if err != nil {
+ return nil, fmt.Errorf("create form file: %w", err)
+ }
+ if _, err := part.Write(fileBytes); err != nil {
+ return nil, fmt.Errorf("write file part: %w", err)
+ }
+ if err := mw.Close(); err != nil {
+ return nil, fmt.Errorf("close multipart: %w", err)
+ }
+
+ // Per-request client: longer timeout for uploads, reuse shared Transport.
+ uploadClient := &http.Client{Timeout: uploadTimeout, Transport: c.http.Transport}
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiBase+path, &buf)
+ if err != nil {
+ return nil, fmt.Errorf("build upload request %s: %w", path, err)
+ }
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ req.Header.Set("access_token", accessToken)
+ return doRequest(uploadClient, req, path)
+}
+
+func (c *Client) do(req *http.Request, path string) (json.RawMessage, error) {
+ return doRequest(c.http, req, path)
+}
+
+// doRequest runs the call and parses Zalo's envelope. Rewrites *url.Error.URL
+// to path-only so any logged error never leaks tokens or full URLs.
+func doRequest(client *http.Client, req *http.Request, path string) (json.RawMessage, error) {
+ resp, err := client.Do(req)
+ if err != nil {
+ var urlErr *url.Error
+ if errors.As(err, &urlErr) {
+ urlErr.URL = path
+ }
+ return nil, fmt.Errorf("zalo api %s: %w", path, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
+ if err != nil {
+ return nil, fmt.Errorf("read body: %w", err)
+ }
+ if traceEnabled {
+ slog.Debug("zalo_oa.raw_response", "path", path, "status", resp.StatusCode, "body", truncateForTrace(raw))
+ }
+ if resp.StatusCode == http.StatusTooManyRequests {
+ return nil, fmt.Errorf("%w (path=%s)", ErrRateLimit, path)
+ }
+ if resp.StatusCode >= 400 {
+ var env APIError
+ if jerr := json.Unmarshal(raw, &env); jerr == nil && (env.Code != 0 || env.Message != "") {
+ return nil, &env
+ }
+ return nil, fmt.Errorf("zalo api %s: http %d", path, resp.StatusCode)
+ }
+ var env APIError
+ if jerr := json.Unmarshal(raw, &env); jerr == nil && env.Code != 0 {
+ return nil, &env
+ }
+ return raw, nil
+}
+
+// postForm POSTs application/x-www-form-urlencoded with optional headers,
+// returns the raw decoded JSON body. HTTP-status errors and Zalo's in-body
+// error envelope (`error != 0`) are both surfaced as errors.
+func (c *Client) postForm(ctx context.Context, fullURL string, headers map[string]string, body url.Values) (json.RawMessage, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(body.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("build request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("http: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, fmt.Errorf("read body: %w", err)
+ }
+ if traceEnabled {
+ // Body omitted: OAuth responses carry plaintext access/refresh tokens.
+ slog.Debug("zalo_oa.raw_response", "path", "oauth_token", "status", resp.StatusCode)
+ }
+
+ if resp.StatusCode >= 400 {
+ var env APIError
+ if jerr := json.Unmarshal(raw, &env); jerr == nil && (env.Code != 0 || env.Message != "") {
+ return nil, &env
+ }
+ return nil, fmt.Errorf("http %d", resp.StatusCode)
+ }
+
+ // Zalo returns HTTP 200 with `{"error":N,"message":"..."}` for app errors.
+ var env APIError
+ if jerr := json.Unmarshal(raw, &env); jerr == nil && env.Code != 0 {
+ return nil, &env
+ }
+ return raw, nil
+}
diff --git a/internal/channels/zalo/oa/auth.go b/internal/channels/zalo/oa/auth.go
new file mode 100644
index 0000000000..7c02e83f86
--- /dev/null
+++ b/internal/channels/zalo/oa/auth.go
@@ -0,0 +1,125 @@
+package oa
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ErrAuthExpired: refresh token rejected (single-use rotation burned or
+// operator revoked OA permission). Operator must re-consent.
+var ErrAuthExpired = errors.New("zalo_oa: refresh token expired, re-auth required")
+
+// ErrNotAuthorized: channel has not yet completed the paste-code consent
+// flow. Health stays Degraded (not Failed).
+var ErrNotAuthorized = errors.New("zalo_oa: not yet authorized (paste consent code first)")
+
+// classifyRefreshError escalates only the language-independent invalid_grant
+// code (-118); substring-matching localized messages would force false
+// re-consent on transient server errors.
+func classifyRefreshError(err error) error {
+ if err == nil {
+ return nil
+ }
+ var apiErr *APIError
+ if errors.As(err, &apiErr) && apiErr.Code == codeInvalidGrant {
+ return fmt.Errorf("%w (zalo error %d: %s)", ErrAuthExpired, apiErr.Code, apiErr.Message)
+ }
+ return err
+}
+
+// Tokens is the parsed OAuth response.
+type Tokens struct {
+ AccessToken string
+ RefreshToken string
+ ExpiresAt time.Time
+ RefreshTokenExpiresAt time.Time // zero if Zalo omits refresh_token_expires_in
+}
+
+type tokenResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn flexSeconds `json:"expires_in"`
+ RefreshTokenExpiresIn flexSeconds `json:"refresh_token_expires_in"`
+}
+
+// flexSeconds accepts either a JSON number or a quoted string for
+// expires_in — Zalo's OA OAuth endpoint returns the latter in practice.
+type flexSeconds int64
+
+func (f *flexSeconds) UnmarshalJSON(b []byte) error {
+ s := strings.Trim(string(b), `"`)
+ if s == "" || s == "null" {
+ return nil
+ }
+ n, err := strconv.ParseInt(s, 10, 64)
+ if err != nil {
+ return fmt.Errorf("expires_in: %w", err)
+ }
+ *f = flexSeconds(n)
+ return nil
+}
+
+// ExchangeCode swaps an authorization code for an (access, refresh) pair.
+// POST oauth.zaloapp.com/v4/oa/access_token, secret_key in HEADER.
+func (c *Client) ExchangeCode(ctx context.Context, appID, secretKey, code string) (*Tokens, error) {
+ form := url.Values{
+ "app_id": {appID},
+ "code": {code},
+ "grant_type": {"authorization_code"},
+ }
+ return c.tokenCall(ctx, secretKey, form)
+}
+
+// RefreshToken trades a refresh token for a new (access, refresh) pair.
+// Refresh tokens are SINGLE-USE — every successful refresh rotates both.
+func (c *Client) RefreshToken(ctx context.Context, appID, secretKey, refresh string) (*Tokens, error) {
+ form := url.Values{
+ "app_id": {appID},
+ "refresh_token": {refresh},
+ "grant_type": {"refresh_token"},
+ }
+ return c.tokenCall(ctx, secretKey, form)
+}
+
+func (c *Client) tokenCall(ctx context.Context, secretKey string, form url.Values) (*Tokens, error) {
+ headers := map[string]string{"secret_key": secretKey}
+ raw, err := c.postForm(ctx, c.oauthBase+pathOAuthAccessToken, headers, form)
+ if err != nil {
+ return nil, err
+ }
+ var resp tokenResponse
+ if err := json.Unmarshal(raw, &resp); err != nil {
+ return nil, fmt.Errorf("decode token response: %w", err)
+ }
+ if resp.AccessToken == "" {
+ return nil, fmt.Errorf("zalo oauth: empty access_token in response")
+ }
+ exp := time.Now().UTC().Add(time.Duration(resp.ExpiresIn) * time.Second)
+ var refreshExp time.Time
+ if resp.RefreshTokenExpiresIn > 0 {
+ refreshExp = time.Now().UTC().Add(time.Duration(resp.RefreshTokenExpiresIn) * time.Second)
+ }
+ return &Tokens{
+ AccessToken: resp.AccessToken,
+ RefreshToken: resp.RefreshToken,
+ ExpiresAt: exp,
+ RefreshTokenExpiresAt: refreshExp,
+ }, nil
+}
+
+// ConsentURL builds the redirect URL the operator visits to authorize
+// the OA. The state token is validated in the WS exchange_code handler.
+func ConsentURL(appID, redirectURI, state string) string {
+ q := url.Values{
+ "app_id": {appID},
+ "redirect_uri": {redirectURI},
+ "state": {state},
+ }
+ return defaultOAuthBase + "/oa/permission?" + q.Encode()
+}
diff --git a/internal/channels/zalo/oa/auth_test.go b/internal/channels/zalo/oa/auth_test.go
new file mode 100644
index 0000000000..d929a062d6
--- /dev/null
+++ b/internal/channels/zalo/oa/auth_test.go
@@ -0,0 +1,293 @@
+package oa
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+// newAuthServer mounts a handler that asserts header + form shape and
+// returns the supplied response body.
+func newAuthServer(t *testing.T, wantHeader, wantGrantType string, body string, status int) (*httptest.Server, *http.Request) {
+ t.Helper()
+ var captured *http.Request
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ buf, _ := io.ReadAll(r.Body)
+ _ = r.Body.Close()
+ r.Body = io.NopCloser(strings.NewReader(string(buf)))
+ captured = r
+
+ if got := r.Header.Get("secret_key"); got != wantHeader {
+ t.Errorf("secret_key header = %q, want %q", got, wantHeader)
+ }
+ if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/x-www-form-urlencoded") {
+ t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", got)
+ }
+ form, err := url.ParseQuery(string(buf))
+ if err != nil {
+ t.Errorf("parse form: %v", err)
+ }
+ if got := form.Get("grant_type"); got != wantGrantType {
+ t.Errorf("grant_type = %q, want %q", got, wantGrantType)
+ }
+ if form.Get("app_id") == "" {
+ t.Errorf("app_id missing")
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(body))
+ }))
+ t.Cleanup(srv.Close)
+ return srv, captured
+}
+
+func TestExchangeCode_HappyPath(t *testing.T) {
+ t.Parallel()
+
+ body := `{"access_token":"AT-NEW","refresh_token":"RT-NEW","expires_in":3600}`
+ srv, _ := newAuthServer(t, "the-secret", "authorization_code", body, http.StatusOK)
+
+ c := NewClient(5 * time.Second)
+ c.oauthBase = srv.URL // override for test
+
+ before := time.Now()
+ tok, err := c.ExchangeCode(context.Background(), "app-1", "the-secret", "the-code")
+ if err != nil {
+ t.Fatalf("ExchangeCode: %v", err)
+ }
+ if tok.AccessToken != "AT-NEW" || tok.RefreshToken != "RT-NEW" {
+ t.Errorf("tokens = %+v", tok)
+ }
+ // expires_in=3600 → ExpiresAt ≈ now+1h. Allow ±5s slack for test wall-clock.
+ wantExp := before.Add(time.Hour)
+ if tok.ExpiresAt.Before(wantExp.Add(-5*time.Second)) || tok.ExpiresAt.After(time.Now().Add(time.Hour+time.Second)) {
+ t.Errorf("ExpiresAt out of range: %v", tok.ExpiresAt)
+ }
+}
+
+func TestRefreshToken_HappyPath(t *testing.T) {
+ t.Parallel()
+
+ body := `{"access_token":"AT-2","refresh_token":"RT-2","expires_in":3600}`
+ srv, _ := newAuthServer(t, "the-secret", "refresh_token", body, http.StatusOK)
+
+ c := NewClient(5 * time.Second)
+ c.oauthBase = srv.URL
+
+ tok, err := c.RefreshToken(context.Background(), "app-1", "the-secret", "old-rt")
+ if err != nil {
+ t.Fatalf("RefreshToken: %v", err)
+ }
+ if tok.AccessToken != "AT-2" || tok.RefreshToken != "RT-2" {
+ t.Errorf("tokens = %+v", tok)
+ }
+}
+
+func TestExchangeCode_ErrorEnvelope(t *testing.T) {
+ t.Parallel()
+
+ // Zalo returns HTTP 200 with non-zero error code in body.
+ body := `{"error":-123,"message":"invalid_code","data":null}`
+ srv, _ := newAuthServer(t, "the-secret", "authorization_code", body, http.StatusOK)
+
+ c := NewClient(5 * time.Second)
+ c.oauthBase = srv.URL
+
+ _, err := c.ExchangeCode(context.Background(), "app-1", "the-secret", "bad")
+ if err == nil {
+ t.Fatal("expected error from non-zero envelope code")
+ }
+ var apiErr *APIError
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("expected *APIError, got %T: %v", err, err)
+ }
+ if apiErr.Code != -123 {
+ t.Errorf("APIError.Code = %d, want -123", apiErr.Code)
+ }
+ if apiErr.Message != "invalid_code" {
+ t.Errorf("APIError.Message = %q", apiErr.Message)
+ }
+}
+
+func TestExchangeCode_ContextCancel(t *testing.T) {
+ t.Parallel()
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Bound the handler so srv.Close() during cleanup never deadlocks
+ // if the client-side context cancel doesn't propagate to the server.
+ select {
+ case <-r.Context().Done():
+ case <-time.After(2 * time.Second):
+ }
+ }))
+ t.Cleanup(srv.Close)
+
+ c := NewClient(5 * time.Second)
+ c.oauthBase = srv.URL
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ _, err := c.ExchangeCode(ctx, "app", "key", "code")
+ if err == nil {
+ t.Fatal("expected error on context cancel")
+ }
+}
+
+func TestExchangeCode_HTTPError(t *testing.T) {
+ t.Parallel()
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"error":500,"message":"boom"}`))
+ }))
+ t.Cleanup(srv.Close)
+
+ c := NewClient(5 * time.Second)
+ c.oauthBase = srv.URL
+
+ _, err := c.ExchangeCode(context.Background(), "app", "key", "code")
+ if err == nil {
+ t.Fatal("expected error on HTTP 500")
+ }
+}
+
+// Sanity: response decoder must tolerate extra unknown fields.
+func TestExchangeCode_UnknownFieldsTolerated(t *testing.T) {
+ t.Parallel()
+
+ body := `{"access_token":"AT","refresh_token":"RT","expires_in":3600,"future_field":"x"}`
+ srv, _ := newAuthServer(t, "k", "authorization_code", body, http.StatusOK)
+
+ c := NewClient(5 * time.Second)
+ c.oauthBase = srv.URL
+
+ tok, err := c.ExchangeCode(context.Background(), "app", "k", "code")
+ if err != nil {
+ t.Fatalf("ExchangeCode: %v", err)
+ }
+ if tok.AccessToken != "AT" {
+ t.Errorf("AccessToken = %q", tok.AccessToken)
+ }
+}
+
+// Compile-time guard: make sure JSON tags on response structs don't drift.
+func TestTokenResponseShape_GuardsTagDrift(t *testing.T) {
+ t.Parallel()
+ // Numeric form (ChickenAI SDK's documented shape).
+ var resp tokenResponse
+ if err := json.Unmarshal([]byte(`{"access_token":"a","refresh_token":"b","expires_in":1}`), &resp); err != nil {
+ t.Fatalf("unmarshal numeric: %v", err)
+ }
+ if resp.AccessToken != "a" || resp.RefreshToken != "b" || resp.ExpiresIn != 1 {
+ t.Errorf("tag drift (numeric): %+v", resp)
+ }
+
+ // String form (what Zalo's live OA endpoint actually returns as of 2026).
+ var resp2 tokenResponse
+ if err := json.Unmarshal([]byte(`{"access_token":"a","refresh_token":"b","expires_in":"3600"}`), &resp2); err != nil {
+ t.Fatalf("unmarshal string form: %v", err)
+ }
+ if resp2.ExpiresIn != 3600 {
+ t.Errorf("string form: ExpiresIn = %d, want 3600", resp2.ExpiresIn)
+ }
+}
+
+// Captures Zalo's refresh_token_expires_in across the response shapes we have
+// seen in the wild (string + numeric) and the omitted case (legacy / shape drift).
+func TestTokenCall_CapturesRefreshExpiry(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ body string
+ wantSet bool
+ wantSecs int64
+ }{
+ {
+ name: "string_form",
+ body: `{"access_token":"AT","refresh_token":"RT","expires_in":"3600","refresh_token_expires_in":"7776000"}`,
+ wantSet: true,
+ wantSecs: 7776000,
+ },
+ {
+ name: "numeric_form",
+ body: `{"access_token":"AT","refresh_token":"RT","expires_in":3600,"refresh_token_expires_in":2592000}`,
+ wantSet: true,
+ wantSecs: 2592000,
+ },
+ {
+ name: "omitted",
+ body: `{"access_token":"AT","refresh_token":"RT","expires_in":3600}`,
+ wantSet: false,
+ },
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ srv, _ := newAuthServer(t, "k", "authorization_code", tc.body, http.StatusOK)
+ c := NewClient(5 * time.Second)
+ c.oauthBase = srv.URL
+
+ before := time.Now()
+ tok, err := c.ExchangeCode(context.Background(), "app", "k", "code")
+ if err != nil {
+ t.Fatalf("ExchangeCode: %v", err)
+ }
+ if tc.wantSet {
+ if tok.RefreshTokenExpiresAt.IsZero() {
+ t.Fatalf("RefreshTokenExpiresAt should be set")
+ }
+ wantExp := before.Add(time.Duration(tc.wantSecs) * time.Second)
+ delta := tok.RefreshTokenExpiresAt.Sub(wantExp)
+ if delta < -2*time.Second || delta > 2*time.Second {
+ t.Errorf("RefreshTokenExpiresAt = %v, want ≈ %v (delta %v)", tok.RefreshTokenExpiresAt, wantExp, delta)
+ }
+ } else {
+ if !tok.RefreshTokenExpiresAt.IsZero() {
+ t.Errorf("RefreshTokenExpiresAt should be zero, got %v", tok.RefreshTokenExpiresAt)
+ }
+ }
+ })
+ }
+}
+
+// WithTokens must NOT zero a previously set RefreshTokenExpiresAt if the
+// freshly returned Tokens omits the field — guards against transient Zalo
+// shape drift wiping out the warning deadline.
+func TestWithTokens_PreservesRefreshExpiryWhenOmitted(t *testing.T) {
+ t.Parallel()
+
+ prev := time.Now().UTC().Add(60 * 24 * time.Hour)
+ c := &ChannelCreds{RefreshTokenExpiresAt: prev}
+ c.WithTokens(&Tokens{
+ AccessToken: "AT",
+ RefreshToken: "RT",
+ ExpiresAt: time.Now().UTC().Add(time.Hour),
+ // RefreshTokenExpiresAt: zero
+ })
+ if !c.RefreshTokenExpiresAt.Equal(prev) {
+ t.Errorf("RefreshTokenExpiresAt = %v, want preserved %v", c.RefreshTokenExpiresAt, prev)
+ }
+
+ // And it MUST overwrite when a fresh value is provided.
+ next := time.Now().UTC().Add(90 * 24 * time.Hour)
+ c.WithTokens(&Tokens{
+ AccessToken: "AT2",
+ RefreshToken: "RT2",
+ ExpiresAt: time.Now().UTC().Add(time.Hour),
+ RefreshTokenExpiresAt: next,
+ })
+ if !c.RefreshTokenExpiresAt.Equal(next) {
+ t.Errorf("RefreshTokenExpiresAt = %v, want overwritten to %v", c.RefreshTokenExpiresAt, next)
+ }
+}
diff --git a/internal/channels/zalo/oa/catchup.go b/internal/channels/zalo/oa/catchup.go
new file mode 100644
index 0000000000..0e3ba67fdb
--- /dev/null
+++ b/internal/channels/zalo/oa/catchup.go
@@ -0,0 +1,56 @@
+package oa
+
+import (
+ "context"
+ "log/slog"
+ "sort"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+const (
+ // catchUpStaleThreshold gates the sweep so a fresh restart doesn't
+ // re-fetch on every boot.
+ catchUpStaleThreshold = 30 * time.Minute
+ catchUpPageSize = 50
+)
+
+// runCatchUpSweep recovers messages possibly missed during downtime.
+// Single bounded page, error-tolerant. Reuses the polling dedup path so
+// overlap with webhook deliveries is harmless.
+func (c *Channel) runCatchUpSweep(parentCtx context.Context) {
+ ctx := store.WithTenantID(parentCtx, c.TenantID())
+
+ last := c.cursor.LastSeenTimestamp()
+ if last != 0 && time.Since(time.UnixMilli(last)) < catchUpStaleThreshold {
+ return
+ }
+
+ msgs, err := c.listRecentChat(ctx, 0, catchUpPageSize)
+ if err != nil {
+ slog.Warn("zalo_oa.webhook.catchup_failed", "err", err)
+ return
+ }
+ sort.SliceStable(msgs, func(i, j int) bool { return msgs[i].Time < msgs[j].Time })
+
+ dispatched := 0
+ for _, m := range msgs {
+ if m.FromID == "" || m.FromID == c.creds().OAID {
+ continue
+ }
+ if m.Time != 0 {
+ if m.Time <= c.cursor.Get(m.FromID) {
+ continue
+ }
+ } else if m.MessageID == "" || c.seenIDs.SeenOrAdd(m.MessageID) {
+ continue
+ }
+ c.dispatchInbound(m)
+ if m.Time != 0 {
+ c.cursor.Advance(m.FromID, m.Time)
+ }
+ dispatched++
+ }
+ slog.Info("zalo_oa.webhook.catchup_done", "fetched", len(msgs), "dispatched", dispatched)
+}
diff --git a/internal/channels/zalo/oa/catchup_test.go b/internal/channels/zalo/oa/catchup_test.go
new file mode 100644
index 0000000000..28f64dca50
--- /dev/null
+++ b/internal/channels/zalo/oa/catchup_test.go
@@ -0,0 +1,157 @@
+package oa
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+)
+
+// newCatchUpChannel returns a webhook-mode channel pointed at the given
+// listrecentchat test server. Cursor is empty by default → catch-up will
+// fire when invoked.
+func newCatchUpChannel(t *testing.T, apiURL, oaID string) (*Channel, *bus.MessageBus, *atomic.Int32) {
+ t.Helper()
+ creds := &ChannelCreds{
+ AppID: "app-1",
+ SecretKey: "k",
+ OAID: oaID,
+ AccessToken: "AT",
+ RefreshToken: "RT",
+ ExpiresAt: time.Now().Add(time.Hour),
+ WebhookSecretKey: "s",
+ }
+ cfg := config.ZaloOAConfig{
+ Transport: "webhook",
+ CatchUpOnRestart: true,
+ }
+ mb := bus.New()
+ c, err := New("catchup_test", cfg, creds, &fakeStore{}, mb, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ c.SetInstanceID(uuid.New())
+ c.client.apiBase = apiURL
+ return c, mb, nil
+}
+
+// catchupServer counts list calls and returns canned bodies.
+type catchupServer struct {
+ srv *httptest.Server
+ listN atomic.Int32
+ listBody string
+ failWith int // status code; 0 → 200
+}
+
+func newCatchupServer(t *testing.T, body string) *catchupServer {
+ t.Helper()
+ s := &catchupServer{listBody: body}
+ s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/v2.0/oa/listrecentchat" {
+ s.listN.Add(1)
+ if s.failWith != 0 {
+ w.WriteHeader(s.failWith)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(s.listBody))
+ return
+ }
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ t.Cleanup(s.srv.Close)
+ return s
+}
+
+// Cursor recently-advanced (<30min) → no list call made.
+func TestCatchUp_FreshCursorSkipsListCall(t *testing.T) {
+ t.Parallel()
+ srv := newCatchupServer(t, `{"error":0,"data":[]}`)
+ c, _, _ := newCatchUpChannel(t, srv.srv.URL, "oa-1")
+
+ // Seed cursor with a recent timestamp (now - 1min). LastSeenTimestamp()
+ // will report this and gate the sweep.
+ c.cursor.Advance("u1", time.Now().UnixMilli()-int64(time.Minute.Milliseconds()))
+
+ c.runCatchUpSweep(context.Background())
+ if got := srv.listN.Load(); got != 0 {
+ t.Errorf("list calls = %d, want 0 (cursor is fresh)", got)
+ }
+}
+
+// Cursor stale (>30min) → exactly one list call, messages dispatched.
+func TestCatchUp_StaleCursorTriggersSingleListCall(t *testing.T) {
+ t.Parallel()
+ srv := newCatchupServer(t, `{"error":0,"data":[
+ {"message_id":"m1","from_id":"u1","time":2000,"message":"hi","type":"text"}
+ ]}`)
+ c, mb, _ := newCatchUpChannel(t, srv.srv.URL, "oa-1")
+ // Cursor empty → LastSeenTimestamp == 0 → stale.
+ c.runCatchUpSweep(context.Background())
+ if got := srv.listN.Load(); got != 1 {
+ t.Fatalf("list calls = %d, want 1", got)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ got, ok := mb.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("no inbound dispatched from catch-up")
+ }
+ if got.Content != "hi" {
+ t.Errorf("Content = %q", got.Content)
+ }
+}
+
+// API error during catch-up is logged and swallowed — no panic, no dispatch.
+func TestCatchUp_ListErrorTolerated(t *testing.T) {
+ t.Parallel()
+ srv := newCatchupServer(t, "")
+ srv.failWith = http.StatusInternalServerError
+ c, mb, _ := newCatchUpChannel(t, srv.srv.URL, "oa-1")
+
+ // Must not panic.
+ c.runCatchUpSweep(context.Background())
+
+ if got := srv.listN.Load(); got < 1 {
+ t.Errorf("list calls = %d, want >=1 (the failing call)", got)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ if _, ok := mb.ConsumeInbound(ctx); ok {
+ t.Error("error path should not have dispatched")
+ }
+}
+
+// Self-echo (from_id == oa_id) is filtered just like polling.
+func TestCatchUp_FiltersOAEcho(t *testing.T) {
+ t.Parallel()
+ srv := newCatchupServer(t, `{"error":0,"data":[
+ {"message_id":"echo","from_id":"oa-1","time":1000,"message":"my own","type":"text"},
+ {"message_id":"real","from_id":"u1","time":2000,"message":"user reply","type":"text"}
+ ]}`)
+ c, mb, _ := newCatchUpChannel(t, srv.srv.URL, "oa-1")
+
+ c.runCatchUpSweep(context.Background())
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ got, ok := mb.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("expected one inbound dispatched")
+ }
+ if got.Content != "user reply" {
+ t.Errorf("OA echo leaked through filter: %q", got.Content)
+ }
+ // No second message.
+ ctx2, cancel2 := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel2()
+ if _, ok := mb.ConsumeInbound(ctx2); ok {
+ t.Error("second inbound queued — echo not filtered")
+ }
+}
diff --git a/internal/channels/zalo/oa/channel.go b/internal/channels/zalo/oa/channel.go
new file mode 100644
index 0000000000..0a16b22ac4
--- /dev/null
+++ b/internal/channels/zalo/oa/channel.go
@@ -0,0 +1,507 @@
+package oa
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "mime"
+ "net/http"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+ "github.com/nextlevelbuilder/goclaw/internal/i18n"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// ErrPartialSend signals that an attachment was delivered but the trailing
+// caption/text message failed. Callers may use errors.Is to special-case retry.
+var ErrPartialSend = errors.New("zalo_oa: attachment delivered but trailing text failed")
+
+const (
+ defaultClientTimeout = 15 * time.Second
+ defaultSafetyTickerInterval = 30 * time.Minute
+ // reauthWarningWindow: surface "re-consent due soon" once the refresh
+ // token's remaining lifetime drops to or below this window.
+ reauthWarningWindow = 14 * 24 * time.Hour
+)
+
+// Channel is the Zalo OA channel. Upload caps enforced by Zalo (error -210):
+// image 1MB, file 5MB, gif 5MB.
+type Channel struct {
+ *channels.BaseChannel
+
+ client *Client
+ ciStore store.ChannelInstanceStore
+ cfg config.ZaloOAConfig
+
+ instanceID uuid.UUID
+
+ tokens *tokenSource
+
+ cursor *pollCursor
+ seenIDs *seenMessageIDs // dedup fallback for messages with time == 0
+ pollInterval time.Duration
+ pollWG sync.WaitGroup
+
+ safetyTickerInterval time.Duration
+
+ stopOnce sync.Once
+ stopCh chan struct{}
+ tickerWG sync.WaitGroup
+ catchUpWG sync.WaitGroup
+
+ webhookRouter *common.Router
+ resolvedSlug string // resolved slug stored at Start; surfaced to RPC
+
+ bootstrapDroppedCount atomic.Int64
+
+ reactions sync.Map // key: ":" → *zaloReactionController
+ reactionWG sync.WaitGroup
+ reactionCtx context.Context
+ reactionCancel context.CancelFunc
+ lastReplyChars sync.Map // key: chatID → int (latest reply char count, used to scale terminal-reaction delay)
+
+ // downloadMediaFn lets tests inject a fixture writer that bypasses SSRF
+ // on httptest loopback URLs. nil → downloadOAMedia.
+ downloadMediaFn func(ctx context.Context, fileURL string) (string, error)
+}
+
+// creds returns a read-only snapshot. Refresh swaps the pointer atomically;
+// callers must not mutate the returned struct.
+func (c *Channel) creds() *ChannelCreds {
+ return c.tokens.Snapshot()
+}
+
+// inBootstrap: webhook + signature-enforcing + no secret yet. Acks Zalo's
+// URL-save ping so the operator can register the URL and retrieve the OA
+// Secret Key from the dev console.
+func (c *Channel) inBootstrap() bool {
+ return c.creds().WebhookSecretKey == "" &&
+ normalizeMode(c.cfg.WebhookSignatureMode) != SignatureModeDisabled
+}
+
+// New constructs the channel. InstanceLoader calls SetInstanceID after.
+func New(name string, cfg config.ZaloOAConfig, creds *ChannelCreds,
+ ciStore store.ChannelInstanceStore, msgBus *bus.MessageBus, _ store.PairingStore) (*Channel, error) {
+
+ if creds == nil {
+ return nil, errors.New("zalo_oa: nil creds")
+ }
+ if creds.AppID == "" || creds.SecretKey == "" {
+ return nil, errors.New("zalo_oa: app_id and secret_key are required")
+ }
+
+ if cfg.Transport == "" {
+ cfg.Transport = "webhook"
+ }
+
+ c := &Channel{
+ BaseChannel: channels.NewBaseChannel(name, msgBus, []string(cfg.AllowFrom)),
+ client: NewClient(defaultClientTimeout),
+ ciStore: ciStore,
+ cfg: cfg,
+ cursor: newPollCursor(defaultCursorMaxEntries),
+ seenIDs: newSeenMessageIDs(0),
+ pollInterval: pollIntervalFromCfg(cfg.PollIntervalSeconds),
+ safetyTickerInterval: tickerInterval(cfg.SafetyTickerMinutes),
+ stopCh: make(chan struct{}),
+ webhookRouter: common.SharedRouter(),
+ }
+ c.tokens = &tokenSource{
+ client: c.client,
+ store: c.ciStore,
+ }
+ c.tokens.creds.Store(creds)
+ c.reactionCtx, c.reactionCancel = context.WithCancel(context.Background())
+ return c, nil
+}
+
+func (c *Channel) SetInstanceID(id uuid.UUID) {
+ c.instanceID = id
+ c.tokens.instanceID = id
+}
+
+// SetTestEndpointsForTest overrides the OAuth + API hosts for integration tests.
+func (c *Channel) SetTestEndpointsForTest(oauthBase, apiBase string) {
+ if oauthBase != "" {
+ c.client.oauthBase = oauthBase
+ }
+ if apiBase != "" {
+ c.client.apiBase = apiBase
+ }
+}
+
+// ForceRefreshForTest exposes tokenSource.ForceRefresh for integration tests.
+func (c *Channel) ForceRefreshForTest() {
+ c.tokens.ForceRefresh()
+}
+
+func (c *Channel) Type() string { return channels.TypeZaloOA }
+
+// QuoteInboundOnDM gates auto-stamping of metadata["reply_to_message_id"]
+// upstream. Default off. Explicit metadata from callers (e.g. agent tools)
+// is still honored in Send regardless.
+func (c *Channel) QuoteInboundOnDM() bool {
+ if c.cfg.QuoteUserMessage == nil {
+ return false
+ }
+ return *c.cfg.QuoteUserMessage
+}
+
+var _ channels.WebhookChannel = (*Channel)(nil)
+var _ channels.DMQuoteChannel = (*Channel)(nil)
+var _ channels.ReactionChannel = (*Channel)(nil)
+
+// WebhookHandler returns (path, handler) on the first caller across the
+// shared router; subsequent calls return ("", nil). Per-instance dispatch
+// uses the slug suffix of the path: /channels/zalo/webhook/.
+func (c *Channel) WebhookHandler() (string, http.Handler) {
+ return common.SharedRouter().MountRoute()
+}
+
+// ResolvedWebhookSlug returns the slug the channel registered with the shared
+// router (empty if not yet started or polling mode).
+func (c *Channel) ResolvedWebhookSlug() string { return c.resolvedSlug }
+
+// Start brings the channel up. Safety ticker always runs. Transport
+// "webhook" (default) registers with the shared router and optionally fires
+// a catch-up sweep; "polling" starts the listrecentchat poll loop.
+func (c *Channel) Start(_ context.Context) error {
+ c.SetRunning(true)
+ if c.creds().OAID == "" {
+ slog.Info("zalo_oa.started", "state", "unauthorized", "name", c.Name())
+ c.MarkDegraded("awaiting consent", "no oa_id yet — paste consent code to authorize",
+ channels.ChannelFailureKindAuth, true)
+ // Pre-consent: only run safety ticker; nothing to poll or receive.
+ c.tickerWG.Add(1)
+ go c.runSafetyTicker()
+ return nil
+ }
+
+ c.tickerWG.Add(1)
+ go c.runSafetyTicker()
+
+ transport := c.cfg.Transport
+ switch transport {
+ case "webhook":
+ return c.startWebhookTransport()
+ case "polling":
+ c.pollWG.Add(1)
+ // Background ctx so the loop survives the caller's ctx cancel; Stop()
+ // is the canonical exit signal. Each cycle uses its own per-tick ctx.
+ go c.runPollLoop(context.Background())
+ slog.Info("zalo_oa.started", "state", "connected", "oa_id", c.creds().OAID, "transport", "polling", "name", c.Name())
+ c.MarkHealthy("connected")
+ default:
+ c.MarkFailed("unknown transport",
+ fmt.Sprintf("unknown transport %q (expected polling|webhook)", transport),
+ channels.ChannelFailureKindConfig, false)
+ return fmt.Errorf("zalo_oa: unknown transport %q", transport)
+ }
+ return nil
+}
+
+// Stop signals ticker, poll loop, and any in-flight catch-up sweep to
+// exit and waits. Webhook teardown unregisters from the shared router.
+// Idempotent.
+func (c *Channel) Stop(_ context.Context) error {
+ c.stopOnce.Do(func() { close(c.stopCh) })
+ if c.cfg.Transport == "webhook" && c.webhookRouter != nil {
+ c.webhookRouter.UnregisterInstance(c.instanceID)
+ }
+ // Cancel reaction debounce timers + any in-flight HTTP call before Wait.
+ c.reactions.Range(func(_, v any) bool {
+ if rc, ok := v.(*zaloReactionController); ok {
+ rc.Stop()
+ }
+ return true
+ })
+ if c.reactionCancel != nil {
+ c.reactionCancel()
+ }
+ c.reactionWG.Wait()
+ c.catchUpWG.Wait()
+ c.tickerWG.Wait()
+ c.pollWG.Wait()
+ c.SetRunning(false)
+ slog.Info("zalo_oa.stopped", "name", c.Name())
+ return nil
+}
+
+// Send dispatches text / image / file based on the Media slice. Zalo OA
+// sends one attachment per message; extra Media entries are skipped.
+// Caption + Content ride as a separate trailing text message (Zalo OA's
+// attachment payload has no caption field). Returns ErrPartialSend if
+// the attachment succeeded but the trailing text failed.
+func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if msg.ChatID == "" {
+ return errors.New("zalo_oa: empty user_id")
+ }
+
+ msg.Content = common.StripMarkdown(msg.Content)
+ if len(msg.Media) > 0 {
+ msg.Media = slices.Clone(msg.Media)
+ for i := range msg.Media {
+ msg.Media[i].Caption = common.StripMarkdown(msg.Media[i].Caption)
+ }
+ }
+
+ quoteID := msg.Metadata["reply_to_message_id"]
+ if len(msg.Media) == 0 {
+ _, err := c.SendText(ctx, msg.ChatID, msg.Content, quoteID)
+ if err == nil {
+ c.recordReplyLen(msg.ChatID, len(msg.Content))
+ }
+ return err
+ }
+ if len(msg.Media) > 1 {
+ slog.Info("zalo_oa.send.extra_media_skipped",
+ "oa_id", c.creds().OAID, "extra", len(msg.Media)-1)
+ }
+
+ m := msg.Media[0]
+ // 50MB stat-first guard prevents OOM; per-type caps enforced below.
+ data, mt, err := c.readMedia(m, 50*1024*1024)
+ if err != nil {
+ return err
+ }
+
+ var attachMID string
+ if mt == "image/gif" {
+ // Dedicated /upload/gif endpoint (5MB cap) preserves animation.
+ const zaloGIFCapBytes = 5 * 1024 * 1024
+ if len(data) > zaloGIFCapBytes {
+ return fmt.Errorf("zalo_oa: gif too large: %d bytes (Zalo cap is 5MB)", len(data))
+ }
+ attachMID, err = c.SendGIF(ctx, msg.ChatID, data)
+ } else if strings.HasPrefix(mt, "image/") {
+ // /upload/image caps at 1MB, jpg/png only. Auto-compress to JPEG.
+ const zaloImageCapBytes = 1 * 1024 * 1024
+ compressed, newMT, cerr := common.CompressImage(data, mt, zaloImageCapBytes)
+ if cerr != nil {
+ return cerr
+ }
+ data, mt = compressed, newMT
+ attachMID, err = c.SendImage(ctx, msg.ChatID, data, mt)
+ } else {
+ // /upload/file accepts PDF/DOC/DOCX up to 5MB.
+ const zaloFileCapBytes = 5 * 1024 * 1024
+ if !isZaloSupportedFileMIME(mt) {
+ // Drop unsupported attachment, deliver trailing text + note.
+ // Avoids surfacing a hard error to the dispatcher.
+ slog.Warn("zalo_oa.send.unsupported_attachment_dropped",
+ "oa_id", c.creds().OAID, "mime", mt, "filename", filepath.Base(m.URL))
+ fallback := mergeTrailingText(m.Caption, msg.Content)
+ heads := i18n.T(store.LocaleFromContext(ctx), i18n.MsgZaloOAUnsupportedAttachment,
+ filepath.Base(m.URL), mt)
+ if fallback == "" {
+ fallback = heads
+ } else {
+ fallback = fallback + "\n\n" + heads
+ }
+ _, terr := c.SendText(ctx, msg.ChatID, fallback, "")
+ return terr
+ }
+ if len(data) > zaloFileCapBytes {
+ return fmt.Errorf("zalo_oa: file too large: %d bytes (Zalo cap is 5MB)", len(data))
+ }
+ attachMID, err = c.SendFile(ctx, msg.ChatID, data, filepath.Base(m.URL))
+ }
+ if err != nil {
+ return err
+ }
+
+ trailing := mergeTrailingText(m.Caption, msg.Content)
+ if trailing == "" {
+ return nil
+ }
+ if _, terr := c.SendText(ctx, msg.ChatID, trailing, ""); terr != nil {
+ slog.Error("zalo_oa.send.text_after_attachment_failed",
+ "oa_id", c.creds().OAID, "user_id", msg.ChatID,
+ "attachment_message_id", attachMID, "error", terr)
+ return fmt.Errorf("%w: %v", ErrPartialSend, terr)
+ }
+ c.recordReplyLen(msg.ChatID, len(trailing))
+ return nil
+}
+
+// mergeTrailingText joins caption + content for the post-attachment text.
+// Both present → joined with a blank line.
+func mergeTrailingText(caption, content string) string {
+ caption = strings.TrimSpace(caption)
+ content = strings.TrimSpace(content)
+ switch {
+ case caption == "" && content == "":
+ return ""
+ case caption == "":
+ return content
+ case content == "":
+ return caption
+ default:
+ return caption + "\n\n" + content
+ }
+}
+
+// readMedia stat-checks before allocating to bound memory on large paths.
+func (c *Channel) readMedia(m bus.MediaAttachment, maxBytes int64) ([]byte, string, error) {
+ if m.URL == "" {
+ return nil, "", errors.New("zalo_oa: media URL empty")
+ }
+ if maxBytes > 0 {
+ info, statErr := os.Stat(m.URL)
+ if statErr == nil && info.Size() > maxBytes {
+ return nil, "", fmt.Errorf("zalo_oa: media too large: %d bytes (local cap %d; Zalo OA hard-caps uploads at 1MB via error -210)", info.Size(), maxBytes)
+ }
+ }
+ data, err := os.ReadFile(m.URL)
+ if err != nil {
+ return nil, "", fmt.Errorf("zalo_oa: read media %s: %w", m.URL, err)
+ }
+ mt := m.ContentType
+ if mt == "" {
+ mt = mime.TypeByExtension(strings.ToLower(filepath.Ext(m.URL)))
+ if mt == "" {
+ mt = "application/octet-stream"
+ }
+ }
+ return data, mt, nil
+}
+
+// runSafetyTicker calls Access() periodically so idle channels don't
+// let the refresh-token rotation window lapse silently.
+func (c *Channel) runSafetyTicker() {
+ defer c.tickerWG.Done()
+
+ t := time.NewTicker(c.safetyTickerInterval)
+ defer t.Stop()
+
+ for {
+ select {
+ case <-c.stopCh:
+ return
+ case <-t.C:
+ if c.skipTickIfAuthFailed() {
+ continue
+ }
+ // TenantID propagated so downstream listeners scoped by tenant
+ // see the right scope.
+ ctx, cancel := context.WithTimeout(store.WithTenantID(context.Background(), c.TenantID()), 30*time.Second)
+ if _, err := c.tokens.Access(ctx); err != nil && !errors.Is(err, ErrNotAuthorized) {
+ c.markAuthFailedIfNeeded(err)
+ slog.Warn("zalo_oa.safety_tick_refresh_failed", "instance_id", c.instanceID, "error", err)
+ } else {
+ c.evaluateReauthWarning()
+ }
+ cancel()
+ }
+ }
+}
+
+func (c *Channel) skipTickIfAuthFailed() bool {
+ snap := c.HealthSnapshot()
+ return snap.State == channels.ChannelHealthStateFailed && snap.FailureKind == channels.ChannelFailureKindAuth
+}
+
+// markAuthFailedIfNeeded transitions health to Failed/Auth on:
+// - ErrAuthExpired: refresh token rejected (refresh-token dead).
+// - *APIError isAuth(): access_token rejected after the retry-once
+// ForceRefresh attempt (OA-app association broken; operator must re-consent).
+//
+// ErrNotAuthorized (pre-consent) is NOT escalated.
+func (c *Channel) markAuthFailedIfNeeded(err error) {
+ if err == nil {
+ return
+ }
+ if errors.Is(err, ErrAuthExpired) {
+ var apiErr *APIError
+ var code int
+ var msg string
+ if errors.As(err, &apiErr) {
+ code, msg = apiErr.Code, apiErr.Message
+ }
+ c.MarkFailed("Re-auth required",
+ i18n.T(i18n.DefaultLocale, i18n.MsgZaloOAErrRefreshExpired, code, msg),
+ channels.ChannelFailureKindAuth,
+ false,
+ )
+ return
+ }
+ var apiErr *APIError
+ if errors.As(err, &apiErr) && apiErr.isAuth() {
+ c.MarkFailed("Re-auth required",
+ i18n.T(i18n.DefaultLocale, i18n.MsgZaloOAErrAuth, apiErr.Code, apiErr.Message),
+ channels.ChannelFailureKindAuth,
+ false,
+ )
+ }
+}
+
+// evaluateReauthWarning transitions Healthy <-> Degraded(warn) based on how
+// close RefreshTokenExpiresAt is. Called after each successful safety-tick
+// refresh. Failed states are left alone (Failed wins over warning); legacy
+// channels with zero RefreshTokenExpiresAt stay silent. Logs only on
+// transitions to avoid 30-minute log spam inside the warning window.
+func (c *Channel) evaluateReauthWarning() {
+ exp := c.creds().RefreshTokenExpiresAt
+ if exp.IsZero() {
+ return
+ }
+ remaining := time.Until(exp)
+ if remaining <= 0 {
+ return // imminent failure — let the Auth path surface it
+ }
+ snap := c.HealthSnapshot()
+ if snap.State == channels.ChannelHealthStateFailed {
+ return
+ }
+
+ inWindow := remaining <= reauthWarningWindow
+ isWarning := snap.State == channels.ChannelHealthStateDegraded &&
+ snap.FailureKind == channels.ChannelFailureKindAuth &&
+ snap.Retryable
+
+ switch {
+ case inWindow && snap.State == channels.ChannelHealthStateHealthy:
+ days := int(remaining.Hours()/24) + 1 // round up; 0.5d → "1 day"
+ c.MarkDegraded(
+ "Re-consent due soon",
+ i18n.T(i18n.DefaultLocale, i18n.MsgZaloOAReauthDueSoon, days),
+ channels.ChannelFailureKindAuth,
+ true,
+ )
+ slog.Info("zalo_oa.reauth_warning",
+ "instance_id", c.instanceID,
+ "days_remaining", days,
+ "expires_at", exp,
+ )
+ case !inWindow && isWarning:
+ c.MarkHealthy("connected")
+ slog.Info("zalo_oa.reauth_warning_cleared",
+ "instance_id", c.instanceID,
+ "expires_at", exp,
+ )
+ }
+}
+
+func tickerInterval(cfgMinutes int) time.Duration {
+ switch {
+ case cfgMinutes < 5:
+ return defaultSafetyTickerInterval
+ case cfgMinutes > 120:
+ return 120 * time.Minute
+ default:
+ return time.Duration(cfgMinutes) * time.Minute
+ }
+}
diff --git a/internal/channels/zalo/oa/creds.go b/internal/channels/zalo/oa/creds.go
new file mode 100644
index 0000000000..f6ab22538c
--- /dev/null
+++ b/internal/channels/zalo/oa/creds.go
@@ -0,0 +1,80 @@
+// Package oa implements the Zalo Official Account channel
+// (OAuth v4 — oauth.zaloapp.com + openapi.zalo.me).
+package oa
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// ChannelCreds is the plaintext credentials JSON stored inside the
+// channel_instances.credentials BLOB. The store layer encrypts the whole
+// blob — do NOT field-level encrypt.
+type ChannelCreds struct {
+ AppID string `json:"app_id"`
+ SecretKey string `json:"secret_key"`
+ OAID string `json:"oa_id,omitempty"`
+
+ // RedirectURI must match the URL registered on the Zalo dev console;
+ // otherwise Zalo returns error_code=-14003 "Invalid redirect uri".
+ RedirectURI string `json:"redirect_uri,omitempty"`
+
+ // WebhookSecretKey is the signing secret from the Zalo dev console
+ // (OA → Webhook). Distinct from SecretKey (OAuth v4). Used to verify
+ // X-ZEvent-Signature headers when Transport=webhook.
+ WebhookSecretKey string `json:"webhook_secret_key,omitempty"`
+
+ AccessToken string `json:"access_token,omitempty"`
+ RefreshToken string `json:"refresh_token,omitempty"`
+ ExpiresAt time.Time `json:"expires_at"`
+ RefreshTokenExpiresAt time.Time `json:"refresh_token_expires_at,omitempty"`
+ LastRefreshAt time.Time `json:"last_refresh_at"`
+}
+
+// LoadCreds parses plaintext credentials JSON.
+func LoadCreds(raw json.RawMessage) (*ChannelCreds, error) {
+ var c ChannelCreds
+ if err := json.Unmarshal(raw, &c); err != nil {
+ return nil, err
+ }
+ return &c, nil
+}
+
+// Marshal returns plaintext JSON; store layer re-encrypts on Update.
+func (c *ChannelCreds) Marshal() (json.RawMessage, error) {
+ return json.Marshal(c)
+}
+
+// WithTokens copies new tokens onto the receiver and stamps LastRefreshAt.
+// Preserves a previously set RefreshTokenExpiresAt if Zalo omits the field on
+// this particular response — a one-time omission must not blank the deadline.
+func (c *ChannelCreds) WithTokens(tok *Tokens) {
+ c.AccessToken = tok.AccessToken
+ c.RefreshToken = tok.RefreshToken
+ c.ExpiresAt = tok.ExpiresAt
+ if !tok.RefreshTokenExpiresAt.IsZero() {
+ c.RefreshTokenExpiresAt = tok.RefreshTokenExpiresAt
+ }
+ c.LastRefreshAt = time.Now().UTC()
+}
+
+// Persist writes the plaintext creds blob; store layer re-encrypts on Update.
+func Persist(ctx context.Context, s store.ChannelInstanceStore, id uuid.UUID, c *ChannelCreds) error {
+ if s == nil {
+ return fmt.Errorf("zalo_oa: nil ChannelInstanceStore in Persist")
+ }
+ if id == uuid.Nil {
+ return fmt.Errorf("zalo_oa: nil instance ID in Persist")
+ }
+ blob, err := c.Marshal()
+ if err != nil {
+ return fmt.Errorf("zalo_oa: marshal creds: %w", err)
+ }
+ return s.Update(ctx, id, map[string]any{"credentials": []byte(blob)})
+}
diff --git a/internal/channels/zalo/oa/creds_test.go b/internal/channels/zalo/oa/creds_test.go
new file mode 100644
index 0000000000..8bf06b7d07
--- /dev/null
+++ b/internal/channels/zalo/oa/creds_test.go
@@ -0,0 +1,113 @@
+package oa
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+)
+
+func TestLoadCreds_PlaintextRoundtrip(t *testing.T) {
+ t.Parallel()
+
+ // Plaintext JSON inside the store-encrypted blob (mirrors zalo bot factory).
+ in := []byte(`{
+ "app_id": "1234567890",
+ "secret_key": "shh-dummy",
+ "oa_id": "9999",
+ "access_token": "at_old",
+ "refresh_token": "rt_old",
+ "expires_at": "2026-04-19T23:00:00Z",
+ "last_refresh_at": "2026-04-19T22:00:00Z"
+ }`)
+
+ c, err := LoadCreds(in)
+ if err != nil {
+ t.Fatalf("LoadCreds: %v", err)
+ }
+ if c.AppID != "1234567890" {
+ t.Errorf("AppID = %q", c.AppID)
+ }
+ if c.SecretKey != "shh-dummy" {
+ t.Errorf("SecretKey = %q", c.SecretKey)
+ }
+ if c.AccessToken != "at_old" {
+ t.Errorf("AccessToken = %q", c.AccessToken)
+ }
+ if c.OAID != "9999" {
+ t.Errorf("OAID = %q", c.OAID)
+ }
+ wantExp, _ := time.Parse(time.RFC3339, "2026-04-19T23:00:00Z")
+ if !c.ExpiresAt.Equal(wantExp) {
+ t.Errorf("ExpiresAt = %v, want %v", c.ExpiresAt, wantExp)
+ }
+
+ out, err := c.Marshal()
+ if err != nil {
+ t.Fatalf("Marshal: %v", err)
+ }
+ c2, err := LoadCreds(out)
+ if err != nil {
+ t.Fatalf("LoadCreds(out): %v", err)
+ }
+ if *c != *c2 {
+ t.Errorf("round-trip mismatch:\n in=%+v\nout=%+v", c, c2)
+ }
+}
+
+func TestWithTokens_MutatesAndStampsRefreshTime(t *testing.T) {
+ t.Parallel()
+
+ c := &ChannelCreds{AppID: "x", SecretKey: "y", AccessToken: "old_at", RefreshToken: "old_rt"}
+ tok := &Tokens{
+ AccessToken: "new_at",
+ RefreshToken: "new_rt",
+ ExpiresAt: time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC),
+ }
+
+ before := time.Now()
+ c.WithTokens(tok)
+ if c.AccessToken != "new_at" || c.RefreshToken != "new_rt" {
+ t.Errorf("tokens not updated: %+v", c)
+ }
+ if !c.ExpiresAt.Equal(tok.ExpiresAt) {
+ t.Errorf("ExpiresAt not updated: %v", c.ExpiresAt)
+ }
+ if c.LastRefreshAt.Before(before) {
+ t.Errorf("LastRefreshAt not stamped: %v", c.LastRefreshAt)
+ }
+}
+
+func TestLoadCreds_InvalidJSON(t *testing.T) {
+ t.Parallel()
+ if _, err := LoadCreds([]byte(`{not json`)); err == nil {
+ t.Fatal("expected error for invalid JSON")
+ }
+}
+
+func TestMarshal_NoFieldEncryption(t *testing.T) {
+ // Guards against accidental field-level encryption — the store layer
+ // already encrypts the entire blob; doing it twice would break decode.
+ t.Parallel()
+
+ c := &ChannelCreds{
+ AppID: "1234",
+ SecretKey: "RAW-IN-JSON",
+ AccessToken: "RAW-AT",
+ RefreshToken: "RAW-RT",
+ }
+ b, err := c.Marshal()
+ if err != nil {
+ t.Fatalf("Marshal: %v", err)
+ }
+
+ var raw map[string]any
+ if err := json.Unmarshal(b, &raw); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if raw["secret_key"] != "RAW-IN-JSON" {
+ t.Errorf("secret_key not plaintext: %v", raw["secret_key"])
+ }
+ if raw["access_token"] != "RAW-AT" {
+ t.Errorf("access_token not plaintext: %v", raw["access_token"])
+ }
+}
diff --git a/internal/channels/zalo/oa/endpoints.go b/internal/channels/zalo/oa/endpoints.go
new file mode 100644
index 0000000000..9137d653ec
--- /dev/null
+++ b/internal/channels/zalo/oa/endpoints.go
@@ -0,0 +1,23 @@
+package oa
+
+// Zalo endpoint surface. Version prefixes are load-bearing — Zalo mixes
+// API versions per family. v2.0: read + upload. v3.0: send. v4: OAuth.
+const (
+ defaultAPIBase = "https://openapi.zalo.me"
+ defaultOAuthBase = "https://oauth.zaloapp.com/v4"
+
+ pathSendMessage = "/v3.0/oa/message/cs"
+ pathListRecentChat = "/v2.0/oa/listrecentchat"
+
+ // Reactions ride the v2.0 message endpoint with a sender_action body —
+ // distinct from pathSendMessage (v3.0/cs) by both version and shape.
+ pathSendReaction = "/v2.0/oa/message"
+
+ // Upload caps enforced by Zalo: image 1MB, file 5MB, gif 5MB.
+ pathUploadImage = "/v2.0/oa/upload/image"
+ pathUploadFile = "/v2.0/oa/upload/file"
+ pathUploadGIF = "/v2.0/oa/upload/gif"
+
+ // Joined onto defaultOAuthBase (which already carries /v4).
+ pathOAuthAccessToken = "/oa/access_token"
+)
diff --git a/internal/channels/zalo/oa/errors.go b/internal/channels/zalo/oa/errors.go
new file mode 100644
index 0000000000..7b70d502a1
--- /dev/null
+++ b/internal/channels/zalo/oa/errors.go
@@ -0,0 +1,192 @@
+package oa
+
+// Known Zalo OA error codes. The access-token-invalid family is returned
+// with inconsistent sign + magnitude (216, -216, 401, -401) for the same
+// cause; all four are treated identically.
+//
+// Sources:
+// - Social API reference: docs/zalo-error-codes.md (auto-scraped)
+// - OA OpenAPI negative codes (-216, -118, -201, -210, -14003) are
+// production-observed and not documented on the public reference page.
+const (
+ // Access token invalid/expired → ForceRefresh + one retry.
+ codeAccessTokenInvalid216Neg = -216
+ codeAccessTokenInvalid216Pos = 216
+ codeAccessTokenInvalid401Neg = -401
+ codeAccessTokenInvalid401Pos = 401
+
+ // Refresh token dead → operator must re-consent.
+ codeInvalidGrant = -118
+
+ // Payload shape rejected (e.g. send endpoint requires template/media
+ // shape for images instead of plain attachment_id).
+ codeParamsInvalid = -201
+
+ // Upload body exceeds the endpoint cap (image 1MB, file 5MB, gif 5MB).
+ codeFileSizeExceeded = -210
+
+ // OAuth: redirect_uri does not match the one registered on Zalo console.
+ codeInvalidRedirectURI = -14003
+)
+
+// isAccessTokenInvalid reports whether code is in the access-token
+// invalid/expired family.
+func isAccessTokenInvalid(code int) bool {
+ switch code {
+ case codeAccessTokenInvalid216Neg, codeAccessTokenInvalid216Pos,
+ codeAccessTokenInvalid401Neg, codeAccessTokenInvalid401Pos:
+ return true
+ }
+ return false
+}
+
+// Family classifies a Zalo error so the LLM and the channel UI can react
+// appropriately. Unknown codes return FamilyUnknown and the catalog falls
+// through — the legacy "code N: message" string is still surfaced.
+type Family string
+
+const (
+ FamilyUnknown Family = ""
+ FamilyAuth Family = "auth" // token invalid / refresh dead
+ FamilyPermission Family = "permission" // scope, opt-in, 48h window
+ FamilyPayload Family = "payload" // shape, template, syntax
+ FamilySize Family = "size" // file/image/gif over cap
+ FamilyRate Family = "rate" // per-app or per-user quota
+ FamilyServer Family = "server" // 5xx-equivalent / temporary
+ FamilyConfig Family = "config" // operator-side misconfig (OAuth)
+)
+
+// CodeInfo is what Classify returns. Empty fields mean "use default surfacing".
+//
+// LLMHint is a single short English sentence the agent reads in a tool result;
+// it should describe the cause and the corrective action without leaking the
+// raw numeric code (the code is appended separately by APIError.Error()).
+//
+// OpReason is the i18n key used when MarkFailed shows a reason in the UI.
+// One key may serve multiple codes (e.g. all auth codes share MsgZaloOAErrAuth).
+type CodeInfo struct {
+ Family Family
+ Retryable bool
+ LLMHint string
+ OpReason string
+}
+
+// catalog maps a Zalo error code to its classification. Only curated codes
+// belong here — anything not listed falls through as FamilyUnknown.
+var catalog = map[int]CodeInfo{
+ // Auth — access token invalid/expired (4 sign/magnitude variants).
+ codeAccessTokenInvalid216Neg: authTokenInfo,
+ codeAccessTokenInvalid216Pos: authTokenInfo,
+ codeAccessTokenInvalid401Neg: authTokenInfo,
+ codeAccessTokenInvalid401Pos: authTokenInfo,
+
+ // Auth — refresh token dead, operator must re-consent.
+ codeInvalidGrant: {
+ Family: FamilyAuth,
+ Retryable: false,
+ LLMHint: "Zalo refresh token has expired; the operator must re-authorize the OA before sending will resume.",
+ OpReason: "MsgZaloOAErrRefreshExpired",
+ },
+
+ // Payload — shape/template/syntax rejected.
+ codeParamsInvalid: payloadInfo,
+ 100: payloadInfo, // Invalid parameter
+ 2500: payloadInfo, // Syntax error
+
+ // Size — body over the per-endpoint cap.
+ codeFileSizeExceeded: {
+ Family: FamilySize,
+ Retryable: false,
+ LLMHint: "Attachment exceeds the Zalo cap (image 1MB, file 5MB, gif 5MB); recompress or resize before retrying.",
+ OpReason: "MsgZaloOAErrSize",
+ },
+
+ // Permission — extended scope required.
+ 289: {
+ Family: FamilyPermission,
+ Retryable: false,
+ LLMHint: "The OA app is missing an extended permission required for this call; the operator must grant the additional scope.",
+ OpReason: "MsgZaloOAErrPermission",
+ },
+
+ // Permission — interaction window / opt-in (Zalo's user-must-have-spoken-recently rule).
+ 12007: interactionWindowInfo, // user inactive 30+ days
+ 12008: interactionWindowInfo, // recipient hit per-window receive quota
+ 12009: interactionWindowInfo, // sender and recipient not friends
+
+ // Permission — user not visible / app disabled.
+ 210: {
+ Family: FamilyPermission,
+ Retryable: false,
+ LLMHint: "The target user is not visible to this OA (not opted-in or has hidden their profile); skip and inform the caller.",
+ OpReason: "MsgZaloOAErrUserNotVisible",
+ },
+ 11004: {
+ Family: FamilyPermission,
+ Retryable: false,
+ LLMHint: "The Zalo app is disabled or banned; the operator must contact Zalo support before any send will succeed.",
+ OpReason: "MsgZaloOAErrAppDisabled",
+ },
+
+ // Rate — quota exhausted (app- or user-scoped). Retry only after the
+ // quota window resets; the agent loop should not loop on this.
+ 12000: rateInfo, // app-wide quota
+ 12002: rateInfo, // daily quota
+ 12003: rateInfo, // weekly quota
+ 12004: rateInfo, // monthly quota
+ 12010: rateInfo, // per-user daily quota
+
+ // Server — generic call failure / unknown exception. Safe to retry once
+ // at a higher layer; treat as transient.
+ 10000: serverInfo,
+ 10002: serverInfo,
+
+ // Config — OAuth misconfig (redirect_uri mismatch).
+ codeInvalidRedirectURI: {
+ Family: FamilyConfig,
+ Retryable: false,
+ LLMHint: "Zalo rejected the OAuth redirect_uri; the operator must update the redirect URI in the Zalo console to match the channel config.",
+ OpReason: "MsgZaloOAErrRedirectURI",
+ },
+}
+
+// Shared CodeInfo values reused by multiple codes — declared at file scope
+// so the catalog map stays a literal (no init() side-effects).
+var (
+ authTokenInfo = CodeInfo{
+ Family: FamilyAuth,
+ Retryable: true, // one retry after ForceRefresh — handled in send.go/poll.go
+ LLMHint: "Zalo access token was rejected; the channel will refresh and retry once automatically.",
+ OpReason: "MsgZaloOAErrAuth",
+ }
+ payloadInfo = CodeInfo{
+ Family: FamilyPayload,
+ Retryable: false,
+ LLMHint: "Zalo rejected the request payload; verify the message shape (template vs. plain), required fields, and recipient ID format before retrying.",
+ OpReason: "MsgZaloOAErrPayload",
+ }
+ interactionWindowInfo = CodeInfo{
+ Family: FamilyPermission,
+ Retryable: false,
+ LLMHint: "Zalo only allows messaging users who have interacted with the OA recently; the recipient is outside that window. Wait for the user to message first or use a paid template.",
+ OpReason: "MsgZaloOAErrInteractionWindow",
+ }
+ rateInfo = CodeInfo{
+ Family: FamilyRate,
+ Retryable: false, // not within this request — wait for quota reset
+ LLMHint: "Zalo quota for this OA or user has been exhausted; wait for the quota window to reset before retrying.",
+ OpReason: "MsgZaloOAErrRate",
+ }
+ serverInfo = CodeInfo{
+ Family: FamilyServer,
+ Retryable: true,
+ LLMHint: "Zalo returned a temporary server error; retrying after a short backoff is safe.",
+ OpReason: "MsgZaloOAErrServer",
+ }
+)
+
+// Classify returns the CodeInfo for the given Zalo error code. Unknown codes
+// return CodeInfo{Family: FamilyUnknown}.
+func Classify(code int) CodeInfo {
+ return catalog[code]
+}
diff --git a/internal/channels/zalo/oa/errors_test.go b/internal/channels/zalo/oa/errors_test.go
new file mode 100644
index 0000000000..5c51e0b048
--- /dev/null
+++ b/internal/channels/zalo/oa/errors_test.go
@@ -0,0 +1,116 @@
+package oa
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestClassify_KnownCodes(t *testing.T) {
+ tests := []struct {
+ code int
+ wantFamily Family
+ wantHintNon bool // LLMHint must be non-empty
+ wantOpReason string
+ }{
+ // Auth family — every variant of the access-token-invalid code.
+ {-216, FamilyAuth, true, "MsgZaloOAErrAuth"},
+ {216, FamilyAuth, true, "MsgZaloOAErrAuth"},
+ {-401, FamilyAuth, true, "MsgZaloOAErrAuth"},
+ {401, FamilyAuth, true, "MsgZaloOAErrAuth"},
+ // Auth family — refresh token dead.
+ {-118, FamilyAuth, true, "MsgZaloOAErrRefreshExpired"},
+ // Payload family.
+ {-201, FamilyPayload, true, "MsgZaloOAErrPayload"},
+ {100, FamilyPayload, true, "MsgZaloOAErrPayload"},
+ {2500, FamilyPayload, true, "MsgZaloOAErrPayload"},
+ // Size family.
+ {-210, FamilySize, true, "MsgZaloOAErrSize"},
+ // Permission family — extended permission required.
+ {289, FamilyPermission, true, "MsgZaloOAErrPermission"},
+ // Permission family — user/recipient outside the messaging window.
+ {12007, FamilyPermission, true, "MsgZaloOAErrInteractionWindow"},
+ {12008, FamilyPermission, true, "MsgZaloOAErrInteractionWindow"},
+ {12009, FamilyPermission, true, "MsgZaloOAErrInteractionWindow"},
+ // Rate family — daily/weekly/monthly quotas.
+ {12000, FamilyRate, true, "MsgZaloOAErrRate"},
+ {12002, FamilyRate, true, "MsgZaloOAErrRate"},
+ {12003, FamilyRate, true, "MsgZaloOAErrRate"},
+ {12004, FamilyRate, true, "MsgZaloOAErrRate"},
+ {12010, FamilyRate, true, "MsgZaloOAErrRate"},
+ // Server family — generic exceptions.
+ {10000, FamilyServer, true, "MsgZaloOAErrServer"},
+ {10002, FamilyServer, true, "MsgZaloOAErrServer"},
+ // Permission family — app disabled / user not visible.
+ {210, FamilyPermission, true, "MsgZaloOAErrUserNotVisible"},
+ {11004, FamilyPermission, true, "MsgZaloOAErrAppDisabled"},
+ // Config family — OAuth misconfiguration.
+ {-14003, FamilyConfig, true, "MsgZaloOAErrRedirectURI"},
+ }
+
+ for _, tt := range tests {
+ got := Classify(tt.code)
+ if got.Family != tt.wantFamily {
+ t.Errorf("Classify(%d).Family = %q, want %q", tt.code, got.Family, tt.wantFamily)
+ }
+ if tt.wantHintNon && got.LLMHint == "" {
+ t.Errorf("Classify(%d).LLMHint is empty, want non-empty", tt.code)
+ }
+ if got.OpReason != tt.wantOpReason {
+ t.Errorf("Classify(%d).OpReason = %q, want %q", tt.code, got.OpReason, tt.wantOpReason)
+ }
+ }
+}
+
+func TestClassify_UnknownCode(t *testing.T) {
+ got := Classify(99999)
+ if got.Family != FamilyUnknown {
+ t.Errorf("Classify(99999).Family = %q, want FamilyUnknown", got.Family)
+ }
+ if got.LLMHint != "" || got.OpReason != "" {
+ t.Errorf("Classify(99999) should be zero value, got %+v", got)
+ }
+}
+
+func TestAPIError_Error_AppendsHintWhenKnown(t *testing.T) {
+ e := &APIError{Code: -210, Message: "file too big"}
+ got := e.Error()
+ if !strings.Contains(got, "-210") || !strings.Contains(got, "file too big") {
+ t.Errorf("Error() must include code+message, got %q", got)
+ }
+ if !strings.Contains(got, "1MB") {
+ t.Errorf("Error() should include the size LLMHint, got %q", got)
+ }
+}
+
+func TestAPIError_Error_FallbackForUnknown(t *testing.T) {
+ e := &APIError{Code: 99999, Message: "??"}
+ got := e.Error()
+ want := "zalo api error 99999: ??"
+ if got != want {
+ t.Errorf("Error() unknown-code = %q, want %q", got, want)
+ }
+}
+
+func TestAPIError_Info(t *testing.T) {
+ if (&APIError{Code: -210}).Info().Family != FamilySize {
+ t.Errorf("Info() for -210 should be FamilySize")
+ }
+ if (*APIError)(nil).Info().Family != FamilyUnknown {
+ t.Errorf("Info() on nil receiver should return zero CodeInfo")
+ }
+}
+
+func TestIsAccessTokenInvalid_StillWorks(t *testing.T) {
+ // The legacy helper must keep working — send.go and poll.go branch on it
+ // directly to drive the one-shot token refresh retry.
+ for _, code := range []int{-216, 216, -401, 401} {
+ if !isAccessTokenInvalid(code) {
+ t.Errorf("isAccessTokenInvalid(%d) = false, want true", code)
+ }
+ }
+ for _, code := range []int{-118, -201, -210, 12000, 12009, 99999, 0} {
+ if isAccessTokenInvalid(code) {
+ t.Errorf("isAccessTokenInvalid(%d) = true, want false", code)
+ }
+ }
+}
diff --git a/internal/channels/zalo/oa/export_test.go b/internal/channels/zalo/oa/export_test.go
new file mode 100644
index 0000000000..a92a997d0f
--- /dev/null
+++ b/internal/channels/zalo/oa/export_test.go
@@ -0,0 +1,3 @@
+package oa
+
+func (c *Channel) BootstrapDroppedForTest() int64 { return c.bootstrapDroppedCount.Load() }
diff --git a/internal/channels/zalo/oa/factory.go b/internal/channels/zalo/oa/factory.go
new file mode 100644
index 0000000000..abcfec82c1
--- /dev/null
+++ b/internal/channels/zalo/oa/factory.go
@@ -0,0 +1,46 @@
+package oa
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// Factory returns a channels.ChannelFactory closure capturing the store.
+// Webhook-mode channels register with common.SharedRouter() at Start().
+func Factory(ciStore store.ChannelInstanceStore) channels.ChannelFactory {
+ return func(name string, credsRaw json.RawMessage, cfgRaw json.RawMessage,
+ msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) {
+
+ if ciStore == nil {
+ return nil, errors.New("zalo_oa: nil ChannelInstanceStore")
+ }
+
+ creds, err := LoadCreds(credsRaw)
+ if err != nil {
+ return nil, fmt.Errorf("zalo_oa: decode credentials: %w", err)
+ }
+
+ var cfg config.ZaloOAConfig
+ if len(cfgRaw) > 0 {
+ if err := json.Unmarshal(cfgRaw, &cfg); err != nil {
+ return nil, fmt.Errorf("zalo_oa: decode config: %w", err)
+ }
+ }
+
+ ch, err := New(name, cfg, creds, ciStore, msgBus, pairingSvc)
+ if err != nil {
+ return nil, err
+ }
+ // Seed cursor from persisted channel_instances.config.poll_cursor.
+ if seeded := parseCursorFromConfig(cfgRaw); len(seeded) > 0 {
+ ch.cursor.loadFromMap(seeded)
+ }
+ return ch, nil
+ }
+}
diff --git a/internal/channels/zalo/oa/poll.go b/internal/channels/zalo/oa/poll.go
new file mode 100644
index 0000000000..803a01d934
--- /dev/null
+++ b/internal/channels/zalo/oa/poll.go
@@ -0,0 +1,223 @@
+package oa
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "sort"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
+)
+
+// message is a single entry in the /v2.0/oa/listrecentchat response.
+// Each row is a message (not a thread summary).
+type message struct {
+ MessageID string `json:"message_id"`
+ FromID string `json:"from_id"`
+ FromDisplayName string `json:"from_display_name,omitempty"`
+ ToID string `json:"to_id,omitempty"`
+ Time int64 `json:"time,omitempty"`
+ Text string `json:"message,omitempty"` // Zalo's field is "message", not "text"
+ Type string `json:"type,omitempty"` // text/image/file/sticker
+}
+
+// listRecentChat fetches the most-recent N messages across all users.
+// Zalo v2.0 encodes GET params as a single JSON blob in the `data` query
+// parameter (e.g. ?data={"offset":0,"count":10}).
+func (c *Channel) listRecentChat(ctx context.Context, offset, count int) ([]message, error) {
+ tok, err := c.tokens.Access(ctx)
+ if err != nil {
+ return nil, err
+ }
+ data, err := json.Marshal(map[string]int{"offset": offset, "count": count})
+ if err != nil {
+ return nil, fmt.Errorf("zalo_oa: marshal listrecentchat params: %w", err)
+ }
+ q := url.Values{"data": {string(data)}}
+ raw, err := c.client.apiGet(ctx, pathListRecentChat, q, tok)
+ if err != nil {
+ return nil, err
+ }
+ var wrap struct {
+ Data []message `json:"data"`
+ }
+ if err := json.Unmarshal(raw, &wrap); err != nil {
+ return nil, fmt.Errorf("zalo_oa: decode listrecentchat: %w", err)
+ }
+ return wrap.Data, nil
+}
+
+// pollOnce runs one polling cycle. Iterates oldest-first, filters OA
+// echoes (from_id == OAID), dedups per-user by last-seen timestamp.
+// Returns ErrRateLimit on HTTP 429; one auth retry via ForceRefresh.
+// Burn-down loop pages until a partial page (caught up) or maxPages cap.
+func (c *Channel) pollOnce(ctx context.Context) error {
+ if c.skipPollIfAuthFailed() {
+ return nil
+ }
+
+ pageSize := pollCountFromCfg(c.cfg.PollCount)
+ maxPages := pollBurndownMaxPagesFromCfg(c.cfg.PollBurndownMaxPages)
+
+ for page := range maxPages {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ offset := page * pageSize
+ msgs, err := c.listRecentChatRetryAuth(ctx, offset, pageSize)
+ if err != nil {
+ return err
+ }
+ if len(msgs) == 0 {
+ break
+ }
+ c.processMessages(msgs)
+ if len(msgs) < pageSize {
+ break // partial page — caught up
+ }
+ if page == maxPages-1 {
+ slog.Warn("zalo_oa.poll.burndown_capped",
+ "oa_id", c.creds().OAID,
+ "max_pages", maxPages,
+ "page_size", pageSize,
+ "hint", "raise poll_burndown_max_pages, shorten poll_interval_seconds, or switch to webhook transport")
+ }
+ }
+ return nil
+}
+
+// listRecentChatRetryAuth wraps listRecentChat with one retry on auth
+// failure that forces a token refresh.
+func (c *Channel) listRecentChatRetryAuth(ctx context.Context, offset, count int) ([]message, error) {
+ msgs, err := c.listRecentChat(ctx, offset, count)
+ if err == nil {
+ return msgs, nil
+ }
+ var apiErr *APIError
+ if errors.As(err, &apiErr) && apiErr.isAuth() {
+ slog.Warn("zalo_oa.poll.token_rejected_forcing_refresh",
+ "oa_id", c.creds().OAID, "zalo_code", apiErr.Code, "zalo_msg", apiErr.Message)
+ c.tokens.ForceRefresh()
+ return c.listRecentChat(ctx, offset, count)
+ }
+ return nil, err
+}
+
+// processMessages iterates a page oldest-first, filters OA echoes and
+// malformed rows, dedups via (cursor, seenIDs), and dispatches via
+// BaseChannel.HandleMessage.
+func (c *Channel) processMessages(msgs []message) {
+ // Oldest-first so the cursor advances monotonically.
+ sort.SliceStable(msgs, func(i, j int) bool { return msgs[i].Time < msgs[j].Time })
+
+ for _, m := range msgs {
+ if m.FromID == "" || m.FromID == c.creds().OAID {
+ continue
+ }
+ if m.Time == 0 && m.MessageID == "" {
+ // No dedup signal — warn so a future Zalo field rename surfaces
+ // instead of silently swallowing every inbound message.
+ slog.Warn("zalo_oa.poll.dropped_no_dedup_signal",
+ "from_id", m.FromID,
+ "type", m.Type)
+ continue
+ }
+ // Prefer (from_id, time) cursor; fall back to message_id LRU when
+ // Zalo omits time (rare).
+ if m.Time != 0 {
+ if m.Time <= c.cursor.Get(m.FromID) {
+ continue
+ }
+ } else if m.MessageID != "" && c.seenIDs.SeenOrAdd(m.MessageID) {
+ continue
+ }
+ c.dispatchInbound(m)
+ if m.Time != 0 {
+ c.cursor.Advance(m.FromID, m.Time)
+ }
+ }
+}
+
+// dispatchInbound maps a Zalo message into a BaseChannel.HandleMessage call.
+// Zalo OA is DM-only, so chatID == senderID. Text only; non-text is skipped.
+func (c *Channel) dispatchInbound(m message) {
+ if m.Type != "" && m.Type != "text" {
+ slog.Info("zalo_oa.poll.non_text_skipped",
+ "oa_id", c.creds().OAID, "user_id", m.FromID, "message_id", m.MessageID, "type", m.Type)
+ return
+ }
+ if m.Text == "" {
+ return
+ }
+ metadata := common.InboundMeta{
+ MessageID: m.MessageID,
+ Platform: common.PlatformZaloOA,
+ SenderDisplayName: m.FromDisplayName,
+ }.ToMap()
+ c.BaseChannel.HandleMessage(m.FromID, m.FromID, m.Text, nil, metadata, "direct")
+}
+
+// skipPollIfAuthFailed stops polling once health is Failed/Auth so we
+// don't hammer the API while waiting for operator re-auth.
+func (c *Channel) skipPollIfAuthFailed() bool {
+ snap := c.HealthSnapshot()
+ return snap.State == channels.ChannelHealthStateFailed && snap.FailureKind == channels.ChannelFailureKindAuth
+}
+
+const (
+ defaultPollInterval = 15 * time.Second
+ rateLimitBackoff = 30 * time.Second
+ cursorFlushInterval = 60 * time.Second
+
+ // Zalo /v2.0/oa/listrecentchat caps `count` at 10 (server returns -210 above).
+ defaultPollCount = 10
+ pollCountFloor = 1
+ pollCountCeil = 10
+ defaultPollBurndownMaxPages = 10
+ pollBurndownMaxPagesCeil = 20
+)
+
+// pollIntervalFromCfg clamps cfg.PollIntervalSeconds to the safe range.
+func pollIntervalFromCfg(s int) time.Duration {
+ switch {
+ case s < 5:
+ return defaultPollInterval
+ case s > 120:
+ return 120 * time.Second
+ default:
+ return time.Duration(s) * time.Second
+ }
+}
+
+// pollCountFromCfg clamps cfg.PollCount to [pollCountFloor, pollCountCeil].
+// Zero/negative → defaultPollCount.
+func pollCountFromCfg(n int) int {
+ switch {
+ case n <= 0:
+ return defaultPollCount
+ case n < pollCountFloor:
+ return pollCountFloor
+ case n > pollCountCeil:
+ return pollCountCeil
+ default:
+ return n
+ }
+}
+
+// pollBurndownMaxPagesFromCfg clamps cfg.PollBurndownMaxPages to [1, 20].
+// Zero/negative → defaultPollBurndownMaxPages. 1 disables burn-down.
+func pollBurndownMaxPagesFromCfg(n int) int {
+ switch {
+ case n <= 0:
+ return defaultPollBurndownMaxPages
+ case n > pollBurndownMaxPagesCeil:
+ return pollBurndownMaxPagesCeil
+ default:
+ return n
+ }
+}
diff --git a/internal/channels/zalo/oa/poll_burndown_test.go b/internal/channels/zalo/oa/poll_burndown_test.go
new file mode 100644
index 0000000000..aac70c80c6
--- /dev/null
+++ b/internal/channels/zalo/oa/poll_burndown_test.go
@@ -0,0 +1,386 @@
+package oa
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+)
+
+// TestPollCountFromCfg covers the [1, 10] clamp + zero/negative default.
+// Zalo's listrecentchat hard-caps count at 10 (error -210 above).
+func TestPollCountFromCfg(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ in, want int
+ }{
+ {-1, 10}, // negative → default
+ {0, 10}, // zero → default
+ {1, 1}, // floor
+ {5, 5}, // identity
+ {10, 10}, // ceiling
+ {11, 10}, // above ceiling → ceiling (Zalo cap)
+ {50, 10},
+ {999, 10},
+ }
+ for _, tc := range cases {
+ got := pollCountFromCfg(tc.in)
+ if got != tc.want {
+ t.Errorf("pollCountFromCfg(%d) = %d, want %d", tc.in, got, tc.want)
+ }
+ }
+}
+
+// TestPollBurndownMaxPagesFromCfg covers the [1, 20] clamp + zero/negative default.
+func TestPollBurndownMaxPagesFromCfg(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ in, want int
+ }{
+ {-1, 10}, // negative → default
+ {0, 10}, // zero → default
+ {1, 1}, // floor (disable burn-down)
+ {5, 5}, // identity
+ {10, 10}, // identity (default)
+ {20, 20}, // ceiling
+ {21, 20}, // above ceiling → ceiling
+ {999, 20},
+ }
+ for _, tc := range cases {
+ got := pollBurndownMaxPagesFromCfg(tc.in)
+ if got != tc.want {
+ t.Errorf("pollBurndownMaxPagesFromCfg(%d) = %d, want %d", tc.in, got, tc.want)
+ }
+ }
+}
+
+// burnDownServer fakes listrecentchat with per-call bodies so tests can
+// drive multi-page burn-down behavior.
+type burnDownServer struct {
+ srv *httptest.Server
+ mu sync.Mutex
+ calls []burnDownCall // (offset, count) per call, in order
+ pages []string // body to return per call (nth call returns nth body)
+ defaultB string // returned when calls > len(pages)
+ hits atomic.Int32
+}
+
+type burnDownCall struct {
+ offset string
+ count string
+}
+
+func newBurnDownServer(t *testing.T, pages []string) *burnDownServer {
+ t.Helper()
+ bs := &burnDownServer{pages: pages, defaultB: `{"error":0,"data":[]}`}
+ bs.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v2.0/oa/listrecentchat" {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ // data={"offset":N,"count":M}
+ data := r.URL.Query().Get("data")
+ bs.mu.Lock()
+ idx := int(bs.hits.Load())
+ bs.hits.Add(1)
+ bs.calls = append(bs.calls, parseDataParam(data))
+ bs.mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+ if idx < len(bs.pages) {
+ _, _ = w.Write([]byte(bs.pages[idx]))
+ return
+ }
+ _, _ = w.Write([]byte(bs.defaultB))
+ }))
+ t.Cleanup(bs.srv.Close)
+ return bs
+}
+
+func parseDataParam(data string) burnDownCall {
+ // Cheap extract of "offset" and "count" without bringing in encoding/json
+ // for the test helper. Body is always {"offset":N,"count":M}.
+ c := burnDownCall{}
+ for _, key := range []string{"offset", "count"} {
+ needle := `"` + key + `":`
+ i := strings.Index(data, needle)
+ if i < 0 {
+ continue
+ }
+ j := i + len(needle)
+ end := j
+ for end < len(data) && data[end] >= '0' && data[end] <= '9' {
+ end++
+ }
+ val := data[j:end]
+ if key == "offset" {
+ c.offset = val
+ } else {
+ c.count = val
+ }
+ }
+ return c
+}
+
+func newBurnDownChannel(t *testing.T, bs *burnDownServer, cfg config.ZaloOAConfig) (*Channel, *bus.MessageBus) {
+ t.Helper()
+ creds := &ChannelCreds{
+ AppID: "app", SecretKey: "key", OAID: "oa-1",
+ AccessToken: "AT", RefreshToken: "RT", ExpiresAt: time.Now().Add(time.Hour),
+ }
+ msgBus := bus.New()
+ c, err := New("burndown_test", cfg, creds, &fakeStore{}, msgBus, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ c.SetInstanceID(uuid.New())
+ c.client.apiBase = bs.srv.URL
+ return c, msgBus
+}
+
+// drainInbound consumes inbound messages until the bus is empty or budget exceeded.
+func drainInbound(t *testing.T, msgBus *bus.MessageBus, max int) []string {
+ t.Helper()
+ out := make([]string, 0, max)
+ for i := 0; i < max+1; i++ {
+ ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
+ msg, ok := msgBus.ConsumeInbound(ctx)
+ cancel()
+ if !ok {
+ return out
+ }
+ out = append(out, msg.Metadata["message_id"]+":"+msg.Content)
+ }
+ return out
+}
+
+// genFullPage produces a JSON listrecentchat response with `n` messages.
+// Each message has unique IDs and monotonically-increasing time so cursor
+// dedup is exercised correctly.
+func genFullPage(prefix string, startTime int64, n int) string {
+ var sb strings.Builder
+ sb.WriteString(`{"error":0,"data":[`)
+ for i := range n {
+ if i > 0 {
+ sb.WriteString(",")
+ }
+ // from_id: alternate users to mimic realistic spread; not "oa-1" (avoid self-echo filter)
+ userID := "u" + intStr(1+(i%3))
+ sb.WriteString(`{"message_id":"`)
+ sb.WriteString(prefix)
+ sb.WriteString("-")
+ sb.WriteString(intStr(i))
+ sb.WriteString(`","from_id":"`)
+ sb.WriteString(userID)
+ sb.WriteString(`","time":`)
+ sb.WriteString(int64Str(startTime + int64(i)))
+ sb.WriteString(`,"message":"hi `)
+ sb.WriteString(intStr(i))
+ sb.WriteString(`","type":"text"}`)
+ }
+ sb.WriteString(`]}`)
+ return sb.String()
+}
+
+func intStr(n int) string { return int64Str(int64(n)) }
+func int64Str(n int64) string {
+ if n == 0 {
+ return "0"
+ }
+ neg := n < 0
+ if neg {
+ n = -n
+ }
+ var buf [20]byte
+ i := len(buf)
+ for n > 0 {
+ i--
+ buf[i] = byte('0' + n%10)
+ n /= 10
+ }
+ if neg {
+ i--
+ buf[i] = '-'
+ }
+ return string(buf[i:])
+}
+
+// TestPollOnce_BurnDown_PartialPageStops: page 0 returns 10 (full), page 1 returns 6 (partial).
+// Expect 2 calls, 16 unique messages dispatched.
+func TestPollOnce_BurnDown_PartialPageStops(t *testing.T) {
+ t.Parallel()
+ bs := newBurnDownServer(t, []string{
+ genFullPage("p0", 1000, 10),
+ genFullPage("p1", 2000, 6),
+ })
+ c, msgBus := newBurnDownChannel(t, bs, config.ZaloOAConfig{PollCount: 10, PollBurndownMaxPages: 5})
+
+ if err := c.pollOnce(context.Background()); err != nil {
+ t.Fatalf("pollOnce: %v", err)
+ }
+
+ if got := bs.hits.Load(); got != 2 {
+ t.Errorf("listrecentchat calls = %d, want 2 (full then partial)", got)
+ }
+ bs.mu.Lock()
+ if len(bs.calls) >= 2 {
+ if bs.calls[0].offset != "0" || bs.calls[0].count != "10" {
+ t.Errorf("call[0] = (offset=%s,count=%s), want (0,10)", bs.calls[0].offset, bs.calls[0].count)
+ }
+ if bs.calls[1].offset != "10" || bs.calls[1].count != "10" {
+ t.Errorf("call[1] = (offset=%s,count=%s), want (10,10)", bs.calls[1].offset, bs.calls[1].count)
+ }
+ }
+ bs.mu.Unlock()
+
+ got := drainInbound(t, msgBus, 100)
+ if len(got) != 16 {
+ t.Errorf("inbound count = %d, want 16", len(got))
+ }
+}
+
+// TestPollOnce_BurnDown_EmptyPageStops: page 0 returns 10 (full), page 1 returns 0 (empty).
+// Expect 2 calls, 10 unique messages dispatched.
+func TestPollOnce_BurnDown_EmptyPageStops(t *testing.T) {
+ t.Parallel()
+ bs := newBurnDownServer(t, []string{
+ genFullPage("p0", 1000, 10),
+ `{"error":0,"data":[]}`,
+ })
+ c, msgBus := newBurnDownChannel(t, bs, config.ZaloOAConfig{PollCount: 10, PollBurndownMaxPages: 5})
+
+ if err := c.pollOnce(context.Background()); err != nil {
+ t.Fatalf("pollOnce: %v", err)
+ }
+ if got := bs.hits.Load(); got != 2 {
+ t.Errorf("listrecentchat calls = %d, want 2", got)
+ }
+ got := drainInbound(t, msgBus, 100)
+ if len(got) != 10 {
+ t.Errorf("inbound count = %d, want 10", len(got))
+ }
+}
+
+// TestPollOnce_BurnDown_MaxPagesCapsAndWarns: pages are saturated (always full),
+// burn-down stops at max_pages with a warn log.
+func TestPollOnce_BurnDown_MaxPagesCapsAndWarns(t *testing.T) {
+ t.Parallel()
+ // Five full pages (10 each) then an empty one we should never reach.
+ bs := newBurnDownServer(t, []string{
+ genFullPage("p0", 1000, 10),
+ genFullPage("p1", 2000, 10),
+ genFullPage("p2", 3000, 10),
+ genFullPage("p3", 4000, 10),
+ genFullPage("p4", 5000, 10),
+ `{"error":0,"data":[]}`, // should NOT be hit
+ })
+ c, msgBus := newBurnDownChannel(t, bs, config.ZaloOAConfig{PollCount: 10, PollBurndownMaxPages: 5})
+
+ if err := c.pollOnce(context.Background()); err != nil {
+ t.Fatalf("pollOnce: %v", err)
+ }
+ if got := bs.hits.Load(); got != 5 {
+ t.Errorf("listrecentchat calls = %d, want 5 (capped by max_pages)", got)
+ }
+ got := drainInbound(t, msgBus, 100)
+ if len(got) != 50 {
+ t.Errorf("inbound count = %d, want 50", len(got))
+ }
+}
+
+// TestPollOnce_BurnDown_MaxPagesOne_DisablesBurnDown: max_pages=1 → exactly one call,
+// no burn-down even on a full page.
+func TestPollOnce_BurnDown_MaxPagesOne_DisablesBurnDown(t *testing.T) {
+ t.Parallel()
+ bs := newBurnDownServer(t, []string{
+ genFullPage("p0", 1000, 10),
+ genFullPage("p1", 2000, 10), // never reached
+ })
+ c, msgBus := newBurnDownChannel(t, bs, config.ZaloOAConfig{PollCount: 10, PollBurndownMaxPages: 1})
+
+ if err := c.pollOnce(context.Background()); err != nil {
+ t.Fatalf("pollOnce: %v", err)
+ }
+ if got := bs.hits.Load(); got != 1 {
+ t.Errorf("listrecentchat calls = %d, want 1 (max_pages=1 disables burn-down)", got)
+ }
+ got := drainInbound(t, msgBus, 100)
+ if len(got) != 10 {
+ t.Errorf("inbound count = %d, want 10", len(got))
+ }
+}
+
+// TestPollOnce_BurnDown_DefaultsApplyWhenZero: PollCount=0, PollBurndownMaxPages=0
+// → default count=10 applied (matches Zalo's API hard cap).
+func TestPollOnce_BurnDown_DefaultsApplyWhenZero(t *testing.T) {
+ t.Parallel()
+ bs := newBurnDownServer(t, []string{
+ genFullPage("p0", 1000, 10),
+ `{"error":0,"data":[]}`,
+ })
+ c, _ := newBurnDownChannel(t, bs, config.ZaloOAConfig{}) // both unset
+
+ if err := c.pollOnce(context.Background()); err != nil {
+ t.Fatalf("pollOnce: %v", err)
+ }
+ bs.mu.Lock()
+ if len(bs.calls) > 0 && bs.calls[0].count != "10" {
+ t.Errorf("first call count = %s, want 10 (default)", bs.calls[0].count)
+ }
+ bs.mu.Unlock()
+}
+
+// TestPollOnce_BurnDown_NoDoubleDispatchAcrossPages: page 0 messages partially
+// reappear in page 1 (new arrivals shifted the window). Cursor dedup must
+// drop the overlap so each unique message dispatches exactly once.
+func TestPollOnce_BurnDown_NoDoubleDispatchAcrossPages(t *testing.T) {
+ t.Parallel()
+ // Page 0: 10 messages from u1, time 1000..1009 (full → burndown continues)
+ // Page 1: 6 messages — 4 overlapping (1006..1009) + 2 fresh (1010..1011)
+ page0 := genSingleUserPage("p0", "u1", 1000, 10)
+ page1 := genSingleUserPage("overlap", "u1", 1006, 6)
+ bs := newBurnDownServer(t, []string{page0, page1})
+ c, msgBus := newBurnDownChannel(t, bs, config.ZaloOAConfig{PollCount: 10, PollBurndownMaxPages: 5})
+
+ if err := c.pollOnce(context.Background()); err != nil {
+ t.Fatalf("pollOnce: %v", err)
+ }
+ got := drainInbound(t, msgBus, 100)
+ // 10 unique from page 0, then page 1 brings 2 NEW (times 1010..1011);
+ // the 4 overlapping (1006..1009) are dropped by the cursor.
+ if len(got) != 12 {
+ t.Errorf("inbound count = %d, want 12 (10 unique + 2 fresh; 4 overlap deduped)", len(got))
+ }
+}
+
+// genSingleUserPage: all messages from one user_id with monotonic times.
+func genSingleUserPage(prefix, userID string, startTime int64, n int) string {
+ var sb strings.Builder
+ sb.WriteString(`{"error":0,"data":[`)
+ for i := range n {
+ if i > 0 {
+ sb.WriteString(",")
+ }
+ sb.WriteString(`{"message_id":"`)
+ sb.WriteString(prefix)
+ sb.WriteString("-")
+ sb.WriteString(intStr(i))
+ sb.WriteString(`","from_id":"`)
+ sb.WriteString(userID)
+ sb.WriteString(`","time":`)
+ sb.WriteString(int64Str(startTime + int64(i)))
+ sb.WriteString(`,"message":"m`)
+ sb.WriteString(intStr(i))
+ sb.WriteString(`","type":"text"}`)
+ }
+ sb.WriteString(`]}`)
+ return sb.String()
+}
diff --git a/internal/channels/zalo/oa/poll_cursor.go b/internal/channels/zalo/oa/poll_cursor.go
new file mode 100644
index 0000000000..707bc29109
--- /dev/null
+++ b/internal/channels/zalo/oa/poll_cursor.go
@@ -0,0 +1,161 @@
+package oa
+
+import (
+ "container/list"
+ "encoding/json"
+ "sort"
+ "sync"
+)
+
+const (
+ defaultCursorMaxEntries = 500
+ configCursorKey = "poll_cursor"
+)
+
+// pollCursor tracks last-seen unix-ms per user_id to dedup polling.
+// Bounded LRU; evicted users may re-receive a single message next time.
+type pollCursor struct {
+ mu sync.Mutex
+ max int
+ data map[string]*list.Element // user_id → element holding cursorEntry
+ order *list.List // front = most-recently-used
+ dirty bool
+}
+
+type cursorEntry struct {
+ userID string
+ ts int64
+}
+
+func newPollCursor(max int) *pollCursor {
+ if max <= 0 {
+ max = defaultCursorMaxEntries
+ }
+ return &pollCursor{
+ max: max,
+ data: make(map[string]*list.Element),
+ order: list.New(),
+ }
+}
+
+// Advance sets the cursor for userID if ts is strictly newer. Always
+// promotes to MRU. Returns true if the cursor moved.
+func (c *pollCursor) Advance(userID string, ts int64) bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if elem, ok := c.data[userID]; ok {
+ entry := elem.Value.(*cursorEntry)
+ if ts <= entry.ts {
+ c.order.MoveToFront(elem)
+ return false
+ }
+ entry.ts = ts
+ c.order.MoveToFront(elem)
+ c.dirty = true
+ return true
+ }
+ entry := &cursorEntry{userID: userID, ts: ts}
+ elem := c.order.PushFront(entry)
+ c.data[userID] = elem
+ c.dirty = true
+ c.evictLocked()
+ return true
+}
+
+func (c *pollCursor) Get(userID string) int64 {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if elem, ok := c.data[userID]; ok {
+ return elem.Value.(*cursorEntry).ts
+ }
+ return 0
+}
+
+// LastSeenTimestamp returns the max unix-ms across all entries (0 if empty).
+func (c *pollCursor) LastSeenTimestamp() int64 {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ var max int64
+ for _, elem := range c.data {
+ if ts := elem.Value.(*cursorEntry).ts; ts > max {
+ max = ts
+ }
+ }
+ return max
+}
+
+// Snapshot returns a mutable copy of the cursor map.
+func (c *pollCursor) Snapshot() map[string]int64 {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ out := make(map[string]int64, len(c.data))
+ for k, elem := range c.data {
+ out[k] = elem.Value.(*cursorEntry).ts
+ }
+ return out
+}
+
+func (c *pollCursor) IsDirty() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.dirty
+}
+
+func (c *pollCursor) ClearDirty() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.dirty = false
+}
+
+// evictLocked drops the LRU tail until size <= max. Holds mu.
+func (c *pollCursor) evictLocked() {
+ for c.order.Len() > c.max {
+ tail := c.order.Back()
+ if tail == nil {
+ return
+ }
+ entry := tail.Value.(*cursorEntry)
+ delete(c.data, entry.userID)
+ c.order.Remove(tail)
+ }
+}
+
+// loadFromMap seeds the cursor. Sorts by timestamp ascending so eviction
+// on overflow drops the oldest cursors deterministically.
+func (c *pollCursor) loadFromMap(m map[string]int64) {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Slice(keys, func(i, j int) bool {
+ if m[keys[i]] != m[keys[j]] {
+ return m[keys[i]] < m[keys[j]]
+ }
+ return keys[i] < keys[j]
+ })
+ for _, k := range keys {
+ c.Advance(k, m[k])
+ }
+ c.ClearDirty()
+}
+
+// parseCursorFromConfig extracts the poll_cursor sub-object from the
+// channel_instances.config blob (empty map on missing/invalid).
+func parseCursorFromConfig(raw []byte) map[string]int64 {
+ out := map[string]int64{}
+ if len(raw) == 0 {
+ return out
+ }
+ var top map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &top); err != nil {
+ return out
+ }
+ cursorRaw, ok := top[configCursorKey]
+ if !ok {
+ return out
+ }
+ _ = json.Unmarshal(cursorRaw, &out)
+ return out
+}
+
diff --git a/internal/channels/zalo/oa/poll_cursor_test.go b/internal/channels/zalo/oa/poll_cursor_test.go
new file mode 100644
index 0000000000..d3186f5726
--- /dev/null
+++ b/internal/channels/zalo/oa/poll_cursor_test.go
@@ -0,0 +1,172 @@
+package oa
+
+import (
+ "testing"
+)
+
+func TestPollCursor_AdvanceAndGet(t *testing.T) {
+ t.Parallel()
+ pc := newPollCursor(10)
+
+ if got := pc.Get("u1"); got != 0 {
+ t.Errorf("Get(missing) = %d, want 0", got)
+ }
+ if !pc.Advance("u1", 100) {
+ t.Errorf("Advance(u1, 100) returned false on fresh cursor")
+ }
+ if got := pc.Get("u1"); got != 100 {
+ t.Errorf("Get(u1) = %d, want 100", got)
+ }
+
+ // Newer ts updates.
+ if !pc.Advance("u1", 200) {
+ t.Errorf("Advance(u1, 200) returned false (newer ts)")
+ }
+ if got := pc.Get("u1"); got != 200 {
+ t.Errorf("Get(u1) = %d, want 200", got)
+ }
+
+ // Older ts is ignored, returns false.
+ if pc.Advance("u1", 150) {
+ t.Errorf("Advance(u1, 150) returned true on older ts; want false")
+ }
+ if got := pc.Get("u1"); got != 200 {
+ t.Errorf("Get(u1) = %d after stale advance, want 200", got)
+ }
+}
+
+func TestPollCursor_LRUEvictsOldestEntry(t *testing.T) {
+ t.Parallel()
+ pc := newPollCursor(3)
+
+ pc.Advance("u1", 1)
+ pc.Advance("u2", 2)
+ pc.Advance("u3", 3)
+
+ // All three present, no eviction yet.
+ for k, want := range map[string]int64{"u1": 1, "u2": 2, "u3": 3} {
+ if got := pc.Get(k); got != want {
+ t.Errorf("Get(%s) = %d, want %d", k, got, want)
+ }
+ }
+
+ // Touch u1 → moves to MRU.
+ pc.Advance("u1", 10)
+ // Insert u4 → triggers eviction of LEAST-recent = u2.
+ pc.Advance("u4", 4)
+
+ if got := pc.Get("u2"); got != 0 {
+ t.Errorf("Get(u2 evicted) = %d, want 0", got)
+ }
+ if got := pc.Get("u1"); got != 10 {
+ t.Errorf("Get(u1 still present) = %d, want 10", got)
+ }
+ if got := pc.Get("u4"); got != 4 {
+ t.Errorf("Get(u4) = %d, want 4", got)
+ }
+}
+
+func TestPollCursor_DirtyFlag(t *testing.T) {
+ t.Parallel()
+ pc := newPollCursor(10)
+
+ if pc.IsDirty() {
+ t.Error("fresh cursor is dirty")
+ }
+ pc.Advance("u1", 100)
+ if !pc.IsDirty() {
+ t.Error("after Advance, cursor not dirty")
+ }
+ pc.ClearDirty()
+ if pc.IsDirty() {
+ t.Error("after ClearDirty, still dirty")
+ }
+ // Re-advance same value → no change → not dirty
+ pc.Advance("u1", 100)
+ if pc.IsDirty() {
+ t.Error("re-advance with same value marked dirty")
+ }
+ // Advance with new value → dirty
+ pc.Advance("u1", 200)
+ if !pc.IsDirty() {
+ t.Error("advance with new value didn't dirty")
+ }
+}
+
+func TestPollCursor_Snapshot(t *testing.T) {
+ t.Parallel()
+ pc := newPollCursor(10)
+ pc.Advance("u1", 1)
+ pc.Advance("u2", 2)
+ pc.Advance("u3", 3)
+
+ snap := pc.Snapshot()
+ if len(snap) != 3 {
+ t.Errorf("snap len = %d, want 3", len(snap))
+ }
+ if snap["u2"] != 2 {
+ t.Errorf("snap[u2] = %d, want 2", snap["u2"])
+ }
+ // Snapshot is a copy — mutating it does not affect cursor.
+ snap["u2"] = 999
+ if pc.Get("u2") != 2 {
+ t.Errorf("Snapshot returned a live ref; cursor mutated")
+ }
+}
+
+func TestPollCursor_LastSeenTimestamp(t *testing.T) {
+ t.Parallel()
+ pc := newPollCursor(10)
+
+ // Empty cursor → 0.
+ if got := pc.LastSeenTimestamp(); got != 0 {
+ t.Errorf("LastSeenTimestamp(empty) = %d, want 0", got)
+ }
+
+ pc.Advance("u1", 100)
+ pc.Advance("u2", 300)
+ pc.Advance("u3", 200)
+
+ if got := pc.LastSeenTimestamp(); got != 300 {
+ t.Errorf("LastSeenTimestamp = %d, want 300 (max)", got)
+ }
+
+ // Advancing a smaller user does not lower the max.
+ pc.Advance("u1", 250)
+ if got := pc.LastSeenTimestamp(); got != 300 {
+ t.Errorf("LastSeenTimestamp = %d, want 300", got)
+ }
+ // New higher entry wins.
+ pc.Advance("u4", 500)
+ if got := pc.LastSeenTimestamp(); got != 500 {
+ t.Errorf("LastSeenTimestamp = %d, want 500", got)
+ }
+}
+
+func TestParseCursorFromConfig(t *testing.T) {
+ t.Parallel()
+ raw := []byte(`{
+ "poll_interval_seconds": 15,
+ "poll_cursor": {"u1": 100, "u2": 200}
+ }`)
+ got := parseCursorFromConfig(raw)
+ if got["u1"] != 100 || got["u2"] != 200 {
+ t.Errorf("parseCursorFromConfig = %v", got)
+ }
+
+ // Missing key → empty map (not nil).
+ got2 := parseCursorFromConfig([]byte(`{"poll_interval_seconds":15}`))
+ if got2 == nil {
+ t.Errorf("expected non-nil map for missing poll_cursor key")
+ }
+ if len(got2) != 0 {
+ t.Errorf("expected empty map, got %v", got2)
+ }
+
+ // Garbage input → empty map (no panic).
+ if parseCursorFromConfig([]byte(`{not json`)) == nil {
+ t.Errorf("expected non-nil map for invalid JSON")
+ }
+}
+
+
diff --git a/internal/channels/zalo/oa/poll_loop.go b/internal/channels/zalo/oa/poll_loop.go
new file mode 100644
index 0000000000..5ecd702fa1
--- /dev/null
+++ b/internal/channels/zalo/oa/poll_loop.go
@@ -0,0 +1,103 @@
+package oa
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// runPollLoop runs a polling cycle on each tick; ErrRateLimit switches
+// to rate-limit ticker until a clean cycle. Cursor flushes are debounced.
+// Early-returns on webhook transport so a regression can't double-dispatch.
+func (c *Channel) runPollLoop(parentCtx context.Context) {
+ defer c.pollWG.Done()
+ if c.cfg.Transport == "webhook" {
+ slog.Info("zalo_oa.poll.skipped_for_webhook_transport", "name", c.Name())
+ return
+ }
+
+ t := time.NewTicker(c.pollInterval)
+ defer t.Stop()
+ flush := time.NewTicker(cursorFlushInterval)
+ defer flush.Stop()
+
+ rateLimited := false
+ pollCtx := store.WithTenantID(parentCtx, c.TenantID())
+
+ for {
+ select {
+ case <-c.stopCh:
+ c.flushCursorOnExit(pollCtx)
+ return
+ case <-flush.C:
+ if c.cursor.IsDirty() {
+ if err := c.flushCursor(pollCtx); err != nil {
+ slog.Warn("zalo_oa.poll.cursor_flush_failed", "error", err)
+ }
+ }
+ case <-t.C:
+ // Cycle ctx outlives the HTTP client timeout (30s) so errors
+ // surface their real cause, not "context deadline exceeded".
+ cycleCtx, cancel := context.WithTimeout(pollCtx, 45*time.Second)
+ err := c.pollOnce(cycleCtx)
+ cancel()
+ switch {
+ case errors.Is(err, ErrRateLimit):
+ if !rateLimited {
+ c.MarkDegraded("rate limited", err.Error(), channels.ChannelFailureKindNetwork, true)
+ t.Reset(rateLimitBackoff)
+ rateLimited = true
+ }
+ case err != nil:
+ slog.Warn("zalo_oa.poll_failed", "oa_id", c.creds().OAID, "error", err)
+ // Auth errors after pollOnce's retry-once-on-auth mean the
+ // operator must re-consent.
+ c.markAuthFailedIfNeeded(err)
+ default:
+ if rateLimited {
+ c.MarkHealthy("polling")
+ t.Reset(c.pollInterval)
+ rateLimited = false
+ }
+ }
+ }
+ }
+}
+
+// flushCursor persists the cursor via SQL JSONB merge so a sibling-key
+// update from the UI (e.g. dm_policy) isn't clobbered by a read-modify-write.
+func (c *Channel) flushCursor(ctx context.Context) error {
+ if c.ciStore == nil || c.instanceID == [16]byte{} {
+ return errors.New("zalo_oa: cursor flush without store/instance ID")
+ }
+ snapshot := c.cursor.Snapshot()
+ // Guard against total LRU eviction wiping the persisted cursor:
+ // MergeConfig is shallow merge, so {"poll_cursor":{}} would clobber.
+ if len(snapshot) == 0 {
+ c.cursor.ClearDirty()
+ return nil
+ }
+ patch := map[string]any{configCursorKey: snapshot}
+ if err := c.ciStore.MergeConfig(ctx, c.instanceID, patch); err != nil {
+ return fmt.Errorf("merge cursor into config: %w", err)
+ }
+ c.cursor.ClearDirty()
+ return nil
+}
+
+// flushCursorOnExit is best-effort persistence at Stop.
+func (c *Channel) flushCursorOnExit(parentCtx context.Context) {
+ if !c.cursor.IsDirty() {
+ return
+ }
+ ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
+ defer cancel()
+ if err := c.flushCursor(ctx); err != nil {
+ slog.Warn("zalo_oa.poll.cursor_flush_on_exit_failed", "error", err)
+ }
+}
diff --git a/internal/channels/zalo/oa/poll_test.go b/internal/channels/zalo/oa/poll_test.go
new file mode 100644
index 0000000000..3d620a3cdb
--- /dev/null
+++ b/internal/channels/zalo/oa/poll_test.go
@@ -0,0 +1,351 @@
+package oa
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+)
+
+// pollServer simulates the GET /v2.0/oa/listrecentchat endpoint. Tests
+// configure the canned body; the server captures call count for
+// assertions. listrecentchat returns MESSAGES directly (verified against
+// live Zalo API via the developer API explorer, 2026-04-20) so there's
+// no separate /conversation endpoint to mock.
+type pollServerOpts struct {
+ listResp string // body for /listrecentchat
+ status int // override status code (0 = 200)
+}
+
+type pollServer struct {
+ srv *httptest.Server
+ listN atomic.Int32
+}
+
+func newPollServer(t *testing.T, opts pollServerOpts) *pollServer {
+ t.Helper()
+ ps := &pollServer{}
+ ps.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ status := opts.status
+ if status == 0 {
+ status = http.StatusOK
+ }
+ switch r.URL.Path {
+ case "/v2.0/oa/listrecentchat":
+ ps.listN.Add(1)
+ w.WriteHeader(status)
+ if opts.listResp != "" {
+ _, _ = w.Write([]byte(opts.listResp))
+ }
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ t.Cleanup(ps.srv.Close)
+ return ps
+}
+
+// newPollChannel wires a Channel for poll tests. Use t.Cleanup to Stop()
+// any started loops.
+func newPollChannel(t *testing.T, ps *pollServer, oaID string) (*Channel, *bus.MessageBus) {
+ t.Helper()
+ creds := &ChannelCreds{
+ AppID: "app",
+ SecretKey: "key",
+ OAID: oaID,
+ AccessToken: "AT",
+ RefreshToken: "RT",
+ ExpiresAt: time.Now().Add(time.Hour),
+ }
+ cfg := config.ZaloOAConfig{
+ Transport: "polling",
+ PollIntervalSeconds: 1,
+ }
+ msgBus := bus.New()
+ c, err := New("poll_test", cfg, creds, &fakeStore{}, msgBus, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ c.SetInstanceID(uuid.New())
+ c.client.apiBase = ps.srv.URL
+ return c, msgBus
+}
+
+func TestPollOnce_FetchesThreadsAndPublishesInbound(t *testing.T) {
+ t.Parallel()
+ ps := newPollServer(t, pollServerOpts{
+ // listrecentchat returns MESSAGES directly (not thread summaries).
+ // Zalo's actual field is `message`, not `text`.
+ listResp: `{"error":0,"message":"Success","data":[
+ {"message_id":"m1","from_id":"u1","to_id":"oa-1","time":1000,"message":"hi","type":"text","from_display_name":"Alice"}
+ ]}`,
+ })
+ c, msgBus := newPollChannel(t, ps, "oa-1")
+
+ if err := c.pollOnce(context.Background()); err != nil {
+ t.Fatalf("pollOnce: %v", err)
+ }
+ // Drain bus.
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ msg, ok := msgBus.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("expected inbound message published")
+ }
+ if msg.SenderID != "u1" {
+ t.Errorf("SenderID = %q", msg.SenderID)
+ }
+ if msg.ChatID != "u1" {
+ t.Errorf("ChatID = %q (Zalo OA is DM-only)", msg.ChatID)
+ }
+ if msg.Content != "hi" {
+ t.Errorf("Content = %q", msg.Content)
+ }
+ if msg.PeerKind != "direct" {
+ t.Errorf("PeerKind = %q, want direct", msg.PeerKind)
+ }
+ if msg.Metadata["message_id"] != "m1" {
+ t.Errorf("metadata.message_id = %q", msg.Metadata["message_id"])
+ }
+}
+
+// FilterOAMessages: messages with from_id == oa_id are echoes of our own
+// outbound — must NOT be re-published as inbound.
+func TestPollOnce_FiltersOAEchoMessages(t *testing.T) {
+ t.Parallel()
+ ps := newPollServer(t, pollServerOpts{
+ listResp: `{"error":0,"data":[
+ {"message_id":"oa-echo","from_id":"oa-1","to_id":"u1","time":900,"message":"my own outbound","type":"text"},
+ {"message_id":"real","from_id":"u1","to_id":"oa-1","time":1000,"message":"user reply","type":"text"}
+ ]}`,
+ })
+ c, msgBus := newPollChannel(t, ps, "oa-1")
+
+ if err := c.pollOnce(context.Background()); err != nil {
+ t.Fatalf("pollOnce: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ msg, ok := msgBus.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("expected one inbound message")
+ }
+ if msg.Content != "user reply" {
+ t.Errorf("got OA echo through filter: %q", msg.Content)
+ }
+ // No second message should be queued.
+ ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel2()
+ if _, ok := msgBus.ConsumeInbound(ctx2); ok {
+ t.Error("a second inbound was queued — OA echo not filtered")
+ }
+}
+
+// CursorAdvances: a second pollOnce on the same conversation must NOT
+// re-emit the already-seen message.
+func TestPollOnce_CursorPreventsDuplicate(t *testing.T) {
+ t.Parallel()
+ ps := newPollServer(t, pollServerOpts{
+ listResp: `{"error":0,"data":[
+ {"message_id":"m1","from_id":"u1","time":1000,"message":"hi","type":"text"}
+ ]}`,
+ })
+ c, msgBus := newPollChannel(t, ps, "oa-1")
+
+ for i := range 3 {
+ if err := c.pollOnce(context.Background()); err != nil {
+ t.Fatalf("pollOnce #%d: %v", i, err)
+ }
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ count := 0
+ for {
+ ctx2, cancel2 := context.WithTimeout(ctx, 50*time.Millisecond)
+ _, ok := msgBus.ConsumeInbound(ctx2)
+ cancel2()
+ if !ok {
+ break
+ }
+ count++
+ if count > 5 {
+ break
+ }
+ }
+ if count != 1 {
+ t.Errorf("inbound count = %d, want 1 (cursor must dedupe)", count)
+ }
+}
+
+// HaltOnReauth: when health is Failed/Auth, pollOnce skips the API entirely.
+func TestPollOnce_HaltsWhenAuthFailed(t *testing.T) {
+ t.Parallel()
+ ps := newPollServer(t, pollServerOpts{
+ listResp: `{"error":0,"data":[{"message_id":"m1","from_id":"u1","time":1000,"message":"hi","type":"text"}]}`,
+ })
+ c, _ := newPollChannel(t, ps, "oa-1")
+ c.MarkFailed("re-auth required", "test-only", channels.ChannelFailureKindAuth, false)
+
+ if err := c.pollOnce(context.Background()); err != nil {
+ t.Fatalf("pollOnce: %v", err)
+ }
+ if got := ps.listN.Load(); got != 0 {
+ t.Errorf("listrecentchat hits = %d while auth-failed; want 0", got)
+ }
+}
+
+// RateLimit: HTTP 429 → ErrRateLimit returned (caller switches into backoff).
+func TestPollOnce_RateLimitDetected(t *testing.T) {
+ t.Parallel()
+ ps := newPollServer(t, pollServerOpts{
+ status: http.StatusTooManyRequests,
+ listResp: `{"error":429,"message":"rate limited"}`,
+ })
+ c, _ := newPollChannel(t, ps, "oa-1")
+
+ err := c.pollOnce(context.Background())
+ if err == nil {
+ t.Fatal("expected rate-limit error")
+ }
+ if !errors.Is(err, ErrRateLimit) {
+ t.Errorf("err = %v, want ErrRateLimit", err)
+ }
+}
+
+// FlushCursor: SQL-level merge writes only the poll_cursor key, leaving
+// operator-set sibling keys untouched. Simulated by seeding the fakeStore's
+// in-memory config with operator keys before flushing.
+func TestFlushCursor_PreservesOperatorConfigKeys(t *testing.T) {
+ t.Parallel()
+ fs := &fakeStore{}
+ fs.lastConfig = map[string]any{
+ "poll_interval_seconds": 15,
+ "dm_policy": "open",
+ }
+ c, _ := newPollChannel(t, newPollServer(t, pollServerOpts{}), "oa-1")
+ c.ciStore = fs
+ c.SetInstanceID(uuid.New())
+ c.cursor.Advance("u1", 100)
+ c.cursor.Advance("u2", 200)
+
+ if err := c.flushCursor(context.Background()); err != nil {
+ t.Fatalf("flushCursor: %v", err)
+ }
+ if fs.MergeCount() != 1 {
+ t.Errorf("MergeCount = %d, want 1", fs.MergeCount())
+ }
+
+ got := parseCursorFromConfig(fs.ConfigBlob())
+ if got["u1"] != 100 || got["u2"] != 200 {
+ t.Errorf("persisted cursor = %v", got)
+ }
+ // Operator keys must survive the merge.
+ if v, _ := fs.lastConfig["dm_policy"].(string); v != "open" {
+ t.Errorf("dm_policy lost after merge: %v", fs.lastConfig)
+ }
+}
+
+// AllowlistEnforcement: pollOnce → dispatchInbound → BaseChannel.HandleMessage
+// must drop messages from senders not on cfg.AllowFrom when the allowlist is
+// non-empty. Empty allowlist = allow-all (verified separately by phase-04 audit).
+func TestPollOnce_AllowlistBlocksNonAllowedSender(t *testing.T) {
+ t.Parallel()
+ ps := newPollServer(t, pollServerOpts{
+ listResp: `{"error":0,"data":[
+ {"message_id":"m-ok","from_id":"allowed","time":1000,"message":"hi from allowed","type":"text"},
+ {"message_id":"m-block","from_id":"blocked","time":2000,"message":"hi from blocked","type":"text"}
+ ]}`,
+ })
+ // Set allowlist to only "allowed". newPollChannel uses cfg.AllowFrom=nil
+ // (allow all), so we construct manually here.
+ creds := &ChannelCreds{
+ AppID: "app", SecretKey: "key", OAID: "oa-1",
+ AccessToken: "AT", RefreshToken: "RT", ExpiresAt: time.Now().Add(time.Hour),
+ }
+ cfg := config.ZaloOAConfig{
+ AllowFrom: config.FlexibleStringSlice{"allowed"},
+ }
+ msgBus := bus.New()
+ c, err := New("allowlist_test", cfg, creds, &fakeStore{}, msgBus, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ c.SetInstanceID(uuid.New())
+ c.client.apiBase = ps.srv.URL
+
+ if err := c.pollOnce(context.Background()); err != nil {
+ t.Fatalf("pollOnce: %v", err)
+ }
+ // Drain bus.
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ msg, ok := msgBus.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("expected one inbound from allowed sender")
+ }
+ if msg.SenderID != "allowed" || msg.Content != "hi from allowed" {
+ t.Errorf("unexpected msg: sender=%q content=%q", msg.SenderID, msg.Content)
+ }
+ // Confirm no second message (the blocked one) arrives.
+ ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel2()
+ if extra, ok := msgBus.ConsumeInbound(ctx2); ok {
+ t.Errorf("blocked sender slipped through allowlist: sender=%q content=%q", extra.SenderID, extra.Content)
+ }
+}
+
+// dispatchInbound must drop messages with empty Text even when type=="text"
+// (e.g., a sticker mis-tagged as text wouldn't have body content). Otherwise
+// HandleMessage receives empty content and downstream agents see noise.
+func TestDispatchInbound_EmptyTextDropped(t *testing.T) {
+ t.Parallel()
+ ps := newPollServer(t, pollServerOpts{
+ listResp: `{"error":0,"data":[
+ {"message_id":"empty","from_id":"u1","time":1000,"message":"","type":"text"}
+ ]}`,
+ })
+ c, msgBus := newPollChannel(t, ps, "oa-1")
+
+ if err := c.pollOnce(context.Background()); err != nil {
+ t.Fatalf("pollOnce: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ if _, ok := msgBus.ConsumeInbound(ctx); ok {
+ t.Error("empty-text message should not be published as inbound")
+ }
+}
+
+// Start/Stop with poll loop: the goroutine must shut down within bounded time.
+func TestStartStop_PollGoroutineExitsPromptly(t *testing.T) {
+ t.Parallel()
+ ps := newPollServer(t, pollServerOpts{
+ listResp: `{"error":0,"data":[]}`,
+ })
+ c, _ := newPollChannel(t, ps, "oa-1")
+ c.pollInterval = 50 * time.Millisecond
+
+ if err := c.Start(context.Background()); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+
+ done := make(chan struct{})
+ go func() {
+ _ = c.Stop(context.Background())
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(3 * time.Second):
+ t.Fatal("Stop did not return within 3s — poll goroutine leaked")
+ }
+}
diff --git a/internal/channels/zalo/oa/reactions.go b/internal/channels/zalo/oa/reactions.go
new file mode 100644
index 0000000000..c120c11c57
--- /dev/null
+++ b/internal/channels/zalo/oa/reactions.go
@@ -0,0 +1,259 @@
+package oa
+
+import (
+ "context"
+ "log/slog"
+ "math/rand/v2"
+ "sync"
+ "time"
+)
+
+const (
+ reactionDebounceMs = 700 * time.Millisecond
+ // Late stale events within this window hit the terminal rc and short-circuit
+ // instead of LoadOrStore-ing a fresh controller that would stomp the heart.
+ reactionTombstoneTTL = 60 * time.Second
+ defaultReactionTerminalMinMs = 800 * time.Millisecond
+ defaultReactionTerminalMaxMs = 2000 * time.Millisecond
+ reactionLengthBonusPerCharMs = 1 * time.Millisecond
+ reactionLengthBonusCap = 1500 * time.Millisecond
+)
+
+// Tone tuned for OA's B2C surface: one "received, working" ack on the
+// first intermediate event plus a warm/sad terminal. tool/coding/web are
+// intentionally NOT mapped — chatty mid-run reactions look unprofessional
+// in customer chats and burn through the 50-per-message cap.
+//
+// Angry (`:-h`) is intentionally excluded — dropping an angry face on the
+// customer's own message reads as blaming them, even on agent-side errors.
+var statusReactionVariants = map[string][]string{
+ "thinking": {reactionIconLike, reactionIconHeart},
+ "done": {reactionIconHeart, reactionIconLike},
+ "error": {reactionIconWorry, reactionIconCry},
+}
+
+func resolveReactionEmoji(status string) string {
+ variants, ok := statusReactionVariants[status]
+ if !ok {
+ return ""
+ }
+ for _, v := range variants {
+ if zaloSupportedReactions[v] {
+ return v
+ }
+ }
+ return ""
+}
+
+type zaloReactionController struct {
+ ch *Channel
+ userID string
+ sourceMessageID string
+
+ mu sync.Mutex
+ currentIcon string
+ lastStatus string
+ terminal bool
+ debounceTimer *time.Timer
+ tombstoneOnce sync.Once
+}
+
+func newZaloReactionController(ch *Channel, userID, sourceMessageID string) *zaloReactionController {
+ return &zaloReactionController{
+ ch: ch,
+ userID: userID,
+ sourceMessageID: sourceMessageID,
+ }
+}
+
+func (rc *zaloReactionController) SetStatus(ctx context.Context, status string) {
+ rc.mu.Lock()
+ defer rc.mu.Unlock()
+
+ if rc.terminal {
+ return
+ }
+ rc.lastStatus = status
+
+ if status == "done" || status == "error" {
+ rc.terminal = true
+ rc.cancelDebounceLocked()
+ icon := resolveReactionEmoji(status)
+ if icon == "" {
+ return
+ }
+ select {
+ case <-rc.ch.stopCh:
+ return
+ default:
+ }
+ rc.ch.reactionWG.Add(1)
+ rc.debounceTimer = time.AfterFunc(rc.ch.terminalReactionDelay(rc.userID), func() {
+ defer rc.ch.reactionWG.Done()
+ rc.mu.Lock()
+ defer rc.mu.Unlock()
+ rc.applyReactionLocked(rc.ch.reactionCtx, icon)
+ })
+ return
+ }
+
+ if _, mapped := statusReactionVariants[status]; !mapped {
+ return
+ }
+
+ rc.cancelDebounceLocked()
+ // Re-check stopCh under lock: wg.Add after Stop's Wait would panic.
+ select {
+ case <-rc.ch.stopCh:
+ return
+ default:
+ }
+ rc.ch.reactionWG.Add(1)
+ rc.debounceTimer = time.AfterFunc(reactionDebounceMs, func() {
+ defer rc.ch.reactionWG.Done()
+ rc.mu.Lock()
+ defer rc.mu.Unlock()
+ if rc.terminal {
+ return
+ }
+ if icon := resolveReactionEmoji(rc.lastStatus); icon != "" {
+ // Stop-aware ctx so Channel.Stop can drain in-flight HTTP calls.
+ rc.applyReactionLocked(rc.ch.reactionCtx, icon)
+ }
+ })
+}
+
+func (rc *zaloReactionController) Stop() {
+ rc.mu.Lock()
+ defer rc.mu.Unlock()
+ rc.cancelDebounceLocked()
+}
+
+func (rc *zaloReactionController) cancelDebounceLocked() {
+ if rc.debounceTimer != nil {
+ // If Stop returns true the closure won't run; balance the Add.
+ if rc.debounceTimer.Stop() {
+ rc.ch.reactionWG.Done()
+ }
+ rc.debounceTimer = nil
+ }
+}
+
+// applyReactionLocked: caller MUST hold rc.mu. On error, leaves currentIcon
+// unset so the next transition retries. Never flips channel health.
+func (rc *zaloReactionController) applyReactionLocked(ctx context.Context, icon string) {
+ if icon == rc.currentIcon {
+ return
+ }
+ if _, err := rc.ch.SendReaction(ctx, rc.userID, rc.sourceMessageID, icon); err != nil {
+ slog.Debug("zalo_oa.reaction.set_failed",
+ "user_id", rc.userID,
+ "source_message_id", rc.sourceMessageID,
+ "icon", icon,
+ "error", err)
+ return
+ }
+ rc.currentIcon = icon
+}
+
+func (c *Channel) terminalReactionDelay(chatID string) time.Duration {
+ minD := defaultReactionTerminalMinMs
+ maxD := defaultReactionTerminalMaxMs
+ if c.cfg.ReactionTerminalDelayMinMs > 0 {
+ minD = time.Duration(c.cfg.ReactionTerminalDelayMinMs) * time.Millisecond
+ }
+ if c.cfg.ReactionTerminalDelayMaxMs > 0 {
+ maxD = time.Duration(c.cfg.ReactionTerminalDelayMaxMs) * time.Millisecond
+ }
+ if maxD < minD {
+ maxD = minD
+ }
+ d := minD
+ if maxD > minD {
+ d += time.Duration(rand.Int64N(int64(maxD-minD) + 1))
+ }
+ if v, ok := c.lastReplyChars.Load(chatID); ok {
+ if n, ok := v.(int); ok && n > 0 {
+ bonus := time.Duration(n) * reactionLengthBonusPerCharMs
+ if bonus > reactionLengthBonusCap {
+ bonus = reactionLengthBonusCap
+ }
+ d += bonus
+ }
+ }
+ return d
+}
+
+func (c *Channel) recordReplyLen(chatID string, n int) {
+ if chatID == "" || n <= 0 {
+ return
+ }
+ c.lastReplyChars.Store(chatID, n)
+}
+
+// chatID for Zalo OA is the user_id (1:1 DM), so it doubles as recipient.
+func (c *Channel) OnReactionEvent(ctx context.Context, chatID, messageID, status string) error {
+ if c.cfg.ReactionLevel == "" || c.cfg.ReactionLevel == "off" {
+ return nil
+ }
+ if c.cfg.ReactionLevel == "minimal" && status != "done" && status != "error" {
+ return nil
+ }
+ if chatID == "" || messageID == "" {
+ return nil
+ }
+ // Webhook entry is fenced by router drain; event-bus entry isn't, so
+ // reactionWG.Add can race past Stop()'s Wait without this gate.
+ select {
+ case <-c.stopCh:
+ return nil
+ default:
+ }
+
+ key := chatID + ":" + messageID
+ val, _ := c.reactions.LoadOrStore(key, newZaloReactionController(c, chatID, messageID))
+ rc, ok := val.(*zaloReactionController)
+ if !ok {
+ return nil
+ }
+ rc.SetStatus(ctx, status)
+
+ if status == "done" || status == "error" {
+ // One tombstone per controller — duplicate terminal events used to
+ // each spawn a fresh 60s goroutine.
+ rc.tombstoneOnce.Do(func() {
+ // Re-check stopCh inside Once: Stop() may have closed it
+ // between the entry gate and Add — Add after Wait panics.
+ select {
+ case <-c.stopCh:
+ return
+ default:
+ }
+ c.reactionWG.Add(1)
+ go func() {
+ defer c.reactionWG.Done()
+ t := time.NewTimer(reactionTombstoneTTL)
+ defer t.Stop()
+ select {
+ case <-t.C:
+ c.reactions.CompareAndDelete(key, rc)
+ case <-c.stopCh:
+ }
+ }()
+ })
+ }
+ return nil
+}
+
+func (c *Channel) ClearReaction(ctx context.Context, chatID, messageID string) error {
+ if chatID == "" || messageID == "" {
+ return nil
+ }
+ key := chatID + ":" + messageID
+ if val, ok := c.reactions.LoadAndDelete(key); ok {
+ if rc, ok := val.(*zaloReactionController); ok {
+ rc.Stop()
+ }
+ }
+ return c.SendClearReaction(ctx, chatID, messageID)
+}
diff --git a/internal/channels/zalo/oa/reactions_test.go b/internal/channels/zalo/oa/reactions_test.go
new file mode 100644
index 0000000000..387ce4d7fa
--- /dev/null
+++ b/internal/channels/zalo/oa/reactions_test.go
@@ -0,0 +1,336 @@
+package oa
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+)
+
+// reactionTestServer is a counting http server that signals each request
+// onto reqCh so tests can wait deterministically instead of fixed sleeps.
+type reactionTestServer struct {
+ srv *httptest.Server
+ reqCh chan capturedRequest
+ count atomic.Int32
+ mu sync.Mutex
+ bodies []map[string]any
+}
+
+func newReactionCountingServer(t *testing.T) *reactionTestServer {
+ t.Helper()
+ rts := &reactionTestServer{reqCh: make(chan capturedRequest, 32)}
+ rts.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ rts.count.Add(1)
+ req := capturedRequest{
+ path: r.URL.Path,
+ contentType: r.Header.Get("Content-Type"),
+ accessToken: r.Header.Get("access_token"),
+ body: body,
+ }
+ var parsed map[string]any
+ _ = json.Unmarshal(body, &parsed)
+ rts.mu.Lock()
+ rts.bodies = append(rts.bodies, parsed)
+ rts.mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":{"message_id":"reaction-mid","user_id":"u"},"error":0,"message":"Success"}`))
+ // Non-blocking signal so the server never deadlocks if the test
+ // stops listening.
+ select {
+ case rts.reqCh <- req:
+ default:
+ }
+ }))
+ t.Cleanup(rts.srv.Close)
+ return rts
+}
+
+func (rts *reactionTestServer) waitForRequest(t *testing.T, timeout time.Duration) capturedRequest {
+ t.Helper()
+ select {
+ case r := <-rts.reqCh:
+ return r
+ case <-time.After(timeout):
+ t.Fatalf("no request within %v", timeout)
+ return capturedRequest{}
+ }
+}
+
+func (rts *reactionTestServer) requireNoRequest(t *testing.T, window time.Duration) {
+ t.Helper()
+ select {
+ case r := <-rts.reqCh:
+ t.Fatalf("unexpected request within %v: %s", window, string(r.body))
+ case <-time.After(window):
+ }
+}
+
+func (rts *reactionTestServer) lastBody() map[string]any {
+ rts.mu.Lock()
+ defer rts.mu.Unlock()
+ if len(rts.bodies) == 0 {
+ return nil
+ }
+ return rts.bodies[len(rts.bodies)-1]
+}
+
+func newReactionChannel(t *testing.T, level string) (*Channel, *reactionTestServer) {
+ t.Helper()
+ rts := newReactionCountingServer(t)
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, rts.srv, refresh, &fakeStore{})
+ c.cfg.ReactionLevel = level
+ c.cfg.ReactionTerminalDelayMinMs = 1
+ c.cfg.ReactionTerminalDelayMaxMs = 1
+ return c, rts
+}
+
+// --- emoji resolution ---
+
+func TestResolveReactionEmoji_AllStatusesProduceIcon(t *testing.T) {
+ t.Parallel()
+ for status := range statusReactionVariants {
+ icon := resolveReactionEmoji(status)
+ if icon == "" {
+ t.Errorf("status %q: empty icon", status)
+ }
+ if !zaloSupportedReactions[icon] {
+ t.Errorf("status %q resolved to unsupported icon %q", status, icon)
+ }
+ }
+}
+
+func TestResolveReactionEmoji_FallbackOnUnsupported(t *testing.T) {
+ // Mutates the package-global zaloSupportedReactions; can't run in parallel
+ // with tests that resolve reactions.
+ // Snapshot + restore the supported set so we can shrink it for one test.
+ orig := make(map[string]bool, len(zaloSupportedReactions))
+ for k, v := range zaloSupportedReactions {
+ orig[k] = v
+ }
+ t.Cleanup(func() {
+ zaloSupportedReactions = orig
+ })
+
+ // Drop the primary variant for "thinking" and confirm the resolver
+ // advances to the fallback.
+ primary := statusReactionVariants["thinking"][0]
+ zaloSupportedReactions = map[string]bool{}
+ for k, v := range orig {
+ zaloSupportedReactions[k] = v
+ }
+ delete(zaloSupportedReactions, primary)
+
+ icon := resolveReactionEmoji("thinking")
+ if icon == primary {
+ t.Errorf("expected fallback after dropping primary %q, got primary back", primary)
+ }
+ if icon == "" {
+ t.Error("expected non-empty fallback icon")
+ }
+}
+
+func TestResolveReactionEmoji_UnknownStatus(t *testing.T) {
+ t.Parallel()
+ if got := resolveReactionEmoji("not-a-status"); got != "" {
+ t.Errorf("unknown status returned %q, want empty", got)
+ }
+}
+
+// --- ReactionChannel guard contract ---
+
+func TestChannelImplementsReactionChannel(t *testing.T) {
+ t.Parallel()
+ var _ channels.ReactionChannel = (*Channel)(nil)
+}
+
+// --- gate / level ---
+
+func TestOnReactionEvent_OffShortCircuits(t *testing.T) {
+ t.Parallel()
+ for _, lvl := range []string{"", "off"} {
+ c, rts := newReactionChannel(t, lvl)
+ if err := c.OnReactionEvent(context.Background(), "user-1", "msg-1", "done"); err != nil {
+ t.Fatalf("OnReactionEvent: %v", err)
+ }
+ rts.requireNoRequest(t, 250*time.Millisecond)
+ if rts.count.Load() != 0 {
+ t.Errorf("level=%q: %d requests, want 0", lvl, rts.count.Load())
+ }
+ }
+}
+
+func TestOnReactionEvent_MinimalSkipsIntermediate(t *testing.T) {
+ t.Parallel()
+ c, rts := newReactionChannel(t, "minimal")
+ _ = c.OnReactionEvent(context.Background(), "u", "m", "thinking")
+ _ = c.OnReactionEvent(context.Background(), "u", "m", "tool")
+ rts.requireNoRequest(t, 250*time.Millisecond)
+ if rts.count.Load() != 0 {
+ t.Errorf("minimal mode: %d requests, want 0 for non-terminal", rts.count.Load())
+ }
+ _ = c.OnReactionEvent(context.Background(), "u", "m", "done")
+ rts.waitForRequest(t, 500*time.Millisecond)
+ if rts.count.Load() != 1 {
+ t.Errorf("minimal mode: %d requests after done, want 1", rts.count.Load())
+ }
+}
+
+func TestOnReactionEvent_EmptyIDsShortCircuit(t *testing.T) {
+ t.Parallel()
+ c, rts := newReactionChannel(t, "full")
+ _ = c.OnReactionEvent(context.Background(), "", "msg", "done")
+ _ = c.OnReactionEvent(context.Background(), "user", "", "done")
+ rts.requireNoRequest(t, 200*time.Millisecond)
+ if rts.count.Load() != 0 {
+ t.Errorf("empty id: %d requests, want 0", rts.count.Load())
+ }
+}
+
+// --- controller behavior ---
+
+func TestController_TerminalDeferred(t *testing.T) {
+ t.Parallel()
+ c, rts := newReactionChannel(t, "full")
+ _ = c.OnReactionEvent(context.Background(), "u", "m", "done")
+ r := rts.waitForRequest(t, 500*time.Millisecond)
+ if r.path != pathSendReaction {
+ t.Errorf("path = %q", r.path)
+ }
+ body := rts.lastBody()
+ sa, _ := body["sender_action"].(map[string]any)
+ if sa["react_message_id"] != "m" {
+ t.Errorf("react_message_id = %v", sa["react_message_id"])
+ }
+}
+
+func TestController_TerminalRespectsDelay(t *testing.T) {
+ t.Parallel()
+ c, rts := newReactionChannel(t, "full")
+ c.cfg.ReactionTerminalDelayMinMs = 250
+ c.cfg.ReactionTerminalDelayMaxMs = 250
+ start := time.Now()
+ _ = c.OnReactionEvent(context.Background(), "u", "m", "done")
+ rts.requireNoRequest(t, 150*time.Millisecond)
+ rts.waitForRequest(t, 500*time.Millisecond)
+ if elapsed := time.Since(start); elapsed < 200*time.Millisecond {
+ t.Errorf("terminal fired in %v, want >= ~250ms", elapsed)
+ }
+}
+
+func TestController_TerminalCancelledOnStop(t *testing.T) {
+ t.Parallel()
+ c, rts := newReactionChannel(t, "full")
+ c.cfg.ReactionTerminalDelayMinMs = 500
+ c.cfg.ReactionTerminalDelayMaxMs = 500
+ _ = c.OnReactionEvent(context.Background(), "u", "m", "done")
+ if err := c.Stop(context.Background()); err != nil {
+ t.Fatalf("Stop: %v", err)
+ }
+ rts.requireNoRequest(t, 800*time.Millisecond)
+ if got := rts.count.Load(); got != 0 {
+ t.Errorf("got %d requests after Stop, want 0", got)
+ }
+}
+
+func TestController_DebouncesIntermediate(t *testing.T) {
+ t.Parallel()
+ c, rts := newReactionChannel(t, "full")
+ for range 5 {
+ _ = c.OnReactionEvent(context.Background(), "u", "m", "thinking")
+ }
+ // Within the 700ms debounce: no requests yet.
+ rts.requireNoRequest(t, 200*time.Millisecond)
+ // After debounce window: exactly one request.
+ rts.waitForRequest(t, 1500*time.Millisecond)
+ // Quiet window — confirm no further sends.
+ rts.requireNoRequest(t, 400*time.Millisecond)
+ if got := rts.count.Load(); got != 1 {
+ t.Errorf("debounce: total requests = %d, want 1", got)
+ }
+}
+
+func TestController_TerminalCancelsDebounce(t *testing.T) {
+ t.Parallel()
+ c, rts := newReactionChannel(t, "full")
+ _ = c.OnReactionEvent(context.Background(), "u", "m", "thinking")
+ _ = c.OnReactionEvent(context.Background(), "u", "m", "done")
+ rts.waitForRequest(t, 250*time.Millisecond)
+ // Past the debounce window — confirm the debounced thinking didn't fire.
+ rts.requireNoRequest(t, 1*time.Second)
+ if got := rts.count.Load(); got != 1 {
+ t.Errorf("got %d requests, want 1 (terminal must cancel debounce)", got)
+ }
+}
+
+func TestController_StopCancelsTimer(t *testing.T) {
+ t.Parallel()
+ c, rts := newReactionChannel(t, "full")
+ _ = c.OnReactionEvent(context.Background(), "u", "m", "thinking")
+ if err := c.Stop(context.Background()); err != nil {
+ t.Fatalf("Stop: %v", err)
+ }
+ // Past the 700ms debounce — Stop must have cancelled the timer.
+ rts.requireNoRequest(t, 1*time.Second)
+ if got := rts.count.Load(); got != 0 {
+ t.Errorf("got %d requests after Stop, want 0", got)
+ }
+}
+
+// TestController_UnmappedIntermediateNoOp: tool/coding/web are deliberately
+// not mapped on Zalo OA (B2C noise control). They must not produce wire
+// traffic, even on the debounced path.
+func TestController_UnmappedIntermediateNoOp(t *testing.T) {
+ t.Parallel()
+ c, rts := newReactionChannel(t, "full")
+ for _, st := range []string{"tool", "coding", "web"} {
+ _ = c.OnReactionEvent(context.Background(), "u", "m", st)
+ }
+ rts.requireNoRequest(t, 1*time.Second)
+ if got := rts.count.Load(); got != 0 {
+ t.Errorf("got %d requests, want 0 for unmapped statuses", got)
+ }
+}
+
+func TestClearReaction_SendsRemoveSentinel(t *testing.T) {
+ t.Parallel()
+ c, rts := newReactionChannel(t, "full")
+ if err := c.ClearReaction(context.Background(), "u", "m"); err != nil {
+ t.Fatalf("ClearReaction: %v", err)
+ }
+ rts.waitForRequest(t, 500*time.Millisecond)
+ body := rts.lastBody()
+ sa, _ := body["sender_action"].(map[string]any)
+ if sa["react_icon"] != "/-remove" {
+ t.Errorf("react_icon = %v, want /-remove", sa["react_icon"])
+ }
+ if sa["react_message_id"] != "m" {
+ t.Errorf("react_message_id = %v", sa["react_message_id"])
+ }
+}
+
+func TestClearReaction_StopsExistingController(t *testing.T) {
+ t.Parallel()
+ c, rts := newReactionChannel(t, "full")
+ _ = c.OnReactionEvent(context.Background(), "u", "m", "thinking")
+ // Clear before debounce fires; debounced reaction must NOT be sent.
+ if err := c.ClearReaction(context.Background(), "u", "m"); err != nil {
+ t.Fatalf("ClearReaction: %v", err)
+ }
+ // Drain the /-remove send.
+ rts.waitForRequest(t, 500*time.Millisecond)
+ // Past the debounce: nothing else.
+ rts.requireNoRequest(t, 1*time.Second)
+ if got := rts.count.Load(); got != 1 {
+ t.Errorf("got %d requests, want 1 (only the /-remove)", got)
+ }
+}
diff --git a/internal/channels/zalo/oa/safety_ticker_test.go b/internal/channels/zalo/oa/safety_ticker_test.go
new file mode 100644
index 0000000000..a5649ad46e
--- /dev/null
+++ b/internal/channels/zalo/oa/safety_ticker_test.go
@@ -0,0 +1,210 @@
+package oa
+
+import (
+ "context"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+)
+
+// TestStartStop_TickerShutsDownPromptly proves the safety-ticker goroutine
+// exits within a bounded time when Stop() is called. Failure mode being
+// guarded: a leaked goroutine keeps polling forever after channel removal.
+func TestStartStop_TickerShutsDownPromptly(t *testing.T) {
+ t.Parallel()
+
+ cfg := config.ZaloOAConfig{
+ SafetyTickerMinutes: 1, // value irrelevant — we Stop before any tick fires
+ }
+ creds := &ChannelCreds{
+ AppID: "app",
+ SecretKey: "key",
+ AccessToken: "AT",
+ RefreshToken: "RT",
+ ExpiresAt: time.Now().Add(time.Hour),
+ }
+ msgBus := bus.New()
+
+ c, err := New("test_inst", cfg, creds, &fakeStore{}, msgBus, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ c.SetInstanceID(uuid.New())
+
+ if err := c.Start(context.Background()); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+
+ done := make(chan struct{})
+ go func() {
+ _ = c.Stop(context.Background())
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("Stop did not return within 2s — ticker goroutine leaked")
+ }
+}
+
+// TestSafetyTicker_RefreshesWhenWithinThreshold verifies the ticker calls
+// Access() (which triggers refresh) when the token sits inside the safety
+// threshold. We don't measure timing precisely — just that within a few
+// short ticks the upstream gets called.
+func TestSafetyTicker_RefreshesWhenWithinThreshold(t *testing.T) {
+ t.Parallel()
+
+ srv, count := newRefreshServer(t, "")
+ fs := &fakeStore{}
+
+ cfg := config.ZaloOAConfig{
+ // 1-second ticker so the test runs quickly. Forced via newWithInterval helper.
+ }
+ creds := &ChannelCreds{
+ AppID: "app",
+ SecretKey: "key",
+ AccessToken: "AT-old",
+ RefreshToken: "RT-old",
+ ExpiresAt: time.Now().Add(30 * time.Second), // well inside the safety threshold
+ }
+ msgBus := bus.New()
+
+ c, err := New("test_inst", cfg, creds, fs, msgBus, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ c.SetInstanceID(uuid.New())
+ // Override the upstream OAuth host for the test.
+ c.tokens.client.oauthBase = srv.URL
+ // Override the ticker interval so the test doesn't wait the production default.
+ c.safetyTickerInterval = 100 * time.Millisecond
+
+ if err := c.Start(context.Background()); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ defer func() { _ = c.Stop(context.Background()) }()
+
+ // Wait up to 2s for the ticker to fire and trigger one refresh.
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if atomic.LoadInt32(count) >= 1 && fs.UpdateCount() >= 1 {
+ return // pass
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ t.Fatalf("ticker did not refresh within 2s: refresh=%d, updates=%d", atomic.LoadInt32(count), fs.UpdateCount())
+}
+
+// newChannelForReauthTest builds a Channel with the supplied refresh-token
+// expiry so we can drive evaluateReauthWarning() without spinning the ticker.
+func newChannelForReauthTest(t *testing.T, refreshExp time.Time) *Channel {
+ t.Helper()
+ creds := &ChannelCreds{
+ AppID: "app",
+ SecretKey: "key",
+ AccessToken: "AT",
+ RefreshToken: "RT",
+ ExpiresAt: time.Now().Add(time.Hour),
+ RefreshTokenExpiresAt: refreshExp,
+ }
+ c, err := New("test_inst", config.ZaloOAConfig{}, creds, &fakeStore{}, bus.New(), nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ c.SetInstanceID(uuid.New())
+ return c
+}
+
+// In-window + Healthy → Degraded(Auth, retryable) with the i18n summary.
+func TestEvaluateReauthWarning_HealthyToDegraded(t *testing.T) {
+ t.Parallel()
+ c := newChannelForReauthTest(t, time.Now().Add(10*24*time.Hour))
+ c.MarkHealthy("connected")
+
+ c.evaluateReauthWarning()
+
+ snap := c.HealthSnapshot()
+ if snap.State != channels.ChannelHealthStateDegraded {
+ t.Fatalf("state = %q, want degraded", snap.State)
+ }
+ if snap.FailureKind != channels.ChannelFailureKindAuth {
+ t.Errorf("failure_kind = %q, want auth", snap.FailureKind)
+ }
+ if !snap.Retryable {
+ t.Errorf("retryable = false, want true")
+ }
+ if !strings.Contains(snap.Summary, "Re-consent") {
+ t.Errorf("summary = %q, want contains \"Re-consent\"", snap.Summary)
+ }
+}
+
+// Outside the window → Healthy stays Healthy.
+func TestEvaluateReauthWarning_OutsideWindowStaysHealthy(t *testing.T) {
+ t.Parallel()
+ c := newChannelForReauthTest(t, time.Now().Add(30*24*time.Hour))
+ c.MarkHealthy("connected")
+
+ c.evaluateReauthWarning()
+
+ if got := c.HealthSnapshot().State; got != channels.ChannelHealthStateHealthy {
+ t.Errorf("state = %q, want healthy", got)
+ }
+}
+
+// Legacy channel (zero RefreshTokenExpiresAt) → no transition, no false alarm.
+func TestEvaluateReauthWarning_ZeroExpiryNoOp(t *testing.T) {
+ t.Parallel()
+ c := newChannelForReauthTest(t, time.Time{})
+ c.MarkHealthy("connected")
+
+ c.evaluateReauthWarning()
+
+ if got := c.HealthSnapshot().State; got != channels.ChannelHealthStateHealthy {
+ t.Errorf("state = %q, want healthy (legacy channel must stay silent)", got)
+ }
+}
+
+// Re-consent path: warning was set, fresh refresh extends expiry → Healthy.
+func TestEvaluateReauthWarning_ClearsAfterReconsent(t *testing.T) {
+ t.Parallel()
+ c := newChannelForReauthTest(t, time.Now().Add(10*24*time.Hour))
+ c.MarkHealthy("connected")
+ c.evaluateReauthWarning() // warning ON
+ if got := c.HealthSnapshot().State; got != channels.ChannelHealthStateDegraded {
+ t.Fatalf("setup: state = %q, want degraded", got)
+ }
+
+ // Operator re-consents — Phase 1 stamps a fresh expiry.
+ snap := *c.creds()
+ snap.RefreshTokenExpiresAt = time.Now().Add(60 * 24 * time.Hour)
+ c.tokens.creds.Store(&snap)
+ c.evaluateReauthWarning()
+
+ if got := c.HealthSnapshot().State; got != channels.ChannelHealthStateHealthy {
+ t.Errorf("state = %q, want healthy after re-consent", got)
+ }
+}
+
+// Failed state must NOT be downgraded to Degraded(warn) — Failed wins.
+func TestEvaluateReauthWarning_FailedStateLeftAlone(t *testing.T) {
+ t.Parallel()
+ c := newChannelForReauthTest(t, time.Now().Add(10*24*time.Hour))
+ c.MarkFailed("re-auth required", "...", channels.ChannelFailureKindAuth, false)
+
+ c.evaluateReauthWarning()
+
+ snap := c.HealthSnapshot()
+ if snap.State != channels.ChannelHealthStateFailed {
+ t.Errorf("state = %q, want failed (must not downgrade)", snap.State)
+ }
+ if snap.Retryable {
+ t.Errorf("retryable = true, want false (must not flip the failed flag)")
+ }
+}
diff --git a/internal/channels/zalo/oa/seen_ids.go b/internal/channels/zalo/oa/seen_ids.go
new file mode 100644
index 0000000000..6ffaa272c7
--- /dev/null
+++ b/internal/channels/zalo/oa/seen_ids.go
@@ -0,0 +1,48 @@
+package oa
+
+import (
+ "container/list"
+ "sync"
+)
+
+// seenMessageIDs is the time==0 dedup fallback for pollOnce. Bounded LRU
+// set; usually stays empty since Zalo always sets time in practice.
+type seenMessageIDs struct {
+ mu sync.Mutex
+ max int
+ data map[string]*list.Element
+ order *list.List
+}
+
+func newSeenMessageIDs(max int) *seenMessageIDs {
+ if max <= 0 {
+ max = 256
+ }
+ return &seenMessageIDs{
+ max: max,
+ data: make(map[string]*list.Element),
+ order: list.New(),
+ }
+}
+
+// SeenOrAdd reports whether id was already present; otherwise inserts
+// as MRU and evicts the LRU tail to keep size <= max.
+func (s *seenMessageIDs) SeenOrAdd(id string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if elem, ok := s.data[id]; ok {
+ s.order.MoveToFront(elem)
+ return true
+ }
+ elem := s.order.PushFront(id)
+ s.data[id] = elem
+ for s.order.Len() > s.max {
+ tail := s.order.Back()
+ if tail == nil {
+ break
+ }
+ delete(s.data, tail.Value.(string))
+ s.order.Remove(tail)
+ }
+ return false
+}
diff --git a/internal/channels/zalo/oa/seen_ids_test.go b/internal/channels/zalo/oa/seen_ids_test.go
new file mode 100644
index 0000000000..685e5bf9f4
--- /dev/null
+++ b/internal/channels/zalo/oa/seen_ids_test.go
@@ -0,0 +1,82 @@
+package oa
+
+import (
+ "fmt"
+ "sync"
+ "testing"
+)
+
+func TestSeenMessageIDs_NotSeenThenSeen(t *testing.T) {
+ s := newSeenMessageIDs(8)
+ if got := s.SeenOrAdd("m1"); got {
+ t.Fatalf("first SeenOrAdd: got true, want false")
+ }
+ if got := s.SeenOrAdd("m1"); !got {
+ t.Fatalf("second SeenOrAdd: got false, want true")
+ }
+}
+
+func TestSeenMessageIDs_LRUEviction(t *testing.T) {
+ s := newSeenMessageIDs(3)
+ for _, id := range []string{"a", "b", "c"} {
+ if s.SeenOrAdd(id) {
+ t.Fatalf("unexpected hit for %q", id)
+ }
+ }
+ // Touch "a" so it's MRU; then push two more — "b" then "c" should evict.
+ if !s.SeenOrAdd("a") {
+ t.Fatalf("expected hit for a")
+ }
+ if s.SeenOrAdd("d") {
+ t.Fatalf("unexpected hit for d")
+ }
+ if s.SeenOrAdd("e") {
+ t.Fatalf("unexpected hit for e")
+ }
+ // Final state should be {a, d, e}; b and c evicted.
+ if got := s.order.Len(); got != 3 {
+ t.Fatalf("len=%d want 3", got)
+ }
+ for _, id := range []string{"a", "d", "e"} {
+ if _, ok := s.data[id]; !ok {
+ t.Fatalf("expected %q to be present", id)
+ }
+ }
+ for _, id := range []string{"b", "c"} {
+ if _, ok := s.data[id]; ok {
+ t.Fatalf("expected %q to be evicted", id)
+ }
+ }
+}
+
+func TestSeenMessageIDs_DefaultMax(t *testing.T) {
+ s := newSeenMessageIDs(0) // should clamp to default 256
+ for i := range 256 {
+ s.SeenOrAdd(fmt.Sprintf("id-%d", i))
+ }
+ if s.order.Len() != 256 {
+ t.Fatalf("len=%d want 256", s.order.Len())
+ }
+ s.SeenOrAdd("id-256")
+ if s.order.Len() != 256 {
+ t.Fatalf("len=%d want 256 after overflow", s.order.Len())
+ }
+}
+
+func TestSeenMessageIDs_ConcurrentSafe(t *testing.T) {
+ s := newSeenMessageIDs(1024)
+ var wg sync.WaitGroup
+ for g := range 16 {
+ wg.Add(1)
+ go func(g int) {
+ defer wg.Done()
+ for i := range 200 {
+ s.SeenOrAdd(fmt.Sprintf("g%d-i%d", g, i))
+ }
+ }(g)
+ }
+ wg.Wait()
+ if s.order.Len() > 1024 {
+ t.Fatalf("len=%d exceeds cap 1024", s.order.Len())
+ }
+}
diff --git a/internal/channels/zalo/oa/send.go b/internal/channels/zalo/oa/send.go
new file mode 100644
index 0000000000..2414c7f3c8
--- /dev/null
+++ b/internal/channels/zalo/oa/send.go
@@ -0,0 +1,221 @@
+package oa
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+)
+
+// isZaloSupportedFileMIME: /v2.0/oa/upload/file accepts PDF/DOC/DOCX only;
+// other types are silently rejected by Zalo.
+func isZaloSupportedFileMIME(mime string) bool {
+ switch strings.ToLower(strings.TrimSpace(mime)) {
+ case "application/pdf",
+ "application/msword",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
+ return true
+ }
+ return false
+}
+
+// maxTextLength is Zalo's per-message cap; longer payloads error -210.
+// Same value across zalo_bot / zalo_personal / zalo_oa.
+const maxTextLength = 2000
+
+// SendText splits replies via channels.ChunkMarkdown so >2000-char
+// messages reach the user as multiple ordered sends. Returns the final
+// upstream message_id. quoteID, when non-empty, is sent as Zalo's
+// message.quote_message_id on the FIRST chunk only — continuation chunks
+// ride unquoted.
+func (c *Channel) SendText(ctx context.Context, userID, text, quoteID string) (string, error) {
+ if strings.TrimSpace(text) == "" {
+ return "", nil
+ }
+ parts := channels.ChunkMarkdown(text, maxTextLength)
+ if len(parts) == 0 {
+ return "", nil
+ }
+ var lastMID string
+ for i, part := range parts {
+ q := ""
+ if i == 0 {
+ q = quoteID
+ }
+ mid, err := c.postCSWithQuoteFallback(ctx, userID, part, q)
+ if err != nil {
+ return lastMID, fmt.Errorf("zalo_oa.sendtext part %d/%d: %w", i+1, len(parts), err)
+ }
+ lastMID = mid
+ slog.Info("zalo_oa.sent", "type", "text", "message_id", mid, "oa_id", c.creds().OAID,
+ "part", i+1, "total_parts", len(parts), "quoted", q != "")
+ }
+ return lastMID, nil
+}
+
+// postCSWithQuoteFallback posts a text body and, on FamilyPayload errors
+// when a quote was set, retries once without the quote field. Covers the
+// expired/deleted source-message case without masking other error families.
+func (c *Channel) postCSWithQuoteFallback(ctx context.Context, userID, text, quoteID string) (string, error) {
+ mid, err := c.post(ctx, pathSendMessage, buildTextBody(userID, text, quoteID))
+ if err == nil || quoteID == "" {
+ return mid, err
+ }
+ var apiErr *APIError
+ if errors.As(err, &apiErr) && Classify(apiErr.Code).Family == FamilyPayload {
+ slog.Warn("zalo_oa.send.quote_dropped_payload_error",
+ "oa_id", c.creds().OAID,
+ "user_id", userID,
+ "quote_message_id", quoteID,
+ "zalo_code", apiErr.Code,
+ "zalo_msg", apiErr.Message,
+ "hint", "quoted message likely expired/deleted; retrying without quote")
+ return c.post(ctx, pathSendMessage, buildTextBody(userID, text, ""))
+ }
+ return mid, err
+}
+
+// SendImage uploads + sends an image. mime must be image/jpeg or image/png
+// (drives the multipart filename extension Zalo validates against).
+// Image attachments require the template/media payload shape; the simpler
+// {"type":"image","payload":{"attachment_id"}} returns -201.
+func (c *Channel) SendImage(ctx context.Context, userID string, data []byte, mime string) (string, error) {
+ tok, err := c.uploadImage(ctx, data, mime)
+ if err != nil {
+ return "", err
+ }
+ body := buildMediaAttachmentBody(userID, "image", tok)
+ mid, err := c.post(ctx, pathSendMessage, body)
+ if err == nil {
+ slog.Info("zalo_oa.sent", "type", "image", "message_id", mid, "oa_id", c.creds().OAID)
+ }
+ return mid, err
+}
+
+// SendGIF uploads + sends a GIF via /upload/gif (5MB cap, enforced by caller).
+func (c *Channel) SendGIF(ctx context.Context, userID string, data []byte) (string, error) {
+ if len(data) == 0 {
+ return "", errors.New("zalo_oa: refusing to send empty gif")
+ }
+ tok, err := c.uploadGIF(ctx, data)
+ if err != nil {
+ return "", err
+ }
+ body := buildMediaAttachmentBody(userID, "gif", tok)
+ mid, err := c.post(ctx, pathSendMessage, body)
+ if err == nil {
+ slog.Info("zalo_oa.sent", "type", "gif", "message_id", mid, "oa_id", c.creds().OAID)
+ }
+ return mid, err
+}
+
+// Payload builders for /v3.0/oa/message/cs. Images + gifs use template/media;
+// files use plain type=file; text has no attachment wrapper.
+
+func buildTextBody(userID, text, quoteMessageID string) map[string]any {
+ msg := map[string]any{"text": text}
+ if quoteMessageID != "" {
+ msg["quote_message_id"] = quoteMessageID
+ }
+ return map[string]any{
+ "recipient": map[string]any{"user_id": userID},
+ "message": msg,
+ }
+}
+
+// buildMediaAttachmentBody is the template/media shape for image+gif sends.
+// mediaType is "image" or "gif".
+func buildMediaAttachmentBody(userID, mediaType, attachmentID string) map[string]any {
+ return map[string]any{
+ "recipient": map[string]any{"user_id": userID},
+ "message": map[string]any{
+ "attachment": map[string]any{
+ "type": "template",
+ "payload": map[string]any{
+ "template_type": "media",
+ "elements": []map[string]any{{
+ "media_type": mediaType,
+ "attachment_id": attachmentID,
+ }},
+ },
+ },
+ },
+ }
+}
+
+// buildFileAttachmentBody is the plain type=file shape; files do NOT use
+// the template/media wrapper.
+func buildFileAttachmentBody(userID, attachmentID string) map[string]any {
+ return map[string]any{
+ "recipient": map[string]any{"user_id": userID},
+ "message": map[string]any{
+ "attachment": map[string]any{
+ "type": "file",
+ "payload": map[string]any{"attachment_id": attachmentID},
+ },
+ },
+ }
+}
+
+// SendFile uploads + sends a file. filename rides in the multipart
+// "filename" field so Zalo preserves it for the recipient. MIME gating
+// lives at the caller (channel.go dispatch).
+func (c *Channel) SendFile(ctx context.Context, userID string, data []byte, filename string) (string, error) {
+ if len(data) == 0 {
+ return "", fmt.Errorf("zalo_oa: refusing to send empty/zero-byte file %q", filename)
+ }
+ tok, err := c.uploadFile(ctx, data, filename)
+ if err != nil {
+ return "", err
+ }
+ mid, err := c.post(ctx, pathSendMessage, buildFileAttachmentBody(userID, tok))
+ if err == nil {
+ slog.Info("zalo_oa.sent", "type", "file", "message_id", mid, "oa_id", c.creds().OAID)
+ }
+ return mid, err
+}
+
+// post wraps apiPost with retry-once-on-auth: the first auth error triggers
+// ForceRefresh + one retry. Other errors return immediately and flip health
+// to Failed/Auth so the dashboard surfaces the reauth prompt promptly.
+func (c *Channel) post(ctx context.Context, path string, body any) (string, error) {
+ var lastErr error
+ for attempt := range 2 {
+ tok, err := c.tokens.Access(ctx)
+ if err != nil {
+ c.markAuthFailedIfNeeded(err)
+ return "", err
+ }
+ raw, err := c.client.apiPost(ctx, path, body, tok)
+ if err == nil {
+ return parseMessageResponse(raw)
+ }
+ var apiErr *APIError
+ if errors.As(err, &apiErr) && apiErr.isAuth() && attempt == 0 {
+ c.tokens.ForceRefresh()
+ lastErr = err
+ continue
+ }
+ c.markAuthFailedIfNeeded(err)
+ return "", err
+ }
+ return "", lastErr
+}
+
+// parseMessageResponse pulls message_id from the standard envelope:
+// {"error":0,"data":{"message_id":"...","recipient_id":"..."}}
+func parseMessageResponse(raw json.RawMessage) (string, error) {
+ var env struct {
+ Data struct {
+ MessageID string `json:"message_id"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(raw, &env); err != nil {
+ return "", fmt.Errorf("zalo_oa: decode message response: %w", err)
+ }
+ return env.Data.MessageID, nil
+}
diff --git a/internal/channels/zalo/oa/send_fixture_test.go b/internal/channels/zalo/oa/send_fixture_test.go
new file mode 100644
index 0000000000..637d4a4118
--- /dev/null
+++ b/internal/channels/zalo/oa/send_fixture_test.go
@@ -0,0 +1,162 @@
+package oa
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "sync/atomic"
+ "testing"
+)
+
+// TestSend_WireShape_Fixtures locks the exact JSON bytes each Send* function
+// sends to /v3.0/oa/message/cs. Guards against accidental byte-drift in the
+// outbound wire shape. Runs under plain `go test -race`, no build tag.
+//
+// On mismatch: either (a) an unintended behavior change — revert it, or
+// (b) the wire shape was intentionally changed — regenerate the fixture
+// AND land that behavior change as a separate commit with a clear subject.
+func TestSend_WireShape_Fixtures(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ call func(c *Channel) (string, error)
+ wantReqFixture string
+ uploadFixture string // empty for text-only
+ uploadPath string // empty for text-only
+ wantMID string
+ }{
+ {
+ name: "SendText",
+ call: func(c *Channel) (string, error) {
+ return c.SendText(context.Background(), "user-fixture", "hello fixture", "")
+ },
+ wantReqFixture: "testdata/send_text_request.json",
+ wantMID: "msg-fixture-1",
+ },
+ {
+ name: "SendText_Quote",
+ call: func(c *Channel) (string, error) {
+ return c.SendText(context.Background(), "186729651760683225", "Chào bạn", "48687128d04c9410cd5f")
+ },
+ wantReqFixture: "testdata/send_text_quote_request.json",
+ wantMID: "msg-fixture-1",
+ },
+ {
+ name: "SendImage",
+ call: func(c *Channel) (string, error) {
+ return c.SendImage(context.Background(), "user-fixture", []byte("\x89PNG\r\n\x1a\nfake"), "image/png")
+ },
+ wantReqFixture: "testdata/send_image_request.json",
+ uploadFixture: "testdata/upload_image_200.json",
+ uploadPath: "/v2.0/oa/upload/image",
+ wantMID: "msg-fixture-1",
+ },
+ {
+ name: "SendGIF",
+ call: func(c *Channel) (string, error) {
+ return c.SendGIF(context.Background(), "user-fixture", []byte("GIF89a-fake"))
+ },
+ wantReqFixture: "testdata/send_gif_request.json",
+ uploadFixture: "testdata/upload_gif_200.json",
+ uploadPath: "/v2.0/oa/upload/gif",
+ wantMID: "msg-fixture-1",
+ },
+ {
+ name: "SendFile",
+ call: func(c *Channel) (string, error) {
+ return c.SendFile(context.Background(), "user-fixture", []byte("%PDF-fake"), "doc.pdf")
+ },
+ wantReqFixture: "testdata/send_file_request.json",
+ uploadFixture: "testdata/upload_file_200.json",
+ uploadPath: "/v2.0/oa/upload/file",
+ wantMID: "msg-fixture-1",
+ },
+ }
+
+ sendReply := mustReadFixture(t, "testdata/send_message_200.json")
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ var sendBody []byte
+ var msgCount int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case tc.uploadPath:
+ // drain multipart body but don't need it for wire-shape assertions
+ _, _ = io.Copy(io.Discard, r.Body)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write(mustReadFixture(t, tc.uploadFixture))
+ case "/v3.0/oa/message/cs":
+ if atomic.AddInt32(&msgCount, 1) == 1 {
+ body, _ := io.ReadAll(r.Body)
+ sendBody = body
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write(sendReply)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ t.Cleanup(srv.Close)
+
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, srv, refresh, &fakeStore{})
+
+ mid, err := tc.call(c)
+ if err != nil {
+ t.Fatalf("%s: %v", tc.name, err)
+ }
+ if mid != tc.wantMID {
+ t.Errorf("message_id = %q, want %q", mid, tc.wantMID)
+ }
+ if sendBody == nil {
+ t.Fatalf("send body not captured")
+ }
+
+ want := mustReadFixture(t, tc.wantReqFixture)
+ if !jsonCanonicalEqual(t, sendBody, want) {
+ t.Errorf("wire-shape drift for %s\n got: %s\nwant: %s",
+ tc.name, canonicalize(t, sendBody), canonicalize(t, want))
+ }
+ })
+ }
+}
+
+// mustReadFixture reads a testdata file relative to this test package.
+func mustReadFixture(t *testing.T, rel string) []byte {
+ t.Helper()
+ b, err := os.ReadFile(filepath.FromSlash(rel))
+ if err != nil {
+ t.Fatalf("read fixture %s: %v", rel, err)
+ }
+ return b
+}
+
+// jsonCanonicalEqual compares two JSON byte slices after unmarshal+remarshal
+// so field order doesn't matter. Go's json.Marshal sorts map keys, so the
+// remarshaled output is deterministic.
+func jsonCanonicalEqual(t *testing.T, a, b []byte) bool {
+ t.Helper()
+ return bytes.Equal(canonicalize(t, a), canonicalize(t, b))
+}
+
+func canonicalize(t *testing.T, raw []byte) []byte {
+ t.Helper()
+ var v any
+ if err := json.Unmarshal(raw, &v); err != nil {
+ t.Fatalf("canonicalize unmarshal: %v\nraw: %s", err, string(raw))
+ }
+ out, err := json.Marshal(v)
+ if err != nil {
+ t.Fatalf("canonicalize marshal: %v", err)
+ }
+ return out
+}
diff --git a/internal/channels/zalo/oa/send_quote_test.go b/internal/channels/zalo/oa/send_quote_test.go
new file mode 100644
index 0000000000..c6c5d5d48f
--- /dev/null
+++ b/internal/channels/zalo/oa/send_quote_test.go
@@ -0,0 +1,334 @@
+package oa
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+)
+
+func TestBuildTextBody_NoQuote(t *testing.T) {
+ t.Parallel()
+ body := buildTextBody("u1", "hi", "")
+ msg, _ := body["message"].(map[string]any)
+ if msg == nil {
+ t.Fatalf("message missing in body: %v", body)
+ }
+ if _, ok := msg["quote_message_id"]; ok {
+ t.Fatalf("quote_message_id must be absent when empty, got body=%v", body)
+ }
+ if msg["text"] != "hi" {
+ t.Errorf("message.text = %v, want hi", msg["text"])
+ }
+}
+
+func TestBuildTextBody_WithQuote(t *testing.T) {
+ t.Parallel()
+ body := buildTextBody("u1", "hi", "qid42")
+ msg, _ := body["message"].(map[string]any)
+ if msg["quote_message_id"] != "qid42" {
+ t.Fatalf("message.quote_message_id = %v, want qid42", msg["quote_message_id"])
+ }
+}
+
+// hasQuote reads JSON request body and returns the quote_message_id (or "").
+func extractQuoteID(t *testing.T, raw []byte) string {
+ t.Helper()
+ var b map[string]any
+ if err := json.Unmarshal(raw, &b); err != nil {
+ t.Fatalf("unmarshal: %v\nraw=%s", err, raw)
+ }
+ msg, _ := b["message"].(map[string]any)
+ q, _ := msg["quote_message_id"].(string)
+ return q
+}
+
+func TestSendText_QuoteOnFirstChunkOnly(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ messageReplies: []string{
+ `{"error":0,"data":{"message_id":"mid-1"}}`,
+ `{"error":0,"data":{"message_id":"mid-2"}}`,
+ `{"error":0,"data":{"message_id":"mid-3"}}`,
+ `{"error":0,"data":{"message_id":"mid-4"}}`,
+ `{"error":0,"data":{"message_id":"mid-5"}}`,
+ },
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ var bldr strings.Builder
+ for range 10 {
+ bldr.WriteString(strings.Repeat("a", 499))
+ bldr.WriteString("\n\n")
+ }
+ long := bldr.String()
+ _, err := c.SendText(context.Background(), "u1", long, "qid-first")
+ if err != nil {
+ t.Fatalf("SendText: %v", err)
+ }
+ if len(*captured) < 2 {
+ t.Fatalf("captured %d, want >=2", len(*captured))
+ }
+ if got := extractQuoteID(t, (*captured)[0].body); got != "qid-first" {
+ t.Errorf("chunk 1 quote = %q, want qid-first", got)
+ }
+ for i := 1; i < len(*captured); i++ {
+ if got := extractQuoteID(t, (*captured)[i].body); got != "" {
+ t.Errorf("chunk %d quote = %q, must be empty (continuation chunks unquoted)", i+1, got)
+ }
+ }
+}
+
+func TestSendText_NoQuoteWhenIDEmpty(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ messageReplies: []string{`{"error":0,"data":{"message_id":"m"}}`},
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ if _, err := c.SendText(context.Background(), "u1", "hi", ""); err != nil {
+ t.Fatalf("SendText: %v", err)
+ }
+ if got := extractQuoteID(t, (*captured)[0].body); got != "" {
+ t.Errorf("quote present without metadata: %q", got)
+ }
+}
+
+// TestSendText_QuoteDroppedOnPayloadError: server rejects quoted body with
+// -201 (FamilyPayload) → channel retries once without quote, succeeds.
+func TestSendText_QuoteDroppedOnPayloadError(t *testing.T) {
+ t.Parallel()
+ var seenQuoted, seenUnquoted int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v3.0/oa/message/cs" {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ raw, _ := io.ReadAll(r.Body)
+ var b map[string]any
+ _ = json.Unmarshal(raw, &b)
+ msg, _ := b["message"].(map[string]any)
+ w.Header().Set("Content-Type", "application/json")
+ if _, ok := msg["quote_message_id"]; ok {
+ atomic.AddInt32(&seenQuoted, 1)
+ _, _ = w.Write([]byte(`{"error":-201,"message":"params invalid"}`))
+ return
+ }
+ atomic.AddInt32(&seenUnquoted, 1)
+ _, _ = w.Write([]byte(`{"error":0,"data":{"message_id":"m-no-quote"}}`))
+ }))
+ t.Cleanup(srv.Close)
+
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, srv, refresh, &fakeStore{})
+ mid, err := c.SendText(context.Background(), "u1", "hi", "qid-old")
+ if err != nil {
+ t.Fatalf("SendText: %v", err)
+ }
+ if mid != "m-no-quote" {
+ t.Errorf("mid = %q, want m-no-quote", mid)
+ }
+ if g := atomic.LoadInt32(&seenQuoted); g != 1 {
+ t.Errorf("seenQuoted = %d, want 1", g)
+ }
+ if g := atomic.LoadInt32(&seenUnquoted); g != 1 {
+ t.Errorf("seenUnquoted = %d, want 1", g)
+ }
+}
+
+// TestSendText_RateErrorPropagatesNoQuoteRetry: rate error (12010) is NOT a
+// payload-family code; quote-fallback must not trigger.
+func TestSendText_RateErrorPropagatesNoQuoteRetry(t *testing.T) {
+ t.Parallel()
+ var count int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&count, 1)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"error":12010,"message":"per-user daily quota"}`))
+ }))
+ t.Cleanup(srv.Close)
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, srv, refresh, &fakeStore{})
+ _, err := c.SendText(context.Background(), "u1", "hi", "qid")
+ if err == nil {
+ t.Fatal("expected rate error")
+ }
+ if g := atomic.LoadInt32(&count); g != 1 {
+ t.Errorf("hit count = %d, want 1 (no fallback retry)", g)
+ }
+}
+
+// TestSendText_PayloadErrorWithoutQuote_NoRetry: a -201 with no quote set
+// must NOT trigger fallback (no quote to drop).
+func TestSendText_PayloadErrorWithoutQuote_NoRetry(t *testing.T) {
+ t.Parallel()
+ var count int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&count, 1)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"error":-201,"message":"params invalid"}`))
+ }))
+ t.Cleanup(srv.Close)
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, srv, refresh, &fakeStore{})
+ _, err := c.SendText(context.Background(), "u1", "hi", "")
+ if err == nil {
+ t.Fatal("expected payload error to propagate when no quote set")
+ }
+ if g := atomic.LoadInt32(&count); g != 1 {
+ t.Errorf("hit count = %d, want 1 (no retry without quote to drop)", g)
+ }
+ var apiErr *APIError
+ if !errors.As(err, &apiErr) {
+ t.Errorf("err type = %T, want *APIError", err)
+ }
+}
+
+// TestChannelSend_MetadataReplyToBecomesQuote: full Send path threads
+// metadata["reply_to_message_id"] → message.quote_message_id.
+func TestChannelSend_MetadataReplyToBecomesQuote(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ messageReplies: []string{`{"error":0,"data":{"message_id":"m"}}`},
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ err := c.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "u1",
+ Content: "hello",
+ Metadata: map[string]string{"reply_to_message_id": "qid-meta"},
+ })
+ if err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+ if len(*captured) != 1 {
+ t.Fatalf("captured %d, want 1", len(*captured))
+ }
+ if got := extractQuoteID(t, (*captured)[0].body); got != "qid-meta" {
+ t.Errorf("quote_message_id = %q, want qid-meta", got)
+ }
+}
+
+// TestChannelSend_TrailingTextAfterAttachmentDoesNotQuote: when both image
+// and text ride together, the trailing text must NOT carry the quote.
+func TestChannelSend_TrailingTextAfterAttachmentDoesNotQuote(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ uploadReply: `{"error":0,"data":{"attachment_id":"T"}}`,
+ messageReplies: []string{
+ `{"error":0,"data":{"message_id":"mid-img"}}`,
+ `{"error":0,"data":{"message_id":"mid-txt"}}`,
+ },
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ dir := t.TempDir()
+ p := filepath.Join(dir, "x.png")
+ if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ err := c.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "u1",
+ Content: "trailing note",
+ Media: []bus.MediaAttachment{{URL: p, ContentType: "image/png"}},
+ Metadata: map[string]string{"reply_to_message_id": "qid-meta"},
+ })
+ if err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+ // Find the text-only request (last /v3.0/oa/message/cs that has text, no attachment)
+ var trailingBody []byte
+ for _, r := range *captured {
+ if r.path != "/v3.0/oa/message/cs" {
+ continue
+ }
+ var b map[string]any
+ _ = json.Unmarshal(r.body, &b)
+ msg, _ := b["message"].(map[string]any)
+ if _, isText := msg["text"]; isText {
+ trailingBody = r.body
+ }
+ }
+ if trailingBody == nil {
+ t.Fatal("no trailing text request captured")
+ }
+ if got := extractQuoteID(t, trailingBody); got != "" {
+ t.Errorf("trailing text quote = %q, must be empty", got)
+ }
+}
+
+// TestSendText_AuthRetryThenPayloadFallback: -216 (auth) on first call
+// triggers ForceRefresh+retry; second call hits -201 (payload) → quote
+// dropped → third call succeeds. Total 3 message requests.
+func TestSendText_AuthRetryThenPayloadFallback(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ messageReplies: []string{
+ `{"error":-216,"message":"access_token invalid"}`,
+ `{"error":-201,"message":"params invalid"}`,
+ `{"error":0,"data":{"message_id":"mid-final"}}`,
+ },
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ mid, err := c.SendText(context.Background(), "u1", "hi", "qid")
+ if err != nil {
+ t.Fatalf("SendText: %v", err)
+ }
+ if mid != "mid-final" {
+ t.Errorf("mid = %q, want mid-final", mid)
+ }
+ if len(*captured) != 3 {
+ t.Errorf("captured %d, want 3 (auth retry + payload fallback)", len(*captured))
+ }
+ // Assert quote present on first 2, absent on 3rd.
+ if got := extractQuoteID(t, (*captured)[0].body); got != "qid" {
+ t.Errorf("call 1 quote = %q, want qid", got)
+ }
+ if got := extractQuoteID(t, (*captured)[1].body); got != "qid" {
+ t.Errorf("call 2 quote = %q, want qid (auth retry preserves quote)", got)
+ }
+ if got := extractQuoteID(t, (*captured)[2].body); got != "" {
+ t.Errorf("call 3 quote = %q, want empty (payload fallback drops it)", got)
+ }
+}
+
+func TestQuoteInboundOnDM_HonorsConfig(t *testing.T) {
+ t.Parallel()
+ off := false
+ on := true
+ cases := []struct {
+ name string
+ ptr *bool
+ want bool
+ }{
+ {"unset_defaults_off", nil, false},
+ {"explicit_true", &on, true},
+ {"explicit_false", &off, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ c := &Channel{cfg: config.ZaloOAConfig{QuoteUserMessage: tc.ptr}}
+ if got := c.QuoteInboundOnDM(); got != tc.want {
+ t.Errorf("QuoteInboundOnDM = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/channels/zalo/oa/send_reaction.go b/internal/channels/zalo/oa/send_reaction.go
new file mode 100644
index 0000000000..90aaf4cab9
--- /dev/null
+++ b/internal/channels/zalo/oa/send_reaction.go
@@ -0,0 +1,95 @@
+package oa
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+)
+
+// react_icon codes per Zalo OA v2.0 doc, in the order the Zalo client
+// renders them in the reaction picker (haha → worry → cry → like → heart
+// → angry → wow). /-remove is the retract sentinel, not a picker entry.
+const (
+ reactionIconHaha = ":>"
+ reactionIconWorry = "--b"
+ reactionIconCry = ":-(("
+ reactionIconLike = "/-strong"
+ reactionIconHeart = "/-heart"
+ reactionIconAngry = ":-h"
+ reactionIconWow = ":o"
+ reactionIconRemove = "/-remove"
+)
+
+// /-remove omitted: it's a control sentinel, not a status emoji the
+// controller may resolve to.
+var zaloSupportedReactions = map[string]bool{
+ reactionIconHaha: true,
+ reactionIconWorry: true,
+ reactionIconCry: true,
+ reactionIconLike: true,
+ reactionIconHeart: true,
+ reactionIconAngry: true,
+ reactionIconWow: true,
+}
+
+func buildReactionBody(userID, sourceMessageID, reactIcon string) map[string]any {
+ return map[string]any{
+ "recipient": map[string]any{"user_id": userID},
+ "sender_action": map[string]any{
+ "react_icon": reactIcon,
+ "react_message_id": sourceMessageID,
+ },
+ }
+}
+
+// SendReaction bypasses c.post: reactions are best-effort and must not
+// flip channel health on auth failure (no ForceRefresh, no MarkFailed).
+func (c *Channel) SendReaction(ctx context.Context, userID, sourceMessageID, reactIcon string) (string, error) {
+ if userID == "" || sourceMessageID == "" || reactIcon == "" {
+ return "", errors.New("zalo_oa: SendReaction requires user_id, source message_id, react_icon")
+ }
+ tok, err := c.tokens.Access(ctx)
+ if err != nil {
+ return "", err
+ }
+ raw, err := c.client.apiPost(ctx, pathSendReaction,
+ buildReactionBody(userID, sourceMessageID, reactIcon), tok)
+ if err != nil {
+ var apiErr *APIError
+ if errors.As(err, &apiErr) && apiErr.Info().Family == FamilyPayload {
+ slog.Warn("zalo_oa.reaction.dropped_payload_error",
+ "oa_id", c.creds().OAID,
+ "user_id", userID,
+ "source_message_id", sourceMessageID,
+ "icon", reactIcon,
+ "zalo_code", apiErr.Code,
+ "zalo_msg", apiErr.Message,
+ "hint", "source message_id likely expired/deleted/over-50-cap")
+ } else {
+ slog.Debug("zalo_oa.reaction.send_failed",
+ "oa_id", c.creds().OAID,
+ "user_id", userID,
+ "source_message_id", sourceMessageID,
+ "icon", reactIcon,
+ "error", err)
+ }
+ return "", err
+ }
+ mid, _ := parseMessageResponse(raw)
+ slog.Debug("zalo_oa.reaction.sent",
+ "oa_id", c.creds().OAID,
+ "user_id", userID,
+ "source_message_id", sourceMessageID,
+ "icon", reactIcon,
+ "message_id", mid)
+ return mid, nil
+}
+
+func (c *Channel) SendClearReaction(ctx context.Context, userID, sourceMessageID string) error {
+ if userID == "" || sourceMessageID == "" {
+ return fmt.Errorf("zalo_oa: SendClearReaction requires user_id, source message_id")
+ }
+ _, err := c.SendReaction(ctx, userID, sourceMessageID, reactionIconRemove)
+ return err
+}
diff --git a/internal/channels/zalo/oa/send_reaction_test.go b/internal/channels/zalo/oa/send_reaction_test.go
new file mode 100644
index 0000000000..14906c3fd0
--- /dev/null
+++ b/internal/channels/zalo/oa/send_reaction_test.go
@@ -0,0 +1,202 @@
+package oa
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+)
+
+// newReactionAPIServer captures requests to /v2.0/oa/message and replies
+// from canned bodies. Distinct from newAPIServer to avoid touching the
+// existing /v3.0/oa/message/cs routing the rest of the suite depends on.
+func newReactionAPIServer(t *testing.T, replies []string) (*httptest.Server, *[]capturedRequest, *int32) {
+ t.Helper()
+ var captured []capturedRequest
+ var idx int32
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ captured = append(captured, capturedRequest{
+ path: r.URL.Path,
+ contentType: r.Header.Get("Content-Type"),
+ accessToken: r.Header.Get("access_token"),
+ body: body,
+ })
+ if r.URL.Path != pathSendReaction {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ i := atomic.AddInt32(&idx, 1) - 1
+ if int(i) >= len(replies) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"error":-1,"message":"no canned reply"}`))
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(replies[i]))
+ }))
+ t.Cleanup(srv.Close)
+ return srv, &captured, &idx
+}
+
+func TestBuildReactionBody_Shape(t *testing.T) {
+ t.Parallel()
+ body := buildReactionBody("user-1", "msg-abc", "/-heart")
+ rec, _ := body["recipient"].(map[string]any)
+ sa, _ := body["sender_action"].(map[string]any)
+ if rec["user_id"] != "user-1" {
+ t.Errorf("recipient.user_id = %v", rec["user_id"])
+ }
+ if sa["react_icon"] != "/-heart" {
+ t.Errorf("sender_action.react_icon = %v", sa["react_icon"])
+ }
+ if sa["react_message_id"] != "msg-abc" {
+ t.Errorf("sender_action.react_message_id = %v", sa["react_message_id"])
+ }
+ // Round-trip JSON to confirm marshalability.
+ if _, err := json.Marshal(body); err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+}
+
+func TestSendReaction_HappyPath(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newReactionAPIServer(t,
+ []string{`{"data":{"message_id":"react-mid-1","user_id":"user-1"},"error":0,"message":"Success"}`})
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ mid, err := c.SendReaction(context.Background(), "user-1", "src-msg-1", "/-heart")
+ if err != nil {
+ t.Fatalf("SendReaction: %v", err)
+ }
+ if mid != "react-mid-1" {
+ t.Errorf("mid = %q, want react-mid-1", mid)
+ }
+ if len(*captured) != 1 {
+ t.Fatalf("captured %d, want 1", len(*captured))
+ }
+ r := (*captured)[0]
+ if r.path != "/v2.0/oa/message" {
+ t.Errorf("path = %q, want /v2.0/oa/message", r.path)
+ }
+ if r.accessToken != "AT-current" {
+ t.Errorf("access_token = %q", r.accessToken)
+ }
+ if !strings.HasPrefix(r.contentType, "application/json") {
+ t.Errorf("content-type = %q", r.contentType)
+ }
+ var body map[string]any
+ if err := json.Unmarshal(r.body, &body); err != nil {
+ t.Fatalf("body unmarshal: %v", err)
+ }
+ sa, _ := body["sender_action"].(map[string]any)
+ if sa["react_icon"] != "/-heart" || sa["react_message_id"] != "src-msg-1" {
+ t.Errorf("sender_action wrong: %v", sa)
+ }
+}
+
+func TestSendReaction_PayloadFamilyError(t *testing.T) {
+ t.Parallel()
+ // -201 (params invalid) is FamilyPayload — source message_id might be
+ // expired/over-cap. Must surface, must not retry.
+ api, captured, _ := newReactionAPIServer(t,
+ []string{`{"error":-201,"message":"params invalid"}`})
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ _, err := c.SendReaction(context.Background(), "user-1", "stale-msg", "/-heart")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ var apiErr *APIError
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("err = %T %v, want *APIError", err, err)
+ }
+ if Classify(apiErr.Code).Family != FamilyPayload {
+ t.Errorf("family = %v, want payload", Classify(apiErr.Code).Family)
+ }
+ if len(*captured) != 1 {
+ t.Errorf("captured %d, want 1 (payload errors must not retry)", len(*captured))
+ }
+}
+
+// TestSendReaction_AuthError_NoRetryNoHealthFlip: phase-2 step 6 — reactions
+// bypass c.post, so a 401-class error is returned as-is (one request, no
+// ForceRefresh) and channel health is NOT flipped to Failed.
+func TestSendReaction_AuthError_NoRetryNoHealthFlip(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newReactionAPIServer(t,
+ []string{`{"error":-216,"message":"access_token invalid"}`})
+ refresh, refreshHits := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ _, err := c.SendReaction(context.Background(), "user-1", "msg", "/-heart")
+ if err == nil {
+ t.Fatal("expected auth error")
+ }
+ if len(*captured) != 1 {
+ t.Errorf("captured %d, want 1 (no retry on auth)", len(*captured))
+ }
+ if n := atomic.LoadInt32(refreshHits); n != 0 {
+ t.Errorf("refresh hits = %d, want 0 (reactions must not trigger ForceRefresh)", n)
+ }
+ if state := c.HealthSnapshot().State; state == channels.ChannelHealthStateFailed {
+ t.Errorf("channel state = %v, must not flip to Failed on reaction auth error", state)
+ }
+}
+
+func TestSendReaction_RejectsEmptyArgs(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newReactionAPIServer(t, []string{`{"error":0,"data":{}}`})
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ cases := []struct {
+ name string
+ userID, mid, ico string
+ }{
+ {"empty userID", "", "msg", "/-heart"},
+ {"empty messageID", "user", "", "/-heart"},
+ {"empty icon", "user", "msg", ""},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := c.SendReaction(context.Background(), tc.userID, tc.mid, tc.ico)
+ if err == nil {
+ t.Errorf("expected error for %s", tc.name)
+ }
+ })
+ }
+ if len(*captured) != 0 {
+ t.Errorf("captured %d, want 0 (empty args must short-circuit)", len(*captured))
+ }
+}
+
+// TestClearReactionAPI uses the real /-remove sentinel icon to retract a
+// previously dropped reaction. Verifies the wire-level shape.
+func TestSendReaction_RemoveSentinel(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newReactionAPIServer(t,
+ []string{`{"data":{"message_id":"rem-1","user_id":"u"},"error":0,"message":"Success"}`})
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ if _, err := c.SendReaction(context.Background(), "u", "src", reactionIconRemove); err != nil {
+ t.Fatalf("SendReaction(remove): %v", err)
+ }
+ var body map[string]any
+ _ = json.Unmarshal((*captured)[0].body, &body)
+ sa, _ := body["sender_action"].(map[string]any)
+ if sa["react_icon"] != "/-remove" {
+ t.Errorf("react_icon = %v, want /-remove", sa["react_icon"])
+ }
+}
diff --git a/internal/channels/zalo/oa/send_test.go b/internal/channels/zalo/oa/send_test.go
new file mode 100644
index 0000000000..30fa577dca
--- /dev/null
+++ b/internal/channels/zalo/oa/send_test.go
@@ -0,0 +1,697 @@
+package oa
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+)
+
+// newAPIServer returns an httptest server that captures every request in
+// requests[] and replies with the body for that index. The server uses the
+// path as a discriminator: /v3.0/oa/message/cs returns the next item from
+// `messageReplies`; /v2.0/oa/upload/image and /upload/file return uploadReply.
+type apiServerOpts struct {
+ messageReplies []string // consumed FIFO per /message/cs call
+ uploadReply string // returned for any /upload/* call
+}
+
+type capturedRequest struct {
+ path string
+ query string
+ contentType string
+ accessToken string // from the `access_token` header (Zalo's auth convention)
+ body []byte
+ multipart *capturedMultipart
+}
+
+type capturedMultipart struct {
+ fileFieldName string
+ fileName string
+ fileBytes []byte
+ fields map[string]string
+}
+
+func newAPIServer(t *testing.T, opts apiServerOpts) (*httptest.Server, *[]capturedRequest, *int32) {
+ t.Helper()
+ var captured []capturedRequest
+ var msgIdx int32
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ req := capturedRequest{
+ path: r.URL.Path,
+ query: r.URL.RawQuery,
+ contentType: r.Header.Get("Content-Type"),
+ accessToken: r.Header.Get("access_token"),
+ }
+
+ if strings.HasPrefix(req.contentType, "multipart/") {
+ if err := r.ParseMultipartForm(10 << 20); err != nil {
+ t.Errorf("ParseMultipartForm: %v", err)
+ }
+ cm := &capturedMultipart{fields: map[string]string{}}
+ for k, v := range r.MultipartForm.Value {
+ if len(v) > 0 {
+ cm.fields[k] = v[0]
+ }
+ }
+ for fieldName, fhs := range r.MultipartForm.File {
+ if len(fhs) == 0 {
+ continue
+ }
+ fh := fhs[0]
+ cm.fileFieldName = fieldName
+ cm.fileName = fh.Filename
+ f, _ := fh.Open()
+ cm.fileBytes, _ = io.ReadAll(f)
+ _ = f.Close()
+ }
+ req.multipart = cm
+ } else {
+ req.body, _ = io.ReadAll(r.Body)
+ }
+ captured = append(captured, req)
+
+ // Route response.
+ if strings.HasPrefix(r.URL.Path, "/v2.0/oa/upload/") {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(opts.uploadReply))
+ return
+ }
+ if r.URL.Path == "/v3.0/oa/message/cs" {
+ i := atomic.AddInt32(&msgIdx, 1) - 1
+ if int(i) >= len(opts.messageReplies) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"error":-1,"message":"no canned reply"}`))
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(opts.messageReplies[i]))
+ return
+ }
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ t.Cleanup(srv.Close)
+ return srv, &captured, &msgIdx
+}
+
+// newSendChannel wires a Channel against the test server. Refresh server
+// rotates tokens — test code that needs to assert token use can read
+// captured query strings.
+func newSendChannel(t *testing.T, apiSrv, refreshSrv *httptest.Server, fs *fakeStore) *Channel {
+ t.Helper()
+ creds := &ChannelCreds{
+ AppID: "app",
+ SecretKey: "key",
+ AccessToken: "AT-current",
+ RefreshToken: "RT-current",
+ ExpiresAt: time.Now().Add(time.Hour),
+ }
+ cfg := config.ZaloOAConfig{}
+ msgBus := bus.New()
+ c, err := New("send_test", cfg, creds, fs, msgBus, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ c.SetInstanceID(uuid.New())
+ c.client.apiBase = apiSrv.URL
+ c.client.oauthBase = refreshSrv.URL
+ return c
+}
+
+func TestSendText_HappyPath(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ messageReplies: []string{`{"error":0,"data":{"message_id":"mid-1"}}`},
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ mid, err := c.SendText(context.Background(), "user-1", "hello", "")
+ if err != nil {
+ t.Fatalf("SendText: %v", err)
+ }
+ if mid != "mid-1" {
+ t.Errorf("message_id = %q, want mid-1", mid)
+ }
+ if len(*captured) != 1 {
+ t.Fatalf("captured %d requests, want 1", len(*captured))
+ }
+ r := (*captured)[0]
+ if r.path != "/v3.0/oa/message/cs" {
+ t.Errorf("path = %q", r.path)
+ }
+ if r.accessToken != "AT-current" {
+ t.Errorf("access_token header = %q, want AT-current", r.accessToken)
+ }
+ if !strings.HasPrefix(r.contentType, "application/json") {
+ t.Errorf("content-type = %q", r.contentType)
+ }
+ var body map[string]any
+ if err := json.Unmarshal(r.body, &body); err != nil {
+ t.Fatalf("body unmarshal: %v", err)
+ }
+ rec, _ := body["recipient"].(map[string]any)
+ msg, _ := body["message"].(map[string]any)
+ if rec["user_id"] != "user-1" {
+ t.Errorf("recipient.user_id = %v", rec["user_id"])
+ }
+ if msg["text"] != "hello" {
+ t.Errorf("message.text = %v", msg["text"])
+ }
+}
+
+func TestSendText_ChunksLongMessages(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ messageReplies: []string{
+ `{"error":0,"data":{"message_id":"mid-1"}}`,
+ `{"error":0,"data":{"message_id":"mid-2"}}`,
+ `{"error":0,"data":{"message_id":"mid-3"}}`,
+ `{"error":0,"data":{"message_id":"mid-4"}}`,
+ `{"error":0,"data":{"message_id":"mid-5"}}`,
+ },
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ // Build a body well over the 2000-rune cap with paragraph breaks every
+ // ~500 runes so the chunker has natural cut points.
+ var bldr strings.Builder
+ for range 10 {
+ bldr.WriteString(strings.Repeat("a", 499))
+ bldr.WriteString("\n\n")
+ }
+ long := bldr.String()
+ mid, err := c.SendText(context.Background(), "user-1", long, "")
+ if err != nil {
+ t.Fatalf("SendText: %v", err)
+ }
+ if len(*captured) < 2 {
+ t.Fatalf("captured %d requests, want ≥2 chunks", len(*captured))
+ }
+ wantLastMID := fmt.Sprintf("mid-%d", len(*captured))
+ if mid != wantLastMID {
+ t.Errorf("message_id = %q, want last chunk %q", mid, wantLastMID)
+ }
+ for i, r := range *captured {
+ var body map[string]any
+ _ = json.Unmarshal(r.body, &body)
+ msg, _ := body["message"].(map[string]any)
+ text, _ := msg["text"].(string)
+ if n := len([]rune(text)); n > 2000 {
+ t.Errorf("chunk %d has %d runes, exceeds 2000-cap", i+1, n)
+ }
+ }
+}
+
+// TestSendText_AuthErrorRetriesOnce: first reply is auth error → ForceRefresh
+// fires → second reply is OK. Send returns mid from second reply. Refresh
+// server hit exactly once.
+func TestSendText_AuthErrorRetriesOnce(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ messageReplies: []string{
+ `{"error":-216,"message":"access_token invalid"}`,
+ `{"error":0,"data":{"message_id":"mid-after-refresh"}}`,
+ },
+ })
+ refresh, refreshCount := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ mid, err := c.SendText(context.Background(), "user-1", "hi", "")
+ if err != nil {
+ t.Fatalf("SendText: %v", err)
+ }
+ if mid != "mid-after-refresh" {
+ t.Errorf("mid = %q, want mid-after-refresh", mid)
+ }
+ if n := atomic.LoadInt32(refreshCount); n != 1 {
+ t.Errorf("refresh hits = %d, want 1", n)
+ }
+ if len(*captured) != 2 {
+ t.Fatalf("captured %d requests, want 2", len(*captured))
+ }
+ tok1 := (*captured)[0].accessToken
+ tok2 := (*captured)[1].accessToken
+ if tok1 == tok2 {
+ t.Errorf("retry used same token %q (refresh should have rotated it)", tok1)
+ }
+}
+
+// TestSendText_AuthErrorTwice_FailsCleanly: both attempts return auth error.
+// Send returns the APIError without an infinite loop. ForceRefresh fires once.
+func TestSendText_AuthErrorTwice_FailsCleanly(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ messageReplies: []string{
+ `{"error":-216,"message":"access_token invalid"}`,
+ `{"error":-216,"message":"access_token invalid"}`,
+ },
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ _, err := c.SendText(context.Background(), "user-1", "hi", "")
+ if err == nil {
+ t.Fatal("expected error after second auth failure")
+ }
+ var apiErr *APIError
+ if !errors.As(err, &apiErr) {
+ t.Errorf("err = %T %v, want *APIError", err, err)
+ }
+ if len(*captured) != 2 {
+ t.Errorf("captured %d requests, want 2 (no infinite loop)", len(*captured))
+ }
+}
+
+func TestSendText_NonAuthErrorNoRetry(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ messageReplies: []string{`{"error":-3,"message":"recipient not in 48h consultation window"}`},
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ _, err := c.SendText(context.Background(), "user-1", "hi", "")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if len(*captured) != 1 {
+ t.Errorf("captured %d requests, want 1 (non-auth must not retry)", len(*captured))
+ }
+}
+
+func TestSendImage_UploadsThenAttaches(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ uploadReply: `{"error":0,"data":{"attachment_id":"img-tok-abc"}}`,
+ messageReplies: []string{`{"error":0,"data":{"message_id":"mid-img"}}`},
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ imgBytes := []byte("\x89PNG\r\n\x1a\nfake-image")
+ mid, err := c.SendImage(context.Background(), "user-1", imgBytes, "image/png")
+ if err != nil {
+ t.Fatalf("SendImage: %v", err)
+ }
+ if mid != "mid-img" {
+ t.Errorf("mid = %q", mid)
+ }
+ if len(*captured) != 2 {
+ t.Fatalf("captured %d, want 2 (upload + send)", len(*captured))
+ }
+ upload := (*captured)[0]
+ if upload.path != "/v2.0/oa/upload/image" {
+ t.Errorf("upload path = %q", upload.path)
+ }
+ if upload.multipart == nil {
+ t.Fatalf("upload not multipart")
+ }
+ if upload.multipart.fileFieldName != "file" {
+ t.Errorf("upload form field = %q, want 'file'", upload.multipart.fileFieldName)
+ }
+ if string(upload.multipart.fileBytes) != string(imgBytes) {
+ t.Errorf("upload bytes mismatch")
+ }
+ send := (*captured)[1]
+ var body map[string]any
+ _ = json.Unmarshal(send.body, &body)
+ msg, _ := body["message"].(map[string]any)
+ att, _ := msg["attachment"].(map[string]any)
+ payload, _ := att["payload"].(map[string]any)
+ // Zalo's template/media shape: {"type":"template","payload":{
+ // "template_type":"media","elements":[{"media_type":"image","attachment_id":"..."}]}}
+ if att["type"] != "template" {
+ t.Errorf("attachment.type = %v, want template", att["type"])
+ }
+ if payload["template_type"] != "media" {
+ t.Errorf("payload.template_type = %v, want media", payload["template_type"])
+ }
+ elements, _ := payload["elements"].([]any)
+ if len(elements) != 1 {
+ t.Fatalf("elements = %v, want 1 entry", elements)
+ }
+ elem := elements[0].(map[string]any)
+ if elem["media_type"] != "image" {
+ t.Errorf("elements[0].media_type = %v, want image", elem["media_type"])
+ }
+ if elem["attachment_id"] != "img-tok-abc" {
+ t.Errorf("elements[0].attachment_id = %v", elem["attachment_id"])
+ }
+}
+
+func TestSendFile_UploadsThenAttaches(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ uploadReply: `{"error":0,"data":{"attachment_id":"file-tok-xyz"}}`,
+ messageReplies: []string{`{"error":0,"data":{"message_id":"mid-file"}}`},
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ mid, err := c.SendFile(context.Background(), "user-1", []byte("doc bytes"), "report.pdf")
+ if err != nil {
+ t.Fatalf("SendFile: %v", err)
+ }
+ if mid != "mid-file" {
+ t.Errorf("mid = %q", mid)
+ }
+ upload := (*captured)[0]
+ if upload.path != "/v2.0/oa/upload/file" {
+ t.Errorf("upload path = %q", upload.path)
+ }
+ if upload.multipart.fileName != "report.pdf" {
+ t.Errorf("filename = %q", upload.multipart.fileName)
+ }
+ send := (*captured)[1]
+ var body map[string]any
+ _ = json.Unmarshal(send.body, &body)
+ msg, _ := body["message"].(map[string]any)
+ att, _ := msg["attachment"].(map[string]any)
+ if att["type"] != "file" {
+ t.Errorf("attachment.type = %v", att["type"])
+ }
+}
+
+// Channel.Send dispatch by Media[].ContentType.
+func TestChannelSend_DispatchByContentType(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ media []bus.MediaAttachment
+ content string
+ wantUpload string // "" if no upload expected
+ wantMsgPath string
+ }{
+ {
+ name: "no media → text",
+ content: "hello",
+ wantMsgPath: "/v3.0/oa/message/cs",
+ },
+ {
+ name: "image/png → upload/image",
+ media: []bus.MediaAttachment{{ContentType: "image/png"}},
+ wantUpload: "/v2.0/oa/upload/image",
+ wantMsgPath: "/v3.0/oa/message/cs",
+ },
+ {
+ name: "image/jpeg → upload/image",
+ media: []bus.MediaAttachment{{ContentType: "image/jpeg"}},
+ wantUpload: "/v2.0/oa/upload/image",
+ wantMsgPath: "/v3.0/oa/message/cs",
+ },
+ {
+ name: "application/pdf → upload/file",
+ media: []bus.MediaAttachment{{ContentType: "application/pdf"}},
+ wantUpload: "/v2.0/oa/upload/file",
+ wantMsgPath: "/v3.0/oa/message/cs",
+ },
+ {
+ name: "empty content-type with .png URL → upload/image",
+ media: []bus.MediaAttachment{{ContentType: ""}}, // URL .png filled in by test
+ wantUpload: "/v2.0/oa/upload/image",
+ wantMsgPath: "/v3.0/oa/message/cs",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ uploadReply: `{"error":0,"data":{"attachment_id":"tok"}}`,
+ messageReplies: []string{`{"error":0,"data":{"message_id":"mid"}}`},
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ // Materialize the media URL on disk if needed.
+ media := tc.media
+ if len(media) > 0 {
+ dir := t.TempDir()
+ ext := ".bin"
+ if strings.HasPrefix(media[0].ContentType, "image/jpeg") {
+ ext = ".jpg"
+ } else if strings.HasPrefix(media[0].ContentType, "image/png") || media[0].ContentType == "" {
+ ext = ".png"
+ } else if media[0].ContentType == "application/pdf" {
+ ext = ".pdf"
+ }
+ p := filepath.Join(dir, "blob"+ext)
+ _ = os.WriteFile(p, []byte("x"), 0o600)
+ media[0].URL = p
+ }
+
+ err := c.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "user-1",
+ Content: tc.content,
+ Media: media,
+ })
+ if err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+
+ gotUpload := false
+ gotMsg := false
+ for _, r := range *captured {
+ if r.path == tc.wantUpload && tc.wantUpload != "" {
+ gotUpload = true
+ }
+ if r.path == tc.wantMsgPath {
+ gotMsg = true
+ }
+ }
+ if tc.wantUpload != "" && !gotUpload {
+ t.Errorf("expected upload to %s, captured=%v", tc.wantUpload, pathsOf(*captured))
+ }
+ if !gotMsg {
+ t.Errorf("expected msg to %s, captured=%v", tc.wantMsgPath, pathsOf(*captured))
+ }
+ })
+ }
+}
+
+func pathsOf(rs []capturedRequest) []string {
+ out := make([]string, len(rs))
+ for i, r := range rs {
+ out[i] = r.path
+ }
+ return out
+}
+
+func TestChannelSend_MediaTooLarge(t *testing.T) {
+ t.Parallel()
+ api, _, _ := newAPIServer(t, apiServerOpts{
+ uploadReply: `{"error":0,"data":{"attachment_id":"tok"}}`,
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ // PDF >5MB — routes to SendFile path and must be rejected for exceeding
+ // Zalo's /v2.0/oa/upload/file cap. (Image path auto-compresses, so the
+ // size-limit test shifted to the file path where compression isn't
+ // applicable.)
+ dir := t.TempDir()
+ p := filepath.Join(dir, "big.pdf")
+ if err := os.WriteFile(p, make([]byte, 6<<20), 0o600); err != nil { // 6MB > 5MB Zalo cap
+ t.Fatalf("write: %v", err)
+ }
+
+ err := c.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "u",
+ Media: []bus.MediaAttachment{{URL: p, ContentType: "application/pdf"}},
+ })
+ if err == nil {
+ t.Fatal("expected size-limit error")
+ }
+ if !strings.Contains(err.Error(), "too large") && !strings.Contains(err.Error(), "exceeds") && !strings.Contains(err.Error(), "5MB") {
+ t.Errorf("err message = %v, want 'too large'/'exceeds'/'5MB'", err)
+ }
+}
+
+func TestChannelSend_StripsMarkdown(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ messageReplies: []string{`{"error":0,"data":{"message_id":"mid-md"}}`},
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ err := c.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "u",
+ Content: "**Bold** and __very emphatic__\n\n---\n\n# Header\n- bullet\n`code`",
+ })
+ if err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+ if len(*captured) != 1 {
+ t.Fatalf("captured %d, want 1", len(*captured))
+ }
+ var body map[string]any
+ _ = json.Unmarshal((*captured)[0].body, &body)
+ msg, _ := body["message"].(map[string]any)
+ text, _ := msg["text"].(string)
+ for _, banned := range []string{"**", "__", "---", "# Header", "`code`"} {
+ if strings.Contains(text, banned) {
+ t.Errorf("markdown not stripped: %q still contains %q", text, banned)
+ }
+ }
+ for _, want := range []string{"Bold", "very emphatic", "Header", "bullet", "code"} {
+ if !strings.Contains(text, want) {
+ t.Errorf("content lost during strip: missing %q in %q", want, text)
+ }
+ }
+}
+
+func TestChannelSend_UnsupportedMIMEFallsBackToText(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ messageReplies: []string{`{"error":0,"data":{"message_id":"mid-fallback"}}`},
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ dir := t.TempDir()
+ p := filepath.Join(dir, "report.xlsx")
+ if err := os.WriteFile(p, []byte("PK\x03\x04xlsx"), 0o600); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ err := c.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "u",
+ Content: "Here is the summary.",
+ Media: []bus.MediaAttachment{{
+ URL: p,
+ ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ }},
+ })
+ if err != nil {
+ t.Fatalf("xlsx attachment should fall back to text, got err: %v", err)
+ }
+ if len(*captured) != 1 {
+ t.Fatalf("captured %d requests, want exactly 1 (trailing text only)", len(*captured))
+ }
+ var body map[string]any
+ _ = json.Unmarshal((*captured)[0].body, &body)
+ msg, _ := body["message"].(map[string]any)
+ text, _ := msg["text"].(string)
+ if !strings.Contains(text, "Here is the summary.") {
+ t.Errorf("trailing content dropped: %q", text)
+ }
+ if !strings.Contains(text, "report.xlsx") || !strings.Contains(text, "cannot be delivered") {
+ t.Errorf("fallback note missing filename/explanation: %q", text)
+ }
+}
+
+func TestChannelSend_EmptyChatID(t *testing.T) {
+ t.Parallel()
+ api, _, _ := newAPIServer(t, apiServerOpts{})
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ err := c.Send(context.Background(), bus.OutboundMessage{Content: "hello"})
+ if err == nil {
+ t.Fatal("expected error for empty ChatID")
+ }
+}
+
+// Compile-time guard: the response decoder must extract message_id from the
+// nested "data" envelope, not from the top level.
+func TestMessageResponse_ParseShape(t *testing.T) {
+ t.Parallel()
+ body := []byte(`{"error":0,"data":{"message_id":"M","recipient_id":"U"}}`)
+ mid, err := parseMessageResponse(body)
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if mid != "M" {
+ t.Errorf("mid = %q, want M", mid)
+ }
+}
+
+var _ = multipart.NewWriter // silence unused import in some test builds
+
+// TestChannelSend_CaptionAndContentMerged: when both Caption + Content are
+// set on a media message, both must ride in the trailing text msg.
+func TestChannelSend_CaptionAndContentMerged(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{
+ uploadReply: `{"error":0,"data":{"attachment_id":"T"}}`,
+ messageReplies: []string{`{"error":0,"data":{"message_id":"mid-img"}}`, `{"error":0,"data":{"message_id":"mid-txt"}}`},
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ dir := t.TempDir()
+ p := filepath.Join(dir, "x.png")
+ _ = os.WriteFile(p, []byte("x"), 0o600)
+
+ err := c.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "u",
+ Content: "the body",
+ Media: []bus.MediaAttachment{{URL: p, ContentType: "image/png", Caption: "the caption"}},
+ })
+ if err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+ // Find the text-message request (last /v3.0/oa/message/cs after upload + first message/cs).
+ var textBody string
+ for _, r := range *captured {
+ if r.path == "/v3.0/oa/message/cs" {
+ var b map[string]any
+ _ = json.Unmarshal(r.body, &b)
+ if msg, ok := b["message"].(map[string]any); ok {
+ if t, ok := msg["text"].(string); ok {
+ textBody = t // last one wins (the trailing text)
+ }
+ }
+ }
+ }
+ if !strings.Contains(textBody, "the caption") || !strings.Contains(textBody, "the body") {
+ t.Errorf("trailing text = %q, want both 'the caption' and 'the body'", textBody)
+ }
+}
+
+// TestChannelSend_PartialSendOnTrailingTextFailure: attachment succeeds,
+// trailing text fails → returns ErrPartialSend.
+func TestChannelSend_PartialSendOnTrailingTextFailure(t *testing.T) {
+ t.Parallel()
+ api, _, _ := newAPIServer(t, apiServerOpts{
+ uploadReply: `{"error":0,"data":{"attachment_id":"T"}}`,
+ messageReplies: []string{`{"error":0,"data":{"message_id":"mid-img"}}`, `{"error":-99,"message":"blocked"}`},
+ })
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ dir := t.TempDir()
+ p := filepath.Join(dir, "x.png")
+ _ = os.WriteFile(p, []byte("x"), 0o600)
+
+ err := c.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "u",
+ Content: "follow-up text",
+ Media: []bus.MediaAttachment{{URL: p, ContentType: "image/png"}},
+ })
+ if err == nil {
+ t.Fatal("expected ErrPartialSend")
+ }
+ if !errors.Is(err, ErrPartialSend) {
+ t.Errorf("err = %v, want ErrPartialSend", err)
+ }
+}
diff --git a/internal/channels/zalo/oa/testdata/send_file_request.json b/internal/channels/zalo/oa/testdata/send_file_request.json
new file mode 100644
index 0000000000..4d91f62df6
--- /dev/null
+++ b/internal/channels/zalo/oa/testdata/send_file_request.json
@@ -0,0 +1,9 @@
+{
+ "message": {
+ "attachment": {
+ "payload": {"attachment_id": "ATT-file-xyz"},
+ "type": "file"
+ }
+ },
+ "recipient": {"user_id": "user-fixture"}
+}
diff --git a/internal/channels/zalo/oa/testdata/send_gif_request.json b/internal/channels/zalo/oa/testdata/send_gif_request.json
new file mode 100644
index 0000000000..4885614290
--- /dev/null
+++ b/internal/channels/zalo/oa/testdata/send_gif_request.json
@@ -0,0 +1,12 @@
+{
+ "message": {
+ "attachment": {
+ "payload": {
+ "elements": [{"attachment_id": "ATT-gif-xyz", "media_type": "gif"}],
+ "template_type": "media"
+ },
+ "type": "template"
+ }
+ },
+ "recipient": {"user_id": "user-fixture"}
+}
diff --git a/internal/channels/zalo/oa/testdata/send_image_request.json b/internal/channels/zalo/oa/testdata/send_image_request.json
new file mode 100644
index 0000000000..a7d56956ec
--- /dev/null
+++ b/internal/channels/zalo/oa/testdata/send_image_request.json
@@ -0,0 +1,12 @@
+{
+ "message": {
+ "attachment": {
+ "payload": {
+ "elements": [{"attachment_id": "ATT-image-xyz", "media_type": "image"}],
+ "template_type": "media"
+ },
+ "type": "template"
+ }
+ },
+ "recipient": {"user_id": "user-fixture"}
+}
diff --git a/internal/channels/zalo/oa/testdata/send_message_200.json b/internal/channels/zalo/oa/testdata/send_message_200.json
new file mode 100644
index 0000000000..6fc56f107a
--- /dev/null
+++ b/internal/channels/zalo/oa/testdata/send_message_200.json
@@ -0,0 +1 @@
+{"error":0,"data":{"message_id":"msg-fixture-1","recipient_id":"user-fixture"}}
diff --git a/internal/channels/zalo/oa/testdata/send_text_quote_request.json b/internal/channels/zalo/oa/testdata/send_text_quote_request.json
new file mode 100644
index 0000000000..31e2dfed8b
--- /dev/null
+++ b/internal/channels/zalo/oa/testdata/send_text_quote_request.json
@@ -0,0 +1,9 @@
+{
+ "message": {
+ "quote_message_id": "48687128d04c9410cd5f",
+ "text": "Chào bạn"
+ },
+ "recipient": {
+ "user_id": "186729651760683225"
+ }
+}
diff --git a/internal/channels/zalo/oa/testdata/send_text_request.json b/internal/channels/zalo/oa/testdata/send_text_request.json
new file mode 100644
index 0000000000..e4881f9cd4
--- /dev/null
+++ b/internal/channels/zalo/oa/testdata/send_text_request.json
@@ -0,0 +1,4 @@
+{
+ "message": {"text": "hello fixture"},
+ "recipient": {"user_id": "user-fixture"}
+}
diff --git a/internal/channels/zalo/oa/testdata/upload_file_200.json b/internal/channels/zalo/oa/testdata/upload_file_200.json
new file mode 100644
index 0000000000..e19071ea82
--- /dev/null
+++ b/internal/channels/zalo/oa/testdata/upload_file_200.json
@@ -0,0 +1 @@
+{"data":{"attachment_id":"ATT-file-xyz"},"error":0,"message":"Success"}
diff --git a/internal/channels/zalo/oa/testdata/upload_gif_200.json b/internal/channels/zalo/oa/testdata/upload_gif_200.json
new file mode 100644
index 0000000000..ccc1977384
--- /dev/null
+++ b/internal/channels/zalo/oa/testdata/upload_gif_200.json
@@ -0,0 +1 @@
+{"data":{"attachment_id":"ATT-gif-xyz"},"error":0,"message":"Success"}
diff --git a/internal/channels/zalo/oa/testdata/upload_image_200.json b/internal/channels/zalo/oa/testdata/upload_image_200.json
new file mode 100644
index 0000000000..14c2034a1a
--- /dev/null
+++ b/internal/channels/zalo/oa/testdata/upload_image_200.json
@@ -0,0 +1 @@
+{"data":{"attachment_id":"ATT-image-xyz"},"error":0,"message":"Success"}
diff --git a/internal/channels/zalo/oa/token_source.go b/internal/channels/zalo/oa/token_source.go
new file mode 100644
index 0000000000..88736999d5
--- /dev/null
+++ b/internal/channels/zalo/oa/token_source.go
@@ -0,0 +1,130 @@
+package oa
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "sync/atomic"
+ "time"
+
+ "github.com/google/uuid"
+ "golang.org/x/sync/singleflight"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// refreshMargin: refresh when the access token expires within this window.
+const refreshMargin = 5 * time.Minute
+
+// refreshHTTPTimeout caps the refresh HTTP roundtrip independent of the
+// caller ctx so a misconfigured caller can't park the singleflighted
+// refresh indefinitely. Shorter than the 15s defaultClientTimeout.
+const refreshHTTPTimeout = 12 * time.Second
+
+// tokenSource lazily refreshes the access token. singleflight serializes
+// concurrent refresh attempts (Zalo refresh tokens are single-use) without
+// holding a lock across the HTTP call, so concurrent readers see the new
+// token as soon as it's stored. Reads of creds go through the atomic
+// pointer; callers must treat the returned struct as read-only.
+type tokenSource struct {
+ client *Client
+ creds atomic.Pointer[ChannelCreds]
+ store store.ChannelInstanceStore
+ instanceID uuid.UUID
+
+ refreshSF singleflight.Group
+}
+
+// Snapshot returns a read-only pointer to the current creds.
+func (ts *tokenSource) Snapshot() *ChannelCreds {
+ if p := ts.creds.Load(); p != nil {
+ return p
+ }
+ return &ChannelCreds{}
+}
+
+// ForceRefresh marks the cached token stale so the next Access() refreshes.
+func (ts *tokenSource) ForceRefresh() {
+ for {
+ cur := ts.creds.Load()
+ if cur == nil {
+ return
+ }
+ next := *cur
+ next.ExpiresAt = time.Time{}
+ next.AccessToken = ""
+ if ts.creds.CompareAndSwap(cur, &next) {
+ return
+ }
+ }
+}
+
+// Access returns a valid access token, refreshing if within refreshMargin.
+// Uses singleflight so concurrent callers share one HTTP refresh.
+func (ts *tokenSource) Access(ctx context.Context) (string, error) {
+ if cur := ts.Snapshot(); cur.AccessToken != "" && time.Until(cur.ExpiresAt) > refreshMargin {
+ return cur.AccessToken, nil
+ }
+
+ _, err, _ := ts.refreshSF.Do("refresh", func() (any, error) {
+ // Re-check inside singleflight: a sibling caller may have just
+ // finished a refresh while we waited.
+ if cur := ts.Snapshot(); cur.AccessToken != "" && time.Until(cur.ExpiresAt) > refreshMargin {
+ return nil, nil
+ }
+ return nil, ts.doRefresh(ctx)
+ })
+ if err != nil {
+ return "", err
+ }
+ return ts.Snapshot().AccessToken, nil
+}
+
+// doRefresh performs the HTTP refresh + persistence. Called under
+// singleflight so at most one refresh is in flight per tokenSource.
+// Persist-before-commit: if Persist fails after a successful refresh we
+// keep the new tokens in memory (the old refresh token is already burned)
+// but DB has stale tokens — next process restart will fail to invalid_grant
+// and surface re-auth, which is the safe failure mode.
+func (ts *tokenSource) doRefresh(ctx context.Context) error {
+ cur := ts.Snapshot()
+ if cur.RefreshToken == "" {
+ // Pre-authorization: distinct from a burned refresh token; do NOT
+ // escalate to Failed. Log so ops can distinguish "never consented"
+ // (OAID empty) from "consent dropped mid-flow" (OAID set).
+ slog.Info("zalo_oa.pre_authorization",
+ "instance_id", ts.instanceID,
+ "has_oa_id", cur.OAID != "")
+ return ErrNotAuthorized
+ }
+
+ refreshCtx, cancel := context.WithTimeout(ctx, refreshHTTPTimeout)
+ defer cancel()
+ tok, rawErr := ts.client.RefreshToken(refreshCtx, cur.AppID, cur.SecretKey, cur.RefreshToken)
+ if rawErr != nil {
+ err := classifyRefreshError(rawErr)
+ if errors.Is(err, ErrAuthExpired) {
+ slog.Warn("zalo_oa.reauth_required", "instance_id", ts.instanceID, "oa_id", cur.OAID)
+ return err
+ }
+ slog.Warn("zalo_oa.refresh_failed", "instance_id", ts.instanceID, "oa_id", cur.OAID, "error", err)
+ return err
+ }
+
+ snapshot := *cur
+ snapshot.WithTokens(tok)
+ if err := Persist(ctx, ts.store, ts.instanceID, &snapshot); err != nil {
+ slog.Error("zalo_oa.persist_failed", "instance_id", ts.instanceID, "oa_id", cur.OAID, "error", err)
+ // Commit in memory: the new pair is the only valid one until restart.
+ ts.creds.Store(&snapshot)
+ return err
+ }
+ ts.creds.Store(&snapshot)
+ slog.Info("zalo_oa.token_refreshed",
+ "instance_id", ts.instanceID,
+ "oa_id", snapshot.OAID,
+ "new_expires_at", snapshot.ExpiresAt,
+ "refresh_expires_at", snapshot.RefreshTokenExpiresAt,
+ )
+ return nil
+}
diff --git a/internal/channels/zalo/oa/token_source_test.go b/internal/channels/zalo/oa/token_source_test.go
new file mode 100644
index 0000000000..28ff82d35d
--- /dev/null
+++ b/internal/channels/zalo/oa/token_source_test.go
@@ -0,0 +1,359 @@
+package oa
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "maps"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// fakeStore is a minimal in-memory ChannelInstanceStore for token-refresh tests.
+// We only exercise Update — other methods are intentionally unimplemented.
+// updateN uses atomic.Int32 so concurrent test goroutines can read it
+// without the lock.
+type fakeStore struct {
+ mu sync.Mutex
+ updateN atomic.Int32
+ mergeN atomic.Int32
+ lastBlob []byte
+ lastConfig map[string]any // tracks merged config across MergeConfig calls
+ updateErr error
+}
+
+func (f *fakeStore) UpdateCount() int { return int(f.updateN.Load()) }
+func (f *fakeStore) MergeCount() int { return int(f.mergeN.Load()) }
+
+// ConfigBlob returns the merged config as JSON bytes, mirroring what would
+// be persisted via SQL JSONB merge.
+func (f *fakeStore) ConfigBlob() []byte {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.lastConfig == nil {
+ return nil
+ }
+ b, _ := json.Marshal(f.lastConfig)
+ return b
+}
+
+// MergeConfig mirrors PG's SQL-level shallow merge: keys in `partial`
+// overwrite, keys-only-in-existing are preserved.
+func (f *fakeStore) MergeConfig(_ context.Context, _ uuid.UUID, partial map[string]any) error {
+ f.mergeN.Add(1)
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.updateErr != nil {
+ return f.updateErr
+ }
+ if f.lastConfig == nil {
+ f.lastConfig = make(map[string]any)
+ }
+ maps.Copy(f.lastConfig, partial)
+ return nil
+}
+
+func (f *fakeStore) Update(_ context.Context, _ uuid.UUID, updates map[string]any) error {
+ f.updateN.Add(1)
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.updateErr != nil {
+ return f.updateErr
+ }
+ if v, ok := updates["credentials"]; ok {
+ if b, ok := v.([]byte); ok {
+ f.lastBlob = b
+ }
+ }
+ if v, ok := updates["config"]; ok {
+ if b, ok := v.([]byte); ok {
+ f.lastBlob = b
+ }
+ }
+ return nil
+}
+
+// Unused store-interface methods. Kept tight.
+func (f *fakeStore) Create(context.Context, *store.ChannelInstanceData) error { return nil }
+func (f *fakeStore) Get(context.Context, uuid.UUID) (*store.ChannelInstanceData, error) {
+ return nil, errors.New("unused")
+}
+func (f *fakeStore) GetByName(context.Context, string) (*store.ChannelInstanceData, error) {
+ return nil, errors.New("unused")
+}
+func (f *fakeStore) Delete(context.Context, uuid.UUID) error { return nil }
+func (f *fakeStore) ListEnabled(context.Context) ([]store.ChannelInstanceData, error) {
+ return nil, nil
+}
+func (f *fakeStore) ListAll(context.Context) ([]store.ChannelInstanceData, error) { return nil, nil }
+func (f *fakeStore) ListAllInstances(context.Context) ([]store.ChannelInstanceData, error) {
+ return nil, nil
+}
+func (f *fakeStore) ListAllEnabled(context.Context) ([]store.ChannelInstanceData, error) {
+ return nil, nil
+}
+func (f *fakeStore) ListPaged(context.Context, store.ChannelInstanceListOpts) ([]store.ChannelInstanceData, error) {
+ return nil, nil
+}
+func (f *fakeStore) CountInstances(context.Context, store.ChannelInstanceListOpts) (int, error) {
+ return 0, nil
+}
+
+// newRefreshServer counts incoming refresh-token requests and replies with
+// fresh tokens. Optional `errBody` overrides the response with a Zalo
+// error envelope (HTTP 200 + non-zero error code).
+func newRefreshServer(t *testing.T, errBody string) (*httptest.Server, *int32) {
+ t.Helper()
+ var n int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ atomic.AddInt32(&n, 1)
+ if errBody != "" {
+ _, _ = w.Write([]byte(errBody))
+ return
+ }
+ // Each call returns a NEW (rotated) refresh token.
+ seq := atomic.LoadInt32(&n)
+ body := []byte(`{"access_token":"AT-` + itoa(seq) + `","refresh_token":"RT-` + itoa(seq) + `","expires_in":3600}`)
+ _, _ = w.Write(body)
+ }))
+ t.Cleanup(srv.Close)
+ return srv, &n
+}
+
+func itoa(n int32) string {
+ if n == 0 {
+ return "0"
+ }
+ digits := []byte{}
+ for n > 0 {
+ digits = append([]byte{'0' + byte(n%10)}, digits...)
+ n /= 10
+ }
+ return string(digits)
+}
+
+// newTokenSourceForTest wires a tokenSource against a httptest server.
+func newTokenSourceForTest(t *testing.T, srvURL string, expiresAt time.Time, fs *fakeStore) *tokenSource {
+ t.Helper()
+ creds := &ChannelCreds{
+ AppID: "app",
+ SecretKey: "key",
+ AccessToken: "AT-old",
+ RefreshToken: "RT-old",
+ ExpiresAt: expiresAt,
+ }
+ client := NewClient(5 * time.Second)
+ client.oauthBase = srvURL
+ ts := &tokenSource{
+ client: client,
+ store: fs,
+ instanceID: uuid.New(),
+ }
+ ts.creds.Store(creds)
+ return ts
+}
+
+func TestAccess_FreshTokenSkipsRefresh(t *testing.T) {
+ t.Parallel()
+ srv, count := newRefreshServer(t, "")
+ fs := &fakeStore{}
+
+ ts := newTokenSourceForTest(t, srv.URL, time.Now().Add(time.Hour), fs) // 1h until expiry
+ got, err := ts.Access(context.Background())
+ if err != nil {
+ t.Fatalf("Access: %v", err)
+ }
+ if got != "AT-old" {
+ t.Errorf("Access = %q, want %q", got, "AT-old")
+ }
+ if n := atomic.LoadInt32(count); n != 0 {
+ t.Errorf("refresh hits = %d, want 0 (token still fresh)", n)
+ }
+ if fs.UpdateCount() != 0 {
+ t.Errorf("store.Update calls = %d, want 0", fs.UpdateCount())
+ }
+}
+
+func TestAccess_StaleTokenTriggersExactlyOneRefresh(t *testing.T) {
+ t.Parallel()
+ srv, count := newRefreshServer(t, "")
+ fs := &fakeStore{}
+
+ // Token expires in 1min — within refreshMargin (5min) → must refresh.
+ ts := newTokenSourceForTest(t, srv.URL, time.Now().Add(time.Minute), fs)
+ got, err := ts.Access(context.Background())
+ if err != nil {
+ t.Fatalf("Access: %v", err)
+ }
+ if got != "AT-1" {
+ t.Errorf("Access = %q, want refreshed AT-1", got)
+ }
+ if n := atomic.LoadInt32(count); n != 1 {
+ t.Errorf("refresh hits = %d, want 1", n)
+ }
+ if fs.UpdateCount() != 1 {
+ t.Errorf("store.Update calls = %d, want 1", fs.UpdateCount())
+ }
+}
+
+// Refresh propagates refresh_token_expires_in into ChannelCreds so the
+// safety ticker can light a re-consent warning ahead of expiry.
+func TestAccess_PropagatesRefreshTokenExpiry(t *testing.T) {
+ t.Parallel()
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ // 90 days = 7776000s, matches Zalo's documented refresh_token lifespan.
+ _, _ = w.Write([]byte(`{"access_token":"AT-1","refresh_token":"RT-1","expires_in":3600,"refresh_token_expires_in":"7776000"}`))
+ }))
+ t.Cleanup(srv.Close)
+
+ fs := &fakeStore{}
+ ts := newTokenSourceForTest(t, srv.URL, time.Now().Add(time.Minute), fs)
+
+ before := time.Now()
+ if _, err := ts.Access(context.Background()); err != nil {
+ t.Fatalf("Access: %v", err)
+ }
+ got := ts.Snapshot().RefreshTokenExpiresAt
+ if got.IsZero() {
+ t.Fatal("RefreshTokenExpiresAt is zero, expected ~90d ahead")
+ }
+ want := before.Add(7776000 * time.Second)
+ delta := got.Sub(want)
+ if delta < -2*time.Second || delta > 2*time.Second {
+ t.Errorf("RefreshTokenExpiresAt = %v, want ≈ %v (delta %v)", got, want, delta)
+ }
+}
+
+// Single-flight: 10 concurrent Access() calls on a stale token must result
+// in exactly ONE upstream refresh call. Mirrors DBTokenSource.Token() single-mutex pattern.
+func TestAccess_SingleFlightUnderConcurrency(t *testing.T) {
+ t.Parallel()
+ srv, count := newRefreshServer(t, "")
+ fs := &fakeStore{}
+ ts := newTokenSourceForTest(t, srv.URL, time.Now().Add(time.Minute), fs)
+
+ const N = 10
+ var wg sync.WaitGroup
+ results := make([]string, N)
+ errs := make([]error, N)
+ start := make(chan struct{})
+
+ for i := range N {
+ wg.Add(1)
+ go func(idx int) {
+ defer wg.Done()
+ <-start
+ results[idx], errs[idx] = ts.Access(context.Background())
+ }(i)
+ }
+ close(start)
+ wg.Wait()
+
+ for i, e := range errs {
+ if e != nil {
+ t.Errorf("goroutine %d: Access err = %v", i, e)
+ }
+ }
+ if n := atomic.LoadInt32(count); n != 1 {
+ t.Errorf("refresh hits = %d, want 1 (single-flight broken)", n)
+ }
+ if fs.UpdateCount() != 1 {
+ t.Errorf("store.Update calls = %d, want 1", fs.UpdateCount())
+ }
+ // All goroutines see the same refreshed token.
+ for i, r := range results {
+ if r != "AT-1" {
+ t.Errorf("goroutine %d got %q, want AT-1", i, r)
+ }
+ }
+}
+
+func TestAccess_AuthExpiredMarksFailedAndReturnsErr(t *testing.T) {
+ t.Parallel()
+ // Zalo HTTP 200 + non-zero error code with "invalid" message → ErrAuthExpired.
+ srv, _ := newRefreshServer(t, `{"error":-118,"message":"invalid_grant: refresh token expired","data":null}`)
+ fs := &fakeStore{}
+ ts := newTokenSourceForTest(t, srv.URL, time.Now().Add(time.Minute), fs)
+
+ _, err := ts.Access(context.Background())
+ if err == nil {
+ t.Fatal("expected error on auth-expired refresh")
+ }
+ if !errors.Is(err, ErrAuthExpired) {
+ t.Fatalf("expected ErrAuthExpired, got %T: %v", err, err)
+ }
+ // On auth-expired, do NOT persist (the old refresh token is dead anyway).
+ if fs.UpdateCount() != 0 {
+ t.Errorf("store.Update calls = %d on auth-expired refresh, want 0", fs.UpdateCount())
+ }
+}
+
+// ForceRefresh: zero out ExpiresAt under mu so next Access triggers refresh
+// even when the cached token would otherwise still be considered fresh.
+// Used by Send's retry-once-on-auth path (phase 03).
+func TestForceRefresh_ClearsCache(t *testing.T) {
+ t.Parallel()
+ srv, count := newRefreshServer(t, "")
+ fs := &fakeStore{}
+
+ // Plenty of time left — without ForceRefresh, Access would skip refresh.
+ ts := newTokenSourceForTest(t, srv.URL, time.Now().Add(time.Hour), fs)
+
+ // Pre-flight: confirm fresh token doesn't refresh.
+ if _, err := ts.Access(context.Background()); err != nil {
+ t.Fatalf("Access(fresh): %v", err)
+ }
+ if n := atomic.LoadInt32(count); n != 0 {
+ t.Errorf("expected 0 refresh calls before ForceRefresh, got %d", n)
+ }
+
+ // Force, then Access — must hit upstream.
+ ts.ForceRefresh()
+ if _, err := ts.Access(context.Background()); err != nil {
+ t.Fatalf("Access(post-force): %v", err)
+ }
+ if n := atomic.LoadInt32(count); n != 1 {
+ t.Errorf("ForceRefresh did not trigger refresh: count = %d, want 1", n)
+ }
+}
+
+func TestClassifyRefreshError(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ in error
+ wantAuth bool
+ }{
+ {"invalid_grant envelope", &APIError{Code: -118, Message: "invalid_grant"}, true},
+ {"transient 5xx", errors.New("http 503"), false},
+ {"transient timeout", errors.New("http: read timeout"), false},
+ {"nil", nil, false},
+ // Below: must NOT escalate. Only the language-independent -118 code
+ // signals refresh-token death. Localized server messages containing
+ // "expired" or "invalid" must stay transient — substring matching
+ // would falsely force re-consent on FamilyServer 10000 in Vietnamese.
+ {"server with localized expired", &APIError{Code: 10000, Message: "Hết hạn (expired)"}, false},
+ {"invalid app_id (config bug)", &APIError{Code: -1, Message: "invalid app_id"}, false},
+ {"invalid parameter", &APIError{Code: -2, Message: "invalid parameter"}, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := classifyRefreshError(tc.in)
+ if tc.wantAuth && !errors.Is(got, ErrAuthExpired) {
+ t.Errorf("input %v → %v, want ErrAuthExpired", tc.in, got)
+ }
+ if !tc.wantAuth && errors.Is(got, ErrAuthExpired) {
+ t.Errorf("input %v → ErrAuthExpired, want transient", tc.in)
+ }
+ })
+ }
+}
diff --git a/internal/channels/zalo/oa/upload.go b/internal/channels/zalo/oa/upload.go
new file mode 100644
index 0000000000..c630d048e8
--- /dev/null
+++ b/internal/channels/zalo/oa/upload.go
@@ -0,0 +1,112 @@
+package oa
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+var legacyTokenWarnOnce sync.Once
+
+const maxFilenameLen = 200
+
+// uploadImage uploads bytes and returns the attachment_id. Filename must
+// carry an extension — Zalo validates payload type by extension and
+// silently returns empty-data otherwise.
+func (c *Channel) uploadImage(ctx context.Context, data []byte, mime string) (string, error) {
+ tok, err := c.tokens.Access(ctx)
+ if err != nil {
+ return "", err
+ }
+ filename := "image.jpg"
+ if mime == "image/png" {
+ filename = "image.png"
+ }
+ raw, err := c.client.apiPostMultipart(ctx, pathUploadImage, "file", filename, data, nil, tok)
+ if err != nil {
+ return "", err
+ }
+ return parseUploadAttachmentID(raw)
+}
+
+// uploadGIF uploads to /upload/gif (5MB cap).
+func (c *Channel) uploadGIF(ctx context.Context, data []byte) (string, error) {
+ tok, err := c.tokens.Access(ctx)
+ if err != nil {
+ return "", err
+ }
+ raw, err := c.client.apiPostMultipart(ctx, pathUploadGIF, "file", "image.gif", data, nil, tok)
+ if err != nil {
+ return "", err
+ }
+ return parseUploadAttachmentID(raw)
+}
+
+// uploadFile uploads a file. filename is sanitized (path traversal,
+// dot-only, oversized inputs get a safe fallback).
+func (c *Channel) uploadFile(ctx context.Context, data []byte, filename string) (string, error) {
+ tok, err := c.tokens.Access(ctx)
+ if err != nil {
+ return "", err
+ }
+ safe := sanitizeFilename(filename)
+ raw, err := c.client.apiPostMultipart(ctx, pathUploadFile, "file", safe,
+ data, map[string]string{"filename": safe}, tok)
+ if err != nil {
+ return "", err
+ }
+ return parseUploadAttachmentID(raw)
+}
+
+// sanitizeFilename strips path components, falls back for dot-only/empty
+// inputs, and caps length at maxFilenameLen.
+func sanitizeFilename(raw string) string {
+ name := filepath.Base(strings.TrimSpace(raw))
+ switch name {
+ case "", ".", "..", string(filepath.Separator):
+ // crypto/rand suffix avoids collisions on coarse-clock platforms
+ // where UnixNano() can repeat across tight bursts.
+ var b [4]byte
+ _, _ = rand.Read(b[:])
+ return fmt.Sprintf("file-%s.bin", hex.EncodeToString(b[:]))
+ }
+ if len(name) > maxFilenameLen {
+ name = name[:maxFilenameLen]
+ }
+ return name
+}
+
+// parseUploadAttachmentID reads data.attachment_id from the upload
+// response. Falls back to data.token (legacy alias) and warns once if seen.
+func parseUploadAttachmentID(raw json.RawMessage) (string, error) {
+ var env struct {
+ Data struct {
+ AttachmentID string `json:"attachment_id"`
+ Token string `json:"token"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(raw, &env); err != nil {
+ return "", fmt.Errorf("zalo_oa: decode upload response: %w", err)
+ }
+ id := env.Data.AttachmentID
+ if id == "" && env.Data.Token != "" {
+ legacyTokenWarnOnce.Do(func() {
+ slog.Warn("zalo_oa.upload.legacy_token_field_seen")
+ })
+ id = env.Data.Token
+ }
+ if id == "" {
+ preview := string(raw)
+ if len(preview) > 500 {
+ preview = preview[:500] + "…(truncated)"
+ }
+ return "", fmt.Errorf("zalo_oa: upload response missing data.attachment_id (raw=%s)", preview)
+ }
+ return id, nil
+}
diff --git a/internal/channels/zalo/oa/upload_hardening_test.go b/internal/channels/zalo/oa/upload_hardening_test.go
new file mode 100644
index 0000000000..5c26da90c2
--- /dev/null
+++ b/internal/channels/zalo/oa/upload_hardening_test.go
@@ -0,0 +1,82 @@
+package oa
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+func TestSanitizeFilename(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ in string
+ want func(string) bool // matcher
+ }{
+ {"plain", "report.pdf", func(s string) bool { return s == "report.pdf" }},
+ {"strip path", "/etc/passwd", func(s string) bool { return s == "passwd" }},
+ {"trim spaces", " doc.txt ", func(s string) bool { return s == "doc.txt" }},
+ {"dot only", ".", func(s string) bool { return strings.HasPrefix(s, "file-") && strings.HasSuffix(s, ".bin") }},
+ {"double dot", "..", func(s string) bool { return strings.HasPrefix(s, "file-") && strings.HasSuffix(s, ".bin") }},
+ {"empty", "", func(s string) bool { return strings.HasPrefix(s, "file-") && strings.HasSuffix(s, ".bin") }},
+ {"path traversal", "../../etc/passwd", func(s string) bool { return s == "passwd" }},
+ {"long name capped", strings.Repeat("a", 300) + ".pdf", func(s string) bool { return len(s) <= 200 }},
+ {"unicode preserved", "báo cáo.pdf", func(s string) bool { return s == "báo cáo.pdf" }},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := sanitizeFilename(tc.in)
+ if !tc.want(got) {
+ t.Errorf("sanitizeFilename(%q) = %q, predicate failed", tc.in, got)
+ }
+ })
+ }
+}
+
+func TestExtFromURL_AcceptsAnySafeExt(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ in, want string
+ }{
+ {"https://cdn.example/foo.jpg", ".jpg"},
+ {"https://cdn.example/foo.JPEG", ".jpeg"},
+ {"https://cdn.example/foo.pdf?token=abc", ".pdf"},
+ {"https://cdn.example/foo.docx", ".docx"},
+ {"https://cdn.example/foo.mp4", ".mp4"},
+ {"https://cdn.example/foo.m4a", ".m4a"},
+ {"https://cdn.example/foo.zip", ".zip"},
+ {"https://cdn.example/foo.webp", ".webp"},
+ {"https://cdn.example/foo", ".bin"},
+ {"https://cdn.example/foo.weirdest", ".bin"},
+ {"https://cdn.example/foo.sh-bad", ".bin"},
+ {"https://cdn.example/foo.x.y", ".y"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.in, func(t *testing.T) {
+ if got := extFromURL(tc.in); got != tc.want {
+ t.Errorf("extFromURL(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestSendFile_RejectsZeroBytes(t *testing.T) {
+ t.Parallel()
+ api, captured, _ := newAPIServer(t, apiServerOpts{})
+ refresh, _ := newRefreshServer(t, "")
+ c := newSendChannel(t, api, refresh, &fakeStore{})
+
+ _, err := c.SendFile(context.Background(), "u1", []byte{}, "empty.txt")
+ if err == nil {
+ t.Fatal("expected error for zero-byte file")
+ }
+ if !strings.Contains(err.Error(), "empty") && !strings.Contains(err.Error(), "zero") {
+ t.Errorf("err = %v, want 'empty/zero' message", err)
+ }
+ if len(*captured) != 0 {
+ t.Errorf("captured %d HTTP calls; expected 0 (rejected before upload)", len(*captured))
+ }
+}
+
diff --git a/internal/channels/zalo/oa/webhook.go b/internal/channels/zalo/oa/webhook.go
new file mode 100644
index 0000000000..a153f8f615
--- /dev/null
+++ b/internal/channels/zalo/oa/webhook.go
@@ -0,0 +1,161 @@
+package oa
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
+)
+
+// oaInboundEvent maps a Zalo OA webhook event. Top-level "timestamp" is
+// intentionally omitted — Zalo sends it as a string in real traffic;
+// the signature verifier reads it independently via extractTimestamp.
+type oaInboundEvent struct {
+ EventName string `json:"event_name"`
+ AppID string `json:"app_id"`
+ OAID string `json:"oa_id"`
+ Sender struct {
+ ID string `json:"id"`
+ DisplayName string `json:"display_name,omitempty"`
+ } `json:"sender"`
+ Recipient struct {
+ ID string `json:"id"`
+ } `json:"recipient"`
+ Message struct {
+ MessageID string `json:"message_id,omitempty"`
+ MsgID string `json:"msg_id,omitempty"` // alternate field in some OA payloads
+ Text string `json:"text,omitempty"`
+ Time int64 `json:"time,omitempty"`
+ Attachments []oaAttachment `json:"attachments,omitempty"`
+ } `json:"message"`
+}
+
+func (e *oaInboundEvent) messageID() string {
+ if e.Message.MessageID != "" {
+ return e.Message.MessageID
+ }
+ return e.Message.MsgID
+}
+
+// HandleWebhookEvent routes a verified+deduped event onto the message bus.
+// Drops self-echoes (Sender.ID == OAID). In bootstrap mode drops every
+// event without decoding so Zalo's URL-save ping is acked but not
+// dispatched.
+func (c *Channel) HandleWebhookEvent(ctx context.Context, raw json.RawMessage) error {
+ if c.inBootstrap() {
+ n := c.bootstrapDroppedCount.Add(1)
+ // Cap warn-level at first hit so a guessed slug can't amplify logs.
+ if n == 1 {
+ slog.Warn("zalo_oa.webhook.bootstrap_drop",
+ "instance_id", c.instanceID,
+ "drop_count", n,
+ "hint", "paste OA Secret Key in Credentials tab to enable processing")
+ } else {
+ slog.Debug("zalo_oa.webhook.bootstrap_drop",
+ "instance_id", c.instanceID, "drop_count", n)
+ }
+ return nil
+ }
+ var e oaInboundEvent
+ if err := json.Unmarshal(raw, &e); err != nil {
+ return fmt.Errorf("zalo_oa.webhook: decode event: %w", err)
+ }
+ if e.Sender.ID != "" && e.Sender.ID == c.creds().OAID {
+ slog.Debug("zalo_oa.webhook.self_echo_filtered",
+ "oa_id", c.creds().OAID, "message_id", e.messageID())
+ return nil
+ }
+
+ // Advance the per-sender cursor so a post-restart catch-up sweep skips
+ // messages already delivered via webhook. Prefer message.time (matches
+ // poll.go's cursor semantic); fall back to envelope timestamp only when
+ // absent. Mixing message-time vs envelope-time would let envelope skew
+ // over-advance the cursor and silently skip later messages on burst.
+ if e.Sender.ID != "" {
+ if e.Message.Time > 0 {
+ c.cursor.Advance(e.Sender.ID, e.Message.Time)
+ } else if ts, err := extractTimestamp(raw); err == nil && ts > 0 {
+ c.cursor.Advance(e.Sender.ID, ts)
+ }
+ }
+
+ switch e.EventName {
+ case "user_send_text":
+ c.dispatchWebhookText(&e)
+ return nil
+ case "user_send_image", "user_send_gif", "user_send_sticker":
+ c.dispatchWebhookMedia(ctx, &e, true) // force image kind regardless of CDN MIME
+ return nil
+ case "user_send_file":
+ c.dispatchWebhookMedia(ctx, &e, false)
+ return nil
+ case "user_send_link":
+ c.dispatchWebhookLink(&e)
+ return nil
+ case "user_follow", "user_unfollow":
+ slog.Info("zalo_oa.webhook.follow_event", "event", e.EventName, "user_id", e.Sender.ID)
+ return nil
+ case "oa_send_text", "oa_send_image", "oa_send_gif", "oa_send_sticker",
+ "oa_send_file", "oa_send_link", "oa_send_list", "oa_send_request_user_info":
+ // Name-match in case Zalo's payload shape change ever bypasses Sender.ID == OAID.
+ slog.Debug("zalo_oa.webhook.outbound_mirror_dropped", "event", e.EventName)
+ return nil
+ default:
+ slog.Debug("zalo_oa.webhook.unknown_event", "event", e.EventName)
+ return nil
+ }
+}
+
+func (c *Channel) dispatchWebhookText(e *oaInboundEvent) {
+ if e.Message.Text == "" || e.Sender.ID == "" {
+ return
+ }
+ metadata := common.InboundMeta{
+ MessageID: e.messageID(),
+ Platform: common.PlatformZaloOA,
+ SenderDisplayName: e.Sender.DisplayName,
+ }.ToMap()
+ c.BaseChannel.HandleMessage(e.Sender.ID, e.Sender.ID, e.Message.Text, nil, metadata, "direct")
+}
+
+// SignatureVerifier returns a verifier bound to this channel's webhook
+// secret + signature mode. Bootstrap mode accepts any payload so Zalo's
+// URL-save ping returns 200; events are dropped in HandleWebhookEvent.
+func (c *Channel) SignatureVerifier() common.SignatureVerifier {
+ if c.inBootstrap() {
+ return newOASignatureVerifier(c.creds().AppID, "", SignatureModeDisabled, 0)
+ }
+ return newOASignatureVerifier(
+ c.creds().AppID,
+ c.creds().WebhookSecretKey,
+ c.cfg.WebhookSignatureMode,
+ clampReplayWindowSeconds(c.cfg.WebhookReplayWindowSeconds),
+ )
+}
+
+// MessageIDExtractor pulls the per-event id for the router's dedup.
+// Empty id → router skips dedup; the streak counter watches for persistent
+// emptiness as a schema-drift signal.
+func (c *Channel) MessageIDExtractor() common.MessageIDExtractor {
+ return oaMessageIDExtractor{}
+}
+
+type oaMessageIDExtractor struct{}
+
+func (oaMessageIDExtractor) ExtractMessageID(raw json.RawMessage) string {
+ var probe struct {
+ Message struct {
+ MessageID string `json:"message_id,omitempty"`
+ MsgID string `json:"msg_id,omitempty"`
+ } `json:"message"`
+ }
+ if err := json.Unmarshal(raw, &probe); err != nil {
+ return ""
+ }
+ if probe.Message.MessageID != "" {
+ return probe.Message.MessageID
+ }
+ return probe.Message.MsgID
+}
diff --git a/internal/channels/zalo/oa/webhook_attachments.go b/internal/channels/zalo/oa/webhook_attachments.go
new file mode 100644
index 0000000000..65328eee73
--- /dev/null
+++ b/internal/channels/zalo/oa/webhook_attachments.go
@@ -0,0 +1,212 @@
+package oa
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels/media"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
+ "github.com/nextlevelbuilder/goclaw/internal/tools"
+)
+
+type oaAttachment struct {
+ Type string `json:"type"`
+ Payload oaAttachmentPayload `json:"payload"`
+}
+
+type oaAttachmentPayload struct {
+ URL string `json:"url,omitempty"`
+ Thumbnail string `json:"thumbnail,omitempty"`
+ Name string `json:"name,omitempty"`
+ Title string `json:"title,omitempty"`
+ Description string `json:"description,omitempty"`
+}
+
+func firstAttachmentURL(atts []oaAttachment) string {
+ for _, a := range atts {
+ if a.Payload.URL != "" {
+ return a.Payload.URL
+ }
+ }
+ return ""
+}
+
+func firstAttachment(atts []oaAttachment) *oaAttachment {
+ if len(atts) == 0 {
+ return nil
+ }
+ return &atts[0]
+}
+
+// dispatchWebhookMedia downloads the attachment URL and forwards it as a
+// MediaInfo-tagged inbound. forceImageKind classifies stickers/gifs as
+// image regardless of detected MIME so the agent treats them visually.
+// The parent ctx (router's inst.ctx) cancels on UnregisterInstance, so
+// downloads are aborted on Stop and Unregister can drain dispatchWG.
+func (c *Channel) dispatchWebhookMedia(parent context.Context, e *oaInboundEvent, forceImageKind bool) {
+ if e.Sender.ID == "" {
+ return
+ }
+ url := firstAttachmentURL(e.Message.Attachments)
+ if url == "" {
+ slog.Warn("zalo_oa.webhook.attachment_missing_url",
+ "event", e.EventName, "message_id", e.messageID())
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(parent, 60*time.Second)
+ defer cancel()
+ dl := c.downloadMediaFn
+ if dl == nil {
+ dl = downloadOAMedia
+ }
+ path, err := dl(ctx, url)
+ if err != nil {
+ slog.Warn("zalo_oa.webhook.attachment_download_failed",
+ "event", e.EventName, "message_id", e.messageID(), "url", url, "error", err)
+ return
+ }
+
+ mimeType := media.DetectMIMEType(path)
+ kind := media.MediaKindFromMime(mimeType)
+ if forceImageKind {
+ kind = media.TypeImage
+ }
+
+ att := firstAttachment(e.Message.Attachments)
+ fileName := ""
+ if att != nil {
+ fileName = att.Payload.Name
+ if fileName == "" {
+ fileName = att.Payload.Title
+ }
+ }
+
+ tag := media.BuildMediaTags([]media.MediaInfo{{
+ Type: kind,
+ FilePath: path,
+ ContentType: mimeType,
+ FileName: fileName,
+ SourceURL: url,
+ }})
+
+ content := strings.TrimSpace(e.Message.Text)
+ if content == "" {
+ content = tag
+ } else {
+ content = content + "\n" + tag
+ }
+
+ metadata := common.InboundMeta{
+ MessageID: e.messageID(),
+ Platform: common.PlatformZaloOA,
+ SenderDisplayName: e.Sender.DisplayName,
+ }.ToMap()
+ c.BaseChannel.HandleMessage(e.Sender.ID, e.Sender.ID, content, []string{path}, metadata, "direct")
+}
+
+// dispatchWebhookLink forwards a shared link as plain text. We don't fetch
+// the URL — arbitrary user-shared links would risk SSRF.
+func (c *Channel) dispatchWebhookLink(e *oaInboundEvent) {
+ if e.Sender.ID == "" {
+ return
+ }
+ att := firstAttachment(e.Message.Attachments)
+ if att == nil || att.Payload.URL == "" {
+ if strings.TrimSpace(e.Message.Text) != "" {
+ c.dispatchWebhookText(e)
+ }
+ return
+ }
+
+ var b strings.Builder
+ if t := strings.TrimSpace(e.Message.Text); t != "" {
+ b.WriteString(t)
+ b.WriteString("\n\n")
+ }
+ b.WriteString("[link] ")
+ if att.Payload.Title != "" {
+ b.WriteString(att.Payload.Title)
+ b.WriteString(" — ")
+ }
+ b.WriteString(att.Payload.URL)
+ if att.Payload.Description != "" {
+ b.WriteString("\n")
+ b.WriteString(att.Payload.Description)
+ }
+
+ metadata := common.InboundMeta{
+ MessageID: e.messageID(),
+ Platform: common.PlatformZaloOA,
+ SenderDisplayName: e.Sender.DisplayName,
+ }.ToMap()
+ c.BaseChannel.HandleMessage(e.Sender.ID, e.Sender.ID, b.String(), nil, metadata, "direct")
+}
+
+const oaWebhookMaxMediaBytes = 20 * 1024 * 1024
+
+func downloadOAMedia(ctx context.Context, fileURL string) (string, error) {
+ if err := tools.CheckSSRF(fileURL); err != nil {
+ return "", fmt.Errorf("ssrf check: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
+ if err != nil {
+ return "", fmt.Errorf("new request: %w", err)
+ }
+ client := tools.NewSSRFSafeClient(0) // ctx governs deadline
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("download: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("download status %d", resp.StatusCode)
+ }
+
+ ext := extFromURL(fileURL)
+ tmpFile, err := os.CreateTemp("", "goclaw_zoa_*"+ext)
+ if err != nil {
+ return "", fmt.Errorf("create temp: %w", err)
+ }
+ defer tmpFile.Close()
+
+ written, err := io.Copy(tmpFile, io.LimitReader(resp.Body, oaWebhookMaxMediaBytes+1))
+ if err != nil {
+ os.Remove(tmpFile.Name())
+ return "", fmt.Errorf("save: %w", err)
+ }
+ if written > oaWebhookMaxMediaBytes {
+ os.Remove(tmpFile.Name())
+ return "", fmt.Errorf("attachment too large: %d bytes (cap %d)", written, oaWebhookMaxMediaBytes)
+ }
+ return tmpFile.Name(), nil
+}
+
+func extFromURL(fileURL string) string {
+ path := fileURL
+ if i := strings.IndexByte(path, '?'); i >= 0 {
+ path = path[:i]
+ }
+ ext := strings.ToLower(filepath.Ext(path))
+ if ext == "" || len(ext) > 8 || !isSafeExt(ext) {
+ return ".bin"
+ }
+ return ext
+}
+
+func isSafeExt(ext string) bool {
+ for _, r := range ext[1:] {
+ if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/channels/zalo/oa/webhook_signature.go b/internal/channels/zalo/oa/webhook_signature.go
new file mode 100644
index 0000000000..5df43ae0ae
--- /dev/null
+++ b/internal/channels/zalo/oa/webhook_signature.go
@@ -0,0 +1,187 @@
+package oa
+
+import (
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
+)
+
+// Webhook signature scheme:
+// X-ZEvent-Signature = hex(SHA256(appID + rawBody + timestamp + secret))
+//
+// timestamp is read via json.Number → strconv.FormatInt so scientific-notation
+// inputs round-trip to the canonical decimal Zalo signed against.
+
+const (
+ zaloOASignatureHeader = "X-ZEvent-Signature"
+ defaultReplayWindow = 5 * time.Minute
+ tsMillisecondsThreshold = int64(1e12) // ~year 2001 in ms; below = seconds
+)
+
+// SignatureMode controls verifier behavior; empty/unknown → disabled.
+// Defaulting to disabled keeps onboarding frictionless — operators can
+// opt into strict (or log_only during migration) once they've pasted
+// the OA Secret Key into Credentials.
+type SignatureMode = string
+
+const (
+ SignatureModeStrict SignatureMode = "strict"
+ SignatureModeLogOnly SignatureMode = "log_only"
+ SignatureModeDisabled SignatureMode = "disabled"
+)
+
+func normalizeMode(m string) string {
+ switch m {
+ case SignatureModeStrict, SignatureModeLogOnly, SignatureModeDisabled:
+ return m
+ default:
+ return SignatureModeDisabled
+ }
+}
+
+func computeOASignature(appID, body, timestamp, secret string) string {
+ h := sha256.New()
+ h.Write([]byte(appID))
+ h.Write([]byte(body))
+ h.Write([]byte(timestamp))
+ h.Write([]byte(secret))
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// oaSignatureVerifier validates X-ZEvent-Signature.
+// Modes: strict / log_only / disabled.
+type oaSignatureVerifier struct {
+ appID string
+ secret string
+ mode SignatureMode
+ replayWindow time.Duration
+}
+
+func newOASignatureVerifier(appID, secret, mode string, replayWindow time.Duration) *oaSignatureVerifier {
+ return &oaSignatureVerifier{
+ appID: appID,
+ secret: secret,
+ mode: normalizeMode(mode),
+ replayWindow: replayWindow,
+ }
+}
+
+func (v *oaSignatureVerifier) Verify(headers http.Header, body []byte) error {
+ if v.mode == SignatureModeDisabled {
+ slog.Warn("security.zalo_oa_webhook_unsigned_accept", "reason", "signature_mode=disabled")
+ return nil
+ }
+ if v.secret == "" {
+ return errors.New("zalo_oa.webhook: secret unset (open webhook is not allowed)")
+ }
+
+ tsInt, err := extractTimestamp(body)
+ if err != nil {
+ if v.mode == SignatureModeLogOnly {
+ // log_only accepts: signature can't be recomputed without a parseable timestamp.
+ slog.Warn("security.zalo_oa_webhook_bad_timestamp_log_only", "err", err)
+ return nil
+ }
+ return err
+ }
+ tsStr := strconv.FormatInt(tsInt, 10) // canonical decimal
+
+ if rejErr := v.checkReplayWindow(tsInt); rejErr != nil {
+ return rejErr
+ }
+
+ sig := headers.Get(zaloOASignatureHeader)
+ if sig == "" {
+ if v.mode == SignatureModeLogOnly {
+ slog.Warn("security.zalo_oa_webhook_missing_sig_log_only")
+ return nil
+ }
+ return fmt.Errorf("zalo_oa.webhook: missing %s", zaloOASignatureHeader)
+ }
+ expected := computeOASignature(v.appID, string(body), tsStr, v.secret)
+
+ // Reject length mismatch up front; ConstantTimeCompare's len path
+ // isn't documented as constant-time.
+ if len(sig) != len(expected) {
+ if v.mode == SignatureModeLogOnly {
+ slog.Warn("security.zalo_oa_webhook_sig_len_mismatch_log_only",
+ "got_len", len(sig), "want_len", len(expected))
+ return nil
+ }
+ return common.ErrSignatureMismatch
+ }
+ if subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) != 1 {
+ if v.mode == SignatureModeLogOnly {
+ // Never log any part of `expected` — it's secret-keyed.
+ slog.Warn("security.zalo_oa_webhook_sig_mismatch_log_only", "got", sig)
+ return nil
+ }
+ return common.ErrSignatureMismatch
+ }
+ return nil
+}
+
+// extractTimestamp reads the top-level timestamp field via json.Number to
+// preserve canonical-decimal round-trip on scientific-notation inputs.
+func extractTimestamp(body []byte) (int64, error) {
+ var env struct {
+ Timestamp json.Number `json:"timestamp"`
+ }
+ if err := json.Unmarshal(body, &env); err != nil {
+ return 0, fmt.Errorf("zalo_oa.webhook: decode timestamp: %w", err)
+ }
+ tsInt, err := env.Timestamp.Int64()
+ if err != nil {
+ return 0, fmt.Errorf("zalo_oa.webhook: timestamp not integer: %w", err)
+ }
+ return tsInt, nil
+}
+
+// checkReplayWindow rejects events whose timestamp is outside replayWindow.
+// Detects ms vs s by magnitude (Zalo uses ms; older API used s).
+func (v *oaSignatureVerifier) checkReplayWindow(tsInt int64) error {
+ if v.replayWindow <= 0 {
+ return nil
+ }
+ var eventTime time.Time
+ if tsInt < tsMillisecondsThreshold {
+ eventTime = time.Unix(tsInt, 0)
+ } else {
+ eventTime = time.UnixMilli(tsInt)
+ }
+ skew := time.Since(eventTime)
+ if skew > v.replayWindow || skew < -v.replayWindow {
+ if v.mode == SignatureModeLogOnly {
+ // Don't log skew direction/magnitude — it's a clock-skew oracle
+ // for a probing attacker.
+ slog.Warn("security.zalo_oa_webhook_replay_log_only",
+ "reason", "outside replay window")
+ return nil
+ }
+ return fmt.Errorf("event timestamp outside replay window: skew=%v, window=±%v", skew, v.replayWindow)
+ }
+ return nil
+}
+
+// clampReplayWindowSeconds clamps to [60, 3600]; 0 → defaultReplayWindow.
+func clampReplayWindowSeconds(s int) time.Duration {
+ switch {
+ case s <= 0:
+ return defaultReplayWindow
+ case s < 60:
+ return 60 * time.Second
+ case s > 3600:
+ return 3600 * time.Second
+ default:
+ return time.Duration(s) * time.Second
+ }
+}
diff --git a/internal/channels/zalo/oa/webhook_test.go b/internal/channels/zalo/oa/webhook_test.go
new file mode 100644
index 0000000000..66850d3a95
--- /dev/null
+++ b/internal/channels/zalo/oa/webhook_test.go
@@ -0,0 +1,714 @@
+package oa
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+)
+
+// newWebhookChannel builds an OA channel ready for webhook tests with a
+// known app/secret/oa-id and the given sig mode + replay window.
+func newWebhookChannel(t *testing.T, secret, mode string, replaySecs int) (*Channel, *bus.MessageBus) {
+ t.Helper()
+ creds := &ChannelCreds{
+ AppID: "app-1",
+ SecretKey: "oauth-key", // distinct from webhook secret (S7)
+ OAID: "oa-1",
+ WebhookSecretKey: secret,
+ }
+ cfg := config.ZaloOAConfig{
+ Transport: "webhook",
+ WebhookSignatureMode: mode,
+ WebhookReplayWindowSeconds: replaySecs,
+ }
+ mb := bus.New()
+ c, err := New("webhook_test", cfg, creds, &fakeStore{}, mb, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ c.SetInstanceID(uuid.New())
+ return c, mb
+}
+
+// signedPayload builds a body whose top-level timestamp + signature header
+// are computed against (appID, body, ts, secret) per the OA scheme.
+// Uses Header.Set so the canonical key matches verifier's Get lookup.
+func signedPayload(t *testing.T, appID, secret string, ts int64, body string) (http.Header, []byte) {
+ t.Helper()
+ full := fmt.Sprintf(`{"timestamp":%d,%s}`, ts, body)
+ tsStr := fmt.Sprintf("%d", ts)
+ sig := computeOASignature(appID, full, tsStr, secret)
+ h := http.Header{}
+ h.Set(zaloOASignatureHeader, sig)
+ return h, []byte(full)
+}
+
+// nowMs is the canonical millisecond timestamp used by Zalo OA payloads.
+func nowMs() int64 { return time.Now().UnixMilli() }
+
+// ---------- signature scheme + verifier ----------
+
+func TestComputeOASignature_FixedFixture(t *testing.T) {
+ t.Parallel()
+ // Fixed input → known output. Verify with:
+ // echo -n 'XBODY1234567890Y' | shasum -a 256
+ sig := computeOASignature("X", "BODY", "1234567890", "Y")
+ const want = "2f1ef5aabe67e8396a459ca89562e108ad541f82ba5022c85f645bd6b7220cb9"
+ if sig != want {
+ t.Fatalf("sig = %q, want %q", sig, want)
+ }
+}
+
+func TestNormalizeMode(t *testing.T) {
+ t.Parallel()
+ cases := map[string]string{
+ "": "disabled",
+ "strict": "strict",
+ "log_only": "log_only",
+ "disabled": "disabled",
+ "weird": "disabled",
+ }
+ for in, want := range cases {
+ if got := normalizeMode(in); got != want {
+ t.Errorf("normalizeMode(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestClampReplayWindowSeconds(t *testing.T) {
+ t.Parallel()
+ cases := map[int]time.Duration{
+ 0: 5 * time.Minute, // unset → default
+ -5: 5 * time.Minute, // negative → default
+ 30: 60 * time.Second, // below floor
+ 120: 120 * time.Second, // in range
+ 3600: 3600 * time.Second, // at ceiling
+ 10000: 3600 * time.Second, // above ceiling
+ }
+ for in, want := range cases {
+ if got := clampReplayWindowSeconds(in); got != want {
+ t.Errorf("clampReplayWindowSeconds(%d) = %v, want %v", in, got, want)
+ }
+ }
+}
+
+func TestVerifier_AcceptsValidSignature(t *testing.T) {
+ t.Parallel()
+ v := newOASignatureVerifier("app-1", "secret", "strict", time.Hour)
+ hdr, body := signedPayload(t, "app-1", "secret", nowMs(), `"event_name":"x"`)
+ if err := v.Verify(hdr, body); err != nil {
+ t.Errorf("Verify: %v", err)
+ }
+}
+
+func TestVerifier_RejectsMissingHeader(t *testing.T) {
+ t.Parallel()
+ v := newOASignatureVerifier("app-1", "secret", "strict", time.Hour)
+ body := fmt.Appendf(nil, `{"timestamp":%d}`, nowMs())
+ if err := v.Verify(http.Header{}, body); err == nil || !strings.Contains(err.Error(), "missing X-ZEvent-Signature") {
+ t.Errorf("Verify(no header) err = %v, want missing-header", err)
+ }
+}
+
+func TestVerifier_RejectsLengthMismatch(t *testing.T) {
+ t.Parallel()
+ v := newOASignatureVerifier("app-1", "secret", "strict", time.Hour)
+ body := fmt.Appendf(nil, `{"timestamp":%d}`, nowMs())
+ hdr := http.Header{}
+ hdr.Set(zaloOASignatureHeader, "deadbeef") // shorter than 64-char hex
+ err := v.Verify(hdr, body)
+ if !errors.Is(err, common.ErrSignatureMismatch) {
+ t.Errorf("Verify(short sig) err = %v, want ErrSignatureMismatch", err)
+ }
+}
+
+func TestVerifier_RejectsWrongSignature(t *testing.T) {
+ t.Parallel()
+ v := newOASignatureVerifier("app-1", "secret", "strict", time.Hour)
+ body := fmt.Appendf(nil, `{"timestamp":%d}`, nowMs())
+ wrong := strings.Repeat("a", 64) // valid hex length, wrong value
+ hdr := http.Header{}
+ hdr.Set(zaloOASignatureHeader, wrong)
+ err := v.Verify(hdr, body)
+ if !errors.Is(err, common.ErrSignatureMismatch) {
+ t.Errorf("Verify(wrong sig) err = %v, want ErrSignatureMismatch", err)
+ }
+}
+
+func TestVerifier_RejectsEmptySecretInStrict(t *testing.T) {
+ t.Parallel()
+ v := newOASignatureVerifier("app-1", "", "strict", time.Hour)
+ body := fmt.Appendf(nil, `{"timestamp":%d}`, nowMs())
+ if err := v.Verify(http.Header{}, body); err == nil || !strings.Contains(err.Error(), "secret unset") {
+ t.Errorf("Verify err = %v, want secret-unset", err)
+ }
+}
+
+// B5: log_only mode swallows mismatches but still accepts (return nil).
+func TestVerifier_LogOnlyAcceptsMismatch(t *testing.T) {
+ t.Parallel()
+ v := newOASignatureVerifier("app-1", "secret", "log_only", time.Hour)
+ body := fmt.Appendf(nil, `{"timestamp":%d}`, nowMs())
+ hdr := http.Header{}
+ hdr.Set(zaloOASignatureHeader, strings.Repeat("a", 64))
+ if err := v.Verify(hdr, body); err != nil {
+ t.Errorf("log_only Verify(wrong sig) err = %v, want nil", err)
+ }
+}
+
+// B5/N6: disabled mode skips verification entirely (still warns once).
+func TestVerifier_DisabledAcceptsAnything(t *testing.T) {
+ t.Parallel()
+ v := newOASignatureVerifier("app-1", "", "disabled", time.Hour)
+ if err := v.Verify(http.Header{}, []byte(`{"x":1}`)); err != nil {
+ t.Errorf("disabled Verify err = %v, want nil", err)
+ }
+}
+
+// B7: replay window in strict mode rejects out-of-window timestamps.
+func TestVerifier_RejectsReplay(t *testing.T) {
+ t.Parallel()
+ v := newOASignatureVerifier("app-1", "secret", "strict", 5*time.Minute)
+ old := nowMs() - int64((10 * time.Minute).Milliseconds())
+ hdr, body := signedPayload(t, "app-1", "secret", old, `"event_name":"x"`)
+ err := v.Verify(hdr, body)
+ if err == nil || !strings.Contains(err.Error(), "replay window") {
+ t.Errorf("Verify(replay) err = %v, want replay-window error", err)
+ }
+}
+
+func TestVerifier_AcceptsWithinReplayWindow(t *testing.T) {
+ t.Parallel()
+ v := newOASignatureVerifier("app-1", "secret", "strict", 5*time.Minute)
+ recent := nowMs() - int64((1 * time.Minute).Milliseconds())
+ hdr, body := signedPayload(t, "app-1", "secret", recent, `"event_name":"x"`)
+ if err := v.Verify(hdr, body); err != nil {
+ t.Errorf("Verify(within window) err = %v, want nil", err)
+ }
+}
+
+// S4: timestamp parsed via json.Number → strconv.FormatInt produces the
+// canonical decimal Zalo signs against. The verifier hashes the
+// canonical form, not the raw JSON bytes.
+func TestVerifier_TimestampCanonicalizedViaInt64(t *testing.T) {
+ t.Parallel()
+ v := newOASignatureVerifier("app-1", "secret", "strict", time.Hour)
+ tsInt := nowMs()
+ body := fmt.Appendf(nil, `{"timestamp":%d,"event_name":"x"}`, tsInt)
+ tsStr := fmt.Sprintf("%d", tsInt)
+ sig := computeOASignature("app-1", string(body), tsStr, "secret")
+ hdr := http.Header{}
+ hdr.Set(zaloOASignatureHeader, sig)
+ if err := v.Verify(hdr, body); err != nil {
+ t.Errorf("Verify(canonical ts) err = %v", err)
+ }
+
+ // Also verify extractTimestamp handles json.Number happily (covers the
+ // internal canonicalization path even if the body is well-formed int).
+ got, err := extractTimestamp(body)
+ if err != nil {
+ t.Fatalf("extractTimestamp: %v", err)
+ }
+ if got != tsInt {
+ t.Errorf("extractTimestamp = %d, want %d", got, tsInt)
+ }
+}
+
+// ---------- HandleWebhookEvent dispatch ----------
+
+func TestHandleWebhookEvent_DispatchesText(t *testing.T) {
+ t.Parallel()
+ ch, mb := newWebhookChannel(t, "secret", "strict", 0)
+ payload := `{"event_name":"user_send_text","sender":{"id":"alice","display_name":"Alice"},"message":{"message_id":"m1","text":"hello"}}`
+ if err := ch.HandleWebhookEvent(context.Background(), json.RawMessage(payload)); err != nil {
+ t.Fatalf("HandleWebhookEvent: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ got, ok := mb.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("no inbound published")
+ }
+ if got.Content != "hello" {
+ t.Errorf("Content = %q", got.Content)
+ }
+ if got.SenderID != "alice" || got.ChatID != "alice" {
+ t.Errorf("sender/chat = %q/%q, want alice/alice", got.SenderID, got.ChatID)
+ }
+ if got.Metadata["message_id"] != "m1" {
+ t.Errorf("metadata.message_id = %q", got.Metadata["message_id"])
+ }
+}
+
+// A8: sender == OAID is the bot's own outbound — must drop, not forward.
+func TestHandleWebhookEvent_FiltersSelfEcho(t *testing.T) {
+ t.Parallel()
+ ch, mb := newWebhookChannel(t, "secret", "strict", 0)
+ payload := `{"event_name":"user_send_text","sender":{"id":"oa-1"},"message":{"message_id":"m1","text":"loop"}}`
+ if err := ch.HandleWebhookEvent(context.Background(), json.RawMessage(payload)); err != nil {
+ t.Fatalf("HandleWebhookEvent: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ if _, ok := mb.ConsumeInbound(ctx); ok {
+ t.Error("self-echo should not have published")
+ }
+}
+
+// stubDownloader writes a fixture file and bypasses SSRF for hermetic tests.
+func stubDownloader(t *testing.T, c *Channel, ext string, body []byte) {
+ t.Helper()
+ c.downloadMediaFn = func(_ context.Context, _ string) (string, error) {
+ f, err := os.CreateTemp(t.TempDir(), "oa_test_*"+ext)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ if _, werr := f.Write(body); werr != nil {
+ return "", werr
+ }
+ return f.Name(), nil
+ }
+}
+
+func TestHandleWebhookEvent_DispatchesImage(t *testing.T) {
+ ch, mb := newWebhookChannel(t, "secret", "strict", 0)
+ stubDownloader(t, ch, ".jpg", []byte("\xff\xd8\xff\xe0fake-jpeg"))
+ payload := `{"event_name":"user_send_image","sender":{"id":"alice"},"message":{"message_id":"m_img","attachments":[{"type":"image","payload":{"url":"https://cdn.zalo.example/photo.jpg"}}]}}`
+ if err := ch.HandleWebhookEvent(context.Background(), json.RawMessage(payload)); err != nil {
+ t.Fatalf("HandleWebhookEvent: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ defer cancel()
+ got, ok := mb.ConsumeInbound(ctx)
+ if !ok {
+ t.Fatal("image event was not dispatched")
+ }
+ if len(got.Media) != 1 {
+ t.Fatalf("Media len = %d, want 1", len(got.Media))
+ }
+ if !strings.Contains(got.Content, "= 0 {
- end := strings.Index(msg.Content[start:], "]")
- if end > 0 {
- photoURL := msg.Content[start+7 : start+end]
- caption := strings.TrimSpace(msg.Content[:start] + msg.Content[start+end+1:])
- return c.sendPhoto(msg.ChatID, photoURL, caption)
- }
- }
- }
-
- // Send as text, chunking if over 2000 chars
- return c.sendChunkedText(msg.ChatID, msg.Content)
-}
-
-// --- Polling ---
-
-func (c *Channel) pollLoop(ctx context.Context) {
- slog.Info("zalo polling loop started")
-
- for {
- select {
- case <-ctx.Done():
- slog.Info("zalo polling loop stopped (context)")
- return
- case <-c.stopCh:
- slog.Info("zalo polling loop stopped")
- return
- default:
- }
-
- updates, err := c.getUpdates(defaultPollTimeout)
- if err != nil {
- // 408 = no updates (timeout), not an error
- if !strings.Contains(err.Error(), "408") {
- slog.Warn("zalo getUpdates error", "error", err)
- select {
- case <-ctx.Done():
- return
- case <-c.stopCh:
- return
- case <-time.After(pollErrorBackoff):
- }
- }
- continue
- }
-
- for _, update := range updates {
- c.processUpdate(update)
- }
- }
-}
-
-func (c *Channel) processUpdate(update zaloUpdate) {
- switch update.EventName {
- case "message.text.received":
- if update.Message != nil {
- c.handleTextMessage(update.Message)
- }
- case "message.image.received":
- if update.Message != nil {
- c.handleImageMessage(update.Message)
- }
- default:
- slog.Debug("zalo unsupported event", "event", update.EventName)
- }
-}
-
-func (c *Channel) handleTextMessage(msg *zaloMessage) {
- ctx := context.Background()
- ctx = store.WithTenantID(ctx, c.TenantID())
- senderID := msg.From.ID
- if senderID == "" {
- slog.Warn("zalo: dropping text message with empty sender ID", "message_id", msg.MessageID)
- return
- }
- chatID := msg.Chat.ID
- if chatID == "" {
- chatID = senderID
- }
-
- // DM policy enforcement (Zalo is DM-only)
- if !c.checkDMPolicy(ctx, senderID, chatID) {
- return
- }
-
- content := msg.Text
- if content == "" {
- content = "[empty message]"
- }
-
- slog.Debug("zalo text message received",
- "sender_id", senderID,
- "chat_id", chatID,
- "preview", channels.Truncate(content, 50),
- )
-
- metadata := map[string]string{
- "message_id": msg.MessageID,
- "platform": "zalo",
- }
-
- c.HandleMessage(senderID, chatID, content, nil, metadata, "direct")
-}
-
-func (c *Channel) handleImageMessage(msg *zaloMessage) {
- ctx := context.Background()
- ctx = store.WithTenantID(ctx, c.TenantID())
- senderID := msg.From.ID
- if senderID == "" {
- slog.Warn("zalo: dropping image message with empty sender ID", "message_id", msg.MessageID)
- return
- }
- chatID := msg.Chat.ID
- if chatID == "" {
- chatID = senderID
- }
-
- if !c.checkDMPolicy(ctx, senderID, chatID) {
- return
- }
-
- content := msg.Caption
- if content == "" {
- content = "[image]"
- }
-
- // Download photo from Zalo CDN to local temp file (CDN URLs are auth-restricted/expiring)
- var media []string
- var photoURL string
- switch {
- case msg.PhotoURL != "":
- photoURL = msg.PhotoURL
- case msg.Photo != "":
- photoURL = msg.Photo
- }
-
- if photoURL != "" {
- localPath, err := c.downloadMedia(photoURL)
- if err != nil {
- slog.Warn("zalo photo download failed, passing URL as fallback",
- "photo_url", photoURL, "error", err)
- media = []string{photoURL}
- } else {
- media = []string{localPath}
- }
- }
-
- slog.Info("zalo image message received",
- "sender_id", senderID,
- "chat_id", chatID,
- "photo_url", photoURL,
- "has_media", len(media) > 0,
- )
-
- metadata := map[string]string{
- "message_id": msg.MessageID,
- "platform": "zalo",
- }
-
- c.HandleMessage(senderID, chatID, content, media, metadata, "direct")
-}
-
-// --- DM Policy ---
-
-func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool {
- result := c.CheckDMPolicy(ctx, senderID, c.dmPolicy)
- switch result {
- case channels.PolicyAllow:
- return true
- case channels.PolicyNeedsPairing:
- c.sendPairingReply(ctx, senderID, chatID)
- return false
- default:
- slog.Debug("zalo message rejected by policy", "sender_id", senderID, "policy", c.dmPolicy)
- return false
- }
-}
-
-func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) {
- ps := c.PairingService()
- if ps == nil {
- return
- }
-
- if !c.CanSendPairingNotif(senderID, pairingDebounce) {
- return
- }
-
- code, err := ps.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil)
- if err != nil {
- slog.Debug("zalo pairing request failed", "sender_id", senderID, "error", err)
- return
- }
-
- replyText := fmt.Sprintf(
- "GoClaw: access not configured.\n\nYour Zalo user id: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s",
- senderID, code, code,
- )
-
- if err := c.sendMessage(chatID, replyText); err != nil {
- slog.Warn("failed to send zalo pairing reply", "error", err)
- } else {
- c.MarkPairingNotifSent(senderID)
- slog.Info("zalo pairing reply sent", "sender_id", senderID, "code", code)
- }
-}
-
-// --- Media download ---
-
-const maxMediaBytes = 10 * 1024 * 1024 // 10MB
-
-// downloadMedia fetches a photo from a Zalo CDN URL and saves it as a local temp file.
-// Zalo CDN URLs are auth-restricted and expire, so we must download immediately.
-func (c *Channel) downloadMedia(url string) (string, error) {
- resp, err := c.client.Get(url)
- if err != nil {
- return "", fmt.Errorf("fetch: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("http %d", resp.StatusCode)
- }
-
- // Detect extension from Content-Type
- ext := ".jpg"
- ct := resp.Header.Get("Content-Type")
- switch {
- case strings.Contains(ct, "png"):
- ext = ".png"
- case strings.Contains(ct, "gif"):
- ext = ".gif"
- case strings.Contains(ct, "webp"):
- ext = ".webp"
- }
-
- f, err := os.CreateTemp("", "goclaw_zalo_*"+ext)
- if err != nil {
- return "", fmt.Errorf("create temp: %w", err)
- }
- defer f.Close()
-
- n, err := io.Copy(f, io.LimitReader(resp.Body, maxMediaBytes))
- if err != nil {
- os.Remove(f.Name())
- return "", fmt.Errorf("write: %w", err)
- }
- if n == 0 {
- os.Remove(f.Name())
- return "", fmt.Errorf("empty response")
- }
-
- slog.Debug("zalo media downloaded", "path", f.Name(), "size", n)
- return f.Name(), nil
-}
-
-// --- Chunked text sending ---
-
-func (c *Channel) sendChunkedText(chatID, text string) error {
- for _, chunk := range channels.ChunkMarkdown(text, maxTextLength) {
- if err := c.sendMessage(chatID, chunk); err != nil {
- return err
- }
- }
- return nil
-}
-
-// --- API methods ---
-
-type zaloAPIResponse struct {
- OK bool `json:"ok"`
- Result json.RawMessage `json:"result,omitempty"`
- ErrorCode int `json:"error_code,omitempty"`
- Description string `json:"description,omitempty"`
-}
-
-type zaloBotInfo struct {
- ID string `json:"id"`
- Name string `json:"display_name"`
-}
-
-type zaloMessage struct {
- MessageID string `json:"message_id"`
- Text string `json:"text"`
- Photo string `json:"photo"`
- PhotoURL string `json:"photo_url"`
- Caption string `json:"caption"`
- From zaloFrom `json:"from"`
- Chat zaloChat `json:"chat"`
- Date int64 `json:"date"`
-}
-
-type zaloFrom struct {
- ID string `json:"id"`
- Username string `json:"display_name"`
-}
-
-type zaloChat struct {
- ID string `json:"id"`
- Type string `json:"chat_type"`
-}
-
-type zaloUpdate struct {
- EventName string `json:"event_name"`
- Message *zaloMessage `json:"message,omitempty"`
-}
-
-func (c *Channel) callAPI(method string, body any) (json.RawMessage, error) {
- return c.callAPIWith(context.Background(), c.client, method, body)
-}
-
-func (c *Channel) callAPIWith(ctx context.Context, client *http.Client, method string, body any) (json.RawMessage, error) {
- url := fmt.Sprintf("%s/bot%s/%s", apiBase, c.token, method)
-
- var reqBody io.Reader
- if body != nil {
- data, err := json.Marshal(body)
- if err != nil {
- return nil, fmt.Errorf("marshal request: %w", err)
- }
- reqBody = bytes.NewReader(data)
- }
-
- req, err := http.NewRequestWithContext(ctx, "POST", url, reqBody)
- if err != nil {
- return nil, fmt.Errorf("create request: %w", err)
- }
- if reqBody != nil {
- req.Header.Set("Content-Type", "application/json")
- }
-
- resp, err := client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("api call %s: %w", method, err)
- }
- defer resp.Body.Close()
-
- respData, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("read response: %w", err)
- }
-
- var apiResp zaloAPIResponse
- if err := json.Unmarshal(respData, &apiResp); err != nil {
- return nil, fmt.Errorf("unmarshal response: %w", err)
- }
-
- if !apiResp.OK {
- return nil, fmt.Errorf("zalo API error %d: %s", apiResp.ErrorCode, apiResp.Description)
- }
-
- return apiResp.Result, nil
-}
-
-func (c *Channel) getMe() (*zaloBotInfo, error) {
- result, err := c.callAPI("getMe", nil)
- if err != nil {
- return nil, err
- }
-
- var info zaloBotInfo
- if err := json.Unmarshal(result, &info); err != nil {
- return nil, fmt.Errorf("unmarshal bot info: %w", err)
- }
- return &info, nil
-}
-
-func (c *Channel) getUpdates(timeout int) ([]zaloUpdate, error) {
- params := map[string]any{
- "timeout": timeout,
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second+pollTimeoutHeadroom)
- defer cancel()
-
- result, err := c.callAPIWith(ctx, c.pollClient, "getUpdates", params)
- if err != nil {
- return nil, err
- }
-
- var update zaloUpdate
- if err := json.Unmarshal(result, &update); err != nil {
- return nil, fmt.Errorf("unmarshal updates: %w", err)
- }
- if update.EventName == "" {
- return nil, nil
- }
- return []zaloUpdate{update}, nil
-}
-
-func (c *Channel) sendMessage(chatID, text string) error {
- params := map[string]any{
- "chat_id": chatID,
- "text": text,
- }
-
- _, err := c.callAPI("sendMessage", params)
- return err
-}
-
-func (c *Channel) sendPhoto(chatID, photoURL, caption string) error {
- params := map[string]any{
- "chat_id": chatID,
- "photo": photoURL,
- }
- if caption != "" {
- params["caption"] = caption
- }
-
- _, err := c.callAPI("sendPhoto", params)
- return err
-}
diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go
index a93fcdd24f..070d84956e 100644
--- a/internal/config/config_channels.go
+++ b/internal/config/config_channels.go
@@ -18,6 +18,7 @@ type ChannelsConfig struct {
Slack SlackConfig `json:"slack"`
WhatsApp WhatsAppConfig `json:"whatsapp"`
Zalo ZaloConfig `json:"zalo"`
+ ZaloOA ZaloOAConfig `json:"zalo_oa"`
ZaloPersonal ZaloPersonalConfig `json:"zalo_personal"`
Feishu FeishuConfig `json:"feishu"`
PendingCompaction *PendingCompactionConfig `json:"pending_compaction,omitempty"` // global pending message compaction settings
@@ -147,12 +148,46 @@ type ZaloConfig struct {
Token string `json:"token"`
AllowFrom FlexibleStringSlice `json:"allow_from"`
DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default), "allowlist", "open", "disabled"
- WebhookURL string `json:"webhook_url,omitempty"`
+ Transport string `json:"transport,omitempty"` // "polling" (default) | "webhook"
+ WebhookPath string `json:"webhook_path,omitempty"` // per-instance routing slug appended to /channels/zalo/webhook/
WebhookSecret string `json:"webhook_secret,omitempty"`
MediaMaxMB int `json:"media_max_mb,omitempty"` // default 5
BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit)
}
+// ZaloOAConfig configures the phone-number-tied Official Account channel
+// that uses Zalo OAuth v4 (oauth.zaloapp.com).
+//
+// AppID, SecretKey, and OAID are NOT here — those credentials live in
+// ChannelInstance.credentials (encrypted JSON blob) and are loaded via
+// LoadCreds. This struct only carries operator-tunable runtime knobs.
+type ZaloOAConfig struct {
+ Enabled bool `json:"enabled"`
+ PollIntervalSeconds int `json:"poll_interval_seconds,omitempty"` // default 15
+ RefreshMarginSeconds int `json:"refresh_margin_seconds,omitempty"` // default 300
+ SafetyTickerMinutes int `json:"safety_ticker_minutes,omitempty"` // default 30
+ AllowFrom FlexibleStringSlice `json:"allow_from,omitempty"`
+ DMPolicy string `json:"dm_policy,omitempty"`
+ BlockReply *bool `json:"block_reply,omitempty"`
+ ReactionLevel string `json:"reaction_level,omitempty"` // "off" (default), "minimal", "full" — status emoji reactions
+ // Terminal reaction (done/error) is deferred by a random delay in
+ // [min, max] ms so the heart/sad doesn't slap right as the reply lands.
+ // Both 0 → defaults (800/2000). max < min → max coerced to min (no jitter).
+ ReactionTerminalDelayMinMs int `json:"reaction_terminal_delay_min_ms,omitempty"`
+ ReactionTerminalDelayMaxMs int `json:"reaction_terminal_delay_max_ms,omitempty"`
+ QuoteUserMessage *bool `json:"quote_user_message,omitempty"` // default false: quote the user's last inbound message in CS replies
+
+ Transport string `json:"transport,omitempty"` // "polling" (default) | "webhook"
+ WebhookPath string `json:"webhook_path,omitempty"` // per-instance routing slug appended to /channels/zalo/webhook/
+ WebhookSignatureMode string `json:"webhook_signature_mode,omitempty"` // "disabled" (default) | "log_only" | "strict"
+ WebhookReplayWindowSeconds int `json:"webhook_replay_window_seconds,omitempty"` // default 300, clamp [60, 3600]
+ CatchUpOnRestart bool `json:"catch_up_on_restart,omitempty"` // single bounded listrecentchat sweep on Start (off by default)
+
+ // Polling knobs. Ignored when Transport="webhook".
+ PollCount int `json:"poll_count,omitempty"` // page size; default 10, clamp [1, 10] (Zalo hard cap, error -210 above)
+ PollBurndownMaxPages int `json:"poll_burndown_max_pages,omitempty"` // max pages per cycle; default 10, clamp [1, 20]; 1 disables burn-down
+}
+
type ZaloPersonalConfig struct {
Enabled bool `json:"enabled"`
AllowFrom FlexibleStringSlice `json:"allow_from"`
diff --git a/internal/gateway/client_testing.go b/internal/gateway/client_testing.go
index b7bb400f6a..d6ce587fe7 100644
--- a/internal/gateway/client_testing.go
+++ b/internal/gateway/client_testing.go
@@ -21,3 +21,20 @@ func NewTestClient(role permissions.Role, tenantID uuid.UUID, userID string) *Cl
tenantID: tenantID,
}
}
+
+// NewCapturingTestClient is like NewTestClient but also returns a buffered
+// send channel so response/event frames can be inspected by the test. The
+// channel is sized to absorb a small burst without blocking the handler.
+//
+// Not for production use.
+func NewCapturingTestClient(role permissions.Role, tenantID uuid.UUID, userID string) (*Client, <-chan []byte) {
+ send := make(chan []byte, 16)
+ return &Client{
+ id: uuid.NewString(),
+ authenticated: true,
+ role: role,
+ userID: userID,
+ tenantID: tenantID,
+ send: send,
+ }, send
+}
diff --git a/internal/gateway/methods/channel_instances.go b/internal/gateway/methods/channel_instances.go
index 3f06f2c092..f649bcf7bd 100644
--- a/internal/gateway/methods/channel_instances.go
+++ b/internal/gateway/methods/channel_instances.go
@@ -240,6 +240,13 @@ func (m *ChannelInstancesMethods) handleDelete(ctx context.Context, client *gate
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"status": "deleted"}))
}
+// nonSecretCredentialKeys lists per-channel credential keys that are safe to
+// expose unmasked (e.g. identifiers, callback URLs) so the UI can pre-populate
+// editable fields. Keys NOT listed here are masked as "***".
+var nonSecretCredentialKeys = map[string]map[string]bool{
+ "zalo_oa": {"app_id": true, "redirect_uri": true, "oa_id": true},
+}
+
// maskInstance returns a map representation with credentials masked.
func maskInstance(inst store.ChannelInstanceData) map[string]any {
result := map[string]any{
@@ -257,13 +264,19 @@ func maskInstance(inst store.ChannelInstanceData) map[string]any {
"updated_at": inst.UpdatedAt,
}
- // Mask credentials: show keys with "***" values
+ // Mask credentials: secrets become "***"; non-secret keys (per channel
+ // type) keep their actual value so the UI can render and edit them.
if len(inst.Credentials) > 0 {
var raw map[string]any
if json.Unmarshal(inst.Credentials, &raw) == nil {
+ allowList := nonSecretCredentialKeys[inst.ChannelType]
masked := make(map[string]any, len(raw))
- for k := range raw {
- masked[k] = "***"
+ for k, v := range raw {
+ if allowList[k] {
+ masked[k] = v
+ } else {
+ masked[k] = "***"
+ }
}
result["credentials"] = masked
} else {
@@ -279,7 +292,7 @@ func maskInstance(inst store.ChannelInstanceData) map[string]any {
// 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_bot", "zalo_personal", "feishu", "facebook", "pancake":
return true
}
return false
diff --git a/internal/gateway/methods/channel_instances_whitelist_test.go b/internal/gateway/methods/channel_instances_whitelist_test.go
new file mode 100644
index 0000000000..04c3a01ab3
--- /dev/null
+++ b/internal/gateway/methods/channel_instances_whitelist_test.go
@@ -0,0 +1,34 @@
+package methods
+
+import "testing"
+
+// TestIsValidChannelType_WS guards the WebSocket-side whitelist.
+// Pre-existing bug surfaced by this test: facebook + pancake were missing
+// from the WS list while the HTTP list at internal/http/channel_instances.go
+// already accepts them.
+func TestIsValidChannelType_WS(t *testing.T) {
+ t.Parallel()
+
+ cases := map[string]bool{
+ "telegram": true,
+ "discord": true,
+ "slack": true,
+ "whatsapp": true,
+ "zalo_oa": true,
+ "zalo_bot": true,
+ "zalo_personal": true,
+ "feishu": true,
+ "facebook": true,
+ "pancake": true,
+ "unknown": false,
+ "": false,
+ "zalo": false,
+ "zalo_oauth": false,
+ }
+
+ for ct, want := range cases {
+ if got := isValidChannelType(ct); got != want {
+ t.Errorf("isValidChannelType(%q) = %v, want %v", ct, got, want)
+ }
+ }
+}
diff --git a/internal/gateway/methods/zalo_oa.go b/internal/gateway/methods/zalo_oa.go
new file mode 100644
index 0000000000..6576514624
--- /dev/null
+++ b/internal/gateway/methods/zalo_oa.go
@@ -0,0 +1,306 @@
+package methods
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ zalooa "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/oa"
+ "github.com/nextlevelbuilder/goclaw/internal/gateway"
+ "github.com/nextlevelbuilder/goclaw/internal/i18n"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/pkg/protocol"
+)
+
+const (
+ zaloOAStateTTL = 10 * time.Minute
+ zaloOAMaxStatesPerInst = 5 // most-recent-N consent attempts per instance
+)
+
+// ZaloOAMethods serves the WS handlers backing the paste-code consent flow.
+type ZaloOAMethods struct {
+ store store.ChannelInstanceStore
+ msgBus *bus.MessageBus
+
+ stateMu sync.Mutex
+ states map[string]zaloOAStateEntry // key: instanceID|state
+}
+
+type zaloOAStateEntry struct {
+ instID uuid.UUID
+ expiresAt time.Time
+}
+
+// NewZaloOAMethods constructs the handler. msgBus may be nil during tests.
+func NewZaloOAMethods(s store.ChannelInstanceStore, msgBus *bus.MessageBus) *ZaloOAMethods {
+ return &ZaloOAMethods{
+ store: s,
+ msgBus: msgBus,
+ states: make(map[string]zaloOAStateEntry),
+ }
+}
+
+// Register wires the methods into the WS router.
+func (m *ZaloOAMethods) Register(router *gateway.MethodRouter) {
+ router.Register(protocol.MethodChannelInstancesZaloOAConsentURL, m.handleConsentURL)
+ router.Register(protocol.MethodChannelInstancesZaloOAExchangeCode, m.handleExchangeCode)
+}
+
+// handleConsentURL builds the Zalo authorization URL server-side so the
+// frontend doesn't have to assemble the OAuth URL itself; the response
+// only echoes the URL plus a state token.
+func (m *ZaloOAMethods) handleConsentURL(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ locale := store.LocaleFromContext(ctx)
+ var params struct {
+ InstanceID string `json:"instance_id"`
+ }
+ if req.Params != nil {
+ _ = json.Unmarshal(req.Params, ¶ms)
+ }
+ instID, err := uuid.Parse(params.InstanceID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")))
+ return
+ }
+
+ inst, err := m.store.Get(ctx, instID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgInstanceNotFound)))
+ return
+ }
+ if inst.TenantID != client.TenantID() {
+ slog.Warn("security.cross_tenant_access_attempt",
+ "method", "zalo_oa.consent_url",
+ "instance_id", instID,
+ "instance_tenant_id", inst.TenantID,
+ "client_tenant_id", client.TenantID())
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgInstanceNotFound)))
+ return
+ }
+ if inst.ChannelType != channels.TypeZaloOA {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgZaloOAInvalidChannelType)))
+ return
+ }
+
+ creds, err := zalooa.LoadCreds(inst.Credentials)
+ if err != nil || creds.AppID == "" {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgZaloOAMissingAppID)))
+ return
+ }
+ if creds.RedirectURI == "" {
+ // Zalo rejects mismatched redirect_uri with error_code=-14003 —
+ // fail fast with an actionable error rather than letting the user
+ // run the consent flow and hit an opaque Zalo error page.
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgZaloOARedirectURIRequired)))
+ return
+ }
+
+ state, err := newStateToken()
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgZaloOAStateGenFailed)))
+ return
+ }
+ m.putState(instID, state)
+
+ url := zalooa.ConsentURL(creds.AppID, creds.RedirectURI, state)
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
+ "url": url,
+ "state": state,
+ }))
+}
+
+// handleExchangeCode swaps the pasted authorization code for tokens and
+// persists them via the store-encrypted credentials blob.
+func (m *ZaloOAMethods) handleExchangeCode(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ locale := store.LocaleFromContext(ctx)
+ var params struct {
+ InstanceID string `json:"instance_id"`
+ Code string `json:"code"`
+ State string `json:"state"`
+ OAID string `json:"oa_id"` // optional — from the callback URL query string
+ }
+ if req.Params != nil {
+ _ = json.Unmarshal(req.Params, ¶ms)
+ }
+ if len(params.InstanceID) > 256 || len(params.Code) > 256 || len(params.OAID) > 256 || len(params.State) > 256 {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "param too long")))
+ return
+ }
+ instID, err := uuid.Parse(params.InstanceID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")))
+ return
+ }
+ if params.Code == "" {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "code")))
+ return
+ }
+ if !m.consumeState(instID, params.State) {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgZaloOAInvalidState)))
+ return
+ }
+
+ inst, err := m.store.Get(ctx, instID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgInstanceNotFound)))
+ return
+ }
+ if inst.TenantID != client.TenantID() {
+ slog.Warn("security.cross_tenant_access_attempt",
+ "method", "zalo_oa.exchange_code",
+ "instance_id", instID,
+ "instance_tenant_id", inst.TenantID,
+ "client_tenant_id", client.TenantID())
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgInstanceNotFound)))
+ return
+ }
+ if inst.ChannelType != channels.TypeZaloOA {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgZaloOAInvalidChannelType)))
+ return
+ }
+
+ creds, err := zalooa.LoadCreds(inst.Credentials)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgZaloOACodeExchangeFailed, err.Error())))
+ return
+ }
+
+ httpClient := zalooa.NewClient(15 * time.Second)
+ tok, err := httpClient.ExchangeCode(ctx, creds.AppID, creds.SecretKey, params.Code)
+ if err != nil {
+ slog.Warn("zalo_oa.exchange_failed", "instance_id", instID, "oa_id", creds.OAID, "error", err)
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgZaloOACodeExchangeFailed, err.Error())))
+ return
+ }
+ creds.WithTokens(tok)
+ // OAID rides the callback URL (token endpoint omits it). Reject mismatched
+ // paste against an already-bound instance — silently re-tagging swaps
+ // routing metadata onto a different OA until the next failed signature.
+ if params.OAID != "" {
+ if creds.OAID != "" && creds.OAID != params.OAID {
+ slog.Warn("zalo_oa.oaid_mismatch_rejected",
+ "instance_id", instID, "bound_oa_id", creds.OAID, "pasted_oa_id", params.OAID)
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgZaloOAOAIDMismatch)))
+ return
+ }
+ creds.OAID = params.OAID
+ }
+ credsBytes, err := creds.Marshal()
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgZaloOACodeExchangeFailed, err.Error())))
+ return
+ }
+ if err := m.store.Update(ctx, instID, map[string]any{"credentials": credsBytes}); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgZaloOACodeExchangeFailed, err.Error())))
+ return
+ }
+ m.emitCacheInvalidate()
+
+ slog.Info("zalo_oa.connected",
+ "instance_id", instID,
+ "oa_id", creds.OAID,
+ "expires_at", tok.ExpiresAt,
+ "refresh_expires_at", tok.RefreshTokenExpiresAt,
+ )
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
+ "ok": true,
+ "oa_id": creds.OAID,
+ "expires_at": tok.ExpiresAt,
+ "message": i18n.T(locale, i18n.MsgZaloOAConnected, creds.OAID),
+ }))
+}
+
+func (m *ZaloOAMethods) emitCacheInvalidate() {
+ if m.msgBus == nil {
+ return
+ }
+ m.msgBus.Broadcast(bus.Event{
+ Name: protocol.EventCacheInvalidate,
+ Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindChannelInstances},
+ })
+}
+
+// putState records a freshly minted state token with a 10min TTL. Caps
+// pending entries per instance to bound memory abuse from an operator
+// repeatedly clicking "Connect" without ever pasting the code.
+func (m *ZaloOAMethods) putState(instID uuid.UUID, state string) {
+ m.stateMu.Lock()
+ defer m.stateMu.Unlock()
+ m.gcStatesLocked()
+ m.evictOldestForInstanceLocked(instID, zaloOAMaxStatesPerInst-1)
+ m.states[stateKey(instID, state)] = zaloOAStateEntry{
+ instID: instID,
+ expiresAt: time.Now().Add(zaloOAStateTTL),
+ }
+}
+
+// evictOldestForInstanceLocked drops oldest-by-expiry entries for instID
+// until at most `keep` remain. Caller MUST hold m.stateMu.
+func (m *ZaloOAMethods) evictOldestForInstanceLocked(instID uuid.UUID, keep int) {
+ type kv struct {
+ key string
+ exp time.Time
+ }
+ var entries []kv
+ for k, v := range m.states {
+ if v.instID == instID {
+ entries = append(entries, kv{k, v.expiresAt})
+ }
+ }
+ if len(entries) <= keep {
+ return
+ }
+ sort.Slice(entries, func(i, j int) bool { return entries[i].exp.Before(entries[j].exp) })
+ for i := 0; i < len(entries)-keep; i++ {
+ delete(m.states, entries[i].key)
+ }
+}
+
+// consumeState atomically validates+removes a state token. Returns false
+// if missing or expired.
+func (m *ZaloOAMethods) consumeState(instID uuid.UUID, state string) bool {
+ if state == "" {
+ return false
+ }
+ m.stateMu.Lock()
+ defer m.stateMu.Unlock()
+ key := stateKey(instID, state)
+ entry, ok := m.states[key]
+ if !ok || time.Now().After(entry.expiresAt) {
+ delete(m.states, key) // GC the expired entry too
+ return false
+ }
+ delete(m.states, key)
+ return true
+}
+
+func (m *ZaloOAMethods) gcStatesLocked() {
+ now := time.Now()
+ for k, v := range m.states {
+ if now.After(v.expiresAt) {
+ delete(m.states, k)
+ }
+ }
+}
+
+func stateKey(instID uuid.UUID, state string) string {
+ return fmt.Sprintf("%s|%s", instID, state)
+}
+
+func newStateToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b), nil
+}
diff --git a/internal/gateway/methods/zalo_webhook.go b/internal/gateway/methods/zalo_webhook.go
new file mode 100644
index 0000000000..cfa2a92484
--- /dev/null
+++ b/internal/gateway/methods/zalo_webhook.go
@@ -0,0 +1,102 @@
+package methods
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
+ zalooa "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/oa"
+ "github.com/nextlevelbuilder/goclaw/internal/gateway"
+ "github.com/nextlevelbuilder/goclaw/internal/i18n"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/pkg/protocol"
+)
+
+// ZaloWebhookMethods serves the WS RPC returning the webhook path fragment
+// the operator pastes into the Zalo developer console (path-only; operator
+// prepends their own externally-reachable host).
+type ZaloWebhookMethods struct {
+ store store.ChannelInstanceStore
+}
+
+func NewZaloWebhookMethods(s store.ChannelInstanceStore) *ZaloWebhookMethods {
+ return &ZaloWebhookMethods{store: s}
+}
+
+func (m *ZaloWebhookMethods) Register(router *gateway.MethodRouter) {
+ router.Register(protocol.MethodChannelInstancesZaloWebhookURL, m.handleWebhookURL)
+}
+
+// handleWebhookURL validates instance ownership + channel type and returns
+// {path, instance_id, hint}. Cross-tenant lookups return ErrNotFound to
+// avoid leaking instance existence across tenants.
+func (m *ZaloWebhookMethods) handleWebhookURL(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ locale := store.LocaleFromContext(ctx)
+ var params struct {
+ InstanceID string `json:"instance_id"`
+ }
+ if req.Params != nil {
+ _ = json.Unmarshal(req.Params, ¶ms)
+ }
+ instID, err := uuid.Parse(params.InstanceID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")))
+ return
+ }
+
+ inst, err := m.store.Get(ctx, instID)
+ if err != nil {
+ slog.Warn("zalo.webhook_url.lookup_failed", "instance_id", instID, "tenant_id", client.TenantID(), "error", err)
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgInstanceNotFound)))
+ return
+ }
+ if inst.TenantID != client.TenantID() {
+ slog.Warn("security.cross_tenant_access_attempt",
+ "method", "zalo.webhook_url",
+ "instance_id", instID,
+ "instance_tenant_id", inst.TenantID,
+ "client_tenant_id", client.TenantID())
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgInstanceNotFound)))
+ return
+ }
+ if inst.ChannelType != channels.TypeZaloBot && inst.ChannelType != channels.TypeZaloOA {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgZaloWebhookWrongChannelType)))
+ return
+ }
+
+ slug := resolveWebhookSlug(inst)
+ path := common.WebhookPathPrefix + slug
+ resp := map[string]any{
+ "path": path,
+ "slug": slug,
+ "instance_id": instID.String(),
+ "hint": i18n.T(locale, i18n.MsgZaloWebhookPathHint),
+ }
+ // For zalo_oa, surface the auto-discovered OA ID read-only so operators
+ // can confirm the connect handshake landed without re-checking creds.
+ if inst.ChannelType == channels.TypeZaloOA {
+ if creds, err := zalooa.LoadCreds(inst.Credentials); err == nil && creds.OAID != "" {
+ resp["oa_id"] = creds.OAID
+ }
+ }
+ client.SendResponse(protocol.NewOKResponse(req.ID, resp))
+}
+
+// resolveWebhookSlug reads the webhook_path config field; if absent, derives
+// from instance name so the RPC matches what the channel registers at Start.
+func resolveWebhookSlug(inst *store.ChannelInstanceData) string {
+ var cfg struct {
+ WebhookPath string `json:"webhook_path,omitempty"`
+ }
+ if len(inst.Config) > 0 {
+ _ = json.Unmarshal(inst.Config, &cfg)
+ }
+ if cfg.WebhookPath != "" {
+ return cfg.WebhookPath
+ }
+ return common.DeriveSlugFromName(inst.Name)
+}
diff --git a/internal/gateway/methods/zalo_webhook_test.go b/internal/gateway/methods/zalo_webhook_test.go
new file mode 100644
index 0000000000..21eead9267
--- /dev/null
+++ b/internal/gateway/methods/zalo_webhook_test.go
@@ -0,0 +1,226 @@
+package methods
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/gateway"
+ "github.com/nextlevelbuilder/goclaw/internal/permissions"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/pkg/protocol"
+)
+
+// fakeWebhookInstStore stubs ChannelInstanceStore for the webhook URL RPC.
+// Only Get is exercised by this RPC.
+type fakeWebhookInstStore struct {
+ store.ChannelInstanceStore // embed for unimplemented defaults
+ byID map[uuid.UUID]*store.ChannelInstanceData
+ getCalls []uuid.UUID
+}
+
+func (f *fakeWebhookInstStore) Get(_ context.Context, id uuid.UUID) (*store.ChannelInstanceData, error) {
+ f.getCalls = append(f.getCalls, id)
+ inst, ok := f.byID[id]
+ if !ok {
+ return nil, errors.New("not found")
+ }
+ return inst, nil
+}
+
+func webhookReqFrame(t *testing.T, params map[string]any) *protocol.RequestFrame {
+ t.Helper()
+ raw, err := json.Marshal(params)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ return &protocol.RequestFrame{
+ Type: protocol.FrameTypeRequest,
+ ID: "req-1",
+ Method: protocol.MethodChannelInstancesZaloWebhookURL,
+ Params: raw,
+ }
+}
+
+// readResp drains a single response frame from the capturing client's send
+// channel. Fails the test if no frame is available.
+func readResp(t *testing.T, ch <-chan []byte) *protocol.ResponseFrame {
+ t.Helper()
+ select {
+ case raw := <-ch:
+ var resp protocol.ResponseFrame
+ if err := json.Unmarshal(raw, &resp); err != nil {
+ t.Fatalf("unmarshal response: %v\nraw: %s", err, raw)
+ }
+ return &resp
+ default:
+ t.Fatal("no response frame written by handler")
+ return nil
+ }
+}
+
+func TestZaloWebhookURL_OAInstance_ReturnsPathAndHint(t *testing.T) {
+ t.Parallel()
+ tenantID := uuid.New()
+ instID := uuid.New()
+ fs := &fakeWebhookInstStore{byID: map[uuid.UUID]*store.ChannelInstanceData{
+ instID: {BaseModel: store.BaseModel{ID: instID}, TenantID: tenantID, ChannelType: channels.TypeZaloOA, Name: "My OA"},
+ }}
+ m := NewZaloWebhookMethods(fs)
+ client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tenantID, "u")
+
+ m.handleWebhookURL(context.Background(), client,
+ webhookReqFrame(t, map[string]any{"instance_id": instID.String()}))
+
+ resp := readResp(t, ch)
+ if resp.Error != nil {
+ t.Fatalf("unexpected error: %+v", resp.Error)
+ }
+ payload, _ := resp.Payload.(map[string]any)
+ if payload == nil {
+ t.Fatalf("nil result payload; resp=%+v", resp)
+ }
+ wantPath := "/channels/zalo/webhook/my-oa"
+ if got, _ := payload["path"].(string); got != wantPath {
+ t.Errorf("path = %q, want %q", got, wantPath)
+ }
+ if got, _ := payload["slug"].(string); got != "my-oa" {
+ t.Errorf("slug = %q, want my-oa", got)
+ }
+ if got, _ := payload["instance_id"].(string); got != instID.String() {
+ t.Errorf("instance_id = %q, want %q", got, instID.String())
+ }
+ if hint, _ := payload["hint"].(string); hint == "" {
+ t.Error("hint should be non-empty (operator guidance)")
+ }
+}
+
+func TestZaloWebhookURL_RespectsExplicitWebhookPath(t *testing.T) {
+ t.Parallel()
+ tenantID := uuid.New()
+ instID := uuid.New()
+ cfg := json.RawMessage(`{"webhook_path":"custom-slug"}`)
+ fs := &fakeWebhookInstStore{byID: map[uuid.UUID]*store.ChannelInstanceData{
+ instID: {BaseModel: store.BaseModel{ID: instID}, TenantID: tenantID, ChannelType: channels.TypeZaloOA, Name: "Ignored Name", Config: cfg},
+ }}
+ m := NewZaloWebhookMethods(fs)
+ client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tenantID, "u")
+
+ m.handleWebhookURL(context.Background(), client,
+ webhookReqFrame(t, map[string]any{"instance_id": instID.String()}))
+
+ resp := readResp(t, ch)
+ if resp.Error != nil {
+ t.Fatalf("unexpected error: %+v", resp.Error)
+ }
+ payload, _ := resp.Payload.(map[string]any)
+ if got, _ := payload["path"].(string); got != "/channels/zalo/webhook/custom-slug" {
+ t.Errorf("path = %q, want /channels/zalo/webhook/custom-slug", got)
+ }
+}
+
+func TestZaloWebhookURL_BotInstance_ReturnsPath(t *testing.T) {
+ t.Parallel()
+ tenantID := uuid.New()
+ instID := uuid.New()
+ fs := &fakeWebhookInstStore{byID: map[uuid.UUID]*store.ChannelInstanceData{
+ instID: {BaseModel: store.BaseModel{ID: instID}, TenantID: tenantID, ChannelType: channels.TypeZaloBot, Name: "support-bot"},
+ }}
+ m := NewZaloWebhookMethods(fs)
+ client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tenantID, "u")
+
+ m.handleWebhookURL(context.Background(), client,
+ webhookReqFrame(t, map[string]any{"instance_id": instID.String()}))
+
+ resp := readResp(t, ch)
+ if resp.Error != nil {
+ t.Fatalf("unexpected error: %+v", resp.Error)
+ }
+ payload, _ := resp.Payload.(map[string]any)
+ wantPath := "/channels/zalo/webhook/support-bot"
+ if got, _ := payload["path"].(string); got != wantPath {
+ t.Errorf("path = %q, want %q", got, wantPath)
+ }
+}
+
+func TestZaloWebhookURL_InvalidUUID_ReturnsInvalidRequest(t *testing.T) {
+ t.Parallel()
+ fs := &fakeWebhookInstStore{byID: map[uuid.UUID]*store.ChannelInstanceData{}}
+ m := NewZaloWebhookMethods(fs)
+ client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, uuid.New(), "u")
+
+ m.handleWebhookURL(context.Background(), client,
+ webhookReqFrame(t, map[string]any{"instance_id": "not-a-uuid"}))
+
+ resp := readResp(t, ch)
+ if resp.Error == nil || resp.Error.Code != protocol.ErrInvalidRequest {
+ t.Errorf("error code = %+v, want %s", resp.Error, protocol.ErrInvalidRequest)
+ }
+ if len(fs.getCalls) != 0 {
+ t.Errorf("store.Get called %d times; want 0 (early-return on bad UUID)", len(fs.getCalls))
+ }
+}
+
+func TestZaloWebhookURL_UnknownInstance_ReturnsNotFound(t *testing.T) {
+ t.Parallel()
+ fs := &fakeWebhookInstStore{byID: map[uuid.UUID]*store.ChannelInstanceData{}}
+ m := NewZaloWebhookMethods(fs)
+ client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, uuid.New(), "u")
+
+ m.handleWebhookURL(context.Background(), client,
+ webhookReqFrame(t, map[string]any{"instance_id": uuid.New().String()}))
+
+ resp := readResp(t, ch)
+ if resp.Error == nil || resp.Error.Code != protocol.ErrNotFound {
+ t.Errorf("error code = %+v, want %s", resp.Error, protocol.ErrNotFound)
+ }
+}
+
+func TestZaloWebhookURL_CrossTenant_ReturnsNotFound(t *testing.T) {
+ t.Parallel()
+ clientTenant := uuid.New()
+ otherTenant := uuid.New()
+ instID := uuid.New()
+ fs := &fakeWebhookInstStore{byID: map[uuid.UUID]*store.ChannelInstanceData{
+ instID: {BaseModel: store.BaseModel{ID: instID}, TenantID: otherTenant, ChannelType: channels.TypeZaloOA},
+ }}
+ m := NewZaloWebhookMethods(fs)
+ client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, clientTenant, "u")
+
+ m.handleWebhookURL(context.Background(), client,
+ webhookReqFrame(t, map[string]any{"instance_id": instID.String()}))
+
+ resp := readResp(t, ch)
+ if resp.Error == nil || resp.Error.Code != protocol.ErrNotFound {
+ t.Errorf("error code = %+v, want %s (cross-tenant must not leak)", resp.Error, protocol.ErrNotFound)
+ }
+ // Defense-in-depth: error message must NOT include the instance UUID
+ // (don't help an attacker confirm an instance exists in another tenant).
+ if resp.Error != nil && strings.Contains(resp.Error.Message, instID.String()) {
+ t.Errorf("error message leaks instance UUID: %q", resp.Error.Message)
+ }
+}
+
+func TestZaloWebhookURL_WrongChannelType_ReturnsInvalidRequest(t *testing.T) {
+ t.Parallel()
+ tenantID := uuid.New()
+ instID := uuid.New()
+ fs := &fakeWebhookInstStore{byID: map[uuid.UUID]*store.ChannelInstanceData{
+ instID: {BaseModel: store.BaseModel{ID: instID}, TenantID: tenantID, ChannelType: channels.TypeTelegram},
+ }}
+ m := NewZaloWebhookMethods(fs)
+ client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tenantID, "u")
+
+ m.handleWebhookURL(context.Background(), client,
+ webhookReqFrame(t, map[string]any{"instance_id": instID.String()}))
+
+ resp := readResp(t, ch)
+ if resp.Error == nil || resp.Error.Code != protocol.ErrInvalidRequest {
+ t.Errorf("error code = %+v, want %s", resp.Error, protocol.ErrInvalidRequest)
+ }
+}
diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go
index 180f87c545..bdcc43972f 100644
--- a/internal/http/channel_instances.go
+++ b/internal/http/channel_instances.go
@@ -556,7 +556,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", "facebook", "pancake":
+ case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_bot", "zalo_personal", "feishu", "facebook", "pancake":
return true
}
return false
diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go
index 61af216afc..cd92aa0eda 100644
--- a/internal/i18n/catalog_en.go
+++ b/internal/i18n/catalog_en.go
@@ -224,6 +224,35 @@ func init() {
MsgHookPerTurnCapReached: "hook invocation per-turn cap reached",
MsgHookBuiltinReadOnly: "builtin hooks are read-only except for the enabled toggle",
+ // Zalo OA OAuth channel
+ MsgZaloOACodeExchangeFailed: "zalo oauth code exchange failed: %s",
+ MsgZaloOAInvalidChannelType: "instance is not a zalo_oa channel",
+ MsgZaloOAConnected: "zalo official account connected: %s",
+ MsgZaloOAInvalidState: "oauth state token is invalid or expired",
+ MsgZaloOARedirectURIRequired: "credentials.redirect_uri is required and must exactly match the callback registered in your Zalo developer console",
+ MsgZaloOAMissingAppID: "credentials.app_id is required — set it on the channel before requesting the consent URL",
+ MsgZaloOAStateGenFailed: "failed to generate consent state token; please retry",
+ MsgZaloOAOAIDMismatch: "callback URL belongs to a different OA — paste the URL from THIS instance's consent page",
+
+ // Zalo webhook URL RPC
+ MsgZaloWebhookWrongChannelType: "channels.instances.zalo.webhook_url only applies to zalo_bot or zalo_oa instances",
+ MsgZaloWebhookPathHint: "Prepend your gateway's externally-reachable URL (e.g. https://gw.example.com) to the path, then register the full URL in the Zalo developer console.",
+
+ // Zalo OA runtime error catalog. Args: (code int, raw_message string)
+ MsgZaloOAErrAuth: "Zalo rejected the access token after a refresh retry (code %d: %s); re-authorize the OA",
+ MsgZaloOAErrRefreshExpired: "Zalo refresh token has expired (code %d: %s); operator must re-consent in the OA console",
+ MsgZaloOAErrPayload: "Zalo rejected the request payload (code %d: %s); verify message shape and required fields",
+ MsgZaloOAErrSize: "Zalo upload exceeds the size cap (code %d: %s); image 1MB / file 5MB / gif 5MB",
+ MsgZaloOAErrPermission: "Zalo requires additional permission for this call (code %d: %s); grant the missing scope to the OA app",
+ MsgZaloOAErrInteractionWindow: "Recipient is outside Zalo's messaging window (code %d: %s); wait for the user to message first or use a paid template",
+ MsgZaloOAErrUserNotVisible: "Target user is not visible to this OA (code %d: %s)",
+ MsgZaloOAErrAppDisabled: "Zalo app is disabled or banned (code %d: %s); contact Zalo support",
+ MsgZaloOAErrRate: "Zalo quota exhausted (code %d: %s); wait for the quota window to reset",
+ MsgZaloOAErrServer: "Zalo returned a temporary server error (code %d: %s); retry later",
+ MsgZaloOAErrRedirectURI: "Zalo rejected the OAuth redirect_uri (code %d: %s); update the redirect URI in the Zalo console to match the channel config",
+ MsgZaloOAReauthDueSoon: "Refresh token expires in %d day(s); re-authorize the OA to avoid downtime",
+ MsgZaloOAUnsupportedAttachment: "(File %q (%s) cannot be delivered via Zalo OA — only PDF/DOC/DOCX are accepted. Content described above.)",
+
// Message tool cross-target forward notice
MessageCrossTargetForwarded: "📤 Forwarded to %s as requested: %q",
})
diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go
index 93ba0d9736..fa28cfb566 100644
--- a/internal/i18n/catalog_vi.go
+++ b/internal/i18n/catalog_vi.go
@@ -224,6 +224,35 @@ func init() {
MsgHookPerTurnCapReached: "đã đạt giới hạn số lần gọi hook trong một lượt",
MsgHookBuiltinReadOnly: "hook dựng sẵn chỉ cho phép bật/tắt, không thể chỉnh sửa",
+ // Zalo OA OAuth channel
+ MsgZaloOACodeExchangeFailed: "đổi mã xác thực Zalo OAuth thất bại: %s",
+ MsgZaloOAInvalidChannelType: "kênh không phải loại zalo_oa",
+ MsgZaloOAConnected: "đã kết nối tài khoản Zalo OA: %s",
+ MsgZaloOAInvalidState: "mã state OAuth không hợp lệ hoặc đã hết hạn",
+ MsgZaloOARedirectURIRequired: "credentials.redirect_uri là bắt buộc và phải khớp chính xác với callback đã đăng ký trong Zalo developer console",
+ MsgZaloOAMissingAppID: "credentials.app_id là bắt buộc — hãy nhập app_id cho kênh trước khi yêu cầu URL cấp quyền",
+ MsgZaloOAStateGenFailed: "không thể sinh mã state cấp quyền; vui lòng thử lại",
+ MsgZaloOAOAIDMismatch: "URL callback thuộc về một OA khác — hãy dán URL lấy từ trang cấp quyền của instance NÀY",
+
+ // RPC URL webhook Zalo
+ MsgZaloWebhookWrongChannelType: "channels.instances.zalo.webhook_url chỉ áp dụng cho instance zalo_bot hoặc zalo_oa",
+ MsgZaloWebhookPathHint: "Thêm URL công khai của gateway (ví dụ https://gw.example.com) vào trước đường dẫn, rồi đăng ký URL đầy đủ trong Zalo developer console.",
+
+ // Catalog lỗi runtime của Zalo OA. Tham số: (mã int, thông điệp gốc)
+ MsgZaloOAErrAuth: "Zalo từ chối access token sau khi đã làm mới (mã %d: %s); cần ủy quyền lại OA",
+ MsgZaloOAErrRefreshExpired: "Refresh token Zalo đã hết hạn (mã %d: %s); người vận hành phải cấp lại quyền trong OA console",
+ MsgZaloOAErrPayload: "Zalo từ chối nội dung yêu cầu (mã %d: %s); kiểm tra cấu trúc tin nhắn và các trường bắt buộc",
+ MsgZaloOAErrSize: "Tệp tải lên Zalo vượt giới hạn (mã %d: %s); ảnh 1MB / tệp 5MB / gif 5MB",
+ MsgZaloOAErrPermission: "Zalo yêu cầu quyền bổ sung cho thao tác này (mã %d: %s); cấp quyền còn thiếu cho ứng dụng OA",
+ MsgZaloOAErrInteractionWindow: "Người nhận đang ngoài cửa sổ tương tác của Zalo (mã %d: %s); chờ người dùng nhắn trước hoặc dùng tin mẫu trả phí",
+ MsgZaloOAErrUserNotVisible: "OA không thấy được người dùng đích (mã %d: %s)",
+ MsgZaloOAErrAppDisabled: "Ứng dụng Zalo đã bị vô hiệu hoặc bị cấm (mã %d: %s); liên hệ hỗ trợ Zalo",
+ MsgZaloOAErrRate: "Quota Zalo đã hết (mã %d: %s); chờ cửa sổ quota làm mới",
+ MsgZaloOAErrServer: "Zalo trả về lỗi server tạm thời (mã %d: %s); thử lại sau",
+ MsgZaloOAErrRedirectURI: "Zalo từ chối OAuth redirect_uri (mã %d: %s); cập nhật redirect URI trong Zalo console khớp với cấu hình kênh",
+ MsgZaloOAReauthDueSoon: "Refresh token sẽ hết hạn trong %d ngày; vui lòng cấp quyền lại OA để tránh gián đoạn",
+ MsgZaloOAUnsupportedAttachment: "(Tệp %q (%s) không thể gửi qua Zalo OA — chỉ chấp nhận PDF/DOC/DOCX. Nội dung đã mô tả ở trên.)",
+
// Message tool cross-target forward notice
MessageCrossTargetForwarded: "📤 Đã forward sang %s theo yêu cầu: %q",
})
diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go
index 0d840cdb7b..0254b3bd8e 100644
--- a/internal/i18n/catalog_zh.go
+++ b/internal/i18n/catalog_zh.go
@@ -224,6 +224,35 @@ func init() {
MsgHookPerTurnCapReached: "单轮钩子调用次数已达上限",
MsgHookBuiltinReadOnly: "内置钩子只读,仅允许切换启用状态",
+ // Zalo OA OAuth 渠道
+ MsgZaloOACodeExchangeFailed: "Zalo OAuth 授权码交换失败:%s",
+ MsgZaloOAInvalidChannelType: "实例不是 zalo_oa 类型",
+ MsgZaloOAConnected: "已连接 Zalo 公众号:%s",
+ MsgZaloOAInvalidState: "OAuth state 令牌无效或已过期",
+ MsgZaloOARedirectURIRequired: "credentials.redirect_uri 必填,且必须与 Zalo 开发者控制台注册的回调完全一致",
+ MsgZaloOAMissingAppID: "credentials.app_id 必填 — 请先在通道中设置 app_id 再请求授权 URL",
+ MsgZaloOAStateGenFailed: "无法生成授权 state 令牌,请重试",
+ MsgZaloOAOAIDMismatch: "回调 URL 属于另一个 OA — 请粘贴当前实例授权页面的 URL",
+
+ // Zalo Webhook URL RPC
+ MsgZaloWebhookWrongChannelType: "channels.instances.zalo.webhook_url 仅适用于 zalo_bot 或 zalo_oa 类型的实例",
+ MsgZaloWebhookPathHint: "在路径前加上网关的公网 URL(例如 https://gw.example.com),然后将完整 URL 注册到 Zalo 开发者控制台。",
+
+ // Zalo OA 运行时错误目录。参数:(代码 int, 原始消息 string)
+ MsgZaloOAErrAuth: "Zalo 在刷新令牌后仍拒绝 access token(代码 %d:%s);需重新授权该公众号",
+ MsgZaloOAErrRefreshExpired: "Zalo refresh token 已过期(代码 %d:%s);运营人员必须在 OA 控制台重新授权",
+ MsgZaloOAErrPayload: "Zalo 拒绝该请求载荷(代码 %d:%s);请检查消息结构与必填字段",
+ MsgZaloOAErrSize: "Zalo 上传文件超出大小上限(代码 %d:%s);图片 1MB / 文件 5MB / GIF 5MB",
+ MsgZaloOAErrPermission: "Zalo 此操作需要额外权限(代码 %d:%s);请为 OA 应用授予所缺少的范围",
+ MsgZaloOAErrInteractionWindow: "接收方处于 Zalo 消息窗口之外(代码 %d:%s);请等待用户先发起会话或使用付费模板",
+ MsgZaloOAErrUserNotVisible: "目标用户对该 OA 不可见(代码 %d:%s)",
+ MsgZaloOAErrAppDisabled: "Zalo 应用已被禁用或封禁(代码 %d:%s);请联系 Zalo 支持",
+ MsgZaloOAErrRate: "Zalo 配额已耗尽(代码 %d:%s);请等待配额窗口重置",
+ MsgZaloOAErrServer: "Zalo 返回临时服务器错误(代码 %d:%s);请稍后重试",
+ MsgZaloOAErrRedirectURI: "Zalo 拒绝 OAuth redirect_uri(代码 %d:%s);请在 Zalo 控制台更新 redirect URI 以匹配渠道配置",
+ MsgZaloOAReauthDueSoon: "Refresh Token 将在 %d 天后到期,请重新授权 OA 以避免中断",
+ MsgZaloOAUnsupportedAttachment: "(文件 %q(%s)无法通过 Zalo OA 投递 — 仅接受 PDF/DOC/DOCX。内容已在上文说明。)",
+
// Message tool cross-target forward notice
MessageCrossTargetForwarded: "📤 已按请求转发至 %s:%q",
})
diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go
index 348012ff3f..f16947f536 100644
--- a/internal/i18n/keys.go
+++ b/internal/i18n/keys.go
@@ -228,4 +228,35 @@ const (
MsgHookBudgetExceeded = "hook.budget_exceeded" // "tenant hook token budget exceeded"
MsgHookPerTurnCapReached = "hook.per_turn_cap_reached" // "hook invocation per-turn cap reached"
MsgHookBuiltinReadOnly = "hook.builtin_readonly" // "builtin hooks are read-only except for the enabled toggle"
+
+ // --- Zalo OA OAuth channel ---
+ MsgZaloOACodeExchangeFailed = "error.zalo_oa_code_exchange_failed" // "zalo oauth code exchange failed: %s"
+ MsgZaloOAInvalidChannelType = "error.zalo_oa_invalid_channel_type" // "instance is not a zalo_oa channel"
+ MsgZaloOAConnected = "info.zalo_oa_connected" // "zalo official account connected: %s"
+ MsgZaloOAInvalidState = "error.zalo_oa_invalid_state" // "oauth state token is invalid or expired"
+ MsgZaloOARedirectURIRequired = "error.zalo_oa_redirect_uri_required" // "credentials.redirect_uri is required and must match the dev-console callback"
+ MsgZaloOAMissingAppID = "error.zalo_oa_missing_app_id" // "credentials.app_id is required (set it on the channel before requesting consent URL)"
+ MsgZaloOAStateGenFailed = "error.zalo_oa_state_gen_failed" // "failed to generate state token"
+ MsgZaloOAOAIDMismatch = "error.zalo_oa_oaid_mismatch" // "callback OA differs from instance OA — paste the URL from THIS instance's consent page"
+
+ // --- Zalo webhook URL RPC ---
+ MsgZaloWebhookWrongChannelType = "error.zalo_webhook_wrong_channel_type" // "channels.instances.zalo.webhook_url only applies to zalo_bot or zalo_oa"
+ MsgZaloWebhookPathHint = "info.zalo_webhook_path_hint" // "Prepend your gateway's externally-reachable URL ..."
+
+ // --- Zalo OA runtime error catalog (used for MarkFailed reason). Args: code, raw message ---
+ MsgZaloOAErrAuth = "error.zalo_oa_err_auth" // access_token rejected after refresh
+ MsgZaloOAErrRefreshExpired = "error.zalo_oa_err_refresh_expired" // refresh token dead, re-consent required
+ MsgZaloOAErrPayload = "error.zalo_oa_err_payload" // request shape rejected
+ MsgZaloOAErrSize = "error.zalo_oa_err_size" // attachment over endpoint cap
+ MsgZaloOAErrPermission = "error.zalo_oa_err_permission" // missing OA scope
+ MsgZaloOAErrInteractionWindow = "error.zalo_oa_err_interaction_window" // user outside messaging window
+ MsgZaloOAErrUserNotVisible = "error.zalo_oa_err_user_not_visible" // recipient not opted in / hidden
+ MsgZaloOAErrAppDisabled = "error.zalo_oa_err_app_disabled" // Zalo app banned/disabled
+ MsgZaloOAErrRate = "error.zalo_oa_err_rate" // quota exhausted
+ MsgZaloOAErrServer = "error.zalo_oa_err_server" // upstream temporary failure
+ MsgZaloOAErrRedirectURI = "error.zalo_oa_err_redirect_uri" // OAuth redirect_uri mismatch
+ MsgZaloOAReauthDueSoon = "info.zalo_oa_reauth_due_soon" // refresh token nearing expiry; re-consent ahead of downtime. Args: days
+
+ // User-facing fallback when an unsupported attachment is dropped. Args: filename, mime
+ MsgZaloOAUnsupportedAttachment = "info.zalo_oa_unsupported_attachment"
)
diff --git a/internal/permissions/policy.go b/internal/permissions/policy.go
index 9c75d61df4..07ef732cb8 100644
--- a/internal/permissions/policy.go
+++ b/internal/permissions/policy.go
@@ -228,6 +228,9 @@ func isAdminMethod(method string) bool {
protocol.MethodChannelInstancesCreate,
protocol.MethodChannelInstancesUpdate,
protocol.MethodChannelInstancesDelete,
+ protocol.MethodChannelInstancesZaloOAConsentURL,
+ protocol.MethodChannelInstancesZaloOAExchangeCode,
+ protocol.MethodChannelInstancesZaloWebhookURL,
// Pairing management (approve/revoke/list/deny require admin).
protocol.MethodPairingApprove,
diff --git a/internal/permissions/policy_test.go b/internal/permissions/policy_test.go
index 03d84592bd..6ecf67c327 100644
--- a/internal/permissions/policy_test.go
+++ b/internal/permissions/policy_test.go
@@ -314,6 +314,25 @@ func TestValidScope(t *testing.T) {
// wrongly classifying exec.approval.list as RoleOperator. exec.approval.list
// is an explicit entry in isReadMethod and must resolve to RoleViewer.
+func TestMethodRole_ZaloOA_IsAdmin(t *testing.T) {
+ // Both consent_url + exchange_code mutate channel_instance credentials
+ // (or generate state for an upcoming mutation), so they sit alongside
+ // channels.instances.create/update/delete in the admin-only block.
+ if got := MethodRole(protocol.MethodChannelInstancesZaloOAConsentURL); got != RoleAdmin {
+ t.Fatalf("zalo_oa.consent_url must be RoleAdmin; got %q", got)
+ }
+ if got := MethodRole(protocol.MethodChannelInstancesZaloOAExchangeCode); got != RoleAdmin {
+ t.Fatalf("zalo_oa.exchange_code must be RoleAdmin; got %q", got)
+ }
+ // webhook_url returns the operator-bound URL for both zalo_bot and
+ // zalo_oa. The URL embeds the instance ID, so it is config-shape data
+ // that must sit alongside channel mutation operations on the admin
+ // allowlist (not viewer / operator).
+ if got := MethodRole(protocol.MethodChannelInstancesZaloWebhookURL); got != RoleAdmin {
+ t.Fatalf("zalo.webhook_url must be RoleAdmin; got %q", got)
+ }
+}
+
func TestMethodRole_ApprovalsList_IsViewer(t *testing.T) {
if got := MethodRole(protocol.MethodApprovalsList); got != RoleViewer {
t.Fatalf("exec.approval.list must be RoleViewer (listed in isReadMethod); got %q", got)
diff --git a/internal/store/channel_instance_store.go b/internal/store/channel_instance_store.go
index f5f8fb1f39..2c2598ecbf 100644
--- a/internal/store/channel_instance_store.go
+++ b/internal/store/channel_instance_store.go
@@ -30,7 +30,7 @@ func IsDefaultChannelInstance(name string) bool {
}
// Legacy config-based defaults that were seeded with bare channel-type names.
switch name {
- case "telegram", "discord", "feishu", "zalo_oa", "whatsapp":
+ case "telegram", "discord", "feishu", "zalo_oa", "zalo_bot", "whatsapp":
return true
}
return false
@@ -49,6 +49,16 @@ type ChannelInstanceStore interface {
Get(ctx context.Context, id uuid.UUID) (*ChannelInstanceData, error)
GetByName(ctx context.Context, name string) (*ChannelInstanceData, error)
Update(ctx context.Context, id uuid.UUID, updates map[string]any) error
+ // MergeConfig applies a top-level JSONB merge of `partial` into the
+ // instance's config column atomically at the SQL layer. Existing keys
+ // not present in `partial` are preserved. Used by background workers
+ // (e.g. polling cursors) to avoid clobbering operator-set fields when
+ // they only own a single config sub-key.
+ //
+ // Nil values in `partial` are stripped before merge so PG (`||`,
+ // preserves nulls) and SQLite (`json_patch`, deletes null keys) agree —
+ // callers wanting to delete a key must do it explicitly via Update.
+ MergeConfig(ctx context.Context, id uuid.UUID, partial map[string]any) error
Delete(ctx context.Context, id uuid.UUID) error
ListEnabled(ctx context.Context) ([]ChannelInstanceData, error)
ListAll(ctx context.Context) ([]ChannelInstanceData, error)
diff --git a/internal/store/pg/channel_instances.go b/internal/store/pg/channel_instances.go
index 6807ce2f3c..f4447c620b 100644
--- a/internal/store/pg/channel_instances.go
+++ b/internal/store/pg/channel_instances.go
@@ -15,6 +15,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/internal/store/base"
)
// PGChannelInstanceStore implements store.ChannelInstanceStore backed by Postgres.
@@ -170,69 +171,188 @@ func (s *PGChannelInstanceStore) scanInstances(rows *sql.Rows) ([]store.ChannelI
}
func (s *PGChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
- // Merge and encrypt credentials if present
+ // Credentials path: load+merge+write under SELECT FOR UPDATE so a
+ // concurrent operator Update vs background tokenSource.Persist can't
+ // clobber each other on the encrypted blob.
if credsVal, ok := updates["credentials"]; ok && credsVal != nil {
- var newCreds map[string]any
- switch v := credsVal.(type) {
- case map[string]any:
- newCreds = v
+ return s.updateCredentialsTx(ctx, id, updates, credsVal)
+ }
+ updates["updated_at"] = time.Now()
+ if store.IsCrossTenant(ctx) {
+ return execMapUpdate(ctx, s.db, "channel_instances", id, updates)
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+ return execMapUpdateWhereTenant(ctx, s.db, "channel_instances", updates, id, tid)
+}
+
+// updateCredentialsTx merges the credentials patch under a row-level lock
+// to serialize concurrent writers (operator UI vs token refresh persist).
+func (s *PGChannelInstanceStore) updateCredentialsTx(ctx context.Context, id uuid.UUID, updates map[string]any, credsVal any) error {
+ var newCreds map[string]any
+ switch v := credsVal.(type) {
+ case map[string]any:
+ newCreds = v
+ default:
+ var raw []byte
+ switch vv := v.(type) {
+ case []byte:
+ raw = vv
+ case string:
+ raw = []byte(vv)
default:
- var raw []byte
- switch vv := v.(type) {
- case []byte:
- raw = vv
- case string:
- raw = []byte(vv)
- default:
- if b, err := json.Marshal(v); err == nil {
- raw = b
- }
+ if b, err := json.Marshal(v); err == nil {
+ raw = b
}
- if len(raw) > 0 {
- if err := json.Unmarshal(raw, &newCreds); err != nil {
- newCreds = nil
- }
+ }
+ if len(raw) > 0 {
+ if err := json.Unmarshal(raw, &newCreds); err != nil {
+ newCreds = nil
}
}
+ }
- // Merge with existing credentials so partial updates don't wipe other fields
- if len(newCreds) > 0 {
- existing, err := s.loadExistingCreds(ctx, id)
- if err != nil {
- return fmt.Errorf("load existing credentials for merge: %w", err)
+ tid := store.TenantIDFromContext(ctx)
+ crossTenant := store.IsCrossTenant(ctx)
+ if !crossTenant && tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for update")
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin tx: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ var raw []byte
+ if crossTenant {
+ err = tx.QueryRowContext(ctx,
+ "SELECT credentials FROM channel_instances WHERE id = $1 FOR UPDATE", id,
+ ).Scan(&raw)
+ } else {
+ err = tx.QueryRowContext(ctx,
+ "SELECT credentials FROM channel_instances WHERE id = $1 AND tenant_id = $2 FOR UPDATE", id, tid,
+ ).Scan(&raw)
+ }
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return fmt.Errorf("lock credentials: %w", err)
+ }
+
+ existing := map[string]any{}
+ if len(raw) > 0 {
+ decoded := raw
+ if s.encKey != "" {
+ if dec, decErr := crypto.Decrypt(string(raw), s.encKey); decErr == nil {
+ decoded = []byte(dec)
+ } else if !json.Valid(raw) {
+ return fmt.Errorf("decrypt existing credentials: %w", decErr)
}
- maps.Copy(existing, newCreds)
- newCreds = existing
}
-
- var credsBytes []byte
- if len(newCreds) > 0 {
- credsBytes, _ = json.Marshal(newCreds)
+ if err := json.Unmarshal(decoded, &existing); err != nil {
+ return fmt.Errorf("unmarshal existing credentials: %w", err)
}
- if len(credsBytes) > 0 && s.encKey != "" {
- encrypted, err := crypto.Encrypt(string(credsBytes), s.encKey)
- if err != nil {
- return fmt.Errorf("encrypt credentials: %w", err)
- }
- credsBytes = []byte(encrypted)
+ }
+ if len(newCreds) > 0 {
+ maps.Copy(existing, newCreds)
+ newCreds = existing
+ }
+
+ var credsBytes []byte
+ if len(newCreds) > 0 {
+ credsBytes, _ = json.Marshal(newCreds)
+ }
+ if len(credsBytes) > 0 && s.encKey != "" {
+ encrypted, err := crypto.Encrypt(string(credsBytes), s.encKey)
+ if err != nil {
+ return fmt.Errorf("encrypt credentials: %w", err)
}
- updates["credentials"] = credsBytes
+ credsBytes = []byte(encrypted)
}
+ updates["credentials"] = credsBytes
updates["updated_at"] = time.Now()
+
+ var query string
+ var args []any
+ if crossTenant {
+ query, args, err = base.BuildMapUpdate(pgDialect, "channel_instances", id, updates)
+ } else {
+ query, args, err = base.BuildMapUpdateWhereTenant(pgDialect, "channel_instances", updates, id, tid)
+ }
+ if err != nil {
+ return err
+ }
+ if query == "" {
+ return tx.Commit()
+ }
+ if _, err := tx.ExecContext(ctx, query, args...); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// MergeConfig atomically merges `partial` into the config JSONB column at
+// SQL level using `||` (top-level shallow merge — keys in `partial`
+// overwrite, keys only in existing are preserved). Avoids the
+// read-modify-write race that the application-layer Update path has
+// when two writers touch the same blob concurrently.
+func (s *PGChannelInstanceStore) MergeConfig(ctx context.Context, id uuid.UUID, partial map[string]any) error {
+ clean := stripNilValues(partial)
+ if len(clean) == 0 {
+ return nil
+ }
+ patch, err := json.Marshal(clean)
+ if err != nil {
+ return fmt.Errorf("marshal config patch: %w", err)
+ }
if store.IsCrossTenant(ctx) {
- return execMapUpdate(ctx, s.db, "channel_instances", id, updates)
+ _, err = s.db.ExecContext(ctx,
+ `UPDATE channel_instances
+ SET config = COALESCE(config, '{}'::jsonb) || $1::jsonb,
+ updated_at = $2
+ WHERE id = $3`,
+ patch, time.Now(), id)
+ return err
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
- return fmt.Errorf("tenant_id required for update")
+ return fmt.Errorf("tenant_id required for merge")
}
- return execMapUpdateWhereTenant(ctx, s.db, "channel_instances", updates, id, tid)
+ _, err = s.db.ExecContext(ctx,
+ `UPDATE channel_instances
+ SET config = COALESCE(config, '{}'::jsonb) || $1::jsonb,
+ updated_at = $2
+ WHERE id = $3 AND tenant_id = $4`,
+ patch, time.Now(), id, tid)
+ return err
+}
+
+// stripNilValues — see ChannelInstanceStore.MergeConfig contract.
+func stripNilValues(in map[string]any) map[string]any {
+ out := make(map[string]any, len(in))
+ for k, v := range in {
+ if v == nil {
+ continue
+ }
+ out[k] = v
+ }
+ return out
}
// loadExistingCreds reads and decrypts the current credentials for merging.
+// Surfaces decrypt/unmarshal errors instead of returning an empty map —
+// otherwise a transient read failure during a partial update would wipe
+// every other credential field on the merge.
func (s *PGChannelInstanceStore) loadExistingCreds(ctx context.Context, id uuid.UUID) (map[string]any, error) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required to load credentials")
+ }
var raw []byte
- err := s.db.QueryRowContext(ctx, "SELECT credentials FROM channel_instances WHERE id = $1", id).Scan(&raw)
+ err := s.db.QueryRowContext(ctx,
+ "SELECT credentials FROM channel_instances WHERE id = $1 AND tenant_id = $2", id, tid,
+ ).Scan(&raw)
if errors.Is(err, sql.ErrNoRows) || len(raw) == 0 {
return make(map[string]any), nil
}
@@ -240,13 +360,16 @@ func (s *PGChannelInstanceStore) loadExistingCreds(ctx context.Context, id uuid.
return nil, err
}
if s.encKey != "" {
- if dec, err := crypto.Decrypt(string(raw), s.encKey); err == nil {
+ dec, decErr := crypto.Decrypt(string(raw), s.encKey)
+ if decErr == nil {
raw = []byte(dec)
+ } else if !json.Valid(raw) {
+ return nil, fmt.Errorf("decrypt existing credentials: %w", decErr)
}
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
- return make(map[string]any), nil
+ return nil, fmt.Errorf("unmarshal existing credentials: %w", err)
}
return m, nil
}
diff --git a/internal/store/sqlitestore/channel_instances.go b/internal/store/sqlitestore/channel_instances.go
index dc88edced9..c7c1e8e646 100644
--- a/internal/store/sqlitestore/channel_instances.go
+++ b/internal/store/sqlitestore/channel_instances.go
@@ -6,6 +6,7 @@ import (
"context"
"database/sql"
"encoding/json"
+ "errors"
"fmt"
"log/slog"
"maps"
@@ -228,20 +229,86 @@ func (s *SQLiteChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, u
return execMapUpdateWhereTenant(ctx, s.db, "channel_instances", updates, id, tid)
}
+// MergeConfig atomically applies a top-level shallow merge of `partial`
+// into the config column using SQLite's json_patch (RFC 7396 semantics).
+// Avoids the read-modify-write race that plagues a Get → mutate → Update
+// pattern when concurrent writers touch different keys in the same blob.
+//
+// Caveat: json_patch removes keys whose value is null in the patch. The
+// only consumer (poll cursor) writes int64 values, so this is fine.
+func (s *SQLiteChannelInstanceStore) MergeConfig(ctx context.Context, id uuid.UUID, partial map[string]any) error {
+ clean := stripNilValues(partial)
+ if len(clean) == 0 {
+ return nil
+ }
+ patch, err := json.Marshal(clean)
+ if err != nil {
+ return fmt.Errorf("marshal config patch: %w", err)
+ }
+ if store.IsCrossTenant(ctx) {
+ _, err = s.db.ExecContext(ctx,
+ `UPDATE channel_instances
+ SET config = json_patch(COALESCE(config, '{}'), ?),
+ updated_at = ?
+ WHERE id = ?`,
+ string(patch), time.Now(), id)
+ return err
+ }
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return fmt.Errorf("tenant_id required for merge")
+ }
+ _, err = s.db.ExecContext(ctx,
+ `UPDATE channel_instances
+ SET config = json_patch(COALESCE(config, '{}'), ?),
+ updated_at = ?
+ WHERE id = ? AND tenant_id = ?`,
+ string(patch), time.Now(), id, tid)
+ return err
+}
+
+// stripNilValues — see ChannelInstanceStore.MergeConfig contract.
+func stripNilValues(in map[string]any) map[string]any {
+ out := make(map[string]any, len(in))
+ for k, v := range in {
+ if v == nil {
+ continue
+ }
+ out[k] = v
+ }
+ return out
+}
+
+// loadExistingCreds reads and decrypts the current credentials for merging.
+// Surfaces decrypt/unmarshal errors instead of returning an empty map —
+// otherwise a transient read failure during a partial update would wipe
+// every other credential field on the merge.
func (s *SQLiteChannelInstanceStore) loadExistingCreds(ctx context.Context, id uuid.UUID) (map[string]any, error) {
+ tid := store.TenantIDFromContext(ctx)
+ if tid == uuid.Nil {
+ return nil, fmt.Errorf("tenant_id required to load credentials")
+ }
var raw []byte
- err := s.db.QueryRowContext(ctx, "SELECT credentials FROM channel_instances WHERE id = ?", id).Scan(&raw)
- if err != nil || len(raw) == 0 {
+ err := s.db.QueryRowContext(ctx,
+ "SELECT credentials FROM channel_instances WHERE id = ? AND tenant_id = ?", id, tid,
+ ).Scan(&raw)
+ if errors.Is(err, sql.ErrNoRows) || len(raw) == 0 {
return make(map[string]any), nil
}
+ if err != nil {
+ return nil, err
+ }
if s.encKey != "" {
- if dec, err := crypto.Decrypt(string(raw), s.encKey); err == nil {
+ dec, decErr := crypto.Decrypt(string(raw), s.encKey)
+ if decErr == nil {
raw = []byte(dec)
+ } else if !json.Valid(raw) {
+ return nil, fmt.Errorf("decrypt existing credentials: %w", decErr)
}
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
- return make(map[string]any), nil
+ return nil, fmt.Errorf("unmarshal existing credentials: %w", err)
}
return m, nil
}
diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go
index 49a1510977..e7ee28c084 100644
--- a/internal/store/sqlitestore/schema.go
+++ b/internal/store/sqlitestore/schema.go
@@ -16,7 +16,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
-const SchemaVersion = 26
+const SchemaVersion = 27
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -561,6 +561,19 @@ ALTER TABLE agent_heartbeats_new RENAME TO agent_heartbeats;
CREATE INDEX IF NOT EXISTS idx_heartbeats_due
ON agent_heartbeats(next_run_at)
WHERE enabled = 1 AND next_run_at IS NOT NULL;`,
+
+ // Version 26 → 27: rename Zalo channel types to align with Zalo's product
+ // taxonomy (mirrors PG migration 000058). Three-step swap via zalo_oa_tmp
+ // sentinel — defensive even though channel_type has no unique constraint.
+ // Without this swap, Lite installs created under the old taxonomy carry
+ // 'zalo_oa' rows with Bot semantics that the new zalo_oa factory loads
+ // expecting OAuth credentials, and channels fail to start silently.
+ 26: `UPDATE channel_instances SET channel_type = 'zalo_oa_tmp' WHERE channel_type = 'zalo_oauth';
+UPDATE channel_instances SET channel_type = 'zalo_bot' WHERE channel_type = 'zalo_oa';
+UPDATE channel_instances SET channel_type = 'zalo_oa' WHERE channel_type = 'zalo_oa_tmp';
+UPDATE channel_contacts SET channel_type = 'zalo_oa_tmp' WHERE channel_type = 'zalo_oauth';
+UPDATE channel_contacts SET channel_type = 'zalo_bot' WHERE channel_type = 'zalo_oa';
+UPDATE channel_contacts SET channel_type = 'zalo_oa' WHERE channel_type = 'zalo_oa_tmp';`,
}
// addHooksTables is the SQLite incremental migration for schema v19 → v20.
diff --git a/internal/tools/web_shared.go b/internal/tools/web_shared.go
index c524707d31..903b2b5201 100644
--- a/internal/tools/web_shared.go
+++ b/internal/tools/web_shared.go
@@ -1,8 +1,11 @@
package tools
import (
+ "context"
+ "errors"
"fmt"
"net"
+ "net/http"
"net/url"
"strings"
"sync"
@@ -160,13 +163,20 @@ func isPrivateIP(ipStr string) bool {
}
// CheckSSRF validates a URL against SSRF attacks.
-// Returns an error if the URL targets a private/blocked host.
+// Returns an error if the URL targets a private/blocked host or uses a
+// scheme other than http/https.
func CheckSSRF(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
+ switch strings.ToLower(parsed.Scheme) {
+ case "http", "https":
+ default:
+ return fmt.Errorf("disallowed scheme %q", parsed.Scheme)
+ }
+
hostname := parsed.Hostname()
if hostname == "" {
return fmt.Errorf("missing hostname")
@@ -199,6 +209,55 @@ func CheckSSRF(rawURL string) error {
return nil
}
+// NewSSRFSafeClient returns an http.Client that pins each Dial to a
+// freshly-validated IP and re-runs CheckSSRF on every redirect hop —
+// closes DNS-rebind TOCTOU and 3xx-to-link-local bypasses. timeout=0
+// leaves the request ctx as the only deadline.
+func NewSSRFSafeClient(timeout time.Duration) *http.Client {
+ dialer := &net.Dialer{Timeout: 10 * time.Second}
+ transport := &http.Transport{
+ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+ host, port, err := net.SplitHostPort(addr)
+ if err != nil {
+ return nil, err
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ if isPrivateIP(host) {
+ return nil, fmt.Errorf("blocked private IP at dial: %s", host)
+ }
+ return dialer.DialContext(ctx, network, addr)
+ }
+ ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
+ if err != nil {
+ return nil, err
+ }
+ for _, ip := range ips {
+ if isPrivateIP(ip.IP.String()) {
+ return nil, fmt.Errorf("hostname %s resolves to private IP %s", host, ip.IP)
+ }
+ }
+ // Pin to the first validated IP — net stack won't re-resolve.
+ return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
+ },
+ MaxIdleConns: 10,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ }
+ return &http.Client{
+ Timeout: timeout,
+ Transport: transport,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 5 {
+ return errors.New("stopped after 5 redirects")
+ }
+ if err := CheckSSRF(req.URL.String()); err != nil {
+ return fmt.Errorf("redirect blocked: %w", err)
+ }
+ return nil
+ },
+ }
+}
+
// --- External Content Wrapping (matching TS src/security/external-content.ts) ---
const (
diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go
index fc18492ddf..2f367bb667 100644
--- a/internal/upgrade/version.go
+++ b/internal/upgrade/version.go
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
-const RequiredSchemaVersion uint = 57
+const RequiredSchemaVersion uint = 58
diff --git a/migrations/000058_rename_zalo_channel_types.down.sql b/migrations/000058_rename_zalo_channel_types.down.sql
new file mode 100644
index 0000000000..c68d4fdf62
--- /dev/null
+++ b/migrations/000058_rename_zalo_channel_types.down.sql
@@ -0,0 +1,17 @@
+-- Reverse of 000058 up: zalo_oa ↔ zalo_bot only.
+-- Up resurrected the transient 'zalo_oauth' name for symmetry, but the
+-- runtime allowlists (gateway/methods/channel_instances.go and
+-- http/channel_instances.go) reject 'zalo_oauth', so a down rollback that
+-- recreates it leaves operators with rows they can't edit.
+--
+-- ROLLBACK CAVEAT: must run alongside a binary revert. The post-up code
+-- treats 'zalo_bot' as Bot semantics; this down restores them to 'zalo_oa'
+-- which the new binary rejects. Old binary expects the swapped names back.
+
+UPDATE channel_instances SET channel_type = 'zalo_oa_tmp' WHERE channel_type = 'zalo_oa';
+UPDATE channel_instances SET channel_type = 'zalo_oa' WHERE channel_type = 'zalo_bot';
+UPDATE channel_instances SET channel_type = 'zalo_bot' WHERE channel_type = 'zalo_oa_tmp';
+
+UPDATE channel_contacts SET channel_type = 'zalo_oa_tmp' WHERE channel_type = 'zalo_oa';
+UPDATE channel_contacts SET channel_type = 'zalo_oa' WHERE channel_type = 'zalo_bot';
+UPDATE channel_contacts SET channel_type = 'zalo_bot' WHERE channel_type = 'zalo_oa_tmp';
diff --git a/migrations/000058_rename_zalo_channel_types.up.sql b/migrations/000058_rename_zalo_channel_types.up.sql
new file mode 100644
index 0000000000..250bcdfd45
--- /dev/null
+++ b/migrations/000058_rename_zalo_channel_types.up.sql
@@ -0,0 +1,23 @@
+-- Rename Zalo channel types in channel_instances to align with Zalo's
+-- own product taxonomy. Pre-refactor names inverted reality:
+-- 'zalo_oa' → static-token Bot variant (actually "zalo_bot")
+-- 'zalo_oauth' → phone-tied Official Account via OAuth (the canonical "zalo_oa")
+--
+-- 'zalo_oauth' was a transient name introduced inside this PR's commit
+-- chain and never released. Production DBs only carry the legacy
+-- 'zalo_oa' rows (Bot semantics) that must flip to 'zalo_bot'.
+--
+-- Three-step swap via zalo_oa_tmp sentinel keeps the rename collision-safe
+-- even though channel_type has no unique constraint today. golang-migrate's
+-- schema_migrations table prevents re-runs, so no idempotency guard is
+-- needed (and an EXISTS('zalo_oauth') guard would silently no-op on prod).
+
+UPDATE channel_instances SET channel_type = 'zalo_oa_tmp' WHERE channel_type = 'zalo_oauth';
+UPDATE channel_instances SET channel_type = 'zalo_bot' WHERE channel_type = 'zalo_oa';
+UPDATE channel_instances SET channel_type = 'zalo_oa' WHERE channel_type = 'zalo_oa_tmp';
+
+-- channel_contacts.channel_type stores the same taxonomy and is read by
+-- ResolveTenantUserID. Skipping the swap silently loses per-user mappings.
+UPDATE channel_contacts SET channel_type = 'zalo_oa_tmp' WHERE channel_type = 'zalo_oauth';
+UPDATE channel_contacts SET channel_type = 'zalo_bot' WHERE channel_type = 'zalo_oa';
+UPDATE channel_contacts SET channel_type = 'zalo_oa' WHERE channel_type = 'zalo_oa_tmp';
diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go
index c57e35f654..f01c3a08cc 100644
--- a/pkg/protocol/methods.go
+++ b/pkg/protocol/methods.go
@@ -112,6 +112,15 @@ const (
MethodChannelInstancesCreate = "channels.instances.create"
MethodChannelInstancesUpdate = "channels.instances.update"
MethodChannelInstancesDelete = "channels.instances.delete"
+
+ // Zalo OA OAuth (paste-code consent flow). zalo_oa-only.
+ MethodChannelInstancesZaloOAConsentURL = "channels.instances.zalo_oa.consent_url"
+ MethodChannelInstancesZaloOAExchangeCode = "channels.instances.zalo_oa.exchange_code"
+
+ // Zalo webhook URL discovery — path-only; operator prepends host.
+ // Channel-family endpoint (no bot/oa suffix): handler dispatches on
+ // the resolved channel_type and serves both zalo_bot and zalo_oa.
+ MethodChannelInstancesZaloWebhookURL = "channels.instances.zalo.webhook_url"
)
// Agent links (inter-agent delegation)
diff --git a/tests/integration/zalo_oa_lifecycle_test.go b/tests/integration/zalo_oa_lifecycle_test.go
new file mode 100644
index 0000000000..dd5b9c4b5b
--- /dev/null
+++ b/tests/integration/zalo_oa_lifecycle_test.go
@@ -0,0 +1,266 @@
+//go:build integration
+
+package integration
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels"
+ zalooa "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/oa"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/internal/store/pg"
+)
+
+// TestZaloOALifecycle exercises the full feature against a real PG
+// (store-layer encryption + tenant scope) and a mocked Zalo API.
+// Skips automatically if TEST_DATABASE_URL is unset / unreachable.
+//
+// 1. Create channel_instance row (creds plaintext, store layer encrypts)
+// 2. Read back via Get → LoadCreds → tokens absent (just app_id/secret)
+// 3. Mock /v4/oa/access_token and call ExchangeCode through Persist
+// 4. Re-read row → tokens decrypted + present
+// 5. Build Channel via factory, Start
+// 6. Send text → mock /v3.0/oa/message/cs receives expected body
+// 7. Force-refresh + Send again → mock refresh hit + send hit
+// 8. Force ErrAuthExpired on refresh → health flips Failed/Auth
+// 9. Stop channel cleanly within bounded time
+func TestZaloOALifecycle(t *testing.T) {
+ db := testDB(t)
+
+ tenantID, agentID := seedTenantAgent(t, db)
+ ciStore := pg.NewPGChannelInstanceStore(db, testEncryptionKey)
+
+ mock := newMockZaloServer(t)
+
+ ctx := store.WithTenantID(context.Background(), tenantID)
+
+ // ── 1. Create instance with plaintext creds JSON ──────────────────
+ credsJSON, err := json.Marshal(map[string]any{
+ "app_id": "app-int",
+ "secret_key": "sec-int",
+ })
+ if err != nil {
+ t.Fatalf("marshal creds: %v", err)
+ }
+ cfgJSON, err := json.Marshal(map[string]any{
+ "poll_interval_seconds": 60,
+ "media_max_mb": 5,
+ })
+ if err != nil {
+ t.Fatalf("marshal cfg: %v", err)
+ }
+ inst := &store.ChannelInstanceData{
+ TenantID: tenantID,
+ Name: fmt.Sprintf("zalo-oauth-int-%d", time.Now().UnixNano()),
+ DisplayName: "Zalo OAuth Integration",
+ ChannelType: channels.TypeZaloOA,
+ AgentID: agentID,
+ Credentials: credsJSON,
+ Config: cfgJSON,
+ Enabled: true,
+ CreatedBy: "test",
+ }
+ if err := ciStore.Create(ctx, inst); err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ t.Cleanup(func() { _ = ciStore.Delete(ctx, inst.ID) })
+
+ // ── 2. Read back; verify store decrypts blob round-trip ───────────
+ got, err := ciStore.Get(ctx, inst.ID)
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ creds, err := zalooa.LoadCreds(got.Credentials)
+ if err != nil {
+ t.Fatalf("LoadCreds: %v", err)
+ }
+ if creds.AppID != "app-int" || creds.SecretKey != "sec-int" {
+ t.Errorf("creds round-trip lost data: %+v", creds)
+ }
+ if creds.AccessToken != "" {
+ t.Errorf("AccessToken should be empty pre-exchange, got %q", creds.AccessToken)
+ }
+
+ // ── 3+4. Simulate an exchange via direct creds.Persist + mock refresh
+ // (We bypass the WS handler here — phase-01 unit tests cover its glue.)
+ creds.AccessToken = "AT-initial"
+ creds.RefreshToken = "RT-initial"
+ creds.ExpiresAt = time.Now().Add(time.Hour)
+ creds.OAID = "oa-int-1"
+ if err := zalooa.Persist(ctx, ciStore, inst.ID, creds); err != nil {
+ t.Fatalf("Persist: %v", err)
+ }
+ // Read back again — verify Update wrote and Get decrypted.
+ got2, _ := ciStore.Get(ctx, inst.ID)
+ creds2, _ := zalooa.LoadCreds(got2.Credentials)
+ if creds2.AccessToken != "AT-initial" || creds2.OAID != "oa-int-1" {
+ t.Errorf("post-Persist round-trip mismatch: %+v", creds2)
+ }
+
+ // ── 5. Build Channel via factory, wire mock host, Start ───────────
+ msgBus := bus.New()
+ factory := zalooa.Factory(ciStore)
+ ch, err := factory(inst.Name, got2.Credentials, got2.Config, msgBus, nil)
+ if err != nil {
+ t.Fatalf("factory: %v", err)
+ }
+ zch, ok := ch.(*zalooa.Channel)
+ if !ok {
+ t.Fatalf("factory returned %T, want *zalooa.Channel", ch)
+ }
+ zch.SetType(channels.TypeZaloOA)
+ zch.SetTenantID(tenantID)
+ zch.SetAgentID(agentID.String())
+ zch.SetInstanceID(inst.ID)
+
+ if err := zch.Start(context.Background()); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ defer func() {
+ stopDone := make(chan struct{})
+ go func() { _ = zch.Stop(context.Background()); close(stopDone) }()
+ select {
+ case <-stopDone:
+ case <-time.After(5 * time.Second):
+ t.Errorf("Stop did not return within 5s")
+ }
+ }()
+
+ // ── 6. Send text — assert mock receives it ────────────────────────
+ mock.Override(zch)
+ if _, err := zch.SendText(ctx, "user-1", "integration-hello", ""); err != nil {
+ t.Fatalf("SendText: %v", err)
+ }
+ if got := mock.SendCount(); got != 1 {
+ t.Errorf("send count = %d, want 1", got)
+ }
+
+ // ── 7. Force refresh + send — assert refresh hit + new token used ──
+ mock.QueueRefreshOK("AT-rotated", "RT-rotated")
+ zch.ForceRefreshForTest()
+ if _, err := zch.SendText(ctx, "user-1", "post-refresh", ""); err != nil {
+ t.Fatalf("SendText post-refresh: %v", err)
+ }
+ if got := mock.RefreshCount(); got != 1 {
+ t.Errorf("refresh count = %d, want 1", got)
+ }
+ if mock.LastSendToken() != "AT-rotated" {
+ t.Errorf("send used token %q, want AT-rotated", mock.LastSendToken())
+ }
+
+ // ── 8. Auth-expired refresh → health flips Failed/Auth ────────────
+ mock.QueueRefreshAuthExpired()
+ zch.ForceRefreshForTest()
+ _, err = zch.SendText(ctx, "user-1", "this should fail", "")
+ if err == nil {
+ t.Error("expected SendText to fail after auth-expired refresh")
+ }
+ // Allow the safety ticker / send path to mark health.
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ snap := zch.HealthSnapshot()
+ if snap.State == channels.ChannelHealthStateFailed && snap.FailureKind == channels.ChannelFailureKindAuth {
+ return // pass
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ snap := zch.HealthSnapshot()
+ t.Errorf("health did not transition to Failed/Auth: state=%v kind=%v", snap.State, snap.FailureKind)
+}
+
+// ─── Mock Zalo API ──────────────────────────────────────────────────────
+
+type mockZaloServer struct {
+ t *testing.T
+ srv *httptest.Server
+ sendCount atomic.Int32
+ refreshCount atomic.Int32
+
+ mu sync.Mutex
+ lastSendToken string
+ refreshAccess string
+ refreshRefresh string
+ refreshError string // if non-empty, return as APIError envelope (HTTP 200)
+}
+
+func newMockZaloServer(t *testing.T) *mockZaloServer {
+ t.Helper()
+ m := &mockZaloServer{t: t}
+ m.srv = httptest.NewServer(http.HandlerFunc(m.handle))
+ t.Cleanup(m.srv.Close)
+ return m
+}
+
+// Override points the channel's HTTP client at the mock for both the OAuth
+// host and the API host. Uses test-only setters added on the Channel.
+func (m *mockZaloServer) Override(ch *zalooa.Channel) {
+ // OAuth base carries /v4 in production (defaultOAuthBase); mirror it here
+ // so refresh URLs end at /v4/oa/access_token like the real upstream.
+ ch.SetTestEndpointsForTest(m.srv.URL+"/v4", m.srv.URL)
+}
+
+func (m *mockZaloServer) QueueRefreshOK(access, refresh string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.refreshAccess = access
+ m.refreshRefresh = refresh
+ m.refreshError = ""
+}
+
+func (m *mockZaloServer) QueueRefreshAuthExpired() {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.refreshError = `{"error":-118,"message":"invalid_grant"}`
+ m.refreshAccess = ""
+ m.refreshRefresh = ""
+}
+
+func (m *mockZaloServer) SendCount() int { return int(m.sendCount.Load()) }
+func (m *mockZaloServer) RefreshCount() int { return int(m.refreshCount.Load()) }
+func (m *mockZaloServer) LastSendToken() string {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.lastSendToken
+}
+
+func (m *mockZaloServer) handle(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/v4/oa/access_token"):
+ m.refreshCount.Add(1)
+ m.mu.Lock()
+ errBody, accTok, refTok := m.refreshError, m.refreshAccess, m.refreshRefresh
+ m.mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ if errBody != "" {
+ _, _ = w.Write([]byte(errBody))
+ return
+ }
+ _, _ = w.Write([]byte(fmt.Sprintf(
+ `{"access_token":%q,"refresh_token":%q,"expires_in":3600}`, accTok, refTok)))
+ case r.URL.Path == "/v3.0/oa/message/cs":
+ m.sendCount.Add(1)
+ m.mu.Lock()
+ // Production sends access_token in the HTTP header, not query
+ // (per d580d490 — Zalo's query-token form returned a generic 404).
+ m.lastSendToken = r.Header.Get("access_token")
+ m.mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"error":0,"data":{"message_id":"int-mid"}}`))
+ case strings.HasPrefix(r.URL.Path, "/v2.0/oa/listrecentchat"):
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"error":0,"data":[]}`)) // no inbound traffic this test
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+}
+
diff --git a/tests/integration/zalo_reload_safety_test.go b/tests/integration/zalo_reload_safety_test.go
new file mode 100644
index 0000000000..2f62125bb5
--- /dev/null
+++ b/tests/integration/zalo_reload_safety_test.go
@@ -0,0 +1,125 @@
+//go:build integration
+
+package integration
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/oa"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+)
+
+// TestZaloWebhook_MountRouteIdempotentAcrossReload exercises the load-bearing
+// invariant of the WebhookChannel collapse: once a path is mounted via
+// MountRoute(), no subsequent caller (different channel instance, post-Reload
+// re-registration, etc.) ever gets a non-empty path again. http.ServeMux
+// panics on duplicate registration, so this is the safety net the entire
+// design depends on.
+//
+// Setup mirrors the Reload path: register an OA instance, mount the route
+// once, then unregister + re-register (simulating instance_loader.Reload's
+// Stop→Start cycle). The route handler must still dispatch and the second
+// MountRoute call must return ("", nil).
+func TestZaloWebhook_MountRouteIdempotentAcrossReload(t *testing.T) {
+ router := common.NewRouter()
+ mux := http.NewServeMux()
+ mux.Handle(common.WebhookPathPrefix, router)
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+
+ msgBus := bus.New()
+
+ // First MountRoute — must claim the path.
+ path1, h1 := router.MountRoute()
+ if path1 != common.WebhookPathPrefix || h1 != router {
+ t.Fatalf("first MountRoute = (%q, %v), want (%q, router)", path1, h1, common.WebhookPathPrefix)
+ }
+
+ // Register an OA instance, send a signed event, drain inbound — proves
+ // dispatch works through the freshly-mounted route.
+ tenantID := uuid.New()
+ instID := uuid.New()
+ secret := "reload-secret"
+ creds := &oa.ChannelCreds{
+ AppID: "oa-app", SecretKey: "oa-sk", OAID: "oa-mt",
+ AccessToken: "AT", RefreshToken: "RT", ExpiresAt: time.Now().Add(time.Hour),
+ WebhookSecretKey: secret,
+ }
+ cfg := config.ZaloOAConfig{
+ Transport: "webhook",
+ WebhookSignatureMode: "strict",
+ WebhookReplayWindowSeconds: 300,
+ }
+ ch, err := oa.New("oa-reload", cfg, creds, &oaIntegrationStubStore{}, msgBus, nil)
+ if err != nil {
+ t.Fatalf("oa.New: %v", err)
+ }
+ ch.SetInstanceID(instID)
+ ch.SetTenantID(tenantID)
+ const slug = "oa-reload"
+ if err := router.RegisterInstance(instID, ch, tenantID, slug); err != nil {
+ t.Fatalf("RegisterInstance: %v", err)
+ }
+
+ body, sig := buildSignedOAEvent(t, "oa-app", "oa-mt", "user-r1", "before-reload", secret)
+ resp, err := postWebhook(t, srv.URL, slug, http.Header{
+ "X-Zevent-Signature": []string{sig},
+ "Content-Type": []string{"application/json"},
+ }, body)
+ if err != nil {
+ t.Fatalf("pre-reload POST: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("pre-reload status = %d, want 200", resp.StatusCode)
+ }
+ msg, ok := drainOneInbound(t, msgBus, time.Second)
+ if !ok || msg.Content != "before-reload" {
+ t.Fatalf("pre-reload inbound: got=%v ok=%v, want before-reload", msg, ok)
+ }
+
+ // Simulate instance_loader.Reload: unregister the instance, then
+ // re-register a fresh channel under the same UUID. Critically, the
+ // route was already mounted once; the second MountRoute MUST stay
+ // silent so a cold-path re-mount cannot panic the mux.
+ router.UnregisterInstance(instID)
+
+ path2, h2 := router.MountRoute()
+ if path2 != "" || h2 != nil {
+ t.Fatalf("second MountRoute after Unregister = (%q, %v), want (\"\", nil) — re-mount would panic the mux", path2, h2)
+ }
+
+ ch2, err := oa.New("oa-reload-2", cfg, creds, &oaIntegrationStubStore{}, msgBus, nil)
+ if err != nil {
+ t.Fatalf("oa.New (post-reload): %v", err)
+ }
+ ch2.SetInstanceID(instID)
+ ch2.SetTenantID(tenantID)
+ if err := router.RegisterInstance(instID, ch2, tenantID, slug); err != nil {
+ t.Fatalf("RegisterInstance (post-reload): %v", err)
+ }
+ t.Cleanup(func() { router.UnregisterInstance(instID) })
+
+ // Dispatch through the same route still works post-reload.
+ body2, sig2 := buildSignedOAEvent(t, "oa-app", "oa-mt", "user-r2", "after-reload", secret)
+ resp, err = postWebhook(t, srv.URL, slug, http.Header{
+ "X-Zevent-Signature": []string{sig2},
+ "Content-Type": []string{"application/json"},
+ }, body2)
+ if err != nil {
+ t.Fatalf("post-reload POST: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("post-reload status = %d, want 200", resp.StatusCode)
+ }
+ msg, ok = drainOneInbound(t, msgBus, time.Second)
+ if !ok || msg.Content != "after-reload" {
+ t.Fatalf("post-reload inbound: got=%v ok=%v, want after-reload", msg, ok)
+ }
+}
diff --git a/tests/integration/zalo_webhook_integration_test.go b/tests/integration/zalo_webhook_integration_test.go
new file mode 100644
index 0000000000..f3b1e7494f
--- /dev/null
+++ b/tests/integration/zalo_webhook_integration_test.go
@@ -0,0 +1,319 @@
+//go:build integration
+
+package integration
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/bot"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/common"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/oa"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// signOAEvent reproduces the production X-ZEvent-Signature scheme:
+// hex(SHA256(appID + body + timestamp + secret))
+// timestamp is taken verbatim as a decimal string (canonicalized to match
+// what the server's verifier will derive from json.Number → Int64 →
+// strconv.FormatInt — see oa/webhook_signature.go S4).
+func signOAEvent(appID, body, timestamp, secret string) string {
+ h := sha256.New()
+ h.Write([]byte(appID))
+ h.Write([]byte(body))
+ h.Write([]byte(timestamp))
+ h.Write([]byte(secret))
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// buildSignedOAEvent returns the canonical body + matching signature for a
+// "user_send_text" event with current ms-precision timestamp.
+func buildSignedOAEvent(t *testing.T, appID, oaID, senderID, text, secret string) (body []byte, sig string) {
+ t.Helper()
+ tsMs := time.Now().UnixMilli()
+ bodyMap := map[string]any{
+ "event_name": "user_send_text",
+ "app_id": appID,
+ "oa_id": oaID,
+ "timestamp": tsMs,
+ "sender": map[string]any{"id": senderID},
+ "recipient": map[string]any{"id": oaID},
+ "message": map[string]any{"message_id": "mid-" + senderID + "-" + strconv.FormatInt(tsMs, 10), "text": text},
+ }
+ body, err := json.Marshal(bodyMap)
+ if err != nil {
+ t.Fatalf("marshal event: %v", err)
+ }
+ sig = signOAEvent(appID, string(body), strconv.FormatInt(tsMs, 10), secret)
+ return body, sig
+}
+
+// drainOneInbound waits up to budget for a single inbound message.
+func drainOneInbound(t *testing.T, msgBus *bus.MessageBus, budget time.Duration) (bus.InboundMessage, bool) {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), budget)
+ defer cancel()
+ return msgBus.ConsumeInbound(ctx)
+}
+
+// ─── Cross-phase integration: shared router + two real channels ──────────
+
+// TestZaloWebhookRouter_MultiInstanceRouting registers ONE OA channel and
+// ONE Bot channel against a shared common.Router. Each channel uses a
+// distinct secret + tenant. Test asserts:
+// 1. POST signed for OA instance lands on OA channel (bus inbound has OA metadata)
+// 2. POST signed for Bot instance lands on Bot channel
+// 3. POSTing OA's payload to Bot's instance ID (cross-route attempt) is rejected by the Bot's signature verifier — no inbound published
+func TestZaloWebhookRouter_MultiInstanceRouting(t *testing.T) {
+ router := common.NewRouter()
+ mux := http.NewServeMux()
+ mux.Handle(common.WebhookPathPrefix, router)
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+
+ msgBus := bus.New()
+
+ // ── OA channel ──
+ oaTenantID := uuid.New()
+ oaInstID := uuid.New()
+ oaSecret := "oa-secret-int"
+ oaCreds := &oa.ChannelCreds{
+ AppID: "oa-app", SecretKey: "oa-sk", OAID: "oa-mt",
+ AccessToken: "AT", RefreshToken: "RT", ExpiresAt: time.Now().Add(time.Hour),
+ WebhookSecretKey: oaSecret,
+ }
+ oaCfg := config.ZaloOAConfig{
+ Transport: "webhook",
+ WebhookSignatureMode: "strict",
+ WebhookReplayWindowSeconds: 300,
+ }
+ oaCh, err := oa.New("oa-int", oaCfg, oaCreds, &oaIntegrationStubStore{}, msgBus, nil)
+ if err != nil {
+ t.Fatalf("oa.New: %v", err)
+ }
+ oaCh.SetInstanceID(oaInstID)
+ oaCh.SetTenantID(oaTenantID)
+ const oaSlug = "oa-int"
+ if err := router.RegisterInstance(oaInstID, oaCh, oaTenantID, oaSlug); err != nil {
+ t.Fatalf("RegisterInstance OA: %v", err)
+ }
+ t.Cleanup(func() { router.UnregisterInstance(oaInstID) })
+
+ // ── Bot channel ──
+ botTenantID := uuid.New()
+ botInstID := uuid.New()
+ botSecret := "bot-secret-int"
+ botCfg := config.ZaloConfig{
+ Enabled: true, Token: "bot-token",
+ Transport: "webhook", WebhookSecret: botSecret,
+ DMPolicy: "open", // bypass pairing-by-default for the integration test
+ }
+ botCh, err := bot.New(botCfg, msgBus, nil)
+ if err != nil {
+ t.Fatalf("bot.New: %v", err)
+ }
+ botCh.SetInstanceID(botInstID)
+ botCh.SetTenantID(botTenantID)
+ // Bot self-echo filter compares against c.botID populated by getMe at
+ // Start(). We bypass Start() in this test, so botID stays "" — no echo
+ // filter trips for our test sender IDs.
+ const botSlug = "bot-int"
+ if err := router.RegisterInstance(botInstID, botCh, botTenantID, botSlug); err != nil {
+ t.Fatalf("RegisterInstance Bot: %v", err)
+ }
+ t.Cleanup(func() { router.UnregisterInstance(botInstID) })
+
+ // 1. OA delivery
+ body, sig := buildSignedOAEvent(t, "oa-app", "oa-mt", "user-1", "hello-from-oa", oaSecret)
+ resp, err := postWebhook(t, srv.URL, oaSlug, http.Header{
+ "X-Zevent-Signature": []string{sig},
+ "Content-Type": []string{"application/json"},
+ }, body)
+ if err != nil {
+ t.Fatalf("OA POST: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("OA POST status = %d, want 200", resp.StatusCode)
+ }
+ msg, ok := drainOneInbound(t, msgBus, 1*time.Second)
+ if !ok {
+ t.Fatal("expected OA inbound, got none")
+ }
+ if msg.Content != "hello-from-oa" {
+ t.Errorf("OA Content = %q, want hello-from-oa", msg.Content)
+ }
+ if msg.Metadata["platform"] != string(common.PlatformZaloOA) {
+ t.Errorf("OA platform metadata = %q, want %q", msg.Metadata["platform"], common.PlatformZaloOA)
+ }
+ if msg.TenantID != oaTenantID {
+ t.Errorf("OA TenantID = %s, want %s", msg.TenantID, oaTenantID)
+ }
+
+ // 2. Bot delivery (uses X-Bot-Api-Secret-Token header, no body sig)
+ botBody := []byte(`{"event_name":"message.text.received","message":{"message_id":"bot-mid-1","from":{"id":"user-bot","display_name":"Bot User"},"chat":{"id":"user-bot"},"text":"hello-from-bot"}}`)
+ resp, err = postWebhook(t, srv.URL, botSlug, http.Header{
+ "X-Bot-Api-Secret-Token": []string{botSecret},
+ "Content-Type": []string{"application/json"},
+ }, botBody)
+ if err != nil {
+ t.Fatalf("Bot POST: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("Bot POST status = %d, want 200", resp.StatusCode)
+ }
+ msg, ok = drainOneInbound(t, msgBus, 1*time.Second)
+ if !ok {
+ t.Fatal("expected Bot inbound, got none")
+ }
+ if msg.Content != "hello-from-bot" {
+ t.Errorf("Bot Content = %q, want hello-from-bot", msg.Content)
+ }
+
+ // 3. Cross-route attempt: send OA payload to Bot instance slug. Bot's
+ // verifier requires X-Bot-Api-Secret-Token, which OA payloads don't
+ // carry — should reject with 401 and not publish.
+ body2, sig2 := buildSignedOAEvent(t, "oa-app", "oa-mt", "user-attacker", "should-not-route", oaSecret)
+ resp, err = postWebhook(t, srv.URL, botSlug, http.Header{
+ "X-Zevent-Signature": []string{sig2},
+ "Content-Type": []string{"application/json"},
+ }, body2)
+ if err != nil {
+ t.Fatalf("cross-route POST: %v", err)
+ }
+ if resp.StatusCode == http.StatusOK {
+ t.Errorf("cross-route POST returned 200 — Bot's verifier should reject OA payload (status=%d)", resp.StatusCode)
+ }
+ if _, ok := drainOneInbound(t, msgBus, 200*time.Millisecond); ok {
+ t.Error("cross-route attempt produced inbound — verifier did not block")
+ }
+}
+
+// TestZaloWebhookRouter_SignatureMismatch_NoInbound asserts that a wrong
+// signature returns 401 and never reaches HandleWebhookEvent.
+func TestZaloWebhookRouter_SignatureMismatch_NoInbound(t *testing.T) {
+ router := common.NewRouter()
+ mux := http.NewServeMux()
+ mux.Handle(common.WebhookPathPrefix, router)
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+
+ msgBus := bus.New()
+ tenantID := uuid.New()
+ instID := uuid.New()
+ creds := &oa.ChannelCreds{
+ AppID: "oa-app", SecretKey: "oa-sk", OAID: "oa-mt",
+ AccessToken: "AT", RefreshToken: "RT", ExpiresAt: time.Now().Add(time.Hour),
+ WebhookSecretKey: "right-secret",
+ }
+ cfg := config.ZaloOAConfig{
+ Transport: "webhook",
+ WebhookSignatureMode: "strict", WebhookReplayWindowSeconds: 300,
+ }
+ ch, err := oa.New("oa-mismatch", cfg, creds, &oaIntegrationStubStore{}, msgBus, nil)
+ if err != nil {
+ t.Fatalf("oa.New: %v", err)
+ }
+ ch.SetInstanceID(instID)
+ ch.SetTenantID(tenantID)
+ const slug = "oa-mismatch"
+ if err := router.RegisterInstance(instID, ch, tenantID, slug); err != nil {
+ t.Fatalf("RegisterInstance: %v", err)
+ }
+ t.Cleanup(func() { router.UnregisterInstance(instID) })
+
+ // Sign with the WRONG secret.
+ body, sig := buildSignedOAEvent(t, "oa-app", "oa-mt", "user-x", "no-route", "wrong-secret")
+ resp, err := postWebhook(t, srv.URL, slug, http.Header{
+ "X-Zevent-Signature": []string{sig},
+ "Content-Type": []string{"application/json"},
+ }, body)
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("status = %d, want 401", resp.StatusCode)
+ }
+ if _, ok := drainOneInbound(t, msgBus, 200*time.Millisecond); ok {
+ t.Error("inbound published despite signature mismatch")
+ }
+}
+
+// TestZaloWebhookRouter_UnknownSlug_404 confirms an unregistered slug
+// returns 404 cleanly.
+func TestZaloWebhookRouter_UnknownSlug_404(t *testing.T) {
+ router := common.NewRouter()
+ mux := http.NewServeMux()
+ mux.Handle(common.WebhookPathPrefix, router)
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+
+ resp, err := postWebhook(t, srv.URL, "ghost-slug", http.Header{
+ "Content-Type": []string{"application/json"},
+ }, []byte(`{}`))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", resp.StatusCode)
+ }
+}
+
+// Note: WS RPC handler branches (UUID parse, store.Get, cross-tenant,
+// wrong channel type, success) are covered by unit tests in
+// internal/gateway/methods/zalo_webhook_test.go. Replicating that here
+// would require a full gateway.Server harness for permission gating with
+// no additional coverage value.
+
+// ─── helpers ─────────────────────────────────────────────────────────────
+
+// oaIntegrationStubStore stubs ChannelInstanceStore enough for oa.New;
+// integration tests that need real PG use ciStore directly.
+type oaIntegrationStubStore struct {
+ store.ChannelInstanceStore
+}
+
+func (oaIntegrationStubStore) Get(_ context.Context, _ uuid.UUID) (*store.ChannelInstanceData, error) {
+ return nil, nil
+}
+
+func (oaIntegrationStubStore) MergeConfig(_ context.Context, _ uuid.UUID, _ map[string]any) error {
+ return nil
+}
+
+func (oaIntegrationStubStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error {
+ return nil
+}
+
+func postWebhook(t *testing.T, baseURL string, slug string, headers http.Header, body []byte) (*http.Response, error) {
+ t.Helper()
+ u := fmt.Sprintf("%s%s%s", baseURL, common.WebhookPathPrefix, slug)
+ req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ for k, vv := range headers {
+ for _, v := range vv {
+ req.Header.Add(k, v)
+ }
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ t.Cleanup(func() { _ = resp.Body.Close() })
+ return resp, nil
+}
+
diff --git a/ui/web/nginx.conf b/ui/web/nginx.conf
index e2205f4c0d..c193ac532d 100644
--- a/ui/web/nginx.conf
+++ b/ui/web/nginx.conf
@@ -1,3 +1,10 @@
+# Resolved at config-load via Docker DNS or kube-dns (whichever serves the pod's
+# /etc/resolv.conf). Stable in K8s where `goclaw` Service has a fixed ClusterIP;
+# in docker-compose, restart this container if the backend gets a new IP.
+upstream goclaw_backend {
+ server goclaw:18790;
+}
+
server {
listen 80;
server_name _;
@@ -10,12 +17,6 @@ server {
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 256;
- # Docker internal DNS resolver — re-resolves upstream when backend
- # container restarts with a new IP (prevents stale DNS cache).
- # Note: valid=10s means up to 10s stale DNS on backend restart.
- resolver 127.0.0.11 valid=10s ipv6=off;
- set $upstream_backend "http://goclaw:18790";
-
# Cache static assets
location /assets/ {
expires 1y;
@@ -24,7 +25,7 @@ server {
# WebSocket proxy
location /ws {
- proxy_pass $upstream_backend;
+ proxy_pass http://goclaw_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -36,7 +37,7 @@ server {
# API proxy
location /v1/ {
- proxy_pass $upstream_backend;
+ proxy_pass http://goclaw_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -44,7 +45,17 @@ server {
# Health check proxy
location /health {
- proxy_pass $upstream_backend;
+ proxy_pass http://goclaw_backend;
+ }
+
+ # Zalo webhook proxy (OA + Bot share the /channels/zalo/webhook/ prefix).
+ # Without this the SPA fallback intercepts POSTs and nginx returns 405.
+ location /channels/zalo/webhook/ {
+ proxy_pass http://goclaw_backend;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA fallback — serve index.html for all other routes
diff --git a/ui/web/src/constants/channels.ts b/ui/web/src/constants/channels.ts
index 0083030b48..2c6eb538e7 100644
--- a/ui/web/src/constants/channels.ts
+++ b/ui/web/src/constants/channels.ts
@@ -6,6 +6,7 @@ export const CHANNEL_TYPES = [
{ value: "slack", label: "Slack" },
{ value: "telegram", label: "Telegram" },
{ value: "whatsapp", label: "WhatsApp" },
+ { value: "zalo_bot", label: "Zalo Bot" },
{ value: "zalo_oa", label: "Zalo OA" },
{ value: "zalo_personal", label: "Zalo Personal" },
] as const;
diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json
index c92085418f..b091a9ad02 100644
--- a/ui/web/src/i18n/locales/en/channels.json
+++ b/ui/web/src/i18n/locales/en/channels.json
@@ -94,7 +94,6 @@
},
"detail": {
"agent": "Agent: {{name}}",
- "lastChecked": "Last checked: {{value}}",
"checkedRelative": "Checked {{value}}",
"advanced": "Advanced",
"advancedTitle": "Advanced Settings",
@@ -121,6 +120,19 @@
"groups": "Groups",
"managers": "Managers"
},
+ "zaloWebhook": {
+ "title": "Webhook setup",
+ "pathLabel": "Path",
+ "urlLabel": "Webhook URL (paste into Zalo console)",
+ "hostLabel": "Gateway host",
+ "hostHint": "Override the gateway host if Zalo cannot reach this UI's origin. Stored locally per-browser.",
+ "hostInvalid": "Host is not a valid URL",
+ "hostInvalidScheme": "Host must start with http:// or https://",
+ "oaIdLabel": "OA ID",
+ "oaIdPlaceholder": "Auto-discovered after Connect",
+ "loading": "Loading...",
+ "copy": "Copy URL"
+ },
"general": {
"identity": "Identity",
"name": "Name",
@@ -142,7 +154,15 @@
"failedSave": "Failed to save",
"saved": "Saved",
"updateCredentials": "Update Credentials",
- "saving": "Saving..."
+ "saving": "Saving...",
+ "bootstrapBanner": {
+ "title": "Bootstrap mode: complete setup on Zalo",
+ "step1": "Copy the webhook URL below.",
+ "step2": "On developers.zalo.me Webhook tab, paste the URL → click Thay đổi → Cập nhật. Zalo verifies and saves it.",
+ "step3": "The Khóa bí mật OA field appears. Click the eye icon to reveal it; copy the value.",
+ "step4": "Paste the secret in the Webhook Secret Key field below → Update Credentials.",
+ "note": "While in bootstrap, this channel acks Zalo's verification ping but drops events. Pasting the secret enables signature verification and message processing."
+ }
},
"config": {
"noSchema": "No configuration schema for this channel type.",
@@ -262,6 +282,10 @@
"help": "Display AI thinking as a separate message before the answer (requires streaming)"
},
"reaction_level": { "label": "Reaction Level" },
+ "quote_user_message": {
+ "label": "Quote user message",
+ "help": "Reply by quoting the user's last inbound message (Zalo's reply-to). Turn off for plain replies without quote."
+ },
"media_max_mb": { "label": "Max Media Size (MB)" },
"link_preview": { "label": "Link Preview" },
"allow_from": { "label": "Allowed Users" },
@@ -269,6 +293,46 @@
"label": "Block Reply",
"help": "Deliver intermediate text during tool iterations"
},
+ "transport": {
+ "label": "Connection Mode",
+ "help": "Webhook is event-driven and lighter. Polling fetches messages on a timer."
+ },
+ "webhook_secret_key": {
+ "label": "Webhook Secret Key",
+ "help": "Signing secret from the Zalo dev console (OA → Webhook). Required when Connection Mode is Webhook (unless Signature Mode is Disabled). Used to verify X-ZEvent-Signature."
+ },
+ "webhook_signature_mode": {
+ "label": "Signature Mode",
+ "help": "Disabled skips verification — easiest to bring up. Switch to Strict once Webhook Secret Key (under Credentials) is set; Log-only is the migration step in between."
+ },
+ "webhook_replay_window_seconds": {
+ "label": "Replay Window (seconds)",
+ "help": "Max age of accepted webhook events. Default 300, range 60–3600."
+ },
+ "catch_up_on_restart": {
+ "label": "Catch Up On Restart",
+ "help": "Run one bounded listrecentchat sweep on Start to backfill events missed while offline."
+ },
+ "poll_interval_seconds": {
+ "label": "Poll Interval (seconds)",
+ "help": "How often to fetch new messages. Min 5, max 120."
+ },
+ "poll_count": {
+ "label": "Poll Page Size",
+ "help": "Messages per listrecentchat call. Zalo caps this at 10 — values above return error -210."
+ },
+ "poll_burndown_max_pages": {
+ "label": "Burn-down Max Pages",
+ "help": "Max consecutive listrecentchat pages per cycle (page size × max pages = messages drained). Default 10, max 20. Set to 1 to disable burn-down."
+ },
+ "redirect_uri": {
+ "label": "Redirect URI",
+ "help": "Set this URL as the Official Account Callback URL at https://developers.zalo.me/app//oa/settings. Mismatch returns error_code=-14003. See docs for full setup."
+ },
+ "webhook_secret": {
+ "label": "Webhook Secret",
+ "help": "Generated by bot.zapps.me when you set the webhook URL there. Paste it back here. Until set, the channel runs in bootstrap mode (acks Zalo's verification ping with HTTP 200 but drops events)."
+ },
"domain": { "label": "Domain" },
"connection_mode": {
"label": "Connection Mode",
@@ -280,9 +344,8 @@
},
"webhook_path": {
"label": "Webhook Path",
- "help": "Path on main server for Lark events"
+ "help": "URL routing path. For Zalo: lowercase letters, numbers, hyphens (2–63 chars), used as /channels/zalo/webhook/. For Lark: full path on main server (e.g. /feishu/events)."
},
- "webhook_url": { "label": "Webhook URL" },
"topic_session_mode": {
"label": "Topic Session Mode",
"help": "Use thread root_id for session isolation"
@@ -363,6 +426,10 @@
"webhook": "Webhook",
"websocket": "WebSocket (recommended)"
},
+ "transport": {
+ "webhook": "Webhook (recommended)",
+ "polling": "Polling"
+ },
"topic_session_mode": {
"disabled": "Disabled",
"enabled": "Enabled"
@@ -442,6 +509,10 @@
"whatsapp": {
"createLabel": "Create & Scan QR",
"formBanner": "After creating, scan the QR code with WhatsApp to authenticate."
+ },
+ "zaloOa": {
+ "createLabel": "Create & Connect",
+ "formBanner": "After creating, you'll approve access in Zalo and paste the returned code to complete authorization."
}
},
"fallback": {
@@ -459,6 +530,11 @@
"description": "Your Lark/Feishu app must have these scopes enabled in the Developer Console. Publish a new app version after adding permissions.",
"publishReminder": "After adding scopes, set Contact Range to \"All members\" and publish a new app version for changes to take effect."
},
+ "zaloOaEvents": {
+ "title": "Enable these events on the Zalo console",
+ "description": "After saving the Webhook URL on developers.zalo.me, toggle each of the following events ON in the event list. Anything left OFF will silently never reach the agent.",
+ "location": "Webhook tab → \"Danh sách sự kiện webhook\" → flip Tắt / Bật to ON for each event above."
+ },
"toast": {
"created": "Channel created",
"createdDesc": "{{name}} has been added",
@@ -506,5 +582,24 @@
"relinkDevice": "Re-link Device",
"connectedSuccess": "✅ WhatsApp connected successfully!",
"tabQrCode": "QR Code"
+ },
+ "zaloOa": {
+ "dialogTitle": "Connect Zalo OA — {{name}}",
+ "dialogDescription": "Authorize the Official Account, then paste the redirect URL from your browser.",
+ "step1Heading": "Step 1 — Authorize",
+ "step2Heading": "Step 2 — Paste callback URL",
+ "authorizeHelp": "Open the URL below, sign in to Zalo, and approve access for the Official Account. After you approve, your browser redirects to a callback URL — copy the full URL from the address bar and paste it into Step 2 below.",
+ "consentLoading": "Generating consent URL…",
+ "consentFailed": "Failed to fetch consent URL",
+ "pasteHelp": "After approving, copy the full URL from your browser's address bar and paste it here.",
+ "pastePlaceholder": "Full callback URL",
+ "exchangeFailed": "Code exchange failed",
+ "errCodeMissing": "Pasted URL has no \"code\" parameter — copy the full callback URL from your browser's address bar after approval.",
+ "connectedClosing": "Connected — closing…",
+ "copyUrl": "Copy URL",
+ "openInTab": "Open in new tab",
+ "cancel": "Cancel",
+ "connect": "Connect",
+ "connecting": "Connecting…"
}
}
diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json
index 77fbbaf4b2..91e111b155 100644
--- a/ui/web/src/i18n/locales/vi/channels.json
+++ b/ui/web/src/i18n/locales/vi/channels.json
@@ -120,6 +120,19 @@
"groups": "Nhóm",
"managers": "Quản lý"
},
+ "zaloWebhook": {
+ "title": "Cấu hình Webhook",
+ "pathLabel": "Đường dẫn",
+ "urlLabel": "URL Webhook (dán vào Zalo console)",
+ "hostLabel": "Host gateway",
+ "hostHint": "Ghi đè host gateway nếu Zalo không thể truy cập origin của UI này. Lưu cục bộ trên trình duyệt.",
+ "hostInvalid": "Host không phải là URL hợp lệ",
+ "hostInvalidScheme": "Host phải bắt đầu bằng http:// hoặc https://",
+ "oaIdLabel": "OA ID",
+ "oaIdPlaceholder": "Tự động phát hiện sau khi Kết nối",
+ "loading": "Đang tải...",
+ "copy": "Sao chép URL"
+ },
"general": {
"identity": "Thông tin",
"name": "Tên",
@@ -141,7 +154,15 @@
"failedSave": "Lưu thất bại",
"saved": "Đã lưu",
"updateCredentials": "Cập nhật thông tin xác thực",
- "saving": "Đang lưu..."
+ "saving": "Đang lưu...",
+ "bootstrapBanner": {
+ "title": "Chế độ khởi tạo: hoàn tất thiết lập trên Zalo",
+ "step1": "Sao chép Webhook URL bên dưới.",
+ "step2": "Trên tab Webhook tại developers.zalo.me, dán URL → bấm Thay đổi → Cập nhật. Zalo sẽ kiểm tra và lưu URL.",
+ "step3": "Trường Khóa bí mật OA xuất hiện. Bấm biểu tượng con mắt để hiện và sao chép giá trị.",
+ "step4": "Dán khóa vào trường Webhook Secret Key bên dưới → Cập nhật thông tin xác thực.",
+ "note": "Trong chế độ khởi tạo, channel xác nhận ping kiểm tra của Zalo nhưng KHÔNG xử lý sự kiện. Sau khi dán khóa, chữ ký sẽ được kiểm tra và tin nhắn được xử lý bình thường."
+ }
},
"config": {
"noSchema": "Không có lược đồ cấu hình cho loại channel này.",
@@ -225,15 +246,28 @@
"draft_transport": { "label": "Xem trước nháp", "help": "Dùng bản nháp ẩn cho stream trả lời trong DM — không thông báo mỗi lần chỉnh sửa (cần bật Stream DM)" },
"reasoning_stream": { "label": "Hiện suy luận", "help": "Hiển thị quá trình suy nghĩ của AI thành tin nhắn riêng trước câu trả lời (cần bật streaming)" },
"reaction_level": { "label": "Mức độ reaction" },
+ "quote_user_message": {
+ "label": "Trích dẫn tin của người dùng",
+ "help": "Trả lời kèm trích dẫn tin nhắn gần nhất của người dùng (reply-to của Zalo). Tắt để gửi trả lời thường, không kèm trích dẫn."
+ },
"media_max_mb": { "label": "Kích thước media tối đa (MB)" },
"link_preview": { "label": "Xem trước liên kết" },
"allow_from": { "label": "Người dùng được phép" },
"block_reply": { "label": "Phản hồi khối", "help": "Gửi văn bản trung gian trong quá trình lặp công cụ" },
+ "transport": { "label": "Chế độ kết nối", "help": "Webhook hoạt động theo sự kiện và nhẹ hơn. Polling lấy tin theo chu kỳ — phương án dự phòng khi không có URL công khai." },
+ "webhook_secret_key": { "label": "Khóa bí mật Webhook", "help": "Khóa ký từ Zalo dev console (OA → Webhook). Bắt buộc khi Chế độ kết nối = Webhook (trừ khi Chế độ chữ ký = Disabled). Dùng để xác thực X-ZEvent-Signature." },
+ "webhook_signature_mode": { "label": "Chế độ chữ ký", "help": "Disabled bỏ qua xác thực — dễ khởi tạo nhất. Chuyển sang Strict sau khi đã đặt Webhook Secret Key (trong Credentials); Log-only là bước trung gian khi di chuyển." },
+ "webhook_replay_window_seconds": { "label": "Cửa sổ replay (giây)", "help": "Thời gian tối đa chấp nhận sự kiện webhook. Mặc định 300, khoảng 60–3600." },
+ "catch_up_on_restart": { "label": "Bắt kịp khi khởi động lại", "help": "Chạy một lần listrecentchat có giới hạn lúc Start để bù sự kiện bị bỏ lỡ khi offline." },
+ "poll_interval_seconds": { "label": "Chu kỳ poll (giây)", "help": "Tần suất kiểm tra tin mới. Tối thiểu 5, tối đa 120." },
+ "poll_count": { "label": "Số tin/lượt poll", "help": "Số tin nhắn mỗi lần gọi listrecentchat. Zalo giới hạn ở 10 — vượt quá trả về lỗi -210." },
+ "poll_burndown_max_pages": { "label": "Số trang burn-down tối đa", "help": "Số trang listrecentchat liên tiếp tối đa mỗi chu kỳ (page size × max pages = số tin được drain). Mặc định 10, tối đa 20. Đặt 1 để tắt burn-down." },
+ "redirect_uri": { "label": "Redirect URI", "help": "Đặt URL này làm Official Account Callback URL tại https://developers.zalo.me/app//oa/settings. Sai khớp sẽ trả error_code=-14003. Xem docs để biết hướng dẫn đầy đủ." },
+ "webhook_secret": { "label": "Webhook Secret", "help": "Do bot.zapps.me sinh ra khi bạn cấu hình webhook URL ở đó. Sao chép và dán vào đây. Khi chưa đặt, kênh chạy ở chế độ bootstrap (trả lời ping xác minh của Zalo bằng HTTP 200 nhưng bỏ qua các sự kiện)." },
"domain": { "label": "Tên miền" },
"connection_mode": { "label": "Chế độ kết nối", "help": "WebSocket không cần IP công khai — chỉ kết nối ra ngoài" },
"webhook_port": { "label": "Cổng webhook", "help": "0 = chia sẻ cổng gateway chính (khuyến nghị)" },
- "webhook_path": { "label": "Đường dẫn webhook", "help": "Đường dẫn trên máy chủ chính cho sự kiện Lark" },
- "webhook_url": { "label": "URL Webhook" },
+ "webhook_path": { "label": "Đường dẫn webhook", "help": "Đường dẫn định tuyến URL. Zalo: chữ thường, số và dấu gạch ngang (2–63 ký tự), dùng làm /channels/zalo/webhook/. Lark: đường dẫn trên máy chủ chính (ví dụ /feishu/events)." },
"topic_session_mode": { "label": "Chế độ phiên chủ đề", "help": "Dùng root_id của luồng để cách ly phiên" },
"render_mode": { "label": "Chế độ hiển thị" },
"text_chunk_limit": { "label": "Giới hạn ký tự", "help": "Số ký tự tối đa mỗi tin nhắn" },
@@ -290,6 +324,10 @@
"webhook": "Webhook",
"websocket": "WebSocket (khuyên dùng)"
},
+ "transport": {
+ "webhook": "Webhook (khuyên dùng)",
+ "polling": "Polling"
+ },
"topic_session_mode": {
"disabled": "Tắt",
"enabled": "Bật"
@@ -357,6 +395,10 @@
"whatsapp": {
"createLabel": "Tạo & Quét QR",
"formBanner": "Sau khi tạo, quét mã QR bằng WhatsApp để xác thực."
+ },
+ "zaloOa": {
+ "createLabel": "Tạo & Kết nối",
+ "formBanner": "Sau khi tạo, bạn sẽ cấp quyền trên Zalo và dán mã trả về để hoàn tất xác thực."
}
},
"fallback": {
@@ -374,6 +416,11 @@
"description": "Ứng dụng Lark/Feishu cần bật các scope này trong Developer Console. Publish phiên bản mới sau khi thêm quyền.",
"publishReminder": "Sau khi thêm scope, đặt Contact Range thành \"All members\" và publish phiên bản mới để thay đổi có hiệu lực."
},
+ "zaloOaEvents": {
+ "title": "Bật các sự kiện này trên Zalo console",
+ "description": "Sau khi lưu Webhook URL tại developers.zalo.me, hãy bật từng sự kiện dưới đây trong danh sách sự kiện. Sự kiện nào để Tắt sẽ không bao giờ tới agent.",
+ "location": "Tab Webhook → \"Danh sách sự kiện webhook\" → gạt Tắt / Bật sang ON cho từng sự kiện ở trên."
+ },
"toast": {
"created": "Đã tạo channel",
"createdDesc": "{{name}} đã được thêm",
@@ -421,5 +468,24 @@
"relinkDevice": "Liên kết lại",
"connectedSuccess": "✅ Kết nối WhatsApp thành công!",
"tabQrCode": "Mã QR"
+ },
+ "zaloOa": {
+ "dialogTitle": "Kết nối Zalo OA — {{name}}",
+ "dialogDescription": "Cấp quyền cho Official Account, sau đó dán URL chuyển hướng từ trình duyệt.",
+ "step1Heading": "Bước 1 — Cấp quyền",
+ "authorizeHelp": "Mở liên kết bên dưới, đăng nhập Zalo và cấp quyền cho Official Account. Sau khi cấp quyền, trình duyệt sẽ chuyển sang URL callback — sao chép toàn bộ URL từ thanh địa chỉ và dán vào Bước 2 bên dưới.",
+ "step2Heading": "Bước 2 — Dán URL callback",
+ "consentLoading": "Đang tạo URL cấp quyền…",
+ "consentFailed": "Không thể lấy URL cấp quyền",
+ "pasteHelp": "Sau khi đồng ý, sao chép toàn bộ URL từ thanh địa chỉ trình duyệt và dán vào đây.",
+ "pastePlaceholder": "URL callback đầy đủ",
+ "exchangeFailed": "Đổi mã thất bại",
+ "errCodeMissing": "URL đã dán không có tham số \"code\" — hãy sao chép toàn bộ URL callback từ thanh địa chỉ trình duyệt sau khi cấp quyền.",
+ "connectedClosing": "Đã kết nối — đang đóng…",
+ "copyUrl": "Sao chép URL",
+ "openInTab": "Mở trong tab mới",
+ "cancel": "Hủy",
+ "connect": "Kết nối",
+ "connecting": "Đang kết nối…"
}
}
diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json
index ea5026ded4..7155287c04 100644
--- a/ui/web/src/i18n/locales/zh/channels.json
+++ b/ui/web/src/i18n/locales/zh/channels.json
@@ -120,6 +120,19 @@
"groups": "群组",
"managers": "管理员"
},
+ "zaloWebhook": {
+ "title": "Webhook 设置",
+ "pathLabel": "路径",
+ "urlLabel": "Webhook URL(粘贴到 Zalo 控制台)",
+ "hostLabel": "网关主机",
+ "hostHint": "如果 Zalo 无法访问此 UI 的源地址,请覆盖网关主机。按浏览器本地存储。",
+ "hostInvalid": "主机不是有效的 URL",
+ "hostInvalidScheme": "主机必须以 http:// 或 https:// 开头",
+ "oaIdLabel": "OA ID",
+ "oaIdPlaceholder": "连接后自动发现",
+ "loading": "加载中...",
+ "copy": "复制 URL"
+ },
"general": {
"identity": "身份信息",
"name": "名称",
@@ -141,7 +154,15 @@
"failedSave": "保存失败",
"saved": "已保存",
"updateCredentials": "更新凭据",
- "saving": "保存中..."
+ "saving": "保存中...",
+ "bootstrapBanner": {
+ "title": "引导模式:在 Zalo 完成设置",
+ "step1": "复制下方的 Webhook URL。",
+ "step2": "在 developers.zalo.me 的 Webhook 标签页粘贴 URL → 点击 Thay đổi(更改)→ Cập nhật(更新)。Zalo 验证并保存。",
+ "step3": "Khóa bí mật OA(OA 密钥)字段出现。点击眼睛图标显示并复制该值。",
+ "step4": "将密钥粘贴到下方的 Webhook Secret Key 字段 → 更新凭据。",
+ "note": "处于引导模式时,Channel 会以 200 应答 Zalo 的验证 ping,但会丢弃事件。粘贴密钥后将启用签名校验和消息处理。"
+ }
},
"config": {
"noSchema": "此Channel类型没有配置模式。",
@@ -225,15 +246,28 @@
"draft_transport": { "label": "草稿预览", "help": "在私聊中使用隐形草稿预览回答流 — 每次编辑无通知(需启用私聊流式传输)" },
"reasoning_stream": { "label": "显示推理", "help": "将AI思考过程显示为单独的消息(需启用流式传输)" },
"reaction_level": { "label": "表情回应级别" },
+ "quote_user_message": {
+ "label": "引用用户消息",
+ "help": "回复时引用用户的最近一条入站消息(Zalo 的 reply-to)。关闭后仅发送普通回复,不带引用。"
+ },
"media_max_mb": { "label": "最大媒体大小 (MB)" },
"link_preview": { "label": "链接预览" },
"allow_from": { "label": "允许的用户" },
"block_reply": { "label": "分块回复", "help": "在工具迭代期间发送中间文本" },
+ "transport": { "label": "连接模式", "help": "Webhook 基于事件,更轻量。Polling 定时拉取消息 — 在没有公网 URL 时作为回退方案。" },
+ "webhook_secret_key": { "label": "Webhook 密钥", "help": "来自 Zalo 开发者控制台(OA → Webhook)的签名密钥。当连接模式为 Webhook 时必填(除非签名模式为 Disabled)。用于校验 X-ZEvent-Signature。" },
+ "webhook_signature_mode": { "label": "签名模式", "help": "Disabled 跳过校验 — 最容易上线。配置好 Webhook 密钥(在凭据中)后再切换到 Strict;Log-only 是迁移过渡阶段。" },
+ "webhook_replay_window_seconds": { "label": "重放窗口(秒)", "help": "接受 webhook 事件的最大时长。默认 300,范围 60–3600。" },
+ "catch_up_on_restart": { "label": "重启后追赶", "help": "Start 时执行一次有界的 listrecentchat 扫描,补回离线期间漏掉的事件。" },
+ "poll_interval_seconds": { "label": "轮询间隔(秒)", "help": "拉取新消息的频率。最小 5,最大 120。" },
+ "poll_count": { "label": "轮询页大小", "help": "每次 listrecentchat 调用的消息数。Zalo 上限为 10 — 超过会返回错误 -210。" },
+ "poll_burndown_max_pages": { "label": "Burn-down 最大页数", "help": "每个周期连续 listrecentchat 的最大页数(页大小 × 最大页数 = 排空消息总数)。默认 10,最大 20。设为 1 可禁用 burn-down。" },
+ "redirect_uri": { "label": "Redirect URI", "help": "在 https://developers.zalo.me/app//oa/settings 将此 URL 设为 Official Account Callback URL。不一致会返回 error_code=-14003。完整设置见文档。" },
+ "webhook_secret": { "label": "Webhook 密钥", "help": "由 bot.zapps.me 在你设置 webhook URL 时生成。复制并粘贴到此处。未设置前,此通道运行在 bootstrap 模式(以 HTTP 200 回应 Zalo 的验证 ping,但丢弃事件)。" },
"domain": { "label": "域名" },
"connection_mode": { "label": "连接模式", "help": "WebSocket 无需公网 IP — 仅需出站连接" },
"webhook_port": { "label": "Webhook 端口", "help": "0 = 共享主网关端口(推荐)" },
- "webhook_path": { "label": "Webhook 路径", "help": "主服务器上 Lark 事件的路径" },
- "webhook_url": { "label": "Webhook URL" },
+ "webhook_path": { "label": "Webhook 路径", "help": "URL 路由路径。Zalo:小写字母、数字、连字符(2–63 字符),用作 /channels/zalo/webhook/。Lark:主服务器上的完整路径(例如 /feishu/events)。" },
"topic_session_mode": { "label": "话题会话模式", "help": "使用线程 root_id 进行会话隔离" },
"render_mode": { "label": "渲染模式" },
"text_chunk_limit": { "label": "文本分段限制", "help": "每条消息最大字符数" },
@@ -290,6 +324,10 @@
"webhook": "Webhook",
"websocket": "WebSocket(推荐)"
},
+ "transport": {
+ "webhook": "Webhook(推荐)",
+ "polling": "Polling"
+ },
"topic_session_mode": {
"disabled": "禁用",
"enabled": "启用"
@@ -357,6 +395,10 @@
"whatsapp": {
"createLabel": "创建并扫码",
"formBanner": "创建后,请用 WhatsApp 扫描二维码完成认证。"
+ },
+ "zaloOa": {
+ "createLabel": "创建并连接",
+ "formBanner": "创建后,您将在 Zalo 中授权访问并粘贴返回的代码以完成授权。"
}
},
"fallback": {
@@ -374,6 +416,11 @@
"description": "您的 Lark/飞书 应用需要在开发者控制台启用这些权限。添加权限后请发布新版本。",
"publishReminder": "添加权限后,将通讯录范围设置为「全部成员」,并发布新版本使更改生效。"
},
+ "zaloOaEvents": {
+ "title": "在 Zalo 控制台启用以下事件",
+ "description": "在 developers.zalo.me 保存 Webhook URL 之后,请将下列每个事件的开关打开。任何未开启的事件都不会到达 agent。",
+ "location": "Webhook 标签页 → \"Danh sách sự kiện webhook\" → 将每个事件的 Tắt / Bật 开关切换为 ON。"
+ },
"toast": {
"created": "Channel已创建",
"createdDesc": "{{name}} 已添加",
@@ -421,5 +468,24 @@
"relinkDevice": "重新连接",
"connectedSuccess": "✅ WhatsApp 连接成功!",
"tabQrCode": "二维码"
+ },
+ "zaloOa": {
+ "dialogTitle": "连接 Zalo OA — {{name}}",
+ "dialogDescription": "授权官方账号,然后粘贴浏览器中的重定向 URL。",
+ "step1Heading": "步骤 1 — 授权",
+ "authorizeHelp": "打开下方链接,登录 Zalo 并为 Official Account 授权。授权后浏览器将跳转到回调 URL — 请从地址栏复制完整 URL,并粘贴到下方步骤 2 中。",
+ "step2Heading": "步骤 2 — 粘贴回调 URL",
+ "consentLoading": "正在生成授权 URL…",
+ "consentFailed": "无法获取授权 URL",
+ "pasteHelp": "授权后,从浏览器地址栏复制完整 URL 并粘贴到此处。",
+ "pastePlaceholder": "完整回调 URL",
+ "exchangeFailed": "代码交换失败",
+ "errCodeMissing": "粘贴的 URL 没有 \"code\" 参数 — 请在授权后从浏览器地址栏复制完整的回调 URL。",
+ "connectedClosing": "已连接 — 正在关闭…",
+ "copyUrl": "复制 URL",
+ "openInTab": "在新标签页中打开",
+ "cancel": "取消",
+ "connect": "连接",
+ "connecting": "连接中…"
}
}
diff --git a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx
index d6c6d16925..4e043c629a 100644
--- a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx
+++ b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx
@@ -22,10 +22,10 @@ interface ChannelAdvancedDialogProps {
const ESSENTIAL_CONFIG_KEYS = new Set(["dm_policy", "group_policy", "require_mention", "mention_mode"]);
-const NETWORK_KEYS = new Set(["api_server", "proxy", "domain", "connection_mode", "webhook_port", "webhook_path", "webhook_url"]);
+const NETWORK_KEYS = new Set(["api_server", "proxy", "domain", "connection_mode", "webhook_port", "webhook_path"]);
const LIMITS_KEYS = new Set(["history_limit", "media_max_mb", "text_chunk_limit"]);
const STREAMING_KEYS = new Set(["dm_stream", "group_stream", "draft_transport", "reasoning_stream", "native_stream", "debounce_delay", "thread_ttl"]);
-const BEHAVIOR_KEYS = new Set(["reaction_level", "link_preview", "block_reply", "render_mode", "topic_session_mode"]);
+const BEHAVIOR_KEYS = new Set(["reaction_level", "link_preview", "block_reply", "render_mode", "topic_session_mode", "quote_user_message"]);
const ACCESS_KEYS = new Set(["allow_from", "group_allow_from"]);
function getAdvancedFields(channelType: string) {
@@ -42,12 +42,17 @@ function getAdvancedFields(channelType: string) {
function deriveInitialValues(instance: ChannelInstanceData): Record {
const config = (instance.config ?? {}) as Record;
- // Only keep advanced keys (exclude essential + groups)
return Object.fromEntries(
Object.entries(config).filter(([k]) => !ESSENTIAL_CONFIG_KEYS.has(k) && k !== "groups"),
);
}
+// Drop keys not present in the current schema (e.g. fields removed in a
+// recent release). Without this, MergeConfig keeps re-posting the orphan.
+function knownKeys(channelType: string): Set {
+ return new Set((configSchema[channelType] ?? []).map((f) => f.key));
+}
+
export function ChannelAdvancedDialog({
open,
onOpenChange,
@@ -75,11 +80,15 @@ export function ChannelAdvancedDialog({
setSaving(true);
try {
const existingConfig = (instance.config ?? {}) as Record;
+ const valid = knownKeys(instance.channel_type);
+ // Preserve essential keys + groups; drop unknown (legacy) keys.
+ const preserved = Object.fromEntries(
+ Object.entries(existingConfig).filter(([k]) => valid.has(k) || ESSENTIAL_CONFIG_KEYS.has(k) || k === "groups"),
+ );
const cleanAdvanced = Object.fromEntries(
Object.entries(values).filter(([, v]) => v !== undefined && v !== "" && v !== null),
);
- // Merge: preserve essential keys and groups from existing, overwrite advanced keys
- const merged = { ...existingConfig, ...cleanAdvanced };
+ const merged = { ...preserved, ...cleanAdvanced };
await onUpdate({ config: merged });
onOpenChange(false);
} catch { // toast shown by hook
diff --git a/ui/web/src/pages/channels/channel-detail/channel-credentials-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-credentials-tab.tsx
index 8f838a36a3..a1070f3d00 100644
--- a/ui/web/src/pages/channels/channel-detail/channel-credentials-tab.tsx
+++ b/ui/web/src/pages/channels/channel-detail/channel-credentials-tab.tsx
@@ -1,22 +1,71 @@
-import { useState, useCallback } from "react";
+import { useState, useCallback, useEffect, useMemo } from "react";
import { Save, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
-import type { ChannelInstanceData } from "@/types/channel";
-import { credentialsSchema } from "../channel-schemas";
+import type { ChannelInstanceData, ChannelRuntimeStatus } from "@/types/channel";
+import { credentialsSchema, configSchema, type FieldDef } from "../channel-schemas";
import { ChannelFields } from "../channel-fields";
+import { ZaloWebhookURLSection } from "../zalo/zalo-webhook-url-section";
import { useTranslation } from "react-i18next";
interface ChannelCredentialsTabProps {
instance: ChannelInstanceData;
+ status?: ChannelRuntimeStatus | null;
onUpdate: (updates: Record) => Promise;
}
-export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentialsTabProps) {
+// Backend masks secrets as "***" and leaves non-secret keys (per channel-type
+// allowlist) plain. Pre-populate non-password fields so users can see and
+// edit values like redirect_uri without retyping.
+function initialCredsValues(
+ fields: FieldDef[],
+ raw: Record | undefined,
+): Record {
+ if (!raw) return {};
+ const out: Record = {};
+ for (const f of fields) {
+ if (f.type === "password") continue;
+ const v = raw[f.key];
+ if (v !== undefined && v !== null && v !== "***" && v !== "") out[f.key] = v;
+ }
+ return out;
+}
+
+// Merge config defaults with instance.config so credential fields' showWhen
+// can resolve config keys (e.g. "transport") even when the saved config
+// relied on schema defaults.
+function buildConfigContext(channelType: string, cfg: Record | null): Record {
+ const schema = configSchema[channelType] ?? [];
+ const ctx: Record = {};
+ for (const f of schema) {
+ if (f.defaultValue !== undefined) ctx[f.key] = f.defaultValue;
+ }
+ if (cfg) Object.assign(ctx, cfg);
+ return ctx;
+}
+
+export function ChannelCredentialsTab({ instance, status, onUpdate }: ChannelCredentialsTabProps) {
const { t } = useTranslation("channels");
- const [values, setValues] = useState>({});
+ const fields = useMemo(
+ () => credentialsSchema[instance.channel_type] ?? [],
+ [instance.channel_type],
+ );
+ const ctx = useMemo(
+ () => buildConfigContext(instance.channel_type, instance.config),
+ [instance.channel_type, instance.config],
+ );
+ const [values, setValues] = useState>(() =>
+ initialCredsValues(fields, instance.credentials),
+ );
const [saving, setSaving] = useState(false);
- const fields = credentialsSchema[instance.channel_type] ?? [];
+ useEffect(() => {
+ setValues(initialCredsValues(fields, instance.credentials));
+ }, [fields, instance.credentials]);
+
+ const isZaloOABootstrap =
+ instance.channel_type === "zalo_oa" &&
+ status?.state === "degraded" &&
+ status?.bootstrap_state === "awaiting_secret";
const handleChange = useCallback((key: string, value: unknown) => {
setValues((prev) => ({ ...prev, [key]: value }));
@@ -49,6 +98,20 @@ export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentials
return (
+ {isZaloOABootstrap && (
+
+ {t("detail.credentials.bootstrapBanner.title")}
+
+ - {t("detail.credentials.bootstrapBanner.step1")}
+ - {t("detail.credentials.bootstrapBanner.step2")}
+ - {t("detail.credentials.bootstrapBanner.step3")}
+ - {t("detail.credentials.bootstrapBanner.step4")}
+
+
+ {t("detail.credentials.bootstrapBanner.note")}
+
+ )}
+
{t("detail.credentials.hint")}
@@ -59,6 +122,7 @@ export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentials
onChange={handleChange}
idPrefix="cd-cred"
isEdit
+ contextValues={ctx}
/>
diff --git a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx
index 4dec407384..0bc09aeff8 100644
--- a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx
+++ b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx
@@ -214,6 +214,7 @@ export function ChannelDetailPage({
diff --git a/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx
index cc97aa0ee6..820b907a69 100644
--- a/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx
+++ b/ui/web/src/pages/channels/channel-detail/channel-general-tab.tsx
@@ -13,6 +13,7 @@ import {
import { StickySaveBar } from "@/components/shared/sticky-save-bar";
import { ChannelFields } from "../channel-fields";
import { configSchema } from "../channel-schemas";
+import { ZaloWebhookURLSection } from "../zalo/zalo-webhook-url-section";
import type { ChannelInstanceData } from "@/types/channel";
import type { AgentData } from "@/types/agent";
import { channelTypeLabels } from "../channels-status-view";
@@ -128,6 +129,14 @@ export function ChannelGeneralTab({ instance, agents, onUpdate }: ChannelGeneral
+ {/* Webhook URL — visible only for zalo_bot/zalo_oa instances */}
+ {(instance.channel_type === "zalo_bot" || instance.channel_type === "zalo_oa") && (
+
+ )}
+
{/* Policies section — only shown if this channel type has essential config fields */}
{essentialFields.length > 0 && (
diff --git a/ui/web/src/pages/channels/channel-fields.tsx b/ui/web/src/pages/channels/channel-fields.tsx
index 1598d3eec4..bd858889bb 100644
--- a/ui/web/src/pages/channels/channel-fields.tsx
+++ b/ui/web/src/pages/channels/channel-fields.tsx
@@ -13,7 +13,7 @@ import {
} from "@/components/ui/select";
import { ToolNameSelect } from "@/components/shared/tool-name-select";
import { SkillNameSelect } from "@/components/shared/skill-name-select";
-import type { FieldDef } from "./channel-schemas";
+import { isFieldVisible, type FieldDef } from "./channel-schemas";
const INHERIT = "__inherit__";
@@ -32,15 +32,7 @@ export function ChannelFields({ fields, values, onChange, idPrefix, isEdit, cont
return (
{fields.map((field) => {
- // Conditional visibility: skip field if showWhen condition is not met
- if (field.showWhen) {
- const depValue = allValues[field.showWhen.key] ?? fields.find((f) => f.key === field.showWhen!.key)?.defaultValue;
- const depStr = depValue !== undefined && depValue !== null ? String(depValue) : "";
- const match = Array.isArray(field.showWhen.value)
- ? field.showWhen.value.includes(depStr)
- : depStr === field.showWhen.value;
- if (!match) return null;
- }
+ if (!isFieldVisible(field, fields, allValues)) return null;
// Check disabledWhen condition
let disabled = false;
let disabledHint: string | undefined;
@@ -96,21 +88,7 @@ function FieldRenderer({
switch (field.type) {
case "text":
case "password":
- return (
-
-
- onChange(e.target.value)}
- placeholder={field.placeholder}
- />
- {help && {help}
}
-
- );
+ return ;
case "number":
return (
@@ -295,3 +273,40 @@ function FieldRenderer({
return null;
}
}
+
+function PasswordOrTextField({
+ field,
+ value,
+ onChange,
+ id,
+ label,
+ labelSuffix,
+ editHint,
+ help,
+}: {
+ field: FieldDef;
+ value: unknown;
+ onChange: (v: unknown) => void;
+ id: string;
+ label: string;
+ labelSuffix: string;
+ editHint: string;
+ help: string;
+}) {
+ return (
+
+
+ onChange(e.target.value)}
+ placeholder={field.placeholder}
+ />
+ {help && {help}
}
+
+ );
+}
diff --git a/ui/web/src/pages/channels/channel-instance-form-dialog.tsx b/ui/web/src/pages/channels/channel-instance-form-dialog.tsx
index 26aaf1fee7..ecf1e66b03 100644
--- a/ui/web/src/pages/channels/channel-instance-form-dialog.tsx
+++ b/ui/web/src/pages/channels/channel-instance-form-dialog.tsx
@@ -12,7 +12,7 @@ import {
import { Button } from "@/components/ui/button";
import type { ChannelInstanceData, ChannelInstanceInput } from "./hooks/use-channel-instances";
import type { AgentData } from "@/types/agent";
-import { credentialsSchema, configSchema, wizardConfig, type FieldDef } from "./channel-schemas";
+import { credentialsSchema, configSchema, isFieldVisible, wizardConfig, type FieldDef } from "./channel-schemas";
import { wizardAuthSteps, wizardConfigSteps } from "./channel-wizard-registry";
import { CHANNEL_TYPES } from "@/constants/channels";
import { channelInstanceSchema, type ChannelInstanceFormData } from "@/schemas/channel.schema";
@@ -84,7 +84,18 @@ export function ChannelInstanceFormDialog({
agentId: instance?.agent_id ?? (agents[0]?.id ?? ""),
enabled: instance?.enabled ?? true,
});
- setCredsValues({});
+ // Pre-populate non-password credential fields when editing — backend
+ // exposes them unmasked (e.g. zalo_oa.redirect_uri), secrets stay "***".
+ const credsInit: Record = {};
+ if (instance?.credentials) {
+ const credsSchema = credentialsSchema[instance.channel_type] ?? [];
+ for (const f of credsSchema) {
+ if (f.type === "password") continue;
+ const v = instance.credentials[f.key];
+ if (v !== undefined && v !== null && v !== "***" && v !== "") credsInit[f.key] = v;
+ }
+ }
+ setCredsValues(credsInit);
const ct = instance?.channel_type ?? "telegram";
const schema = configSchema[ct] ?? [];
@@ -108,6 +119,26 @@ export function ChannelInstanceFormDialog({
}
}, [open, instance, agents, form]);
+ // Re-seed config defaults on channel-type switch so dependent showWhen
+ // fields resolve. Edit mode locks channel_type; this is a no-op there.
+ useEffect(() => {
+ if (!open || instance) return;
+ const schema = configSchema[channelType] ?? [];
+ const defaults: Record = {};
+ for (const f of schema) {
+ if (f.defaultValue !== undefined) defaults[f.key] = f.defaultValue;
+ }
+ const boolSelectKeys = new Set(
+ schema.filter((f) => f.type === "select" && f.options?.some((o) => o.value === "true")).map((f) => f.key),
+ );
+ for (const key of boolSelectKeys) {
+ if (typeof defaults[key] === "boolean") defaults[key] = String(defaults[key]);
+ else if (defaults[key] === undefined) defaults[key] = "inherit";
+ }
+ setConfigValues(defaults);
+ setCredsValues({});
+ }, [open, instance, channelType]);
+
useEffect(() => {
if (step !== "auth" || !authCompleted) return;
const next = getNextWizardStep("auth");
@@ -141,7 +172,11 @@ export function ChannelInstanceFormDialog({
const handleSubmit = form.handleSubmit(async (values) => {
if (!instance) {
const schema = credentialsSchema[values.channelType] ?? [];
- const missing = schema.filter((f: FieldDef) => f.required && !credsValues[f.key]);
+ // Credential showWhen can depend on config keys (e.g. transport).
+ const credsContext = { ...configValues, ...credsValues };
+ const missing = schema.filter(
+ (f: FieldDef) => f.required && isFieldVisible(f, schema, credsContext) && !credsValues[f.key],
+ );
if (missing.length > 0) {
setError(t("form.errors.requiredFields", { fields: missing.map((f: FieldDef) => f.label).join(", ") }));
return;
@@ -153,11 +188,13 @@ export function ChannelInstanceFormDialog({
);
coerceBoolSelects(cleanConfig, configSchema[values.channelType] ?? []);
- // Config required check (create-only): validate after cleanConfig is built so empty strings are caught.
if (!instance) {
const cfgSchema = configSchema[values.channelType] ?? [];
const missingCfg = cfgSchema.filter(
- (f: FieldDef) => f.required && (cleanConfig[f.key] === undefined || cleanConfig[f.key] === "" || cleanConfig[f.key] === null),
+ (f: FieldDef) =>
+ f.required &&
+ isFieldVisible(f, cfgSchema, configValues) &&
+ (cleanConfig[f.key] === undefined || cleanConfig[f.key] === "" || cleanConfig[f.key] === null),
);
if (missingCfg.length > 0) {
setError(t("form.errors.requiredFields", { fields: missingCfg.map((f: FieldDef) => f.label).join(", ") }));
diff --git a/ui/web/src/pages/channels/channel-instance-form-step.tsx b/ui/web/src/pages/channels/channel-instance-form-step.tsx
index d749fedb3c..88dc254a10 100644
--- a/ui/web/src/pages/channels/channel-instance-form-step.tsx
+++ b/ui/web/src/pages/channels/channel-instance-form-step.tsx
@@ -20,6 +20,7 @@ import { slugify } from "@/lib/slug";
import { credentialsSchema, configSchema, wizardConfig, type FieldDef } from "./channel-schemas";
import { ChannelFields } from "./channel-fields";
import { ChannelScopesInfo } from "./channel-scopes-info";
+import { ZaloOAEventsNotice } from "./zalo/zalo-oa-events-notice";
import { wizardEditConfigs } from "./channel-wizard-registry";
import { TelegramGroupOverrides, type GroupConfigWithTopics } from "./telegram-group-overrides";
import { CHANNEL_TYPES } from "@/constants/channels";
@@ -141,6 +142,7 @@ export function ChannelInstanceFormStep({
)}
+
{instance && wizard?.steps.includes("auth") && (
diff --git a/ui/web/src/pages/channels/channel-list-row.tsx b/ui/web/src/pages/channels/channel-list-row.tsx
index 775625a188..3b6f6b3f4b 100644
--- a/ui/web/src/pages/channels/channel-list-row.tsx
+++ b/ui/web/src/pages/channels/channel-list-row.tsx
@@ -1,7 +1,8 @@
-import { QrCode, Radio, Trash2 } from "lucide-react";
+import { KeyRound, QrCode, Radio, Trash2, type LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type {
ChannelInstanceData,
@@ -16,6 +17,12 @@ import {
} from "./channels-status-view";
import { channelsWithAuth } from "./channel-wizard-registry";
+const REAUTH_ICONS: Record = {
+ zalo_personal: QrCode,
+ zalo_oa: KeyRound,
+ whatsapp: QrCode,
+};
+
interface ChannelListRowProps {
instance: ChannelInstanceData;
status: ChannelRuntimeStatus | null;
@@ -55,6 +62,7 @@ export function ChannelListRow({
t("list.openChannelDetail", {
defaultValue: "Open channel detail for the latest diagnosis",
});
+ const ReauthIcon = REAUTH_ICONS[instance.channel_type] ?? QrCode;
return (
{onAuth && supportsReauth && (
-
+
+
+
+
+
+
+ {t("actions.reauthenticate")}
+
+
+
)}
{onDelete && !instance.is_default && (