Skip to content

Commit ffe4b15

Browse files
authored
Merge pull request #59 from maxwelljun/im-chatter-memory-isolation
Isolate IM chatter memory by sender
2 parents 9baae3a + 3107659 commit ffe4b15

2 files changed

Lines changed: 88 additions & 20 deletions

File tree

internal/gateway/routing.go

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ func chatKey(channel, accountID, chatID string) string {
2626

2727
// processInbound consumes the message bus and routes each message to the
2828
// correct user's agent. Identity resolution order:
29-
// 1. msg.OwnerUserID set explicitly (cron, webhook with user_id)
30-
// 2. lookup the receiving channel's row in the channels table — its
31-
// (scope, scope_id) tells us which user owns this conversation
29+
// 1. msg.OwnerUserID set explicitly (cron, webhook with user_id)
30+
// 2. lookup the receiving channel's row in the channels table — its
31+
// (scope, scope_id) tells us which user owns this conversation
32+
//
3233
// If neither yields a user_id the message is dropped, never silently
3334
// routed to a default identity.
3435
func (g *Gateway) processInbound(ctx context.Context) {
@@ -135,16 +136,11 @@ func (g *Gateway) resolveChannelOwner(ctx context.Context, msg bus.InboundMessag
135136
// - empty UserID → "" (caller leaves the slot empty; chatterUserID will
136137
// fall back to the agent owner).
137138
// - already `u_`-prefixed → assume it's already canonical, leave alone.
138-
// - channel owner is an app_user (has apikey_id) → lazy-mint an
139-
// app_user keyed by (apikey_id, "<channel>:<msg.UserID>") so every
140-
// distinct IM sender gets a stable u_xxx of their own. Channel name
141-
// is prefixed so a numeric id colliding across two channel types
142-
// (telegram chat 123, line user 123) can't merge into one row.
143-
// - channel owner is a regular user (no apikey_id) → treat as a single-
144-
// user dogfood/personal bot and pin the chatter to the owner. This
145-
// preserves the simple "I registered my own wechat to my own agent"
146-
// flow without forcing the owner to start over with a fresh empty
147-
// USER.md every conversation.
139+
// - lazy-mint an app_user keyed by the owner namespace plus
140+
// "<channel>:<accountID>:<msg.UserID>" so every distinct IM sender gets
141+
// a stable u_xxx of their own. Channel and account are prefixed so the
142+
// same numeric id on two platforms or two bots cannot merge into one
143+
// USER.md / MEMORY.md row.
148144
//
149145
// Returns "" when the original msg.UserID should be kept unchanged
150146
// (empty input, already canonical, or any error path) — the caller treats
@@ -165,16 +161,15 @@ func (g *Gateway) resolveChatter(ctx context.Context, ownerID string, msg bus.In
165161
"owner", ownerID, "channel", msg.Channel, "error", err)
166162
return ""
167163
}
168-
if owner.APIKeyID == "" {
169-
// Personal / dogfood install — every IM sender is treated as the
170-
// channel owner so the operator's own USER.md applies.
171-
return ownerID
164+
namespace := owner.APIKeyID
165+
if namespace == "" {
166+
namespace = "owner:" + ownerID
172167
}
173-
extID := msg.Channel + ":" + msg.UserID
174-
acc, err := g.accounts.EnsureAppUser(ctx, owner.APIKeyID, extID, "")
168+
extID := msg.Channel + ":" + msg.AccountID + ":" + msg.UserID
169+
acc, err := g.accounts.EnsureAppUser(ctx, namespace, extID, msg.SenderName)
175170
if err != nil {
176171
slog.Warn("resolveChatter: EnsureAppUser failed",
177-
"apikey", owner.APIKeyID, "ext", extID, "error", err)
172+
"owner", ownerID, "namespace", namespace, "ext", extID, "error", err)
178173
return ""
179174
}
180175
return acc.ID

internal/gateway/userspace_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ package gateway
33
import (
44
"context"
55
"testing"
6+
"time"
67

8+
"github.com/fastclaw-ai/fastclaw/internal/bus"
79
"github.com/fastclaw-ai/fastclaw/internal/scope"
810
"github.com/fastclaw-ai/fastclaw/internal/store"
11+
"github.com/fastclaw-ai/fastclaw/internal/users"
912
)
1013

1114
// readUserScopeAgentDefaults must distinguish "user has no row" from
@@ -66,3 +69,73 @@ func TestReadUserScopeAgentDefaults(t *testing.T) {
6669
t.Fatalf("other fields should still parse, got MaxTokens=%d", got.MaxTokens)
6770
}
6871
}
72+
73+
func TestResolveChatterSeparatesIMSendersForRegularOwner(t *testing.T) {
74+
db, err := store.NewDBStore("sqlite", "file::memory:?cache=shared")
75+
if err != nil {
76+
t.Fatalf("open store: %v", err)
77+
}
78+
defer db.Close()
79+
if err := db.Migrate(context.Background()); err != nil {
80+
t.Fatalf("migrate: %v", err)
81+
}
82+
ctx := context.Background()
83+
84+
owner := &store.UserRecord{
85+
ID: "u_owner",
86+
Username: "owner",
87+
Email: "owner@example.com",
88+
PasswordHash: "x",
89+
Role: users.RoleUser,
90+
Status: users.StatusActive,
91+
AgentQuota: -1,
92+
CreatedAt: time.Now().UTC(),
93+
UpdatedAt: time.Now().UTC(),
94+
}
95+
if err := db.CreateUser(ctx, owner); err != nil {
96+
t.Fatalf("create owner: %v", err)
97+
}
98+
accts, err := users.NewAccounts(db)
99+
if err != nil {
100+
t.Fatalf("accounts: %v", err)
101+
}
102+
g := &Gateway{store: db, accounts: accts}
103+
104+
alice := bus.InboundMessage{
105+
Channel: "telegram",
106+
AccountID: "bot-a",
107+
UserID: "111",
108+
SenderName: "Alice",
109+
}
110+
bob := bus.InboundMessage{
111+
Channel: "telegram",
112+
AccountID: "bot-a",
113+
UserID: "222",
114+
SenderName: "Bob",
115+
}
116+
aliceID := g.resolveChatter(ctx, owner.ID, alice)
117+
if aliceID == "" || aliceID == owner.ID {
118+
t.Fatalf("alice should resolve to app_user, got %q", aliceID)
119+
}
120+
bobID := g.resolveChatter(ctx, owner.ID, bob)
121+
if bobID == "" || bobID == owner.ID {
122+
t.Fatalf("bob should resolve to app_user, got %q", bobID)
123+
}
124+
if aliceID == bobID {
125+
t.Fatalf("different Telegram senders resolved to same user: %s", aliceID)
126+
}
127+
if again := g.resolveChatter(ctx, owner.ID, alice); again != aliceID {
128+
t.Fatalf("same sender should resolve stably: got %q want %q", again, aliceID)
129+
}
130+
131+
aliceAccount, err := db.GetUser(ctx, aliceID)
132+
if err != nil {
133+
t.Fatalf("get alice app_user: %v", err)
134+
}
135+
if aliceAccount.APIKeyID != "owner:"+owner.ID {
136+
t.Fatalf("unexpected namespace: %q", aliceAccount.APIKeyID)
137+
}
138+
if aliceAccount.ExternalID != "telegram:bot-a:111" {
139+
t.Fatalf("unexpected external id: %q", aliceAccount.ExternalID)
140+
}
141+
}

0 commit comments

Comments
 (0)