@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.
- package/channel-api.ts +3 -0
- package/index.ts +11 -12
- package/package.json +3 -3
- package/runtime-api.ts +3 -0
- package/src/__sdk-stub__.ts +26 -8
- package/src/channel.ts +391 -336
- package/src/inbound.test.ts +405 -583
- package/src/inbound.ts +175 -407
- package/src/mqtt-client.ts +66 -50
- package/src/outbound.test.ts +25 -16
- package/src/outbound.ts +31 -69
- package/src/runtime-api.ts +28 -0
- package/src/runtime.ts +8 -12
- package/tsconfig.json +1 -1
- package/vitest.config.ts +13 -7
package/src/inbound.test.ts
CHANGED
|
@@ -1,10 +1,61 @@
|
|
|
1
|
-
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
104
|
+
pairing: {
|
|
105
|
+
readAllowFromStore: ReturnType<typeof vi.fn>;
|
|
106
|
+
upsertPairingRequest: ReturnType<typeof vi.fn>;
|
|
107
|
+
};
|
|
52
108
|
};
|
|
53
109
|
};
|
|
54
110
|
|
|
55
|
-
function
|
|
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.
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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("
|
|
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
|
-
|
|
151
|
-
log:
|
|
192
|
+
config: makeConfig({ dmPolicy: "open" }),
|
|
193
|
+
log: makeLog(),
|
|
152
194
|
sendReply,
|
|
153
195
|
});
|
|
154
196
|
|
|
155
|
-
expect(
|
|
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("
|
|
206
|
+
describe("handleSocketChatInbound — dmPolicy=pairing", () => {
|
|
165
207
|
it("blocks unknown sender and sends pairing message on first request", async () => {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
214
|
+
vi.mocked(createChannelPairingController).mockReturnValue(mockController as never);
|
|
176
215
|
|
|
177
|
-
|
|
216
|
+
const sendReply = vi.fn(async () => {});
|
|
217
|
+
await handleSocketChatInbound({
|
|
178
218
|
msg: makeMsg({ senderId: "wxid_unknown" }),
|
|
179
219
|
accountId: "default",
|
|
180
|
-
|
|
181
|
-
log:
|
|
220
|
+
config: makeConfig({ dmPolicy: "pairing" }),
|
|
221
|
+
log: makeLog(),
|
|
182
222
|
sendReply,
|
|
183
223
|
});
|
|
184
224
|
|
|
185
|
-
expect(
|
|
186
|
-
expect(
|
|
187
|
-
expect.objectContaining({
|
|
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
|
-
|
|
194
|
-
|
|
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
|
|
237
|
+
await handleSocketChatInbound({
|
|
203
238
|
msg: makeMsg({ senderId: "wxid_approved" }),
|
|
204
239
|
accountId: "default",
|
|
205
|
-
|
|
206
|
-
log:
|
|
240
|
+
config: makeConfig({ dmPolicy: "pairing" }),
|
|
241
|
+
log: makeLog(),
|
|
207
242
|
sendReply: vi.fn(async () => {}),
|
|
208
243
|
});
|
|
209
244
|
|
|
210
|
-
expect(
|
|
245
|
+
expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
|
|
211
246
|
});
|
|
212
247
|
|
|
213
248
|
it("allows sender present in config allowFrom", async () => {
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
232
|
-
log:
|
|
253
|
+
config: makeConfig({ dmPolicy: "pairing", allowFrom: ["wxid_allowed"] }),
|
|
254
|
+
log: makeLog(),
|
|
233
255
|
sendReply: vi.fn(async () => {}),
|
|
234
256
|
});
|
|
235
257
|
|
|
236
|
-
expect(
|
|
258
|
+
expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
|
|
237
259
|
});
|
|
238
260
|
|
|
239
|
-
it("does not send
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
256
|
-
log:
|
|
274
|
+
config: makeConfig({ dmPolicy: "pairing" }),
|
|
275
|
+
log: makeLog(),
|
|
257
276
|
sendReply,
|
|
258
277
|
});
|
|
259
278
|
|
|
260
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
log:
|
|
287
|
+
config: makeConfig({ dmPolicy: "pairing", allowFrom: ["*"] }),
|
|
288
|
+
log: makeLog(),
|
|
285
289
|
sendReply: vi.fn(async () => {}),
|
|
286
290
|
});
|
|
287
291
|
|
|
288
|
-
expect(
|
|
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("
|
|
300
|
+
describe("handleSocketChatInbound — dmPolicy=allowlist", () => {
|
|
297
301
|
it("blocks sender not in allowFrom (no pairing request sent)", async () => {
|
|
298
|
-
const
|
|
299
|
-
|
|
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
|
-
|
|
305
|
+
vi.mocked(createChannelPairingController).mockReturnValue(mockController as never);
|
|
312
306
|
|
|
313
|
-
|
|
307
|
+
const sendReply = vi.fn(async () => {});
|
|
308
|
+
await handleSocketChatInbound({
|
|
314
309
|
msg: makeMsg({ senderId: "wxid_stranger" }),
|
|
315
310
|
accountId: "default",
|
|
316
|
-
|
|
317
|
-
log:
|
|
311
|
+
config: makeConfig({ dmPolicy: "allowlist", allowFrom: ["wxid_allowed"] }),
|
|
312
|
+
log: makeLog(),
|
|
318
313
|
sendReply,
|
|
319
314
|
});
|
|
320
315
|
|
|
321
|
-
expect(
|
|
322
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
log:
|
|
325
|
+
config: makeConfig({ dmPolicy: "allowlist", allowFrom: ["wxid_allowed"] }),
|
|
326
|
+
log: makeLog(),
|
|
346
327
|
sendReply: vi.fn(async () => {}),
|
|
347
328
|
});
|
|
348
329
|
|
|
349
|
-
expect(
|
|
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("
|
|
338
|
+
describe("handleSocketChatInbound — group messages", () => {
|
|
358
339
|
it("dispatches AI reply for group message with isGroupMention=true (no @text needed)", async () => {
|
|
359
|
-
|
|
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
|
-
|
|
381
|
-
log:
|
|
349
|
+
config: makeConfig({ requireMention: true }),
|
|
350
|
+
log: makeLog(),
|
|
382
351
|
sendReply: vi.fn(async () => {}),
|
|
383
352
|
});
|
|
384
353
|
|
|
385
|
-
expect(
|
|
354
|
+
expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
|
|
386
355
|
});
|
|
387
356
|
|
|
388
357
|
it("skips group message when isGroupMention=false and no @text", async () => {
|
|
389
|
-
|
|
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
|
-
|
|
411
|
-
log:
|
|
367
|
+
config: makeConfig({ requireMention: true }),
|
|
368
|
+
log: makeLog(),
|
|
412
369
|
sendReply: vi.fn(async () => {}),
|
|
413
370
|
});
|
|
414
371
|
|
|
415
|
-
expect(
|
|
372
|
+
expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
|
|
416
373
|
});
|
|
417
374
|
|
|
418
375
|
it("dispatches AI reply for group message mentioning robotId", async () => {
|
|
419
|
-
|
|
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
|
-
|
|
440
|
-
log:
|
|
384
|
+
config: makeConfig({ requireMention: true }),
|
|
385
|
+
log: makeLog(),
|
|
441
386
|
sendReply: vi.fn(async () => {}),
|
|
442
387
|
});
|
|
443
388
|
|
|
444
|
-
expect(
|
|
389
|
+
expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
|
|
445
390
|
});
|
|
446
391
|
|
|
447
392
|
it("skips group message not mentioning bot when requireMention=true", async () => {
|
|
448
|
-
|
|
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
|
-
|
|
469
|
-
log:
|
|
401
|
+
config: makeConfig({ requireMention: true }),
|
|
402
|
+
log: makeLog(),
|
|
470
403
|
sendReply: vi.fn(async () => {}),
|
|
471
404
|
});
|
|
472
405
|
|
|
473
|
-
expect(
|
|
406
|
+
expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
|
|
474
407
|
});
|
|
475
408
|
|
|
476
409
|
it("dispatches group message without mention when requireMention=false", async () => {
|
|
477
|
-
|
|
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
|
-
|
|
498
|
-
log:
|
|
418
|
+
config: makeConfig({ requireMention: false }),
|
|
419
|
+
log: makeLog(),
|
|
499
420
|
sendReply: vi.fn(async () => {}),
|
|
500
421
|
});
|
|
501
422
|
|
|
502
|
-
expect(
|
|
423
|
+
expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
|
|
503
424
|
});
|
|
504
425
|
|
|
505
426
|
it("skips DM policy check for group messages", async () => {
|
|
506
|
-
const
|
|
507
|
-
|
|
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
|
|
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
|
-
|
|
530
|
-
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
|
|
535
|
-
expect(
|
|
536
|
-
expect(
|
|
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("
|
|
455
|
+
describe("handleSocketChatInbound — media messages", () => {
|
|
545
456
|
it("downloads image URL and passes local path as MediaPath/MediaUrl", async () => {
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
564
|
-
log:
|
|
469
|
+
config: makeConfig({ dmPolicy: "open" }),
|
|
470
|
+
log: makeLog(),
|
|
565
471
|
sendReply: vi.fn(async () => {}),
|
|
566
472
|
});
|
|
567
473
|
|
|
568
|
-
|
|
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
|
-
|
|
573
|
-
expect(
|
|
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(
|
|
487
|
+
expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
|
|
585
488
|
});
|
|
586
489
|
|
|
587
490
|
it("downloads video URL and detects correct content type", async () => {
|
|
588
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
-
log:
|
|
507
|
+
config: makeConfig({ dmPolicy: "open" }),
|
|
508
|
+
log: makeLog(),
|
|
611
509
|
sendReply: vi.fn(async () => {}),
|
|
612
510
|
});
|
|
613
511
|
|
|
614
|
-
expect(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
643
|
-
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(
|
|
540
|
+
expect(mockCore.channel.media.fetchRemoteMedia).not.toHaveBeenCalled();
|
|
649
541
|
// Should call saveMediaBuffer with decoded buffer
|
|
650
|
-
expect(
|
|
651
|
-
const [savedBuf, savedMime] =
|
|
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(
|
|
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(
|
|
557
|
+
expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
|
|
663
558
|
});
|
|
664
559
|
|
|
665
560
|
it("skips base64 media that exceeds maxBytes and continues dispatch", async () => {
|
|
666
|
-
const
|
|
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
|
|
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
|
-
|
|
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(
|
|
578
|
+
expect(mockCore.channel.media.saveMediaBuffer).not.toHaveBeenCalled();
|
|
689
579
|
// Dispatch still proceeds without media fields
|
|
690
|
-
expect(
|
|
691
|
-
const callArg =
|
|
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
|
|
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
|
-
|
|
710
|
-
log:
|
|
597
|
+
config: makeConfig({ dmPolicy: "open" }),
|
|
598
|
+
log: makeLog(),
|
|
711
599
|
sendReply: vi.fn(async () => {}),
|
|
712
600
|
});
|
|
713
601
|
|
|
714
|
-
expect(
|
|
715
|
-
expect(
|
|
716
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
729
|
-
log:
|
|
611
|
+
config: makeConfig({ dmPolicy: "open" }),
|
|
612
|
+
log: makeLog(),
|
|
730
613
|
sendReply: vi.fn(async () => {}),
|
|
731
614
|
});
|
|
732
615
|
|
|
733
|
-
expect(
|
|
734
|
-
expect(
|
|
735
|
-
const callArg =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
636
|
+
config: makeConfig({ dmPolicy: "open" }),
|
|
756
637
|
log,
|
|
757
638
|
sendReply: vi.fn(async () => {}),
|
|
758
639
|
});
|
|
759
640
|
|
|
760
|
-
|
|
761
|
-
expect(
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
774
|
-
runtime.media.saveMediaBuffer.mockRejectedValue(new Error("disk full"));
|
|
652
|
+
mockCore.channel.media.saveMediaBuffer.mockRejectedValue(new Error("disk full"));
|
|
775
653
|
|
|
776
|
-
const
|
|
777
|
-
|
|
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
|
-
|
|
662
|
+
config: makeConfig({ dmPolicy: "open" }),
|
|
789
663
|
log,
|
|
790
664
|
sendReply: vi.fn(async () => {}),
|
|
791
665
|
});
|
|
792
666
|
|
|
793
|
-
expect(log.warn).toHaveBeenCalledWith(
|
|
794
|
-
|
|
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
|
-
|
|
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
|
-
|
|
813
|
-
log:
|
|
679
|
+
config: makeConfig({ dmPolicy: "open" }),
|
|
680
|
+
log: makeLog(),
|
|
814
681
|
sendReply: vi.fn(async () => {}),
|
|
815
682
|
});
|
|
816
683
|
|
|
817
|
-
expect(
|
|
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
|
|
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
|
-
|
|
836
|
-
log:
|
|
697
|
+
config: makeConfig({ dmPolicy: "open" }),
|
|
698
|
+
log: makeLog(),
|
|
837
699
|
sendReply: vi.fn(async () => {}),
|
|
838
700
|
});
|
|
839
701
|
|
|
840
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
855
|
-
log:
|
|
711
|
+
config: makeConfig({ dmPolicy: "open" }),
|
|
712
|
+
log: makeLog(),
|
|
856
713
|
sendReply: vi.fn(async () => {}),
|
|
857
714
|
});
|
|
858
715
|
|
|
859
|
-
expect(
|
|
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
|
-
|
|
866
|
-
const ctx = makeCtx(runtime);
|
|
867
|
-
|
|
868
|
-
await handleInboundMessage({
|
|
722
|
+
await handleSocketChatInbound({
|
|
869
723
|
msg: makeMsg({ content: "", url: undefined }),
|
|
870
724
|
accountId: "default",
|
|
871
|
-
|
|
872
|
-
log:
|
|
725
|
+
config: makeConfig(),
|
|
726
|
+
log: makeLog(),
|
|
873
727
|
sendReply: vi.fn(async () => {}),
|
|
874
728
|
});
|
|
875
729
|
|
|
876
|
-
expect(
|
|
877
|
-
expect(
|
|
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("
|
|
739
|
+
describe("handleSocketChatInbound — edge cases", () => {
|
|
886
740
|
it("skips empty message content", async () => {
|
|
887
|
-
|
|
888
|
-
const ctx = makeCtx(runtime);
|
|
889
|
-
|
|
890
|
-
await handleInboundMessage({
|
|
741
|
+
await handleSocketChatInbound({
|
|
891
742
|
msg: makeMsg({ content: " " }),
|
|
892
743
|
accountId: "default",
|
|
893
|
-
|
|
894
|
-
log:
|
|
744
|
+
config: makeConfig(),
|
|
745
|
+
log: makeLog(),
|
|
895
746
|
sendReply: vi.fn(async () => {}),
|
|
896
747
|
});
|
|
897
748
|
|
|
898
|
-
expect(
|
|
749
|
+
expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
|
|
899
750
|
});
|
|
900
751
|
|
|
901
|
-
it("
|
|
902
|
-
|
|
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
|
-
|
|
756
|
+
handleSocketChatInbound({
|
|
911
757
|
msg: makeMsg(),
|
|
912
758
|
accountId: "default",
|
|
913
|
-
|
|
914
|
-
log,
|
|
759
|
+
config: makeConfig(),
|
|
760
|
+
log: makeLog(),
|
|
915
761
|
sendReply: vi.fn(async () => {}),
|
|
916
762
|
}),
|
|
917
|
-
).
|
|
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("
|
|
925
|
-
const
|
|
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
|
-
|
|
937
|
-
log:
|
|
771
|
+
config: makeConfig({ dmPolicy: "open" }),
|
|
772
|
+
log: makeLog(),
|
|
773
|
+
statusSink,
|
|
938
774
|
sendReply: vi.fn(async () => {}),
|
|
939
775
|
});
|
|
940
776
|
|
|
941
|
-
expect(
|
|
942
|
-
expect.objectContaining({
|
|
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("
|
|
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
|
-
|
|
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",
|
|
795
|
+
accountId: "default",
|
|
796
|
+
config: makeConfig({ requireMention: false }),
|
|
797
|
+
log: makeLog(),
|
|
798
|
+
sendReply: vi.fn(async () => {}),
|
|
967
799
|
});
|
|
968
|
-
expect(
|
|
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
|
|
805
|
+
await handleSocketChatInbound({
|
|
978
806
|
msg: makeMsg({ isGroup: true, groupId: "R:any_group", isGroupMention: true }),
|
|
979
|
-
accountId: "default",
|
|
807
|
+
accountId: "default",
|
|
808
|
+
config: makeConfig({ groupPolicy: "disabled" }),
|
|
809
|
+
log: makeLog(),
|
|
810
|
+
sendReply,
|
|
980
811
|
});
|
|
981
|
-
expect(
|
|
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
|
-
|
|
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",
|
|
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(
|
|
824
|
+
expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
|
|
997
825
|
});
|
|
998
826
|
|
|
999
|
-
it("blocks unlisted group without sending notification
|
|
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
|
|
829
|
+
await handleSocketChatInbound({
|
|
1006
830
|
msg: makeMsg({ isGroup: true, groupId: "R:other_group", groupName: "测试群", isGroupMention: true }),
|
|
1007
|
-
accountId: "default",
|
|
831
|
+
accountId: "default",
|
|
832
|
+
config: makeConfig({ groupPolicy: "allowlist", groups: ["R:allowed_group"] }),
|
|
833
|
+
log: makeLog(),
|
|
834
|
+
sendReply,
|
|
1008
835
|
});
|
|
1009
|
-
expect(
|
|
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
|
-
|
|
1022
|
-
|
|
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(
|
|
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
|
|
858
|
+
await handleSocketChatInbound({
|
|
1034
859
|
msg: makeMsg({ isGroup: true, groupId: "R:any_group", isGroupMention: true }),
|
|
1035
|
-
accountId: "default",
|
|
860
|
+
accountId: "default",
|
|
861
|
+
config: makeConfig({ groupPolicy: "allowlist", groups: [] }),
|
|
862
|
+
log: makeLog(),
|
|
863
|
+
sendReply,
|
|
1036
864
|
});
|
|
1037
|
-
expect(
|
|
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
|
-
|
|
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",
|
|
872
|
+
accountId: "default",
|
|
873
|
+
config: makeConfig({ groupPolicy: "allowlist", groups: ["*"], requireMention: false }),
|
|
874
|
+
log: makeLog(),
|
|
875
|
+
sendReply: vi.fn(async () => {}),
|
|
1049
876
|
});
|
|
1050
|
-
expect(
|
|
877
|
+
expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
|
|
1051
878
|
});
|
|
1052
879
|
});
|
|
1053
880
|
|
|
1054
|
-
describe("
|
|
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
|
-
|
|
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",
|
|
889
|
+
accountId: "default",
|
|
890
|
+
config: makeConfig({ requireMention: false }),
|
|
891
|
+
log: makeLog(),
|
|
892
|
+
sendReply: vi.fn(async () => {}),
|
|
1067
893
|
});
|
|
1068
|
-
expect(
|
|
894
|
+
expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
|
|
1069
895
|
});
|
|
1070
896
|
|
|
1071
897
|
it("allows sender matching groupAllowFrom by ID", async () => {
|
|
1072
|
-
|
|
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",
|
|
900
|
+
accountId: "default",
|
|
901
|
+
config: makeConfig({ groupAllowFrom: ["wxid_allowed"], requireMention: false }),
|
|
902
|
+
log: makeLog(),
|
|
903
|
+
sendReply: vi.fn(async () => {}),
|
|
1079
904
|
});
|
|
1080
|
-
expect(
|
|
905
|
+
expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
|
|
1081
906
|
});
|
|
1082
907
|
|
|
1083
908
|
it("allows sender matching groupAllowFrom by name (case-insensitive)", async () => {
|
|
1084
|
-
|
|
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",
|
|
911
|
+
accountId: "default",
|
|
912
|
+
config: makeConfig({ groupAllowFrom: ["alice"], requireMention: false }),
|
|
913
|
+
log: makeLog(),
|
|
914
|
+
sendReply: vi.fn(async () => {}),
|
|
1091
915
|
});
|
|
1092
|
-
expect(
|
|
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
|
|
921
|
+
await handleSocketChatInbound({
|
|
1102
922
|
msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_stranger", senderName: "Stranger", isGroupMention: true }),
|
|
1103
|
-
accountId: "default",
|
|
923
|
+
accountId: "default",
|
|
924
|
+
config: makeConfig({ groupAllowFrom: ["wxid_allowed"], requireMention: false }),
|
|
925
|
+
log: makeLog(),
|
|
926
|
+
sendReply,
|
|
1104
927
|
});
|
|
1105
|
-
expect(
|
|
928
|
+
expect(dispatchInboundReplyWithBase).not.toHaveBeenCalled();
|
|
1106
929
|
expect(sendReply).not.toHaveBeenCalled();
|
|
1107
930
|
});
|
|
1108
931
|
|
|
1109
932
|
it("allows wildcard '*' in groupAllowFrom", async () => {
|
|
1110
|
-
|
|
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",
|
|
935
|
+
accountId: "default",
|
|
936
|
+
config: makeConfig({ groupAllowFrom: ["*"], requireMention: false }),
|
|
937
|
+
log: makeLog(),
|
|
938
|
+
sendReply: vi.fn(async () => {}),
|
|
1117
939
|
});
|
|
1118
|
-
expect(
|
|
940
|
+
expect(dispatchInboundReplyWithBase).toHaveBeenCalledOnce();
|
|
1119
941
|
});
|
|
1120
942
|
});
|