Skip to content

Commit ce19a41

Browse files
committed
fix(synology-chat): scope DM sessions by account
1 parent 4f1e12a commit ce19a41

8 files changed

Lines changed: 188 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ Docs: https://docs.openclaw.ai
203203
- Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob.
204204
- Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc.
205205
- Plugins/runtime-api: pin extension runtime-api export surfaces with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc.
206+
- Synology Chat/multi-account: scope direct-message sessions by account and sender so identical webhook `user_id` values on different Synology accounts no longer share transcript or delivery state.
206207
- Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc.
207208
- Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @restriction and @joshavant.
208209
- Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc.

docs/channels/synology-chat.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ Media sends are supported by URL-based file delivery.
100100

101101
Multiple Synology Chat accounts are supported under `channels.synology-chat.accounts`.
102102
Each account can override token, incoming URL, webhook path, DM policy, and limits.
103+
Direct-message sessions are isolated per account and user, so the same numeric `user_id`
104+
on two different Synology accounts does not share transcript state.
103105

104106
```json5
105107
{

extensions/synology-chat/src/channel.integration.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import type { IncomingMessage, ServerResponse } from "node:http";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33
import {
44
dispatchReplyWithBufferedBlockDispatcher,
5+
finalizeInboundContextMock,
56
registerPluginHttpRouteMock,
7+
resolveAgentRouteMock,
68
} from "./channel.test-mocks.js";
79
import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js";
810

@@ -17,6 +19,8 @@ describe("Synology channel wiring integration", () => {
1719
beforeEach(() => {
1820
registerPluginHttpRouteMock.mockClear();
1921
dispatchReplyWithBufferedBlockDispatcher.mockClear();
22+
finalizeInboundContextMock.mockClear();
23+
resolveAgentRouteMock.mockClear();
2024
});
2125

2226
it("registers real webhook handler with resolved account config and enforces allowlist", async () => {
@@ -73,4 +77,101 @@ describe("Synology channel wiring integration", () => {
7377
abortController.abort();
7478
await started;
7579
});
80+
81+
it("isolates same user_id across different accounts", async () => {
82+
const plugin = createSynologyChatPlugin();
83+
const alphaAbortController = new AbortController();
84+
const betaAbortController = new AbortController();
85+
const cfg = {
86+
channels: {
87+
"synology-chat": {
88+
enabled: true,
89+
accounts: {
90+
alpha: {
91+
enabled: true,
92+
token: "token-alpha",
93+
incomingUrl: "https://nas.example.com/incoming-alpha",
94+
webhookPath: "/webhook/synology-alpha",
95+
dmPolicy: "open",
96+
},
97+
beta: {
98+
enabled: true,
99+
token: "token-beta",
100+
incomingUrl: "https://nas.example.com/incoming-beta",
101+
webhookPath: "/webhook/synology-beta",
102+
dmPolicy: "open",
103+
},
104+
},
105+
},
106+
},
107+
session: {
108+
dmScope: "main",
109+
},
110+
};
111+
112+
const alphaStarted = plugin.gateway.startAccount({
113+
cfg,
114+
accountId: "alpha",
115+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
116+
abortSignal: alphaAbortController.signal,
117+
});
118+
const betaStarted = plugin.gateway.startAccount({
119+
cfg,
120+
accountId: "beta",
121+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
122+
abortSignal: betaAbortController.signal,
123+
});
124+
125+
expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(2);
126+
const alphaRoute = registerPluginHttpRouteMock.mock.calls[0]?.[0];
127+
const betaRoute = registerPluginHttpRouteMock.mock.calls[1]?.[0];
128+
if (!alphaRoute || !betaRoute) {
129+
throw new Error("Expected both Synology Chat routes to register");
130+
}
131+
132+
const alphaReq = makeReq(
133+
"POST",
134+
makeFormBody({
135+
token: "token-alpha",
136+
user_id: "123",
137+
username: "alice",
138+
text: "alpha secret",
139+
}),
140+
);
141+
const alphaRes = makeRes();
142+
await alphaRoute.handler(alphaReq, alphaRes);
143+
144+
const betaReq = makeReq(
145+
"POST",
146+
makeFormBody({
147+
token: "token-beta",
148+
user_id: "123",
149+
username: "bob",
150+
text: "beta secret",
151+
}),
152+
);
153+
const betaRes = makeRes();
154+
await betaRoute.handler(betaReq, betaRes);
155+
156+
expect(alphaRes._status).toBe(204);
157+
expect(betaRes._status).toBe(204);
158+
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2);
159+
expect(finalizeInboundContextMock).toHaveBeenCalledTimes(2);
160+
161+
const alphaCtx = finalizeInboundContextMock.mock.calls[0]?.[0];
162+
const betaCtx = finalizeInboundContextMock.mock.calls[1]?.[0];
163+
expect(alphaCtx).toMatchObject({
164+
AccountId: "alpha",
165+
SessionKey: "agent:agent-alpha:synology-chat:alpha:direct:123",
166+
});
167+
expect(betaCtx).toMatchObject({
168+
AccountId: "beta",
169+
SessionKey: "agent:agent-beta:synology-chat:beta:direct:123",
170+
});
171+
172+
alphaAbortController.abort();
173+
betaAbortController.abort();
174+
await alphaStarted;
175+
await betaStarted;
176+
});
76177
});

extensions/synology-chat/src/channel.test-mocks.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ export const registerPluginHttpRouteMock: Mock<(params: RegisteredRoute) => () =
1515
export const dispatchReplyWithBufferedBlockDispatcher: Mock<
1616
() => Promise<{ counts: Record<string, number> }>
1717
> = vi.fn().mockResolvedValue({ counts: {} });
18+
export const finalizeInboundContextMock: Mock<
19+
(ctx: Record<string, unknown>) => Record<string, unknown>
20+
> = vi.fn((ctx) => ctx);
21+
export const resolveAgentRouteMock: Mock<
22+
(params: { accountId?: string }) => { agentId: string; sessionKey: string; accountId: string }
23+
> = vi.fn((params) => {
24+
const accountId = params.accountId?.trim() || "default";
25+
return {
26+
agentId: `agent-${accountId}`,
27+
sessionKey: `agent:agent-${accountId}:main`,
28+
accountId,
29+
};
30+
});
1831

1932
async function readRequestBodyWithLimitForTest(req: IncomingMessage): Promise<string> {
2033
return await new Promise<string>((resolve, reject) => {
@@ -62,13 +75,18 @@ vi.mock("openclaw/plugin-sdk/webhook-ingress", async () => {
6275
vi.mock("./client.js", () => ({
6376
sendMessage: vi.fn().mockResolvedValue(true),
6477
sendFileUrl: vi.fn().mockResolvedValue(true),
78+
resolveChatUserId: vi.fn().mockResolvedValue(undefined),
6579
}));
6680

6781
vi.mock("./runtime.js", () => ({
6882
getSynologyRuntime: vi.fn(() => ({
6983
config: { loadConfig: vi.fn().mockResolvedValue({}) },
7084
channel: {
85+
routing: {
86+
resolveAgentRoute: resolveAgentRouteMock,
87+
},
7188
reply: {
89+
finalizeInboundContext: finalizeInboundContextMock,
7290
dispatchReplyWithBufferedBlockDispatcher,
7391
},
7492
},

extensions/synology-chat/src/channel.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { z } from "zod";
2222
import { listAccountIds, resolveAccount } from "./accounts.js";
2323
import { sendMessage, sendFileUrl } from "./client.js";
2424
import { getSynologyRuntime } from "./runtime.js";
25+
import { buildSynologyChatInboundSessionKey } from "./session-key.js";
2526
import { synologyChatSetupAdapter, synologyChatSetupWizard } from "./setup-surface.js";
2627
import type { ResolvedSynologyChatAccount } from "./types.js";
2728
import { createWebhookHandler } from "./webhook-handler.js";
@@ -246,6 +247,21 @@ export function createSynologyChatPlugin() {
246247
// The Chat API user_id (for sending) may differ from the webhook
247248
// user_id (used for sessions/pairing). Use chatUserId for API calls.
248249
const sendUserId = msg.chatUserId ?? msg.from;
250+
const route = rt.channel.routing.resolveAgentRoute({
251+
cfg: currentCfg,
252+
channel: CHANNEL_ID,
253+
accountId: account.accountId,
254+
peer: {
255+
kind: "direct",
256+
id: msg.from,
257+
},
258+
});
259+
const sessionKey = buildSynologyChatInboundSessionKey({
260+
agentId: route.agentId,
261+
accountId: account.accountId,
262+
userId: msg.from,
263+
identityLinks: currentCfg.session?.identityLinks,
264+
});
249265

250266
// Build MsgContext using SDK's finalizeInboundContext for proper normalization
251267
const msgCtx = rt.channel.reply.finalizeInboundContext({
@@ -254,7 +270,7 @@ export function createSynologyChatPlugin() {
254270
CommandBody: msg.body,
255271
From: `synology-chat:${msg.from}`,
256272
To: `synology-chat:${msg.from}`,
257-
SessionKey: msg.sessionKey,
273+
SessionKey: sessionKey,
258274
AccountId: account.accountId,
259275
OriginatingChannel: CHANNEL_ID,
260276
OriginatingTo: `synology-chat:${msg.from}`,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildSynologyChatInboundSessionKey } from "./session-key.js";
3+
4+
describe("buildSynologyChatInboundSessionKey", () => {
5+
it("isolates direct-message sessions by account and user", () => {
6+
const alpha = buildSynologyChatInboundSessionKey({
7+
agentId: "main",
8+
accountId: "alpha",
9+
userId: "123",
10+
});
11+
const beta = buildSynologyChatInboundSessionKey({
12+
agentId: "main",
13+
accountId: "beta",
14+
userId: "123",
15+
});
16+
const otherUser = buildSynologyChatInboundSessionKey({
17+
agentId: "main",
18+
accountId: "alpha",
19+
userId: "456",
20+
});
21+
22+
expect(alpha).toBe("agent:main:synology-chat:alpha:direct:123");
23+
expect(beta).toBe("agent:main:synology-chat:beta:direct:123");
24+
expect(otherUser).toBe("agent:main:synology-chat:alpha:direct:456");
25+
expect(alpha).not.toBe(beta);
26+
expect(alpha).not.toBe(otherUser);
27+
});
28+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { buildAgentSessionKey } from "openclaw/plugin-sdk/core";
2+
3+
const CHANNEL_ID = "synology-chat";
4+
5+
export function buildSynologyChatInboundSessionKey(params: {
6+
agentId: string;
7+
accountId: string;
8+
userId: string;
9+
identityLinks?: Record<string, string[]>;
10+
}): string {
11+
return buildAgentSessionKey({
12+
agentId: params.agentId,
13+
channel: CHANNEL_ID,
14+
accountId: params.accountId,
15+
peer: { kind: "direct", id: params.userId },
16+
// Synology Chat supports multiple independent accounts on one gateway.
17+
// Keep direct-message sessions isolated per account and user.
18+
dmScope: "per-account-channel-peer",
19+
identityLinks: params.identityLinks,
20+
});
21+
}

extensions/synology-chat/src/webhook-handler.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,6 @@ export interface WebhookHandlerDeps {
225225
senderName: string;
226226
provider: string;
227227
chatType: string;
228-
sessionKey: string;
229228
accountId: string;
230229
commandAuthorized: boolean;
231230
/** Chat API user_id for sending replies (may differ from webhook user_id) */
@@ -358,14 +357,12 @@ export function createWebhookHandler(deps: WebhookHandlerDeps) {
358357
);
359358
}
360359

361-
const sessionKey = `synology-chat-${payload.user_id}`;
362360
const deliverPromise = deliver({
363361
body: cleanText,
364362
from: payload.user_id,
365363
senderName: payload.username,
366364
provider: "synology-chat",
367365
chatType: "direct",
368-
sessionKey,
369366
accountId: account.accountId,
370367
commandAuthorized: auth.allowed,
371368
chatUserId: replyUserId,

0 commit comments

Comments
 (0)