@dingtalk-real-ai/dingtalk-connector 0.7.6 → 0.7.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.
Files changed (49) hide show
  1. package/.github/workflows/issue-to-AI-table.yml +52 -0
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +23 -21
  4. package/docs/RELEASE_NOTES_V0.7.7.md +122 -0
  5. package/docs/RELEASE_NOTES_V0.7.8.md +101 -0
  6. package/openclaw.plugin.json +1 -1
  7. package/package.json +13 -5
  8. package/plugin.ts +210 -19
  9. package/tests/README.md +54 -0
  10. package/tests/ai-card/PLAN.md +54 -0
  11. package/tests/ai-card/ai-card.test.ts +372 -0
  12. package/tests/audio/PLAN.md +64 -0
  13. package/tests/audio/audio.test.ts +283 -0
  14. package/tests/bindings/PLAN.md +99 -0
  15. package/tests/bindings/bindings.test.ts +191 -0
  16. package/tests/card-update/PLAN.md +29 -0
  17. package/tests/card-update/card-update.test.ts +127 -0
  18. package/tests/config-token/PLAN.md +94 -0
  19. package/tests/config-token/config-token.test.ts +153 -0
  20. package/tests/core/PLAN.md +65 -0
  21. package/tests/core/core.test.ts +286 -0
  22. package/tests/deliver-payload/PLAN.md +59 -0
  23. package/tests/deliver-payload/deliver-payload.test.ts +91 -0
  24. package/tests/download/PLAN.md +47 -0
  25. package/tests/download/download.test.ts +261 -0
  26. package/tests/file-markers/PLAN.md +74 -0
  27. package/tests/file-markers/file-markers.test.ts +105 -0
  28. package/tests/index.ts +129 -0
  29. package/tests/integration/PLAN.md +65 -0
  30. package/tests/integration/integration.test.ts +232 -0
  31. package/tests/mcp-tools/PLAN.md +67 -0
  32. package/tests/mcp-tools/mcp-tools.test.ts +327 -0
  33. package/tests/media/PLAN.md +37 -0
  34. package/tests/media/media.test.ts +50 -0
  35. package/tests/message-extract/PLAN.md +83 -0
  36. package/tests/message-extract/message-extract.test.ts +205 -0
  37. package/tests/proactive/PLAN.md +88 -0
  38. package/tests/proactive/proactive.test.ts +502 -0
  39. package/tests/prompts/PLAN.md +71 -0
  40. package/tests/prompts/prompts.test.ts +64 -0
  41. package/tests/send-message/PLAN.md +44 -0
  42. package/tests/send-message/send-message.test.ts +228 -0
  43. package/tests/session/PLAN.md +90 -0
  44. package/tests/session/session.test.ts +166 -0
  45. package/tests/upload/PLAN.md +72 -0
  46. package/tests/upload/upload.test.ts +390 -0
  47. package/tests/video/PLAN.md +118 -0
  48. package/tests/video/video.test.ts +40 -0
  49. package/vitest.config.ts +13 -0
@@ -0,0 +1,283 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock child_process for extractAudioDuration
4
+ const mockExecFile = vi.hoisted(() => vi.fn());
5
+ vi.mock('child_process', () => ({
6
+ execFile: mockExecFile,
7
+ }));
8
+
9
+ const log = {
10
+ info: vi.fn(),
11
+ warn: vi.fn(),
12
+ error: vi.fn(),
13
+ };
14
+
15
+ describe('audio helpers', () => {
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ });
19
+
20
+ describe('isAudioFile', () => {
21
+ it('should return true for valid audio extensions', async () => {
22
+ const { __testables } = await import('../../plugin');
23
+ const { isAudioFile } = __testables as any;
24
+
25
+ const audioExtensions = ['mp3', 'wav', 'amr', 'ogg', 'aac', 'flac', 'm4a'];
26
+ for (const ext of audioExtensions) {
27
+ expect(isAudioFile(ext)).toBe(true);
28
+ expect(isAudioFile(ext.toUpperCase())).toBe(true);
29
+ }
30
+ });
31
+
32
+ it('should return false for non-audio extensions', async () => {
33
+ const { __testables } = await import('../../plugin');
34
+ const { isAudioFile } = __testables as any;
35
+
36
+ const nonAudioExtensions = ['mp4', 'txt', 'pdf', 'docx', 'png', 'jpg', ''];
37
+ for (const ext of nonAudioExtensions) {
38
+ expect(isAudioFile(ext)).toBe(false);
39
+ }
40
+ });
41
+ });
42
+
43
+ describe('getFfprobePath', () => {
44
+ it('should return FFPROBE_PATH env variable when package not found', async () => {
45
+ vi.resetModules();
46
+ process.env.FFPROBE_PATH = '/custom/ffprobe';
47
+ const { __testables } = await import('../../plugin');
48
+ const { getFfprobePath } = __testables as any;
49
+
50
+ const path = getFfprobePath();
51
+ expect(path).toBe('/custom/ffprobe');
52
+ delete process.env.FFPROBE_PATH;
53
+ });
54
+
55
+ it('should fallback to "ffprobe" when no path is found', async () => {
56
+ vi.resetModules();
57
+ delete process.env.FFPROBE_PATH;
58
+
59
+ const { __testables } = await import('../../plugin');
60
+ const { getFfprobePath } = __testables as any;
61
+
62
+ const path = getFfprobePath();
63
+ expect(path).toBe('ffprobe');
64
+ });
65
+ });
66
+
67
+ describe('extractAudioDuration', () => {
68
+ it('should return duration in milliseconds on success', async () => {
69
+ const { __testables } = await import('../../plugin');
70
+ const { extractAudioDuration } = __testables as any;
71
+
72
+ mockExecFile.mockImplementation((bin: string, args: string[], options: any, callback: Function) => {
73
+ callback(null, JSON.stringify({ format: { duration: '123.45' } }), '');
74
+ });
75
+
76
+ const result = await extractAudioDuration('/fake/audio.mp3', log);
77
+ expect(result).toBe(123450); // 123.45 seconds in ms
78
+ expect(log.info).toHaveBeenCalled();
79
+ });
80
+
81
+ it('should return null when execFile fails', async () => {
82
+ const { __testables } = await import('../../plugin');
83
+ const { extractAudioDuration } = __testables as any;
84
+
85
+ mockExecFile.mockImplementation((bin: string, args: string[], options: any, callback: Function) => {
86
+ callback(new Error('ffprobe failed'), '', '');
87
+ });
88
+
89
+ const result = await extractAudioDuration('/fake/audio.mp3', log);
90
+ expect(result).toBeNull();
91
+ expect(log.error).toHaveBeenCalled();
92
+ });
93
+
94
+ it('should return null when JSON parse fails', async () => {
95
+ const { __testables } = await import('../../plugin');
96
+ const { extractAudioDuration } = __testables as any;
97
+
98
+ mockExecFile.mockImplementation((bin: string, args: string[], options: any, callback: Function) => {
99
+ callback(null, 'invalid json', '');
100
+ });
101
+
102
+ const result = await extractAudioDuration('/fake/audio.mp3', log);
103
+ expect(result).toBeNull();
104
+ expect(log.error).toHaveBeenCalled();
105
+ });
106
+
107
+ it('should return null when duration is not a number', async () => {
108
+ const { __testables } = await import('../../plugin');
109
+ const { extractAudioDuration } = __testables as any;
110
+
111
+ mockExecFile.mockImplementation((bin: string, args: string[], options: any, callback: Function) => {
112
+ callback(null, JSON.stringify({ format: { duration: 'not-a-number' } }), '');
113
+ });
114
+
115
+ const result = await extractAudioDuration('/fake/audio.mp3', log);
116
+ expect(result).toBeNull();
117
+ expect(log.warn).toHaveBeenCalled();
118
+ });
119
+
120
+ it('should return null when format is missing', async () => {
121
+ const { __testables } = await import('../../plugin');
122
+ const { extractAudioDuration } = __testables as any;
123
+
124
+ mockExecFile.mockImplementation((bin: string, args: string[], options: any, callback: Function) => {
125
+ callback(null, JSON.stringify({}), '');
126
+ });
127
+
128
+ const result = await extractAudioDuration('/fake/audio.mp3', log);
129
+ expect(result).toBeNull();
130
+ expect(log.warn).toHaveBeenCalled();
131
+ });
132
+ });
133
+
134
+ describe('processAudioMarkers', () => {
135
+ it('should return original content when oapiToken is null', async () => {
136
+ const { __testables } = await import('../../plugin');
137
+ const { processAudioMarkers } = __testables as any;
138
+
139
+ const content = 'hello [DINGTALK_AUDIO]{"path":"/tmp/audio.mp3"}[/DINGTALK_AUDIO]';
140
+ const result = await processAudioMarkers(content, '', {}, null, log);
141
+
142
+ expect(result).toBe(content);
143
+ expect(log.warn).toHaveBeenCalled();
144
+ });
145
+
146
+ it('should return cleaned content when no audio markers', async () => {
147
+ const { __testables } = await import('../../plugin');
148
+ const { processAudioMarkers } = __testables as any;
149
+
150
+ const content = 'plain text without markers';
151
+ const result = await processAudioMarkers(content, '', {}, 'token', log);
152
+
153
+ expect(result).toBe(content);
154
+ });
155
+
156
+ it('should handle invalid JSON in audio markers', async () => {
157
+ const { __testables } = await import('../../plugin');
158
+ const { processAudioMarkers } = __testables as any;
159
+
160
+ const content = 'text [DINGTALK_AUDIO]{invalid-json}[/DINGTALK_AUDIO]';
161
+ const result = await processAudioMarkers(content, '', {}, 'token', log);
162
+
163
+ expect(result).toBe('text');
164
+ expect(log.warn).toHaveBeenCalled();
165
+ });
166
+
167
+ it('should handle non-existent audio files', async () => {
168
+ const { __testables } = await import('../../plugin');
169
+ const { processAudioMarkers } = __testables as any;
170
+
171
+ // Mock fs.existsSync to return false
172
+ vi.doMock('fs', () => ({
173
+ existsSync: vi.fn().mockReturnValue(false),
174
+ }));
175
+
176
+ const content = 'text [DINGTALK_AUDIO]{"path":"/nonexistent/audio.mp3"}[/DINGTALK_AUDIO]';
177
+ const result = await processAudioMarkers(content, '', {}, 'token', log);
178
+
179
+ // Should remove marker and add warning
180
+ expect(result).toContain('⚠️');
181
+ });
182
+ });
183
+
184
+ describe('sendAudioMessage', () => {
185
+ it('should send audio message with default duration', async () => {
186
+ const { __testables } = await import('../../plugin');
187
+ const { sendAudioMessage } = __testables as any;
188
+
189
+ const mockAxios = await import('axios');
190
+ vi.spyOn(mockAxios.default, 'post').mockResolvedValue({ data: { success: true } });
191
+
192
+ const fileInfo = { path: '/tmp/audio.mp3', fileName: 'audio.mp3', fileType: 'mp3' };
193
+ await sendAudioMessage({}, 'https://webhook', fileInfo, 'mediaId123', 'token', log);
194
+
195
+ expect(log.info).toHaveBeenCalled();
196
+ });
197
+
198
+ it('should send audio message with provided duration', async () => {
199
+ const { __testables } = await import('../../plugin');
200
+ const { sendAudioMessage } = __testables as any;
201
+
202
+ const mockAxios = await import('axios');
203
+ vi.spyOn(mockAxios.default, 'post').mockResolvedValue({ data: { success: true } });
204
+
205
+ const fileInfo = { path: '/tmp/audio.mp3', fileName: 'audio.mp3', fileType: 'mp3' };
206
+ await sendAudioMessage({}, 'https://webhook', fileInfo, 'mediaId123', 'token', log, 30000);
207
+
208
+ expect(log.info).toHaveBeenCalled();
209
+ });
210
+
211
+ it('should handle send failure', async () => {
212
+ const { __testables } = await import('../../plugin');
213
+ const { sendAudioMessage } = __testables as any;
214
+
215
+ const mockAxios = await import('axios');
216
+ vi.spyOn(mockAxios.default, 'post').mockRejectedValue(new Error('Network error'));
217
+
218
+ const fileInfo = { path: '/tmp/audio.mp3', fileName: 'audio.mp3', fileType: 'mp3' };
219
+ await sendAudioMessage({}, 'https://webhook', fileInfo, 'mediaId123', 'token', log);
220
+
221
+ expect(log.error).toHaveBeenCalled();
222
+ });
223
+ });
224
+
225
+ describe('sendAudioProactive', () => {
226
+ it('should send audio to user via proactive API', async () => {
227
+ const { __testables } = await import('../../plugin');
228
+ const { sendAudioProactive } = __testables as any;
229
+
230
+ const mockAxios = await import('axios');
231
+ vi.spyOn(mockAxios.default, 'post').mockResolvedValue({ data: { processQueryKey: 'key123' } });
232
+ vi.spyOn(mockAxios.default, 'get').mockResolvedValue({ data: { accessToken: 'token' } });
233
+
234
+ const config = { clientId: 'test', clientSecret: 'secret' };
235
+ const target = { type: 'user' as const, userId: 'user123' };
236
+ const fileInfo = { path: '/tmp/audio.mp3', fileName: 'audio.mp3', fileType: 'mp3' };
237
+
238
+ await sendAudioProactive(config, target, fileInfo, 'mediaId123', log);
239
+
240
+ expect(log.info).toHaveBeenCalled();
241
+ });
242
+
243
+ it('should send audio to group via proactive API', async () => {
244
+ const { __testables } = await import('../../plugin');
245
+ const { sendAudioProactive } = __testables as any;
246
+
247
+ const mockAxios = await import('axios');
248
+ vi.spyOn(mockAxios.default, 'post').mockResolvedValue({ data: { processQueryKey: 'key123' } });
249
+ vi.spyOn(mockAxios.default, 'get').mockResolvedValue({ data: { accessToken: 'token' } });
250
+
251
+ const config = { clientId: 'test', clientSecret: 'secret' };
252
+ const target = { type: 'group' as const, openConversationId: 'conv123' };
253
+ const fileInfo = { path: '/tmp/audio.mp3', fileName: 'audio.mp3', fileType: 'mp3' };
254
+
255
+ await sendAudioProactive(config, target, fileInfo, 'mediaId123', log);
256
+
257
+ expect(log.info).toHaveBeenCalled();
258
+ });
259
+
260
+ it('should use provided duration in proactive send', async () => {
261
+ const { __testables } = await import('../../plugin');
262
+ const { sendAudioProactive } = __testables as any;
263
+
264
+ const mockAxios = await import('axios');
265
+ const postSpy = vi.spyOn(mockAxios.default, 'post').mockResolvedValue({ data: { processQueryKey: 'key123' } });
266
+ vi.spyOn(mockAxios.default, 'get').mockResolvedValue({ data: { accessToken: 'token' } });
267
+
268
+ const config = { clientId: 'test', clientSecret: 'secret' };
269
+ const target = { type: 'user' as const, userId: 'user123' };
270
+ const fileInfo = { path: '/tmp/audio.mp3', fileName: 'audio.mp3', fileType: 'mp3' };
271
+
272
+ await sendAudioProactive(config, target, fileInfo, 'mediaId123', log, 45000);
273
+
274
+ // Check that the duration was passed
275
+ const callArgs = postSpy.mock.calls.find(c => c[0].includes('batchSend') || c[0].includes('groupMessages'));
276
+ if (callArgs) {
277
+ const body = callArgs[1];
278
+ const msgParam = JSON.parse(body.msgParam);
279
+ expect(msgParam.duration).toBe('45000');
280
+ }
281
+ });
282
+ });
283
+ });
@@ -0,0 +1,99 @@
1
+ # Bindings 解析模块(resolveAgentIdByBindings)测试方案
2
+
3
+ ## 1. 模块划分与职责
4
+
5
+ - **resolveAgentIdByBindings(accountId, peerKind, peerId, log?): string**
6
+ - 职责:根据 OpenClaw 配置的 bindings 与当前会话(accountId、peerKind、peerId)解析出应使用的 agentId。配置从 `~/.openclaw/openclaw.json` 读取;匹配优先级见下。
7
+
8
+ ## 2. 匹配优先级(从高到低)
9
+
10
+ | 优先级 | 条件 | 说明 |
11
+ |--------|------|------|
12
+ | 1 | peer.kind + peer.id 精确匹配(id 非 '*') | 单聊/群聊 + 具体 peerId |
13
+ | 2 | peer.kind + peer.id='*' | 通配该会话类型下所有 peer |
14
+ | 3 | 仅 peer.kind 匹配(无 peer.id) | 按单聊/群聊 |
15
+ | 4 | 无 peer,match.accountId === accountId | 按账号 |
16
+ | 5 | 无 peer 且无 accountId | 仅 channel |
17
+ | 6 | 无匹配 | 返回 defaultAgentId |
18
+
19
+ **defaultAgentId**:当 accountId === `'__default__'` 时为 `'main'`,否则为 accountId。
20
+
21
+ ## 3. 用例矩阵(全覆盖)
22
+
23
+ ### 3.1 无配置文件或配置不可读
24
+
25
+ | 序号 | fs.existsSync | fs.readFileSync / 异常 | 期望 | 说明 |
26
+ |------|----------------|------------------------|------|------|
27
+ | 1 | false | - | defaultAgentId | 文件不存在 |
28
+ | 2 | true | throw Error | defaultAgentId,log.warn | 读文件抛错 |
29
+ | 3 | true | 非 JSON 字符串 | defaultAgentId,log.warn | JSON.parse 抛错 |
30
+
31
+ ### 3.2 无 bindings 或 bindings 为空
32
+
33
+ | 序号 | readFileSync 返回 | 期望 | 说明 |
34
+ |------|-------------------|------|------|
35
+ | 4 | `'{}'` | defaultAgentId | 无 bindings 键 |
36
+ | 5 | `'{"bindings":[]}'` | defaultAgentId | 空数组 |
37
+
38
+ ### 3.3 channel 筛选
39
+
40
+ | 序号 | bindings 内容 | 期望 | 说明 |
41
+ |------|----------------|------|------|
42
+ | 6 | `[{ agentId: 'a', match: { channel: 'other' } }]` | defaultAgentId | 非 dingtalk-connector 被筛掉 |
43
+ | 7 | `[{ agentId: 'a', match: { channel: 'dingtalk-connector' } }]` | 能匹配到 'a'(见优先级5) | channel 匹配 |
44
+ | 8 | `[{ agentId: 'a', match: {} }]` | 能匹配到 'a' | 无 channel 视为匹配 |
45
+
46
+ ### 3.4 defaultAgentId 逻辑
47
+
48
+ | 序号 | accountId | 无配置/无匹配时期望 | 说明 |
49
+ |------|-----------|---------------------|------|
50
+ | 9 | `'__default__'` | `'main'` | 单账号模式 |
51
+ | 10 | `'acc1'` | `'acc1'` | 多账号 |
52
+
53
+ ### 3.5 优先级1:精确 peer.id
54
+
55
+ | 序号 | bindings | accountId | peerKind | peerId | 期望 | 说明 |
56
+ |------|----------|-----------|----------|--------|------|------|
57
+ | 11 | `[{ agentId: 'agent1', match: { peer: { kind: 'direct', id: 'user1' } } }]` | any | 'direct' | 'user1' | 'agent1' | 精确匹配 |
58
+ | 12 | 同上 | any | 'direct' | 'user2' | defaultAgentId | id 不同 |
59
+ | 13 | 同上 | any | 'group' | 'user1' | defaultAgentId | kind 不同 |
60
+ | 14 | `[{ agentId: 'a', match: { accountId: 'acc1', peer: { kind: 'direct', id: 'u1' } } }]` | 'acc1' | 'direct' | 'u1' | 'a' | accountId 一致 |
61
+ | 15 | 同上 | 'acc2' | 'direct' | 'u1' | defaultAgentId | accountId 不一致被 continue |
62
+
63
+ ### 3.6 优先级2:peer.id='*'
64
+
65
+ | 序号 | bindings | peerKind | peerId | 期望 | 说明 |
66
+ |------|----------|----------|--------|------|------|
67
+ | 16 | `[{ agentId: 'wild', match: { peer: { kind: 'direct', id: '*' } } }]` | 'direct' | 'any_id' | 'wild' | 通配单聊 |
68
+ | 17 | 同上 | 'group' | 'any' | defaultAgentId | kind 不同 |
69
+
70
+ ### 3.7 优先级3:仅 peer.kind
71
+
72
+ | 序号 | bindings | peerKind | 期望 | 说明 |
73
+ |------|----------|----------|------|------|
74
+ | 18 | `[{ agentId: 'kindOnly', match: { peer: { kind: 'group' } } }]` | 'group' | 'kindOnly' | 无 peer.id |
75
+ | 19 | 同上 | 'direct' | defaultAgentId | kind 不同 |
76
+
77
+ ### 3.8 优先级4:accountId
78
+
79
+ | 序号 | bindings | accountId | 期望 | 说明 |
80
+ |------|----------|-----------|------|------|
81
+ | 20 | `[{ agentId: 'accAgent', match: { accountId: 'acc1' } }]` | 'acc1' | 'accAgent' | 无 peer |
82
+ | 21 | 同上 | 'acc2' | defaultAgentId | accountId 不同 |
83
+
84
+ ### 3.9 优先级5:仅 channel
85
+
86
+ | 序号 | bindings | 期望 | 说明 |
87
+ |------|----------|------|------|
88
+ | 22 | `[{ agentId: 'channelOnly', match: { channel: 'dingtalk-connector' } }]` | 'channelOnly' | 无 peer 无 accountId 时取第一个 |
89
+
90
+ ### 3.10 binding.agentId 为空
91
+
92
+ | 序号 | bindings | 期望 | 说明 |
93
+ |------|----------|------|------|
94
+ | 23 | `[{ agentId: '', match: { peer: { kind: 'direct', id: 'u1' } } }]` | defaultAgentId | 代码 return binding.agentId \|\| defaultAgentId |
95
+
96
+ ## 4. 预期正确输出与潜在错误
97
+
98
+ - **正确输出**:返回字符串 agentId,无 undefined;优先级顺序正确时先匹配到的先返回。
99
+ - **潜在错误原因**:配置文件路径写死或与文档不一致;channel 筛选条件反了;accountId 比较时 continue 漏写;优先级顺序颠倒;defaultAgentId 未区分 __default__。
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+
3
+ const mockExistsSync = vi.hoisted(() => vi.fn());
4
+ const mockReadFileSync = vi.hoisted(() => vi.fn());
5
+
6
+ vi.mock('fs', () => ({
7
+ existsSync: mockExistsSync,
8
+ readFileSync: mockReadFileSync,
9
+ }));
10
+ vi.mock('path', () => ({
11
+ join: (...args: string[]) => args.join('/'),
12
+ }));
13
+ vi.mock('os', () => ({
14
+ homedir: () => '/fake-home',
15
+ }));
16
+
17
+ // 保证 plugin 在本文件内加载时使用上面 mock 的 fs/path/os
18
+ let resolveAgentIdByBindings: (a: string, b: 'direct' | 'group', c: string, l?: any) => string;
19
+
20
+ const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
21
+ const CONFIG_PATH = '/fake-home/.openclaw/openclaw.json';
22
+
23
+ describe('resolveAgentIdByBindings', () => {
24
+ beforeEach(async () => {
25
+ vi.clearAllMocks();
26
+ mockExistsSync.mockReturnValue(false);
27
+ vi.resetModules();
28
+ const { __testables } = await import('../../plugin');
29
+ resolveAgentIdByBindings = (__testables as any).resolveAgentIdByBindings;
30
+ (__testables as any).setRuntimeForTest({});
31
+ });
32
+
33
+ it('returns main when accountId is __default__ and no config', () => {
34
+ const out = resolveAgentIdByBindings('__default__', 'direct', 'user1', log);
35
+ expect(out).toBe('main');
36
+ });
37
+
38
+ it('returns accountId when accountId is not __default__ and no config', () => {
39
+ const out = resolveAgentIdByBindings('acc1', 'direct', 'user1', log);
40
+ expect(out).toBe('acc1');
41
+ });
42
+
43
+ it('returns defaultAgentId when config file does not exist', () => {
44
+ mockExistsSync.mockReturnValue(false);
45
+ const out = resolveAgentIdByBindings('acc1', 'direct', 'u1', log);
46
+ expect(mockExistsSync).toHaveBeenCalledWith(CONFIG_PATH);
47
+ expect(out).toBe('acc1');
48
+ });
49
+
50
+ it('returns defaultAgentId when bindings is empty array', () => {
51
+ mockExistsSync.mockReturnValue(true);
52
+ mockReadFileSync.mockReturnValue(JSON.stringify({ bindings: [] }));
53
+ const out = resolveAgentIdByBindings('acc1', 'direct', 'u1', log);
54
+ expect(out).toBe('acc1');
55
+ });
56
+
57
+ it('returns defaultAgentId when config has no bindings key', () => {
58
+ mockExistsSync.mockReturnValue(true);
59
+ mockReadFileSync.mockReturnValue('{}');
60
+ const out = resolveAgentIdByBindings('acc1', 'direct', 'u1', log);
61
+ expect(out).toBe('acc1');
62
+ });
63
+
64
+ it('returns defaultAgentId and logs warn when readFileSync throws', () => {
65
+ mockExistsSync.mockReturnValue(true);
66
+ mockReadFileSync.mockImplementation(() => {
67
+ throw new Error('read error');
68
+ });
69
+ const out = resolveAgentIdByBindings('acc1', 'direct', 'u1', log);
70
+ expect(out).toBe('acc1');
71
+ expect(log.warn).toHaveBeenCalled();
72
+ });
73
+
74
+ it('priority 1: exact peer.kind + peer.id match', () => {
75
+ mockExistsSync.mockReturnValue(true);
76
+ mockReadFileSync.mockReturnValue(
77
+ JSON.stringify({
78
+ bindings: [
79
+ { agentId: 'agent1', match: { channel: 'dingtalk-connector', peer: { kind: 'direct', id: 'user1' } } },
80
+ ],
81
+ }),
82
+ );
83
+ const out = resolveAgentIdByBindings('acc1', 'direct', 'user1', log);
84
+ expect(out).toBe('agent1');
85
+ });
86
+
87
+ it('priority 1: no match when peerId differs', () => {
88
+ mockExistsSync.mockReturnValue(true);
89
+ mockReadFileSync.mockReturnValue(
90
+ JSON.stringify({
91
+ bindings: [
92
+ { agentId: 'agent1', match: { peer: { kind: 'direct', id: 'user1' } } },
93
+ ],
94
+ }),
95
+ );
96
+ const out = resolveAgentIdByBindings('acc1', 'direct', 'user2', log);
97
+ expect(out).toBe('acc1');
98
+ });
99
+
100
+ it('priority 1: accountId filter - skip when accountId does not match', () => {
101
+ mockExistsSync.mockReturnValue(true);
102
+ mockReadFileSync.mockReturnValue(
103
+ JSON.stringify({
104
+ bindings: [
105
+ { agentId: 'a', match: { accountId: 'acc1', peer: { kind: 'direct', id: 'u1' } } },
106
+ ],
107
+ }),
108
+ );
109
+ const out = resolveAgentIdByBindings('acc2', 'direct', 'u1', log);
110
+ expect(out).toBe('acc2');
111
+ });
112
+
113
+ it('priority 1: accountId filter - match when accountId matches', () => {
114
+ mockExistsSync.mockReturnValue(true);
115
+ mockReadFileSync.mockReturnValue(
116
+ JSON.stringify({
117
+ bindings: [
118
+ { agentId: 'a', match: { accountId: 'acc1', peer: { kind: 'direct', id: 'u1' } } },
119
+ ],
120
+ }),
121
+ );
122
+ const out = resolveAgentIdByBindings('acc1', 'direct', 'u1', log);
123
+ expect(out).toBe('a');
124
+ });
125
+
126
+ it('priority 2: peer.id=* matches any peerId for same kind', () => {
127
+ mockExistsSync.mockReturnValue(true);
128
+ mockReadFileSync.mockReturnValue(
129
+ JSON.stringify({
130
+ bindings: [{ agentId: 'wild', match: { peer: { kind: 'direct', id: '*' } } }],
131
+ }),
132
+ );
133
+ const out = resolveAgentIdByBindings('acc1', 'direct', 'any_id', log);
134
+ expect(out).toBe('wild');
135
+ });
136
+
137
+ it('priority 3: peer.kind only (no peer.id)', () => {
138
+ mockExistsSync.mockReturnValue(true);
139
+ mockReadFileSync.mockReturnValue(
140
+ JSON.stringify({
141
+ bindings: [{ agentId: 'kindOnly', match: { peer: { kind: 'group' } } }],
142
+ }),
143
+ );
144
+ const out = resolveAgentIdByBindings('acc1', 'group', 'cid1', log);
145
+ expect(out).toBe('kindOnly');
146
+ });
147
+
148
+ it('priority 4: accountId match (no peer)', () => {
149
+ mockExistsSync.mockReturnValue(true);
150
+ mockReadFileSync.mockReturnValue(
151
+ JSON.stringify({
152
+ bindings: [{ agentId: 'accAgent', match: { accountId: 'acc1' } }],
153
+ }),
154
+ );
155
+ const out = resolveAgentIdByBindings('acc1', 'direct', 'u1', log);
156
+ expect(out).toBe('accAgent');
157
+ });
158
+
159
+ it('priority 5: channel only (no peer, no accountId)', () => {
160
+ mockExistsSync.mockReturnValue(true);
161
+ mockReadFileSync.mockReturnValue(
162
+ JSON.stringify({
163
+ bindings: [{ agentId: 'channelOnly', match: { channel: 'dingtalk-connector' } }],
164
+ }),
165
+ );
166
+ const out = resolveAgentIdByBindings('acc1', 'direct', 'u1', log);
167
+ expect(out).toBe('channelOnly');
168
+ });
169
+
170
+ it('channel filter: other channel binding is skipped', () => {
171
+ mockExistsSync.mockReturnValue(true);
172
+ mockReadFileSync.mockReturnValue(
173
+ JSON.stringify({
174
+ bindings: [{ agentId: 'other', match: { channel: 'other-channel' } }],
175
+ }),
176
+ );
177
+ const out = resolveAgentIdByBindings('acc1', 'direct', 'u1', log);
178
+ expect(out).toBe('acc1');
179
+ });
180
+
181
+ it('returns defaultAgentId when binding.agentId is empty', () => {
182
+ mockExistsSync.mockReturnValue(true);
183
+ mockReadFileSync.mockReturnValue(
184
+ JSON.stringify({
185
+ bindings: [{ agentId: '', match: { peer: { kind: 'direct', id: 'u1' } } }],
186
+ }),
187
+ );
188
+ const out = resolveAgentIdByBindings('acc1', 'direct', 'u1', log);
189
+ expect(out).toBe('acc1');
190
+ });
191
+ });
@@ -0,0 +1,29 @@
1
+ # card-update(AI Card 更新)测试方案
2
+
3
+ ## 1. 目的与覆盖范围
4
+
5
+ 该套件定位为 **AI Card“更新链路”的轻量回归集**,目标是用较少用例覆盖最容易被改坏、但线上影响大的关键契约:
6
+
7
+ - **被动场景创建卡片**:`createAICard(config, data, log?)` 会根据 `conversationType` 正确选择 user/group 目标并完成投放。
8
+ - **流式状态机**:`streamAICard(card, content, finished?, log?)` 首次会先切换到 INPUTING,再写入 streaming;后续调用不应重复 INPUTING。
9
+ - **结束流程**:`finishAICard(card, content, log?)` 会 finalize streaming 并设置 FINISHED。
10
+
11
+ 说明:
12
+
13
+ - 本套件**不再**覆盖 `sendAICardInternal/sendAICardToUser/sendAICardToGroup` 或 `buildMsgPayload`,这些更适合放在 `tests/ai-card` 与 `tests/proactive`(避免重复与耦合)。
14
+
15
+ ## 2. 用例表(覆盖现有测试)
16
+
17
+ | 序号 | 场景 | 期望 | 说明 |
18
+ |------|------|------|------|
19
+ | 1 | createAICard:单聊(conversationType='1') | 成功创建并投放到 `dtv1.card//IM_ROBOT.<userId>` | user 目标选择正确 |
20
+ | 2 | createAICard:群聊(conversationType='2') | 成功创建并投放到 `dtv1.card//IM_GROUP.<openConversationId>` | group 目标选择正确 |
21
+ | 3 | streamAICard:首次调用 | 先 PUT INPUTING,再 PUT streaming;并把 `card.inputingStarted=true` | 状态机契约 |
22
+ | 4 | streamAICard:后续调用 | 仅 PUT streaming(不再重复 INPUTING) | 避免多余状态切换 |
23
+ | 5 | finishAICard | streaming `isFinalize=true`,并 PUT FINISHED(flowStatus='3') | 完成闭环 |
24
+
25
+ ## 3. 预期正确输出与潜在错误
26
+
27
+ - **正确**:create 时目标选择与投放 `openSpaceId` 正确;stream/finish 的状态机顺序正确且不会重复 INPUTING;finish 会 finalize streaming 并设置 FINISHED。
28
+ - **潜在错误原因**:conversationType 判断反了导致 user/group 投放错;streamAICard 忘记先 INPUTING 或重复 INPUTING;finishAICard 未 finalize streaming;flowStatus 值写错导致客户端状态异常。
29
+