Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
148 commits
Select commit Hold shift + click to select a range
a64a607
feat(channels): add zalo_oauth channel skeleton with OAuth v4 paste-c…
vanducng Apr 19, 2026
8a1a675
feat(channels/zalo_oauth): lazy token refresh with single-flight + sa…
vanducng Apr 19, 2026
ad7691f
feat(channels/zalo_oauth): outbound text, image, and file send
vanducng Apr 19, 2026
0c87092
feat(channels/zalo_oauth): inbound polling loop with bounded LRU cursor
vanducng Apr 19, 2026
1851dfc
feat(channels/zalo_oauth): SendFile hardening (sanitize, deny-MIME, z…
vanducng Apr 19, 2026
40f7714
feat(ui/channels): zalo_oauth paste-code dialog + schema entries
vanducng Apr 19, 2026
f447321
chore(channels/zalo_oauth): i18n consolidation + migration audit
vanducng Apr 19, 2026
e0e99c0
test(channels/zalo_oauth): integration lifecycle + gap-fill unit tests
vanducng Apr 19, 2026
4f3886a
fix(channels/zalo_oauth): make redirect_uri configurable per instance
vanducng Apr 19, 2026
dc577aa
feat(ui/channels): auto-open zalo_oauth paste-code step in Create wizard
vanducng Apr 19, 2026
e7efc86
fix(channels/zalo_oauth): accept string expires_in + parse code from URL
vanducng Apr 19, 2026
8341d9a
fix(channels/zalo_oauth): persist oa_id from callback URL on exchange
vanducng Apr 19, 2026
1a956c3
fix(channels/zalo_oauth): header auth + v2.0 poll endpoints
vanducng Apr 19, 2026
bd520b1
fix(channels/zalo_oauth): drop get prefix on poll endpoints
vanducng Apr 19, 2026
d9fb6fb
fix(channels/zalo_oauth): retry once on poll auth error
vanducng Apr 19, 2026
f09e9f6
fix(channels/zalo_oauth): listrecentchat returns messages, not threads
vanducng Apr 19, 2026
9b184f6
fix(channels/zalo_oauth): flip health to Failed/Auth on persistent po…
vanducng Apr 19, 2026
33a1e2d
fix(channels/zalo_oauth): upload endpoints live on /v2.0, not /v3.0
vanducng Apr 19, 2026
1cb8ec8
fix(channels/zalo_oauth): default MediaMaxMB to 1 matching Zalo's rea…
vanducng Apr 19, 2026
f1caed7
feat(channels/zalo_oauth): compress-before-upload + per-endpoint MIME…
vanducng Apr 19, 2026
7e96f01
fix(channels/zalo_oauth): tune HTTP client for Zalo's variable latency
vanducng Apr 19, 2026
bce00d4
fix(channels/zalo_oauth): upload filename needs extension + surface r…
vanducng Apr 19, 2026
4b9e24c
fix(channels/zalo_oauth): upload field is attachment_id, not token
vanducng Apr 19, 2026
b004146
fix(channels/zalo_oauth): use template/media payload for image+gif sends
vanducng Apr 19, 2026
dea8bf4
test(channels/zalo_oauth): wire-shape fixture test for SendText/Image…
vanducng Apr 23, 2026
43ef0d1
refactor(channels/zalo_oauth): extract endpoint constants into endpoi…
vanducng Apr 23, 2026
e4ec249
refactor(channels/zalo_oauth): centralize Zalo error codes in errors.go
vanducng Apr 23, 2026
e8385a4
refactor(channels/zalo_oauth): unify Send* payload builders
vanducng Apr 23, 2026
2ca06d1
refactor(channels/zalo_oauth): drop FileDenyMIME + MediaMaxMB overrides
vanducng Apr 23, 2026
beb0938
feat(channels/zalo_oauth): GOCLAW_ZALO_OA_TRACE env dumps raw responses
vanducng Apr 23, 2026
4ec9575
refactor(channels): rename TypeZaloOA→TypeZaloBot; repurpose TypeZalo…
vanducng Apr 23, 2026
4abe7c1
refactor(channels): move zalo bot to zalo/bot; rename zalo/oauth → za…
vanducng Apr 23, 2026
979f011
refactor(channels): swap zalo_oauth → zalo_bot in isValidChannelType …
vanducng Apr 23, 2026
876fe85
refactor(protocol): rename MethodChannelInstancesZaloOAuth* → ZaloOA*
vanducng Apr 23, 2026
b706498
refactor(i18n): rename MsgZaloOAuth* → MsgZaloOA* across 3 catalogs
vanducng Apr 23, 2026
ec945df
refactor(channels/zalo): clean up remaining zalo_oauth prefixes → zal…
vanducng Apr 23, 2026
1a0a65e
docs(channels/zalo/oa): update package-level doc comment after rename
vanducng Apr 23, 2026
32ca12d
refactor(web/channels): rename zalo-oauth-* files and components to z…
vanducng Apr 23, 2026
6e91255
refactor(web/channels): swap zalo channel values — static-token is za…
vanducng Apr 23, 2026
ec3db42
refactor(web/channels): flip zalo_oa/zalo_oauth keys in schemas + wiz…
vanducng Apr 23, 2026
162b5e9
refactor(web/i18n): rename zaloOauth locale blocks to zaloOa across e…
vanducng Apr 23, 2026
09f2a60
feat(migrations): 000057 rename zalo_oauth → zalo_oa; zalo_oa → zalo_…
vanducng Apr 23, 2026
31b0c8e
docs(channels): reflect zalo_bot + zalo_oa split (was zalo_oa + zalo_…
vanducng Apr 23, 2026
4257eac
test(channels/zalo): rename TestZaloOAuthLifecycle → TestZaloOALifecycle
vanducng Apr 23, 2026
64cd05c
fix(tests/integration): use testEncryptionKey in TestZaloOALifecycle
vanducng Apr 25, 2026
b9875b5
fix(tests/integration): align Zalo OA mock with post-consolidation en…
vanducng Apr 25, 2026
62122e0
fix(channels/zalo_oa): mark health Failed/Auth from Send path on auth…
vanducng Apr 25, 2026
5d3b055
fix(channels/zalo_oa): address PR review findings
vanducng Apr 25, 2026
827b787
fix(channels/zalo_oa): address PR review findings
vanducng Apr 26, 2026
044d104
fix(channels/zalo_oa): address PR review findings
vanducng Apr 26, 2026
129fc69
fix(store/channel_instances): add MergeConfig for atomic JSONB merge
vanducng Apr 26, 2026
d2fc393
fix(migrations): make 000057 down idempotent
vanducng Apr 26, 2026
c4b547b
chore(i18n): remove unused Zalo OA error keys
vanducng Apr 26, 2026
decdf8f
refactor(channels/zalo/bot): split monolithic zalo.go into modular files
vanducng Apr 26, 2026
7dea7de
refactor(channels/zalo/bot): drop [photo:URL] sentinel; consume msg.M…
vanducng Apr 26, 2026
676a4c4
feat(channels/zalo/common): add shared webhook router, dedup, markdown
vanducng Apr 26, 2026
e586252
feat(channels/zalo/bot): add webhook transport mode
vanducng Apr 26, 2026
889657a
feat(channels/zalo/oa): add webhook transport mode + catch-up sweep
vanducng Apr 26, 2026
d97f270
feat(channels/zalo/oa): polling window resilience (poll_count + burn-…
vanducng Apr 26, 2026
c3763fe
feat(gateway): add channels.instances.zalo.webhook_url RPC
vanducng Apr 26, 2026
a60a0a5
test(channels/zalo): add cross-phase webhook integration tests
vanducng Apr 26, 2026
8ed5929
docs: update channels-messaging.md with webhook mode
vanducng Apr 26, 2026
1ae95d1
fix(permissions): classify zalo.webhook_url RPC as admin
vanducng Apr 27, 2026
6f32b70
fix(migrations): drop EXISTS guard that no-op'd 000058 on prod
vanducng Apr 27, 2026
7428398
fix(channels/zalo): address PR review findings (I1/S1/S2/S4)
vanducng Apr 27, 2026
0a38451
refactor(channels/zalo/common): add SharedRouter singleton + MountRoute
vanducng Apr 29, 2026
b1dc337
feat(channels/zalo): implement WebhookChannel on bot + oa
vanducng Apr 29, 2026
4b0c265
refactor(channels/zalo,cmd): collapse FactoryWithRouter into SharedRo…
vanducng Apr 29, 2026
5357d98
test(channels/zalo): MountRoute idempotency + Reload safety regression
vanducng Apr 29, 2026
046bd27
refactor(channels/zalo): trim stale plan-phase comments and history n…
vanducng Apr 29, 2026
49bf927
refactor(channels/zalo): drop StripMarkdown shim, consolidate tests
vanducng Apr 29, 2026
ba64cf0
fix(channels/zalo): address review findings on send/stop/download
vanducng Apr 29, 2026
793d027
feat(channels/zalo-oa): webhook bootstrap mode + attachment ingestion…
vanducng Apr 30, 2026
1921b85
feat(ui/channels): zalo OA bootstrap banner + event-toggles notice + …
vanducng Apr 30, 2026
f0e4490
refactor(channels/zalo): consolidate bot + OA webhook routing + add s…
vanducng Apr 30, 2026
1eac75c
test(channels/zalo): MountRoute idempotency + webhook reload safety r…
vanducng Apr 30, 2026
f2d4537
docs(channels/zalo-oa): rewrite setup guide for bootstrap flow + trou…
vanducng Apr 30, 2026
d30ae5e
refactor(channels/zalo-oa): trim narrative comments
vanducng Apr 30, 2026
11544ea
feat(channels/zalo): add error code catalogs for OA + bot APIs
vanducng Apr 30, 2026
5a464c7
chore(channels/zalo): apply go fix modernizations
vanducng Apr 30, 2026
64af22a
fix(channels/zalo): address PR review findings on OA webhook flow
vanducng Apr 30, 2026
eb96ff8
feat(channels/zalo-oa): add outbound quote-message support via DMQuot…
vanducng Apr 30, 2026
51f8281
test(channels/zalo-oa): unit + integration tests for quote-message fe…
vanducng Apr 30, 2026
713b613
docs(channels/zalo-oa): add quoted replies section + DMQuoteChannel c…
vanducng Apr 30, 2026
365e759
chore(docs): document reply-to plumbing pattern in Key Patterns
vanducng Apr 30, 2026
a167133
chore: added zalo hints
vanducng Apr 30, 2026
c9315c6
fix(channels/zalo): address PR review findings (token leak, dispatch …
vanducng Apr 30, 2026
1e878e6
chore(tests): drop PG round-trip test for migration 000058
vanducng Apr 30, 2026
dc8068a
feat(channels/zalo-oa): surface re-consent warning before refresh-tok…
vanducng Apr 30, 2026
71002c5
feat(channels/zalo-oa): outbound status emoji reactions on user messa…
vanducng Apr 30, 2026
dd74036
fix(channels/zalo-oa): restore reactions field + reauth warning lost …
vanducng Apr 30, 2026
8464a6c
feat(ui/channels): tooltip on re-consent icon
vanducng Apr 30, 2026
b45e693
fix(channels/zalo-oa): correct reaction icon names + status mapping
vanducng Apr 30, 2026
ece2f6e
chore(docker): expose GOCLAW_LOG_LEVEL env var
vanducng Apr 30, 2026
ee92137
feat(ui/channels): default zalo_oa reaction_level to minimal
vanducng Apr 30, 2026
1327638
feat(channels/zalo-oa): operator toggle for quote-user-message (defau…
vanducng Apr 30, 2026
32375be
fix(migrations): rename channel_contacts.channel_type alongside chann…
vanducng Apr 30, 2026
5db5355
fix(channels/zalo-oa): observe bad timestamp in log_only mode
vanducng Apr 30, 2026
05269f0
fix(channels/zalo-oa): escalate refresh error by code -118, not messa…
vanducng Apr 30, 2026
8f47ce6
fix(channels/zalo-oa): unregister webhook on Stop for default-transpo…
vanducng Apr 30, 2026
f152c2f
fix(channels/zalo-oa): bound HTTP refresh under ts.mu with 12s timeout
vanducng Apr 30, 2026
3868db7
fix(gateway/zalo-oa): translate missing app_id and state-gen errors
vanducng Apr 30, 2026
09f5472
fix(ui/zalo-oa): surface "URL has no code parameter" hint
vanducng Apr 30, 2026
8a6d8d1
feat(channels/zalo-bot): bootstrap webhook mode allows URL save befor…
vanducng Apr 30, 2026
511020b
refactor(cmd): drop refactor-narration tail from buildOutboundReplyMe…
vanducng Apr 30, 2026
905817a
refactor(channels/zalo-bot): drop migration-narration comment from ch…
vanducng Apr 30, 2026
c9c7617
docs: remove zalo-* docs in favor of goclaw-docs site
vanducng Apr 30, 2026
04e4cd4
feat(ui/channels): generate-button for operator-chosen webhook secrets
vanducng Apr 30, 2026
5e600fd
fix(channels/zalo): close webhook router races and tighten signature/…
vanducng Apr 30, 2026
d5cc7e4
fix(store): align MergeConfig nil-key semantics across PG and SQLite
vanducng Apr 30, 2026
1e9d882
fix(ui/zalo-oa): trim consent code values extracted from callback URL
vanducng Apr 30, 2026
9879016
chore(scripts): drop hardcoded playwright path in fetch-zalo-error-codes
vanducng Apr 30, 2026
7151c3f
fix(ui/channels): re-seed config defaults on channel-type switch in c…
vanducng Apr 30, 2026
2c17e95
refactor(channels/zalo-oa): harden image compression for orientation …
vanducng Apr 30, 2026
b107b55
feat(ui/channels): show/hide toggle for generatable password fields
vanducng Apr 30, 2026
3eb65a1
fix(channels/zalo): address webhook code-review follow-ups
vanducng Apr 30, 2026
28e4878
refactor(channels/zalo): drop unused webhook_url config field
vanducng Apr 30, 2026
ab129fe
refactor(channels/zalo): hoist image compression to common for bot reuse
vanducng Apr 30, 2026
d6bb72f
feat(channels/zalo): add typing indicator + refactor OA token/reactio…
vanducng Apr 30, 2026
13caee6
test(channels/zalo-oa): drop t.Parallel on global-mutating reaction test
vanducng Apr 30, 2026
a3dcaae
chore(compose): expose GOCLAW_AUTO_UPGRADE env (default true)
vanducng Apr 30, 2026
8ae0044
fix(ui/channels): skip hidden showWhen fields in required-field valid…
vanducng Apr 30, 2026
616eb49
feat(ui/channels): default zalo_bot ingestion to polling
vanducng Apr 30, 2026
2c0ad94
fix(channels/zalo-bot): clear stale webhook before starting polling loop
vanducng Apr 30, 2026
6a3ad3e
refactor: trim narration from recent zalo-bot + channel-form changes
vanducng Apr 30, 2026
9996531
fix(channels/zalo-bot): close startTyping race after stop
vanducng Apr 30, 2026
6d0283c
refactor(channels/zalo-oa): default signature mode to disabled + tigh…
vanducng Apr 30, 2026
b94560e
refactor(channels/zalo-bot): typed API error for pattern matching
vanducng Apr 30, 2026
88be498
feat(tools/channels): SSRF-safe HTTP client with DNS-rebind protection
vanducng Apr 30, 2026
82ffb28
docs(channels/zalo-oa): correct polling limits per Zalo API constraint
vanducng Apr 30, 2026
3dc2dae
test(channels/zalo): replace personal domain with example.com
vanducng Apr 30, 2026
5082ceb
fix(gateway/zalo-webhook): log store.Get errors separately from tenan…
vanducng Apr 30, 2026
457d64c
fix(ui/channels): legacy-key cleanup, zalo_bot listing, OA consent race
vanducng Apr 30, 2026
2efae27
refactor(security): generalize cloud-metadata IP blocklist
vanducng Apr 30, 2026
1ef22e8
Revert "refactor(security): generalize cloud-metadata IP blocklist"
vanducng Apr 30, 2026
bb68b75
chore(scripts): remove unused zalo error-code scraper
vanducng Apr 30, 2026
c1e7b03
fix(channels/zalo): address PR review (GH-966) — credentials, SSRF, t…
vanducng May 1, 2026
29d6d45
revert(migrations): drop v26 zalo_oa↔zalo_bot SQLite rename
vanducng May 1, 2026
e6efaad
fix(web): proxy zalo webhook path through nginx (GH-966)
vanducng May 1, 2026
3c92a64
fix(zalo/bot): unwrap webhook {ok, result} envelope (GH-966)
vanducng May 1, 2026
51f3b2b
fix(channels/zalo-bot): accept unwrapped webhook payload (GH-966)
vanducng May 1, 2026
4e3cdfb
fix(web): use upstream block so nginx proxy works in K8s (GH-966)
vanducng May 1, 2026
e82ba19
feat(channels/zalo-bot): default to webhook, drop in-app secret gener…
vanducng May 1, 2026
6e452e0
fix(channels/zalo): address PR review findings (GH-966)
vanducng May 1, 2026
c956b97
fix(channels/zalo): address PR review findings round 2 (GH-966)
vanducng May 1, 2026
8f14d2c
fix(channels/zalo): autocomplete=off + pre-auth log (GH-966)
vanducng May 1, 2026
42c8129
feat(channels/zalo/oa): flip quote_user_message default off
vanducng May 1, 2026
94a0a2d
feat(channels/zalo/oa): defer terminal reaction with jittered delay (…
vanducng May 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<pre>` 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
Expand Down
6 changes: 4 additions & 2 deletions cmd/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down
12 changes: 7 additions & 5 deletions cmd/gateway_channels_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)")
}
}
Expand Down Expand Up @@ -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())
Expand Down
25 changes: 19 additions & 6 deletions cmd/gateway_consumer_normal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
84 changes: 84 additions & 0 deletions cmd/gateway_consumer_reply_meta_test.go
Original file line number Diff line number Diff line change
@@ -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", &quoteOptInChannel{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", &quoteOptInChannel{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)
}
}
1 change: 1 addition & 0 deletions cmd/gateway_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ func isExternalChannel(channelType string) bool {
channels.TypeDiscord,
channels.TypeFeishu,
channels.TypeWhatsApp,
channels.TypeZaloBot,
channels.TypeZaloOA,
channels.TypeZaloPersonal,
channels.TypePancake,
Expand Down
1 change: 1 addition & 0 deletions cmd/gateway_errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
18 changes: 18 additions & 0 deletions cmd/gateway_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ package cmd
import (
"context"
"log/slog"
"net/http"
"os"
"strings"
"time"

"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"
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.selfservice.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/00-architecture-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ flowchart TD
TG[Telegram]
DC[Discord]
FS[Feishu / Lark]
ZB[Zalo Bot]
ZL[Zalo OA]
ZLP[Zalo Personal]
WA[WhatsApp]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down
Loading
Loading