@openclaw-channel/socket-chat 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,61 @@
1
- import { describe, expect, it, vi, beforeEach } from "vitest";
2
- import { handleInboundMessage, _resetNotifiedGroupsForTest } from "./inbound.js";
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+ import { dispatchInboundReplyWithBase, createChannelPairingController } from "./runtime-api.js";
3
+ import { setSocketChatRuntime, clearSocketChatRuntime } from "./runtime.js";
4
+ import { handleSocketChatInbound, _resetNotifiedGroupsForTest } from "./inbound.js";
3
5
  import type { SocketChatInboundMessage } from "./types.js";
4
6
  import type { CoreConfig } from "./config.js";
5
7
 
6
8
  // ---------------------------------------------------------------------------
7
- // helpers
9
+ // Module mocks
10
+ // ---------------------------------------------------------------------------
11
+
12
+ vi.mock("./runtime-api.js", () => {
13
+ return {
14
+ dispatchInboundReplyWithBase: vi.fn(async () => {}),
15
+ createChannelPairingController: vi.fn(),
16
+ deliverFormattedTextWithAttachments: vi.fn(async () => true),
17
+ resolveAllowlistMatchByCandidates: vi.fn(
18
+ ({ allowList, candidates }: { allowList: string[]; candidates: { value: string }[] }) => {
19
+ const allowed = allowList.some(
20
+ (rule) => rule === "*" || candidates.some((c) => c.value === rule),
21
+ );
22
+ return { allowed, matchedRule: undefined };
23
+ },
24
+ ),
25
+ resolveChannelMediaMaxBytes: vi.fn(
26
+ ({
27
+ cfg,
28
+ resolveChannelLimitMb,
29
+ accountId,
30
+ }: {
31
+ cfg: unknown;
32
+ resolveChannelLimitMb: (args: { cfg: unknown; accountId: string }) => number | undefined;
33
+ accountId: string;
34
+ }) => {
35
+ const mb = resolveChannelLimitMb({ cfg, accountId });
36
+ return mb !== undefined ? mb * 1024 * 1024 : undefined;
37
+ },
38
+ ),
39
+ detectMime: vi.fn(async ({ headerMime }: { headerMime?: string }) => headerMime),
40
+ buildMediaPayload: vi.fn(
41
+ (items: Array<{ path: string; contentType?: string }>) => {
42
+ const item = items[0];
43
+ if (!item) return {};
44
+ return {
45
+ MediaPath: item.path,
46
+ MediaUrl: item.path,
47
+ MediaPaths: [item.path],
48
+ MediaUrls: [item.path],
49
+ MediaType: item.contentType,
50
+ MediaTypes: [item.contentType],
51
+ };
52
+ },
53
+ ),
54
+ };
55
+ });
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Helpers
8
59
  // ---------------------------------------------------------------------------
9
60
 
10
61
  function makeMsg(overrides: Partial<SocketChatInboundMessage> = {}): SocketChatInboundMessage {
@@ -20,69 +71,62 @@ function makeMsg(overrides: Partial<SocketChatInboundMessage> = {}): SocketChatI
20
71
  };
21
72
  }
22
73
 
23
- type MockChannelRuntime = {
24
- pairing: {
25
- readAllowFromStore: ReturnType<typeof vi.fn>;
26
- upsertPairingRequest: ReturnType<typeof vi.fn>;
27
- buildPairingReply: ReturnType<typeof vi.fn>;
28
- };
29
- reply: {
30
- finalizeInboundContext: ReturnType<typeof vi.fn>;
31
- dispatchReplyWithBufferedBlockDispatcher: ReturnType<typeof vi.fn>;
32
- };
33
- routing: {
34
- resolveAgentRoute: ReturnType<typeof vi.fn>;
35
- };
36
- session: {
37
- resolveStorePath: ReturnType<typeof vi.fn>;
38
- recordInboundSession: ReturnType<typeof vi.fn>;
39
- };
40
- activity: {
41
- record: ReturnType<typeof vi.fn>;
74
+ function makeConfig(channelCfg: Record<string, unknown> = {}): CoreConfig {
75
+ return {
76
+ channels: {
77
+ "socket-chat": {
78
+ apiKey: "k",
79
+ apiBaseUrl: "https://x.com",
80
+ ...channelCfg,
81
+ },
82
+ },
83
+ } as unknown as CoreConfig;
84
+ }
85
+
86
+ function makeLog() {
87
+ return {
88
+ info: vi.fn(),
89
+ warn: vi.fn(),
90
+ error: vi.fn(),
91
+ debug: vi.fn(),
42
92
  };
93
+ }
94
+
95
+ type MockCore = {
43
96
  channel: {
97
+ routing: { resolveAgentRoute: ReturnType<typeof vi.fn> };
98
+ session: { resolveStorePath: ReturnType<typeof vi.fn> };
99
+ reply: { finalizeInboundContext: ReturnType<typeof vi.fn> };
44
100
  media: {
45
101
  fetchRemoteMedia: ReturnType<typeof vi.fn>;
46
102
  saveMediaBuffer: ReturnType<typeof vi.fn>;
47
103
  };
48
- };
49
- media: {
50
- fetchRemoteMedia: ReturnType<typeof vi.fn>;
51
- saveMediaBuffer: ReturnType<typeof vi.fn>;
104
+ pairing: {
105
+ readAllowFromStore: ReturnType<typeof vi.fn>;
106
+ upsertPairingRequest: ReturnType<typeof vi.fn>;
107
+ };
52
108
  };
53
109
  };
54
110
 
55
- function makeMockRuntime(overrides: Partial<MockChannelRuntime> = {}): MockChannelRuntime {
111
+ function makeMockCore(overrides: Partial<MockCore["channel"]> = {}): MockCore {
56
112
  return {
57
- pairing: {
58
- readAllowFromStore: vi.fn(async () => []),
59
- upsertPairingRequest: vi.fn(async () => ({ code: "CODE123", created: true })),
60
- buildPairingReply: vi.fn(() => "Please pair with code: CODE123"),
61
- ...overrides.pairing,
62
- },
63
- reply: {
64
- finalizeInboundContext: vi.fn((ctx) => ctx),
65
- dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => {}),
66
- ...overrides.reply,
67
- },
68
- routing: {
69
- resolveAgentRoute: vi.fn(() => ({
70
- agentId: "agent-1",
71
- sessionKey: "session-1",
72
- accountId: "default",
73
- })),
74
- ...overrides.routing,
75
- },
76
- session: {
77
- resolveStorePath: vi.fn(() => "/tmp/store"),
78
- recordInboundSession: vi.fn(async () => {}),
79
- ...overrides.session,
80
- },
81
- activity: {
82
- record: vi.fn(),
83
- ...overrides.activity,
84
- },
85
113
  channel: {
114
+ routing: {
115
+ resolveAgentRoute: vi.fn(() => ({
116
+ agentId: "agent-1",
117
+ sessionKey: "session-1",
118
+ accountId: "default",
119
+ })),
120
+ ...overrides.routing,
121
+ },
122
+ session: {
123
+ resolveStorePath: vi.fn(() => "/tmp/store"),
124
+ ...overrides.session,
125
+ },
126
+ reply: {
127
+ finalizeInboundContext: vi.fn((ctx) => ctx),
128
+ ...overrides.reply,
129
+ },
86
130
  media: {
87
131
  fetchRemoteMedia: vi.fn(async () => ({
88
132
  buffer: Buffer.from("fake-image-data"),
@@ -92,67 +136,65 @@ function makeMockRuntime(overrides: Partial<MockChannelRuntime> = {}): MockChann
92
136
  path: "/tmp/openclaw/inbound/saved-img.jpg",
93
137
  contentType: "image/jpeg",
94
138
  })),
95
- ...overrides.channel?.media,
139
+ ...overrides.media,
140
+ },
141
+ pairing: {
142
+ readAllowFromStore: vi.fn(async () => []),
143
+ upsertPairingRequest: vi.fn(async () => ({ code: "CODE123", created: true })),
144
+ ...overrides.pairing,
96
145
  },
97
- },
98
- media: {
99
- fetchRemoteMedia: vi.fn(async () => ({
100
- buffer: Buffer.from("fake-image-data"),
101
- contentType: "image/jpeg",
102
- })),
103
- saveMediaBuffer: vi.fn(async () => ({
104
- path: "/tmp/openclaw/inbound/saved-img.jpg",
105
- contentType: "image/jpeg",
106
- })),
107
- ...overrides.media,
108
146
  },
109
147
  };
110
148
  }
111
149
 
112
- function makeCtx(
113
- runtime: MockChannelRuntime,
114
- cfgOverride: CoreConfig = {
115
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com" } },
116
- },
117
- ) {
150
+ function makeDefaultPairingController(overrides: {
151
+ readAllowFromStore?: () => Promise<string[]>;
152
+ issueChallenge?: (params: { sendPairingReply: (text: string) => Promise<void> }) => Promise<void>;
153
+ } = {}) {
118
154
  return {
119
- channelRuntime: runtime,
120
- cfg: cfgOverride,
121
- abortSignal: new AbortController().signal,
122
- log: {
123
- info: vi.fn(),
124
- warn: vi.fn(),
125
- error: vi.fn(),
126
- debug: vi.fn(),
127
- },
128
- setStatus: vi.fn(),
129
- account: { accountId: "default", apiKey: "k", apiBaseUrl: "https://x.com", name: undefined, enabled: true, config: {} },
155
+ readAllowFromStore: vi.fn(overrides.readAllowFromStore ?? (async () => [])),
156
+ issueChallenge: vi.fn(overrides.issueChallenge ?? (async () => {})),
130
157
  };
131
158
  }
132
159
 
160
+ // ---------------------------------------------------------------------------
161
+ // Setup / teardown
162
+ // ---------------------------------------------------------------------------
163
+
164
+ let mockCore: MockCore;
165
+
166
+ beforeEach(() => {
167
+ mockCore = makeMockCore();
168
+ setSocketChatRuntime(mockCore as never);
169
+ vi.mocked(dispatchInboundReplyWithBase).mockClear();
170
+ vi.mocked(dispatchInboundReplyWithBase).mockResolvedValue(undefined as never);
171
+ // Default pairing controller: nobody in store, issueChallenge is no-op
172
+ vi.mocked(createChannelPairingController).mockReturnValue(
173
+ makeDefaultPairingController() as never,
174
+ );
175
+ });
176
+
177
+ afterEach(() => {
178
+ clearSocketChatRuntime();
179
+ vi.clearAllMocks();
180
+ });
181
+
133
182
  // ---------------------------------------------------------------------------
134
183
  // DM policy: open
135
184
  // ---------------------------------------------------------------------------
136
185
 
137
- describe("handleInboundMessage — dmPolicy=open", () => {
186
+ describe("handleSocketChatInbound — dmPolicy=open", () => {
138
187
  it("dispatches AI reply when dmPolicy is open", async () => {
139
- const runtime = makeMockRuntime();
140
- const ctx = makeCtx(runtime, {
141
- channels: {
142
- "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" },
143
- },
144
- });
145
188
  const sendReply = vi.fn(async () => {});
146
-
147
- await handleInboundMessage({
189
+ await handleSocketChatInbound({
148
190
  msg: makeMsg(),
149
191
  accountId: "default",
150
- ctx: ctx as never,
151
- log: ctx.log,
192
+ config: makeConfig({ dmPolicy: "open" }),
193
+ log: makeLog(),
152
194
  sendReply,
153
195
  });
154
196
 
155
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
197
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
156
198
  expect(sendReply).not.toHaveBeenCalled();
157
199
  });
158
200
  });
@@ -161,131 +203,93 @@ describe("handleInboundMessage — dmPolicy=open", () => {
161
203
  // DM policy: pairing
162
204
  // ---------------------------------------------------------------------------
163
205
 
164
- describe("handleInboundMessage — dmPolicy=pairing", () => {
206
+ describe("handleSocketChatInbound — dmPolicy=pairing", () => {
165
207
  it("blocks unknown sender and sends pairing message on first request", async () => {
166
- const runtime = makeMockRuntime();
167
- // Pairing store is empty — sender is unknown
168
- runtime.pairing.readAllowFromStore.mockResolvedValue([]);
169
-
170
- const ctx = makeCtx(runtime, {
171
- channels: {
172
- "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "pairing" },
208
+ const mockController = makeDefaultPairingController({
209
+ readAllowFromStore: async () => [],
210
+ issueChallenge: async ({ sendPairingReply }) => {
211
+ await sendPairingReply("Please pair with code: CODE123");
173
212
  },
174
213
  });
175
- const sendReply = vi.fn(async () => {});
214
+ vi.mocked(createChannelPairingController).mockReturnValue(mockController as never);
176
215
 
177
- await handleInboundMessage({
216
+ const sendReply = vi.fn(async () => {});
217
+ await handleSocketChatInbound({
178
218
  msg: makeMsg({ senderId: "wxid_unknown" }),
179
219
  accountId: "default",
180
- ctx: ctx as never,
181
- log: ctx.log,
220
+ config: makeConfig({ dmPolicy: "pairing" }),
221
+ log: makeLog(),
182
222
  sendReply,
183
223
  });
184
224
 
185
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
186
- expect(runtime.pairing.upsertPairingRequest).toHaveBeenCalledWith(
187
- expect.objectContaining({ id: "wxid_unknown", channel: "socket-chat" }),
225
+ expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
226
+ expect(mockController.issueChallenge).toHaveBeenCalledWith(
227
+ expect.objectContaining({ senderId: "wxid_unknown" }),
188
228
  );
189
229
  expect(sendReply).toHaveBeenCalledOnce();
190
230
  });
191
231
 
192
232
  it("allows sender present in pairing store", async () => {
193
- const runtime = makeMockRuntime();
194
- runtime.pairing.readAllowFromStore.mockResolvedValue(["wxid_approved"]);
195
-
196
- const ctx = makeCtx(runtime, {
197
- channels: {
198
- "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "pairing" },
199
- },
200
- });
233
+ vi.mocked(createChannelPairingController).mockReturnValue(
234
+ makeDefaultPairingController({ readAllowFromStore: async () => ["wxid_approved"] }) as never,
235
+ );
201
236
 
202
- await handleInboundMessage({
237
+ await handleSocketChatInbound({
203
238
  msg: makeMsg({ senderId: "wxid_approved" }),
204
239
  accountId: "default",
205
- ctx: ctx as never,
206
- log: ctx.log,
240
+ config: makeConfig({ dmPolicy: "pairing" }),
241
+ log: makeLog(),
207
242
  sendReply: vi.fn(async () => {}),
208
243
  });
209
244
 
210
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
245
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
211
246
  });
212
247
 
213
248
  it("allows sender present in config allowFrom", async () => {
214
- const runtime = makeMockRuntime();
215
- runtime.pairing.readAllowFromStore.mockResolvedValue([]);
216
-
217
- const ctx = makeCtx(runtime, {
218
- channels: {
219
- "socket-chat": {
220
- apiKey: "k",
221
- apiBaseUrl: "https://x.com",
222
- dmPolicy: "pairing",
223
- allowFrom: ["wxid_allowed"],
224
- },
225
- },
226
- });
227
-
228
- await handleInboundMessage({
249
+ // Store is empty; allowed via config
250
+ await handleSocketChatInbound({
229
251
  msg: makeMsg({ senderId: "wxid_allowed" }),
230
252
  accountId: "default",
231
- ctx: ctx as never,
232
- log: ctx.log,
253
+ config: makeConfig({ dmPolicy: "pairing", allowFrom: ["wxid_allowed"] }),
254
+ log: makeLog(),
233
255
  sendReply: vi.fn(async () => {}),
234
256
  });
235
257
 
236
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
258
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
237
259
  });
238
260
 
239
- it("does not send second pairing message when request already exists", async () => {
240
- const runtime = makeMockRuntime();
241
- runtime.pairing.readAllowFromStore.mockResolvedValue([]);
242
- // Simulates already-created pairing request (created=false)
243
- runtime.pairing.upsertPairingRequest.mockResolvedValue({ code: "CODE123", created: false });
261
+ it("does not send pairing message when issueChallenge resolves without calling sendPairingReply", async () => {
262
+ // Simulates already-pending request where issueChallenge doesn't send another message
263
+ vi.mocked(createChannelPairingController).mockReturnValue(
264
+ makeDefaultPairingController({
265
+ readAllowFromStore: async () => [],
266
+ issueChallenge: async () => {}, // no-op — doesn't call sendPairingReply
267
+ }) as never,
268
+ );
244
269
 
245
- const ctx = makeCtx(runtime, {
246
- channels: {
247
- "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "pairing" },
248
- },
249
- });
250
270
  const sendReply = vi.fn(async () => {});
251
-
252
- await handleInboundMessage({
271
+ await handleSocketChatInbound({
253
272
  msg: makeMsg({ senderId: "wxid_pending" }),
254
273
  accountId: "default",
255
- ctx: ctx as never,
256
- log: ctx.log,
274
+ config: makeConfig({ dmPolicy: "pairing" }),
275
+ log: makeLog(),
257
276
  sendReply,
258
277
  });
259
278
 
260
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
261
- // No reply because created=false
279
+ expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
262
280
  expect(sendReply).not.toHaveBeenCalled();
263
281
  });
264
282
 
265
283
  it("allows wildcard '*' in allowFrom", async () => {
266
- const runtime = makeMockRuntime();
267
- runtime.pairing.readAllowFromStore.mockResolvedValue([]);
268
-
269
- const ctx = makeCtx(runtime, {
270
- channels: {
271
- "socket-chat": {
272
- apiKey: "k",
273
- apiBaseUrl: "https://x.com",
274
- dmPolicy: "pairing",
275
- allowFrom: ["*"],
276
- },
277
- },
278
- });
279
-
280
- await handleInboundMessage({
284
+ await handleSocketChatInbound({
281
285
  msg: makeMsg({ senderId: "wxid_anyone" }),
282
286
  accountId: "default",
283
- ctx: ctx as never,
284
- log: ctx.log,
287
+ config: makeConfig({ dmPolicy: "pairing", allowFrom: ["*"] }),
288
+ log: makeLog(),
285
289
  sendReply: vi.fn(async () => {}),
286
290
  });
287
291
 
288
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
292
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
289
293
  });
290
294
  });
291
295
 
@@ -293,60 +297,37 @@ describe("handleInboundMessage — dmPolicy=pairing", () => {
293
297
  // DM policy: allowlist
294
298
  // ---------------------------------------------------------------------------
295
299
 
296
- describe("handleInboundMessage — dmPolicy=allowlist", () => {
300
+ describe("handleSocketChatInbound — dmPolicy=allowlist", () => {
297
301
  it("blocks sender not in allowFrom (no pairing request sent)", async () => {
298
- const runtime = makeMockRuntime();
299
- runtime.pairing.readAllowFromStore.mockResolvedValue([]);
300
-
301
- const ctx = makeCtx(runtime, {
302
- channels: {
303
- "socket-chat": {
304
- apiKey: "k",
305
- apiBaseUrl: "https://x.com",
306
- dmPolicy: "allowlist",
307
- allowFrom: ["wxid_allowed"],
308
- },
309
- },
302
+ const mockController = makeDefaultPairingController({
303
+ readAllowFromStore: async () => [],
310
304
  });
311
- const sendReply = vi.fn(async () => {});
305
+ vi.mocked(createChannelPairingController).mockReturnValue(mockController as never);
312
306
 
313
- await handleInboundMessage({
307
+ const sendReply = vi.fn(async () => {});
308
+ await handleSocketChatInbound({
314
309
  msg: makeMsg({ senderId: "wxid_stranger" }),
315
310
  accountId: "default",
316
- ctx: ctx as never,
317
- log: ctx.log,
311
+ config: makeConfig({ dmPolicy: "allowlist", allowFrom: ["wxid_allowed"] }),
312
+ log: makeLog(),
318
313
  sendReply,
319
314
  });
320
315
 
321
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
322
- expect(runtime.pairing.upsertPairingRequest).not.toHaveBeenCalled();
316
+ expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
317
+ expect(mockController.issueChallenge).not.toHaveBeenCalled();
323
318
  expect(sendReply).not.toHaveBeenCalled();
324
319
  });
325
320
 
326
321
  it("allows sender in allowFrom", async () => {
327
- const runtime = makeMockRuntime();
328
- runtime.pairing.readAllowFromStore.mockResolvedValue([]);
329
-
330
- const ctx = makeCtx(runtime, {
331
- channels: {
332
- "socket-chat": {
333
- apiKey: "k",
334
- apiBaseUrl: "https://x.com",
335
- dmPolicy: "allowlist",
336
- allowFrom: ["wxid_allowed"],
337
- },
338
- },
339
- });
340
-
341
- await handleInboundMessage({
322
+ await handleSocketChatInbound({
342
323
  msg: makeMsg({ senderId: "wxid_allowed" }),
343
324
  accountId: "default",
344
- ctx: ctx as never,
345
- log: ctx.log,
325
+ config: makeConfig({ dmPolicy: "allowlist", allowFrom: ["wxid_allowed"] }),
326
+ log: makeLog(),
346
327
  sendReply: vi.fn(async () => {}),
347
328
  });
348
329
 
349
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
330
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
350
331
  });
351
332
  });
352
333
 
@@ -354,21 +335,9 @@ describe("handleInboundMessage — dmPolicy=allowlist", () => {
354
335
  // Group messages
355
336
  // ---------------------------------------------------------------------------
356
337
 
357
- describe("handleInboundMessage — group messages", () => {
338
+ describe("handleSocketChatInbound — group messages", () => {
358
339
  it("dispatches AI reply for group message with isGroupMention=true (no @text needed)", async () => {
359
- const runtime = makeMockRuntime();
360
-
361
- const ctx = makeCtx(runtime, {
362
- channels: {
363
- "socket-chat": {
364
- apiKey: "k",
365
- apiBaseUrl: "https://x.com",
366
- requireMention: true,
367
- },
368
- },
369
- });
370
-
371
- await handleInboundMessage({
340
+ await handleSocketChatInbound({
372
341
  msg: makeMsg({
373
342
  isGroup: true,
374
343
  groupId: "roomid_group1",
@@ -377,28 +346,16 @@ describe("handleInboundMessage — group messages", () => {
377
346
  content: "hello group (no @text in content)",
378
347
  }),
379
348
  accountId: "default",
380
- ctx: ctx as never,
381
- log: ctx.log,
349
+ config: makeConfig({ requireMention: true }),
350
+ log: makeLog(),
382
351
  sendReply: vi.fn(async () => {}),
383
352
  });
384
353
 
385
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
354
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
386
355
  });
387
356
 
388
357
  it("skips group message when isGroupMention=false and no @text", async () => {
389
- const runtime = makeMockRuntime();
390
-
391
- const ctx = makeCtx(runtime, {
392
- channels: {
393
- "socket-chat": {
394
- apiKey: "k",
395
- apiBaseUrl: "https://x.com",
396
- requireMention: true,
397
- },
398
- },
399
- });
400
-
401
- await handleInboundMessage({
358
+ await handleSocketChatInbound({
402
359
  msg: makeMsg({
403
360
  isGroup: true,
404
361
  groupId: "roomid_group1",
@@ -407,28 +364,16 @@ describe("handleInboundMessage — group messages", () => {
407
364
  content: "just chatting",
408
365
  }),
409
366
  accountId: "default",
410
- ctx: ctx as never,
411
- log: ctx.log,
367
+ config: makeConfig({ requireMention: true }),
368
+ log: makeLog(),
412
369
  sendReply: vi.fn(async () => {}),
413
370
  });
414
371
 
415
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
372
+ expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
416
373
  });
417
374
 
418
375
  it("dispatches AI reply for group message mentioning robotId", async () => {
419
- const runtime = makeMockRuntime();
420
-
421
- const ctx = makeCtx(runtime, {
422
- channels: {
423
- "socket-chat": {
424
- apiKey: "k",
425
- apiBaseUrl: "https://x.com",
426
- requireMention: true,
427
- },
428
- },
429
- });
430
-
431
- await handleInboundMessage({
376
+ await handleSocketChatInbound({
432
377
  msg: makeMsg({
433
378
  isGroup: true,
434
379
  groupId: "roomid_group1",
@@ -436,28 +381,16 @@ describe("handleInboundMessage — group messages", () => {
436
381
  content: "@robot_abc hello group",
437
382
  }),
438
383
  accountId: "default",
439
- ctx: ctx as never,
440
- log: ctx.log,
384
+ config: makeConfig({ requireMention: true }),
385
+ log: makeLog(),
441
386
  sendReply: vi.fn(async () => {}),
442
387
  });
443
388
 
444
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
389
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
445
390
  });
446
391
 
447
392
  it("skips group message not mentioning bot when requireMention=true", async () => {
448
- const runtime = makeMockRuntime();
449
-
450
- const ctx = makeCtx(runtime, {
451
- channels: {
452
- "socket-chat": {
453
- apiKey: "k",
454
- apiBaseUrl: "https://x.com",
455
- requireMention: true,
456
- },
457
- },
458
- });
459
-
460
- await handleInboundMessage({
393
+ await handleSocketChatInbound({
461
394
  msg: makeMsg({
462
395
  isGroup: true,
463
396
  groupId: "roomid_group1",
@@ -465,28 +398,16 @@ describe("handleInboundMessage — group messages", () => {
465
398
  content: "just chatting without mention",
466
399
  }),
467
400
  accountId: "default",
468
- ctx: ctx as never,
469
- log: ctx.log,
401
+ config: makeConfig({ requireMention: true }),
402
+ log: makeLog(),
470
403
  sendReply: vi.fn(async () => {}),
471
404
  });
472
405
 
473
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
406
+ expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
474
407
  });
475
408
 
476
409
  it("dispatches group message without mention when requireMention=false", async () => {
477
- const runtime = makeMockRuntime();
478
-
479
- const ctx = makeCtx(runtime, {
480
- channels: {
481
- "socket-chat": {
482
- apiKey: "k",
483
- apiBaseUrl: "https://x.com",
484
- requireMention: false,
485
- },
486
- },
487
- });
488
-
489
- await handleInboundMessage({
410
+ await handleSocketChatInbound({
490
411
  msg: makeMsg({
491
412
  isGroup: true,
492
413
  groupId: "roomid_group1",
@@ -494,31 +415,21 @@ describe("handleInboundMessage — group messages", () => {
494
415
  content: "no mention needed",
495
416
  }),
496
417
  accountId: "default",
497
- ctx: ctx as never,
498
- log: ctx.log,
418
+ config: makeConfig({ requireMention: false }),
419
+ log: makeLog(),
499
420
  sendReply: vi.fn(async () => {}),
500
421
  });
501
422
 
502
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
423
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
503
424
  });
504
425
 
505
426
  it("skips DM policy check for group messages", async () => {
506
- const runtime = makeMockRuntime();
507
- // pairing store empty, but message is group — should not block
508
- runtime.pairing.readAllowFromStore.mockResolvedValue([]);
509
-
510
- const ctx = makeCtx(runtime, {
511
- channels: {
512
- "socket-chat": {
513
- apiKey: "k",
514
- apiBaseUrl: "https://x.com",
515
- dmPolicy: "pairing",
516
- requireMention: false,
517
- },
518
- },
427
+ const mockController = makeDefaultPairingController({
428
+ readAllowFromStore: async () => [], // empty store
519
429
  });
430
+ vi.mocked(createChannelPairingController).mockReturnValue(mockController as never);
520
431
 
521
- await handleInboundMessage({
432
+ await handleSocketChatInbound({
522
433
  msg: makeMsg({
523
434
  isGroup: true,
524
435
  groupId: "roomid_group1",
@@ -526,14 +437,14 @@ describe("handleInboundMessage — group messages", () => {
526
437
  content: "group message",
527
438
  }),
528
439
  accountId: "default",
529
- ctx: ctx as never,
530
- log: ctx.log,
440
+ config: makeConfig({ dmPolicy: "pairing", requireMention: false }),
441
+ log: makeLog(),
531
442
  sendReply: vi.fn(async () => {}),
532
443
  });
533
444
 
534
- // DM pairing check should be skipped; dispatch should happen
535
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
536
- expect(runtime.pairing.upsertPairingRequest).not.toHaveBeenCalled();
445
+ // DM pairing check is skipped; dispatch should happen
446
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
447
+ expect(mockController.issueChallenge).not.toHaveBeenCalled();
537
448
  });
538
449
  });
539
450
 
@@ -541,38 +452,30 @@ describe("handleInboundMessage — group messages", () => {
541
452
  // Media messages
542
453
  // ---------------------------------------------------------------------------
543
454
 
544
- describe("handleInboundMessage — media messages", () => {
455
+ describe("handleSocketChatInbound — media messages", () => {
545
456
  it("downloads image URL and passes local path as MediaPath/MediaUrl", async () => {
546
- const runtime = makeMockRuntime();
547
- runtime.media.saveMediaBuffer.mockResolvedValue({
457
+ mockCore.channel.media.saveMediaBuffer.mockResolvedValue({
548
458
  path: "/tmp/openclaw/inbound/img-001.jpg",
549
459
  contentType: "image/jpeg",
550
460
  });
551
461
 
552
- const ctx = makeCtx(runtime, {
553
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
554
- });
555
-
556
- await handleInboundMessage({
462
+ await handleSocketChatInbound({
557
463
  msg: makeMsg({
558
464
  type: "图片",
559
465
  url: "https://oss.example.com/img.jpg",
560
466
  content: "【图片消息】\n文件名:img.jpg",
561
467
  }),
562
468
  accountId: "default",
563
- ctx: ctx as never,
564
- log: ctx.log,
469
+ config: makeConfig({ dmPolicy: "open" }),
470
+ log: makeLog(),
565
471
  sendReply: vi.fn(async () => {}),
566
472
  });
567
473
 
568
- // fetchRemoteMedia should have been called with the original URL
569
- expect(runtime.media.fetchRemoteMedia).toHaveBeenCalledWith(
474
+ expect(mockCore.channel.media.fetchRemoteMedia).toHaveBeenCalledWith(
570
475
  expect.objectContaining({ url: "https://oss.example.com/img.jpg" }),
571
476
  );
572
- // saveMediaBuffer should have been called
573
- expect(runtime.media.saveMediaBuffer).toHaveBeenCalledOnce();
574
- // ctxPayload should carry the saved local path, not the original URL
575
- expect(runtime.reply.finalizeInboundContext).toHaveBeenCalledWith(
477
+ expect(mockCore.channel.media.saveMediaBuffer).toHaveBeenCalledOnce();
478
+ expect(mockCore.channel.reply.finalizeInboundContext).toHaveBeenCalledWith(
576
479
  expect.objectContaining({
577
480
  MediaPath: "/tmp/openclaw/inbound/img-001.jpg",
578
481
  MediaUrl: "/tmp/openclaw/inbound/img-001.jpg",
@@ -581,37 +484,32 @@ describe("handleInboundMessage — media messages", () => {
581
484
  MediaType: "image/jpeg",
582
485
  }),
583
486
  );
584
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
487
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
585
488
  });
586
489
 
587
490
  it("downloads video URL and detects correct content type", async () => {
588
- const runtime = makeMockRuntime();
589
- runtime.media.fetchRemoteMedia.mockResolvedValue({
491
+ mockCore.channel.media.fetchRemoteMedia.mockResolvedValue({
590
492
  buffer: Buffer.from("fake-video-data"),
591
493
  contentType: "video/mp4",
592
494
  });
593
- runtime.media.saveMediaBuffer.mockResolvedValue({
495
+ mockCore.channel.media.saveMediaBuffer.mockResolvedValue({
594
496
  path: "/tmp/openclaw/inbound/video-001.mp4",
595
497
  contentType: "video/mp4",
596
498
  });
597
499
 
598
- const ctx = makeCtx(runtime, {
599
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
600
- });
601
-
602
- await handleInboundMessage({
500
+ await handleSocketChatInbound({
603
501
  msg: makeMsg({
604
502
  type: "视频",
605
503
  url: "https://oss.example.com/video.mp4",
606
504
  content: "【视频消息】",
607
505
  }),
608
506
  accountId: "default",
609
- ctx: ctx as never,
610
- log: ctx.log,
507
+ config: makeConfig({ dmPolicy: "open" }),
508
+ log: makeLog(),
611
509
  sendReply: vi.fn(async () => {}),
612
510
  });
613
511
 
614
- expect(runtime.reply.finalizeInboundContext).toHaveBeenCalledWith(
512
+ expect(mockCore.channel.reply.finalizeInboundContext).toHaveBeenCalledWith(
615
513
  expect.objectContaining({
616
514
  MediaPath: "/tmp/openclaw/inbound/video-001.mp4",
617
515
  MediaType: "video/mp4",
@@ -620,261 +518,217 @@ describe("handleInboundMessage — media messages", () => {
620
518
  });
621
519
 
622
520
  it("decodes base64 data URL and saves to local file", async () => {
623
- const runtime = makeMockRuntime();
624
- runtime.media.saveMediaBuffer.mockResolvedValue({
521
+ mockCore.channel.media.saveMediaBuffer.mockResolvedValue({
625
522
  path: "/tmp/openclaw/inbound/b64-img.jpg",
626
523
  contentType: "image/jpeg",
627
524
  });
628
525
 
629
- const ctx = makeCtx(runtime, {
630
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
631
- });
632
-
633
- // minimal valid JPEG-ish base64
634
526
  const fakeBase64 = Buffer.from("fake-jpeg-bytes").toString("base64");
635
- await handleInboundMessage({
527
+ await handleSocketChatInbound({
636
528
  msg: makeMsg({
637
529
  type: "图片",
638
530
  url: `data:image/jpeg;base64,${fakeBase64}`,
639
531
  content: "【图片消息】",
640
532
  }),
641
533
  accountId: "default",
642
- ctx: ctx as never,
643
- log: ctx.log,
534
+ config: makeConfig({ dmPolicy: "open" }),
535
+ log: makeLog(),
644
536
  sendReply: vi.fn(async () => {}),
645
537
  });
646
538
 
647
539
  // Should NOT call fetchRemoteMedia for data URLs
648
- expect(runtime.media.fetchRemoteMedia).not.toHaveBeenCalled();
540
+ expect(mockCore.channel.media.fetchRemoteMedia).not.toHaveBeenCalled();
649
541
  // Should call saveMediaBuffer with decoded buffer
650
- expect(runtime.media.saveMediaBuffer).toHaveBeenCalledOnce();
651
- const [savedBuf, savedMime] = runtime.media.saveMediaBuffer.mock.calls[0] as [Buffer, string];
542
+ expect(mockCore.channel.media.saveMediaBuffer).toHaveBeenCalledOnce();
543
+ const [savedBuf, savedMime] = mockCore.channel.media.saveMediaBuffer.mock.calls[0] as [
544
+ Buffer,
545
+ string,
546
+ ];
652
547
  expect(Buffer.isBuffer(savedBuf)).toBe(true);
653
548
  expect(savedBuf.toString()).toBe("fake-jpeg-bytes");
654
549
  expect(savedMime).toBe("image/jpeg");
655
550
  // ctxPayload should carry the local path
656
- expect(runtime.reply.finalizeInboundContext).toHaveBeenCalledWith(
551
+ expect(mockCore.channel.reply.finalizeInboundContext).toHaveBeenCalledWith(
657
552
  expect.objectContaining({
658
553
  MediaPath: "/tmp/openclaw/inbound/b64-img.jpg",
659
554
  MediaUrl: "/tmp/openclaw/inbound/b64-img.jpg",
660
555
  }),
661
556
  );
662
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
557
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
663
558
  });
664
559
 
665
560
  it("skips base64 media that exceeds maxBytes and continues dispatch", async () => {
666
- const runtime = makeMockRuntime();
667
- const ctx = makeCtx(runtime, {
668
- // 1 MB limit
669
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open", mediaMaxMb: 1 } },
670
- });
671
- const log = { ...ctx.log, warn: vi.fn() };
561
+ const log = makeLog();
672
562
 
673
563
  // ~2 MB of base64 data (each char ≈ 0.75 bytes → need > 1.4M chars)
674
564
  const bigBase64 = "A".repeat(1_500_000);
675
- await handleInboundMessage({
565
+ await handleSocketChatInbound({
676
566
  msg: makeMsg({
677
567
  type: "图片",
678
568
  url: `data:image/jpeg;base64,${bigBase64}`,
679
569
  content: "【图片消息】",
680
570
  }),
681
571
  accountId: "default",
682
- ctx: ctx as never,
572
+ config: makeConfig({ dmPolicy: "open", mediaMaxMb: 1 }),
683
573
  log,
684
574
  sendReply: vi.fn(async () => {}),
685
575
  });
686
576
 
687
577
  expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("media localization failed"));
688
- expect(runtime.media.saveMediaBuffer).not.toHaveBeenCalled();
578
+ expect(mockCore.channel.media.saveMediaBuffer).not.toHaveBeenCalled();
689
579
  // Dispatch still proceeds without media fields
690
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
691
- const callArg = runtime.reply.finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
580
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
581
+ const callArg = mockCore.channel.reply.finalizeInboundContext.mock.calls[0]?.[0] as Record<
582
+ string,
583
+ unknown
584
+ >;
692
585
  expect(callArg).not.toHaveProperty("MediaPath");
693
586
  });
694
587
 
695
588
  it("does not call fetchRemoteMedia for base64 data URLs (uses saveMediaBuffer directly)", async () => {
696
- const runtime = makeMockRuntime();
697
- const ctx = makeCtx(runtime, {
698
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
699
- });
700
-
701
589
  const fakeBase64 = Buffer.from("img-bytes").toString("base64");
702
- await handleInboundMessage({
590
+ await handleSocketChatInbound({
703
591
  msg: makeMsg({
704
592
  type: "图片",
705
593
  url: `data:image/jpeg;base64,${fakeBase64}`,
706
594
  content: "【图片消息】",
707
595
  }),
708
596
  accountId: "default",
709
- ctx: ctx as never,
710
- log: ctx.log,
597
+ config: makeConfig({ dmPolicy: "open" }),
598
+ log: makeLog(),
711
599
  sendReply: vi.fn(async () => {}),
712
600
  });
713
601
 
714
- expect(runtime.media.fetchRemoteMedia).not.toHaveBeenCalled();
715
- expect(runtime.media.saveMediaBuffer).toHaveBeenCalledOnce();
716
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
602
+ expect(mockCore.channel.media.fetchRemoteMedia).not.toHaveBeenCalled();
603
+ expect(mockCore.channel.media.saveMediaBuffer).toHaveBeenCalledOnce();
604
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
717
605
  });
718
606
 
719
607
  it("does not call fetchRemoteMedia when url is absent", async () => {
720
- const runtime = makeMockRuntime();
721
- const ctx = makeCtx(runtime, {
722
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
723
- });
724
-
725
- await handleInboundMessage({
608
+ await handleSocketChatInbound({
726
609
  msg: makeMsg({ content: "plain text, no media" }),
727
610
  accountId: "default",
728
- ctx: ctx as never,
729
- log: ctx.log,
611
+ config: makeConfig({ dmPolicy: "open" }),
612
+ log: makeLog(),
730
613
  sendReply: vi.fn(async () => {}),
731
614
  });
732
615
 
733
- expect(runtime.media.fetchRemoteMedia).not.toHaveBeenCalled();
734
- expect(runtime.media.saveMediaBuffer).not.toHaveBeenCalled();
735
- const callArg = runtime.reply.finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
616
+ expect(mockCore.channel.media.fetchRemoteMedia).not.toHaveBeenCalled();
617
+ expect(mockCore.channel.media.saveMediaBuffer).not.toHaveBeenCalled();
618
+ const callArg = mockCore.channel.reply.finalizeInboundContext.mock.calls[0]?.[0] as Record<
619
+ string,
620
+ unknown
621
+ >;
736
622
  expect(callArg).not.toHaveProperty("MediaPath");
737
623
  });
738
624
 
739
625
  it("continues dispatch and logs warning when media download fails", async () => {
740
- const runtime = makeMockRuntime();
741
- runtime.media.fetchRemoteMedia.mockRejectedValue(new Error("network timeout"));
742
-
743
- const ctx = makeCtx(runtime, {
744
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
745
- });
746
- const log = { ...ctx.log, warn: vi.fn() };
626
+ mockCore.channel.media.fetchRemoteMedia.mockRejectedValue(new Error("network timeout"));
747
627
 
748
- await handleInboundMessage({
628
+ const log = makeLog();
629
+ await handleSocketChatInbound({
749
630
  msg: makeMsg({
750
631
  type: "图片",
751
632
  url: "https://oss.example.com/img.jpg",
752
633
  content: "【图片消息】",
753
634
  }),
754
635
  accountId: "default",
755
- ctx: ctx as never,
636
+ config: makeConfig({ dmPolicy: "open" }),
756
637
  log,
757
638
  sendReply: vi.fn(async () => {}),
758
639
  });
759
640
 
760
- // Warning logged
761
- expect(log.warn).toHaveBeenCalledWith(
762
- expect.stringContaining("media localization failed"),
763
- );
764
- // Dispatch still proceeds (text body)
765
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
766
- // No media fields in ctxPayload
767
- const callArg = runtime.reply.finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
641
+ expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("media localization failed"));
642
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
643
+ const callArg = mockCore.channel.reply.finalizeInboundContext.mock.calls[0]?.[0] as Record<
644
+ string,
645
+ unknown
646
+ >;
768
647
  expect(callArg).not.toHaveProperty("MediaPath");
769
648
  expect(callArg).not.toHaveProperty("MediaUrl");
770
649
  });
771
650
 
772
651
  it("continues dispatch and logs warning when saveMediaBuffer fails", async () => {
773
- const runtime = makeMockRuntime();
774
- runtime.media.saveMediaBuffer.mockRejectedValue(new Error("disk full"));
652
+ mockCore.channel.media.saveMediaBuffer.mockRejectedValue(new Error("disk full"));
775
653
 
776
- const ctx = makeCtx(runtime, {
777
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
778
- });
779
- const log = { ...ctx.log, warn: vi.fn() };
780
-
781
- await handleInboundMessage({
654
+ const log = makeLog();
655
+ await handleSocketChatInbound({
782
656
  msg: makeMsg({
783
657
  type: "图片",
784
658
  url: "https://oss.example.com/img.jpg",
785
659
  content: "【图片消息】",
786
660
  }),
787
661
  accountId: "default",
788
- ctx: ctx as never,
662
+ config: makeConfig({ dmPolicy: "open" }),
789
663
  log,
790
664
  sendReply: vi.fn(async () => {}),
791
665
  });
792
666
 
793
- expect(log.warn).toHaveBeenCalledWith(
794
- expect.stringContaining("media localization failed"),
795
- );
796
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
667
+ expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("media localization failed"));
668
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
797
669
  });
798
670
 
799
671
  it("does not skip image-only message (content empty, url present)", async () => {
800
- const runtime = makeMockRuntime();
801
- const ctx = makeCtx(runtime, {
802
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
803
- });
804
-
805
- await handleInboundMessage({
672
+ await handleSocketChatInbound({
806
673
  msg: makeMsg({
807
674
  type: "图片",
808
675
  url: "https://oss.example.com/img.jpg",
809
676
  content: "",
810
677
  }),
811
678
  accountId: "default",
812
- ctx: ctx as never,
813
- log: ctx.log,
679
+ config: makeConfig({ dmPolicy: "open" }),
680
+ log: makeLog(),
814
681
  sendReply: vi.fn(async () => {}),
815
682
  });
816
683
 
817
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
684
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
818
685
  });
819
686
 
820
687
  it("uses content as body when both content and url are present", async () => {
821
- const runtime = makeMockRuntime();
822
- const ctx = makeCtx(runtime, {
823
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
824
- });
825
-
826
688
  const content = "【图片消息】\n文件名:img.jpg\n下载链接:https://oss.example.com/img.jpg";
827
689
 
828
- await handleInboundMessage({
690
+ await handleSocketChatInbound({
829
691
  msg: makeMsg({
830
692
  type: "图片",
831
693
  url: "https://oss.example.com/img.jpg",
832
694
  content,
833
695
  }),
834
696
  accountId: "default",
835
- ctx: ctx as never,
836
- log: ctx.log,
697
+ config: makeConfig({ dmPolicy: "open" }),
698
+ log: makeLog(),
837
699
  sendReply: vi.fn(async () => {}),
838
700
  });
839
701
 
840
- expect(runtime.reply.finalizeInboundContext).toHaveBeenCalledWith(
702
+ expect(mockCore.channel.reply.finalizeInboundContext).toHaveBeenCalledWith(
841
703
  expect.objectContaining({ Body: content, BodyForAgent: content }),
842
704
  );
843
705
  });
844
706
 
845
707
  it("falls back to <media:type> placeholder as body when content is empty", async () => {
846
- const runtime = makeMockRuntime();
847
- const ctx = makeCtx(runtime, {
848
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
849
- });
850
-
851
- await handleInboundMessage({
708
+ await handleSocketChatInbound({
852
709
  msg: makeMsg({ type: "图片", url: "https://oss.example.com/img.jpg", content: "" }),
853
710
  accountId: "default",
854
- ctx: ctx as never,
855
- log: ctx.log,
711
+ config: makeConfig({ dmPolicy: "open" }),
712
+ log: makeLog(),
856
713
  sendReply: vi.fn(async () => {}),
857
714
  });
858
715
 
859
- expect(runtime.reply.finalizeInboundContext).toHaveBeenCalledWith(
716
+ expect(mockCore.channel.reply.finalizeInboundContext).toHaveBeenCalledWith(
860
717
  expect.objectContaining({ Body: "<media:图片>", BodyForAgent: "<media:图片>" }),
861
718
  );
862
719
  });
863
720
 
864
721
  it("skips message when both content and url are absent", async () => {
865
- const runtime = makeMockRuntime();
866
- const ctx = makeCtx(runtime);
867
-
868
- await handleInboundMessage({
722
+ await handleSocketChatInbound({
869
723
  msg: makeMsg({ content: "", url: undefined }),
870
724
  accountId: "default",
871
- ctx: ctx as never,
872
- log: ctx.log,
725
+ config: makeConfig(),
726
+ log: makeLog(),
873
727
  sendReply: vi.fn(async () => {}),
874
728
  });
875
729
 
876
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
877
- expect(runtime.media.fetchRemoteMedia).not.toHaveBeenCalled();
730
+ expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
731
+ expect(mockCore.channel.media.fetchRemoteMedia).not.toHaveBeenCalled();
878
732
  });
879
733
  });
880
734
 
@@ -882,67 +736,46 @@ describe("handleInboundMessage — media messages", () => {
882
736
  // Edge cases
883
737
  // ---------------------------------------------------------------------------
884
738
 
885
- describe("handleInboundMessage — edge cases", () => {
739
+ describe("handleSocketChatInbound — edge cases", () => {
886
740
  it("skips empty message content", async () => {
887
- const runtime = makeMockRuntime();
888
- const ctx = makeCtx(runtime);
889
-
890
- await handleInboundMessage({
741
+ await handleSocketChatInbound({
891
742
  msg: makeMsg({ content: " " }),
892
743
  accountId: "default",
893
- ctx: ctx as never,
894
- log: ctx.log,
744
+ config: makeConfig(),
745
+ log: makeLog(),
895
746
  sendReply: vi.fn(async () => {}),
896
747
  });
897
748
 
898
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
749
+ expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
899
750
  });
900
751
 
901
- it("handles missing channelRuntime gracefully", async () => {
902
- const log = {
903
- info: vi.fn(),
904
- warn: vi.fn(),
905
- error: vi.fn(),
906
- debug: vi.fn(),
907
- };
752
+ it("rejects when runtime is not initialized", async () => {
753
+ clearSocketChatRuntime(); // remove runtime injected in beforeEach
908
754
 
909
755
  await expect(
910
- handleInboundMessage({
756
+ handleSocketChatInbound({
911
757
  msg: makeMsg(),
912
758
  accountId: "default",
913
- ctx: { channelRuntime: null, cfg: {} } as never,
914
- log,
759
+ config: makeConfig(),
760
+ log: makeLog(),
915
761
  sendReply: vi.fn(async () => {}),
916
762
  }),
917
- ).resolves.toBeUndefined();
918
-
919
- expect(log.warn).toHaveBeenCalledWith(
920
- expect.stringContaining("channelRuntime not available"),
921
- );
763
+ ).rejects.toThrow(/socket-chat runtime not initialized/);
922
764
  });
923
765
 
924
- it("records inbound and outbound activity on success", async () => {
925
- const runtime = makeMockRuntime();
926
-
927
- const ctx = makeCtx(runtime, {
928
- channels: {
929
- "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" },
930
- },
931
- });
932
-
933
- await handleInboundMessage({
766
+ it("calls statusSink with lastInboundAt on message arrival", async () => {
767
+ const statusSink = vi.fn();
768
+ await handleSocketChatInbound({
934
769
  msg: makeMsg(),
935
770
  accountId: "default",
936
- ctx: ctx as never,
937
- log: ctx.log,
771
+ config: makeConfig({ dmPolicy: "open" }),
772
+ log: makeLog(),
773
+ statusSink,
938
774
  sendReply: vi.fn(async () => {}),
939
775
  });
940
776
 
941
- expect(runtime.activity.record).toHaveBeenCalledWith(
942
- expect.objectContaining({ direction: "inbound", channel: "socket-chat" }),
943
- );
944
- expect(runtime.activity.record).toHaveBeenCalledWith(
945
- expect.objectContaining({ direction: "outbound", channel: "socket-chat" }),
777
+ expect(statusSink).toHaveBeenCalledWith(
778
+ expect.objectContaining({ lastInboundAt: expect.any(Number) }),
946
779
  );
947
780
  });
948
781
  });
@@ -951,170 +784,159 @@ describe("handleInboundMessage — edge cases", () => {
951
784
  // Group access control — tier 1 (groupId) + tier 2 (sender)
952
785
  // ---------------------------------------------------------------------------
953
786
 
954
- describe("handleInboundMessage — group access control (tier 1: groupId)", () => {
787
+ describe("handleSocketChatInbound — group access control (tier 1: groupId)", () => {
955
788
  beforeEach(() => {
956
789
  _resetNotifiedGroupsForTest();
957
790
  });
958
791
 
959
792
  it("allows all groups when groupPolicy=open (default)", async () => {
960
- const runtime = makeMockRuntime();
961
- const ctx = makeCtx(runtime, {
962
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", requireMention: false } },
963
- });
964
- await handleInboundMessage({
793
+ await handleSocketChatInbound({
965
794
  msg: makeMsg({ isGroup: true, groupId: "R:any_group", robotId: "robot_abc", isGroupMention: true }),
966
- accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
795
+ accountId: "default",
796
+ config: makeConfig({ requireMention: false }),
797
+ log: makeLog(),
798
+ sendReply: vi.fn(async () => {}),
967
799
  });
968
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
800
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
969
801
  });
970
802
 
971
803
  it("blocks all groups when groupPolicy=disabled (no notification)", async () => {
972
- const runtime = makeMockRuntime();
973
- const ctx = makeCtx(runtime, {
974
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "disabled" } },
975
- });
976
804
  const sendReply = vi.fn(async () => {});
977
- await handleInboundMessage({
805
+ await handleSocketChatInbound({
978
806
  msg: makeMsg({ isGroup: true, groupId: "R:any_group", isGroupMention: true }),
979
- accountId: "default", ctx: ctx as never, log: ctx.log, sendReply,
807
+ accountId: "default",
808
+ config: makeConfig({ groupPolicy: "disabled" }),
809
+ log: makeLog(),
810
+ sendReply,
980
811
  });
981
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
812
+ expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
982
813
  expect(sendReply).not.toHaveBeenCalled();
983
814
  });
984
815
 
985
816
  it("allows group in allowlist when groupPolicy=allowlist", async () => {
986
- const runtime = makeMockRuntime();
987
- const ctx = makeCtx(runtime, {
988
- channels: {
989
- "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["R:allowed_group"], requireMention: false },
990
- },
991
- });
992
- await handleInboundMessage({
817
+ await handleSocketChatInbound({
993
818
  msg: makeMsg({ isGroup: true, groupId: "R:allowed_group", robotId: "robot_abc", isGroupMention: true }),
994
- accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
819
+ accountId: "default",
820
+ config: makeConfig({ groupPolicy: "allowlist", groups: ["R:allowed_group"], requireMention: false }),
821
+ log: makeLog(),
822
+ sendReply: vi.fn(async () => {}),
995
823
  });
996
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
824
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
997
825
  });
998
826
 
999
- it("blocks unlisted group without sending notification (notify branch commented out)", async () => {
1000
- const runtime = makeMockRuntime();
1001
- const ctx = makeCtx(runtime, {
1002
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["R:allowed_group"] } },
1003
- });
827
+ it("blocks unlisted group without sending notification", async () => {
1004
828
  const sendReply = vi.fn(async () => {});
1005
- await handleInboundMessage({
829
+ await handleSocketChatInbound({
1006
830
  msg: makeMsg({ isGroup: true, groupId: "R:other_group", groupName: "测试群", isGroupMention: true }),
1007
- accountId: "default", ctx: ctx as never, log: ctx.log, sendReply,
831
+ accountId: "default",
832
+ config: makeConfig({ groupPolicy: "allowlist", groups: ["R:allowed_group"] }),
833
+ log: makeLog(),
834
+ sendReply,
1008
835
  });
1009
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
1010
- // Notification is currently disabled (commented out in inbound.ts)
836
+ expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
1011
837
  expect(sendReply).not.toHaveBeenCalled();
1012
838
  });
1013
839
 
1014
840
  it("silently blocks repeated messages from same unlisted group", async () => {
1015
- const runtime = makeMockRuntime();
1016
- const ctx = makeCtx(runtime, {
1017
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["R:allowed_group"] } },
1018
- });
1019
841
  const sendReply = vi.fn(async () => {});
1020
842
  const msg = makeMsg({ isGroup: true, groupId: "R:notify_once_group", isGroupMention: true });
1021
- await handleInboundMessage({ msg, accountId: "default", ctx: ctx as never, log: ctx.log, sendReply });
1022
- await handleInboundMessage({ msg, accountId: "default", ctx: ctx as never, log: ctx.log, sendReply });
843
+ const args = {
844
+ msg,
845
+ accountId: "default",
846
+ config: makeConfig({ groupPolicy: "allowlist", groups: ["R:allowed_group"] }),
847
+ log: makeLog(),
848
+ sendReply,
849
+ };
850
+ await handleSocketChatInbound(args);
851
+ await handleSocketChatInbound(args);
1023
852
  expect(sendReply).not.toHaveBeenCalled();
1024
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
853
+ expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
1025
854
  });
1026
855
 
1027
856
  it("blocks when groupPolicy=allowlist and groups is empty (no notification)", async () => {
1028
- const runtime = makeMockRuntime();
1029
- const ctx = makeCtx(runtime, {
1030
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: [] } },
1031
- });
1032
857
  const sendReply = vi.fn(async () => {});
1033
- await handleInboundMessage({
858
+ await handleSocketChatInbound({
1034
859
  msg: makeMsg({ isGroup: true, groupId: "R:any_group", isGroupMention: true }),
1035
- accountId: "default", ctx: ctx as never, log: ctx.log, sendReply,
860
+ accountId: "default",
861
+ config: makeConfig({ groupPolicy: "allowlist", groups: [] }),
862
+ log: makeLog(),
863
+ sendReply,
1036
864
  });
1037
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
865
+ expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
1038
866
  expect(sendReply).not.toHaveBeenCalled();
1039
867
  });
1040
868
 
1041
869
  it("allows wildcard '*' in groups list", async () => {
1042
- const runtime = makeMockRuntime();
1043
- const ctx = makeCtx(runtime, {
1044
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["*"], requireMention: false } },
1045
- });
1046
- await handleInboundMessage({
870
+ await handleSocketChatInbound({
1047
871
  msg: makeMsg({ isGroup: true, groupId: "R:any_group", robotId: "robot_abc", isGroupMention: true }),
1048
- accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
872
+ accountId: "default",
873
+ config: makeConfig({ groupPolicy: "allowlist", groups: ["*"], requireMention: false }),
874
+ log: makeLog(),
875
+ sendReply: vi.fn(async () => {}),
1049
876
  });
1050
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
877
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
1051
878
  });
1052
879
  });
1053
880
 
1054
- describe("handleInboundMessage — group access control (tier 2: sender)", () => {
881
+ describe("handleSocketChatInbound — group access control (tier 2: sender)", () => {
1055
882
  beforeEach(() => {
1056
883
  _resetNotifiedGroupsForTest();
1057
884
  });
1058
885
 
1059
886
  it("allows all senders when groupAllowFrom is empty", async () => {
1060
- const runtime = makeMockRuntime();
1061
- const ctx = makeCtx(runtime, {
1062
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", requireMention: false } },
1063
- });
1064
- await handleInboundMessage({
887
+ await handleSocketChatInbound({
1065
888
  msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_anyone", isGroupMention: true }),
1066
- accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
889
+ accountId: "default",
890
+ config: makeConfig({ requireMention: false }),
891
+ log: makeLog(),
892
+ sendReply: vi.fn(async () => {}),
1067
893
  });
1068
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
894
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
1069
895
  });
1070
896
 
1071
897
  it("allows sender matching groupAllowFrom by ID", async () => {
1072
- const runtime = makeMockRuntime();
1073
- const ctx = makeCtx(runtime, {
1074
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupAllowFrom: ["wxid_allowed"], requireMention: false } },
1075
- });
1076
- await handleInboundMessage({
898
+ await handleSocketChatInbound({
1077
899
  msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_allowed", isGroupMention: true }),
1078
- accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
900
+ accountId: "default",
901
+ config: makeConfig({ groupAllowFrom: ["wxid_allowed"], requireMention: false }),
902
+ log: makeLog(),
903
+ sendReply: vi.fn(async () => {}),
1079
904
  });
1080
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
905
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
1081
906
  });
1082
907
 
1083
908
  it("allows sender matching groupAllowFrom by name (case-insensitive)", async () => {
1084
- const runtime = makeMockRuntime();
1085
- const ctx = makeCtx(runtime, {
1086
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupAllowFrom: ["alice"], requireMention: false } },
1087
- });
1088
- await handleInboundMessage({
909
+ await handleSocketChatInbound({
1089
910
  msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_unknown", senderName: "Alice", isGroupMention: true }),
1090
- accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
911
+ accountId: "default",
912
+ config: makeConfig({ groupAllowFrom: ["alice"], requireMention: false }),
913
+ log: makeLog(),
914
+ sendReply: vi.fn(async () => {}),
1091
915
  });
1092
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
916
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
1093
917
  });
1094
918
 
1095
919
  it("blocks sender not in groupAllowFrom (silent drop)", async () => {
1096
- const runtime = makeMockRuntime();
1097
- const ctx = makeCtx(runtime, {
1098
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupAllowFrom: ["wxid_allowed"], requireMention: false } },
1099
- });
1100
920
  const sendReply = vi.fn(async () => {});
1101
- await handleInboundMessage({
921
+ await handleSocketChatInbound({
1102
922
  msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_stranger", senderName: "Stranger", isGroupMention: true }),
1103
- accountId: "default", ctx: ctx as never, log: ctx.log, sendReply,
923
+ accountId: "default",
924
+ config: makeConfig({ groupAllowFrom: ["wxid_allowed"], requireMention: false }),
925
+ log: makeLog(),
926
+ sendReply,
1104
927
  });
1105
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
928
+ expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
1106
929
  expect(sendReply).not.toHaveBeenCalled();
1107
930
  });
1108
931
 
1109
932
  it("allows wildcard '*' in groupAllowFrom", async () => {
1110
- const runtime = makeMockRuntime();
1111
- const ctx = makeCtx(runtime, {
1112
- channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupAllowFrom: ["*"], requireMention: false } },
1113
- });
1114
- await handleInboundMessage({
933
+ await handleSocketChatInbound({
1115
934
  msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_anyone", isGroupMention: true }),
1116
- accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
935
+ accountId: "default",
936
+ config: makeConfig({ groupAllowFrom: ["*"], requireMention: false }),
937
+ log: makeLog(),
938
+ sendReply: vi.fn(async () => {}),
1117
939
  });
1118
- expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
940
+ expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
1119
941
  });
1120
942
  });