Skip to content

Commit 51c38fa

Browse files
idoubiclaude
andcommitted
refactor(channels): extract channel bindings from configs into dedicated channels table
New `channels` table with explicit fields (type, account_id, user_id, agent_id, bot_token, base_url, etc.) replacing the overloaded configs rows where kind='channel'. UNIQUE(type, account_id) enforces one-bot- one-agent at the DB level. Strategy: dual-write + fallback-read for zero-downtime migration. - All writes go to both tables (configs + channels) - Reads prefer channels table, fall back to configs - Auto-migration copies existing configs channel rows on startup - Old configs rows preserved for rollback safety Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fb01326 commit 51c38fa

10 files changed

Lines changed: 646 additions & 23 deletions

File tree

cmd/fastclaw/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ func (a *apiResolver) RegisterChannelFromConfig(rec store.ConfigRecord) error {
7171
return a.gw.RegisterChannelFromConfig(rec)
7272
}
7373

74+
// RegisterChannel hot-starts a freshly-saved ChannelRecord.
75+
func (a *apiResolver) RegisterChannel(rec store.ChannelRecord) error {
76+
return a.gw.RegisterChannel(rec)
77+
}
78+
7479
func (a *apiResolver) UnregisterChannel(channelType, accountID string) {
7580
a.gw.UnregisterChannel(channelType, accountID)
7681
}

internal/gateway/channels.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,62 @@ func registerChannelInstance(rec store.ConfigRecord, mb *bus.MessageBus, chanMgr
5959
return nil
6060
}
6161

62+
// registerChannelFromRecord starts a channel adapter from a ChannelRecord.
63+
// This is the new-table equivalent of registerChannelInstance.
64+
func registerChannelFromRecord(rec store.ChannelRecord, mb *bus.MessageBus, chanMgr *channels.Manager, st store.Store, hot bool) error {
65+
cc := decodeChannelFromRecord(rec)
66+
switch rec.Type {
67+
case "telegram":
68+
return registerTelegramChannels(cc, mb, chanMgr, hot)
69+
case "discord":
70+
return registerDiscordChannels(cc, mb, chanMgr, hot)
71+
case "slack":
72+
return registerSlackChannels(cc, mb, chanMgr, hot)
73+
case "line":
74+
return registerLINEChannels(cc, mb, chanMgr, hot)
75+
case "wechat":
76+
cfgRec := channelRecordToConfigRecord(rec)
77+
return registerWeChatChannels(cfgRec, cc, mb, chanMgr, st, hot)
78+
case "feishu":
79+
return registerFeishuChannels(cc, mb, chanMgr, hot)
80+
}
81+
return nil
82+
}
83+
84+
// channelRecordToConfigRecord builds a ConfigRecord from a ChannelRecord
85+
// for backward compatibility with registerWeChatChannels which needs
86+
// the ConfigRecord shape for its on-expired callback.
87+
func channelRecordToConfigRecord(ch store.ChannelRecord) store.ConfigRecord {
88+
return store.ConfigRecord{
89+
ID: ch.ID,
90+
Kind: store.KindChannel,
91+
UserID: ch.UserID,
92+
AgentID: ch.AgentID,
93+
Name: ch.Type,
94+
Enabled: ch.Enabled,
95+
CredentialKey: ch.AccountID,
96+
Data: ch.Data,
97+
CreatedAt: ch.CreatedAt,
98+
UpdatedAt: ch.UpdatedAt,
99+
}
100+
}
101+
102+
// decodeChannelFromRecord converts a ChannelRecord into a ChannelConfig.
103+
// It reads from both the top-level fields and the Data JSON blob.
104+
func decodeChannelFromRecord(rec store.ChannelRecord) config.ChannelConfig {
105+
cc := config.ChannelConfig{Enabled: rec.Enabled}
106+
// First, decode from the Data blob (preserves the Accounts map, etc.)
107+
if blob, err := json.Marshal(rec.Data); err == nil && len(blob) > 0 {
108+
_ = json.Unmarshal(blob, &cc)
109+
}
110+
cc.Enabled = rec.Enabled
111+
// Override BotToken from top-level field when present.
112+
if rec.BotToken != "" {
113+
cc.BotToken = rec.BotToken
114+
}
115+
return cc
116+
}
117+
62118
// register adds an adapter to the manager via the appropriate path
63119
// (boot-time Register vs hot RegisterAndStart). Keeps the per-channel
64120
// case branches tidy.

internal/gateway/gateway.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -744,14 +744,34 @@ const (
744744
NSBindings = "bindings"
745745
)
746746

747-
// registerChannelsFromStore loads every enabled kind="channel" row from
748-
// configs and starts a channel adapter for each, regardless of
749-
// scope. The owner is captured per-row and resolved at message receipt
750-
// time via LookupChannelByCredential.
747+
// registerChannelsFromStore loads every enabled channel from the
748+
// channels table and starts a channel adapter for each. Falls back
749+
// to configs (kind='channel') when the channels table is empty (pre-
750+
// migration installs). The owner is captured per-row and resolved at
751+
// message receipt time via LookupChannel / LookupChannelByCredential.
751752
func registerChannelsFromStore(st store.Store, mb *bus.MessageBus, chanMgr *channels.Manager) error {
752753
if st == nil {
753754
return nil
754755
}
756+
// Try the new channels table first.
757+
chRows, err := st.ListAllChannels(context.Background())
758+
if err != nil {
759+
slog.Warn("ListAllChannels failed, falling back to configs", "error", err)
760+
chRows = nil
761+
}
762+
if len(chRows) > 0 {
763+
for _, r := range chRows {
764+
if !r.Enabled {
765+
continue
766+
}
767+
if err := registerChannelFromRecord(r, mb, chanMgr, st, false); err != nil {
768+
slog.Warn("register channel failed",
769+
"type", r.Type, "user_id", r.UserID, "agent_id", r.AgentID, "error", err)
770+
}
771+
}
772+
return nil
773+
}
774+
// Fallback: read from configs for pre-migration installs.
755775
rows, err := allChannelRows(st)
756776
if err != nil {
757777
return err

internal/gateway/reload.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ func (g *Gateway) RegisterChannelFromConfig(rec store.ConfigRecord) error {
7373
return registerChannelInstance(rec, g.bus, g.chanMgr, g.store, true)
7474
}
7575

76+
// RegisterChannel hot-starts a channel adapter from a ChannelRecord.
77+
// New-table equivalent of RegisterChannelFromConfig.
78+
func (g *Gateway) RegisterChannel(rec store.ChannelRecord) error {
79+
if g.chanMgr == nil || g.bus == nil {
80+
return nil
81+
}
82+
return registerChannelFromRecord(rec, g.bus, g.chanMgr, g.store, true)
83+
}
84+
7685
// UnregisterChannel removes a channel from the routing table. Note:
7786
// the bot's polling goroutine is left to die when the root ctx ends —
7887
// see channels.Manager.Unregister for why. Inbound messages stop

internal/gateway/routing.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,18 +92,32 @@ func (g *Gateway) resolveChannelOwner(ctx context.Context, msg bus.InboundMessag
9292
if g.store == nil {
9393
return ""
9494
}
95+
// Try the new channels table first.
96+
if ch, err := g.store.LookupChannel(ctx, msg.Channel, msg.AccountID); err == nil && ch != nil {
97+
if ch.UserID != "" {
98+
return ch.UserID
99+
}
100+
if ch.AgentID != "" {
101+
all, err := g.store.ListAllAgents(ctx)
102+
if err != nil {
103+
return ""
104+
}
105+
for _, ar := range all {
106+
if ar.ID == ch.AgentID {
107+
return ar.UserID
108+
}
109+
}
110+
}
111+
return ""
112+
}
113+
// Fallback: legacy configs table lookup.
95114
rec, err := g.store.LookupChannelByCredential(ctx, msg.Channel, msg.AccountID)
96115
if err != nil {
97116
if !errors.Is(err, store.ErrNotFound) {
98117
slog.Warn("channel lookup failed", "channel", msg.Channel, "error", err)
99118
}
100119
return ""
101120
}
102-
// channel rows now carry user_id directly — the binder, not the
103-
// agent owner indirection. The previous "scope=agent → look up
104-
// agent.user_id" branch is gone because every channel row written
105-
// by handleConnect* persists the resolved user_id (owner or
106-
// non-owner) at insert time.
107121
if rec.UserID != "" {
108122
return rec.UserID
109123
}

internal/gateway/userspace.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,8 +1097,41 @@ func bindingsFromChannelRows(ctx context.Context, st store.Store, userID string,
10971097
}
10981098
var out []config.Binding
10991099
covered := make(map[string]bool, len(agents))
1100+
1101+
// Try the new channels table first — build bindings from ChannelRecords.
1102+
hasNewRows := false
11001103
for _, ar := range agents {
11011104
covered[ar.ID] = true
1105+
if chRows, err := st.ListChannels(ctx, "", ar.ID); err == nil && len(chRows) > 0 {
1106+
hasNewRows = true
1107+
out = append(out, expandChannelRecordBindings(chRows, ar.ID)...)
1108+
}
1109+
if userID != "" {
1110+
if chRows, err := st.ListChannels(ctx, userID, ar.ID); err == nil && len(chRows) > 0 {
1111+
hasNewRows = true
1112+
out = append(out, expandChannelRecordBindings(chRows, ar.ID)...)
1113+
}
1114+
}
1115+
}
1116+
// Reverse-lookup from the new table: any channel this user bound
1117+
// to an agent they don't own.
1118+
if userID != "" {
1119+
if allUserCh, err := st.ListAllChannels(ctx); err == nil {
1120+
for _, ch := range allUserCh {
1121+
if ch.UserID != userID || ch.AgentID == "" || covered[ch.AgentID] {
1122+
continue
1123+
}
1124+
hasNewRows = true
1125+
out = append(out, expandChannelRecordBindings([]store.ChannelRecord{ch}, ch.AgentID)...)
1126+
}
1127+
}
1128+
}
1129+
if hasNewRows {
1130+
return out
1131+
}
1132+
1133+
// Fallback: read from configs for pre-migration installs.
1134+
for _, ar := range agents {
11021135
rows, err := st.ListConfigs(ctx, store.KindChannel, "", ar.ID)
11031136
if err == nil {
11041137
out = append(out, expandChannelBindings(rows, ar.ID)...)
@@ -1127,6 +1160,37 @@ func bindingsFromChannelRows(ctx context.Context, st store.Store, userID string,
11271160
return out
11281161
}
11291162

1163+
// expandChannelRecordBindings builds Binding entries from ChannelRecord rows.
1164+
func expandChannelRecordBindings(rows []store.ChannelRecord, agentID string) []config.Binding {
1165+
var out []config.Binding
1166+
for _, r := range rows {
1167+
if !r.Enabled {
1168+
continue
1169+
}
1170+
cc := config.ChannelConfig{}
1171+
if blob, err := json.Marshal(r.Data); err == nil {
1172+
_ = json.Unmarshal(blob, &cc)
1173+
}
1174+
// Each ChannelRecord is one (type, account_id) — but the Data
1175+
// blob may still carry an Accounts map from the migration. Use
1176+
// the top-level AccountID as the primary binding key.
1177+
if len(cc.Accounts) == 0 {
1178+
out = append(out, config.Binding{
1179+
AgentID: agentID,
1180+
Match: config.Match{Channel: r.Type, AccountID: r.AccountID},
1181+
})
1182+
continue
1183+
}
1184+
for accountID := range cc.Accounts {
1185+
out = append(out, config.Binding{
1186+
AgentID: agentID,
1187+
Match: config.Match{Channel: r.Type, AccountID: accountID},
1188+
})
1189+
}
1190+
}
1191+
return out
1192+
}
1193+
11301194
func expandChannelBindings(rows []store.ConfigRecord, agentID string) []config.Binding {
11311195
var out []config.Binding
11321196
for _, r := range rows {

0 commit comments

Comments
 (0)