@dsh-overdrive/gateway 0.2.0 → 0.3.1

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 (61) hide show
  1. package/dist/commands.d.ts +5 -0
  2. package/dist/commands.js +14 -4
  3. package/dist/commands.js.map +1 -1
  4. package/dist/index.d.ts +7 -1
  5. package/dist/index.js +57 -8
  6. package/dist/index.js.map +1 -1
  7. package/dist/memory.d.ts +10 -0
  8. package/dist/memory.js +43 -0
  9. package/dist/memory.js.map +1 -1
  10. package/dist/mention.d.ts +13 -0
  11. package/dist/mention.js +43 -0
  12. package/dist/mention.js.map +1 -0
  13. package/dist/pending-buttons.d.ts +5 -1
  14. package/dist/pending-buttons.js +26 -8
  15. package/dist/pending-buttons.js.map +1 -1
  16. package/dist/text.d.ts +2 -0
  17. package/dist/text.js +24 -0
  18. package/dist/text.js.map +1 -0
  19. package/package.json +9 -4
  20. package/src/adapter.ts +0 -42
  21. package/src/adapters/cli.ts +0 -37
  22. package/src/adapters/dingtalk.ts +0 -206
  23. package/src/adapters/discord.ts +0 -127
  24. package/src/adapters/feishu.ts +0 -224
  25. package/src/adapters/slack.ts +0 -123
  26. package/src/adapters/telegram.ts +0 -142
  27. package/src/adapters/wechat.ts +0 -247
  28. package/src/adapters/wecom.ts +0 -218
  29. package/src/adapters/whatsapp.ts +0 -249
  30. package/src/asr.ts +0 -83
  31. package/src/commands.ts +0 -89
  32. package/src/config.ts +0 -104
  33. package/src/feed.ts +0 -190
  34. package/src/index.ts +0 -456
  35. package/src/memory.ts +0 -133
  36. package/src/pending-buttons.ts +0 -45
  37. package/src/session.ts +0 -23
  38. package/src/setup.ts +0 -252
  39. package/src/status.ts +0 -63
  40. package/src/trajectory.ts +0 -45
  41. package/test/adapters.dingtalk.test.ts +0 -64
  42. package/test/adapters.discord.test.ts +0 -41
  43. package/test/adapters.feishu.test.ts +0 -66
  44. package/test/adapters.slack.test.ts +0 -45
  45. package/test/adapters.telegram.test.ts +0 -37
  46. package/test/adapters.wechat.test.ts +0 -78
  47. package/test/adapters.wecom.test.ts +0 -62
  48. package/test/adapters.whatsapp.test.ts +0 -138
  49. package/test/asr.test.ts +0 -77
  50. package/test/commands.test.ts +0 -43
  51. package/test/config.test.ts +0 -30
  52. package/test/feed.test.ts +0 -111
  53. package/test/memory.test.ts +0 -79
  54. package/test/multi.test.ts +0 -258
  55. package/test/outbound.test.ts +0 -29
  56. package/test/pending-buttons.test.ts +0 -61
  57. package/test/session.test.ts +0 -26
  58. package/test/status.test.ts +0 -41
  59. package/test/streaming.test.ts +0 -162
  60. package/test/trajectory.test.ts +0 -58
  61. package/tsconfig.json +0 -5
@@ -1,78 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import {
3
- buildGetUpdatesBody, buildNumberedReplyText, buildSendMessageBody, chunkText, extractWeChatText, parseWeChatUpdate,
4
- } from '../src/adapters/wechat.js';
5
-
6
- describe('extractWeChatText', () => {
7
- it('提取 text_item 文本', () => {
8
- expect(extractWeChatText({ from_user_id: 'u1', text_item: { text: 'hello' } })).toBe('hello');
9
- });
10
- it('提取 item_list 中 type===1 的文本', () => {
11
- expect(extractWeChatText({ item_list: [{ type: 2, text_item: { text: 'img' } }, { type: 1, text_item: { text: 'text' } }] })).toBe('text');
12
- });
13
- it('无文本返回 null', () => {
14
- expect(extractWeChatText({ from_user_id: 'u1' })).toBeNull();
15
- });
16
- });
17
-
18
- describe('parseWeChatUpdate', () => {
19
- it('文本消息 → NormalizedMessage(chatId=userId=from_user_id)', () => {
20
- expect(parseWeChatUpdate({ from_user_id: 'wx-1', context_token: 'tok', text_item: { text: 'hi' } }))
21
- .toEqual({ chatId: 'wx-1', userId: 'wx-1', text: 'hi' });
22
- });
23
- it('非文本或缺发送者返回 null', () => {
24
- expect(parseWeChatUpdate({ from_user_id: 'wx-1', text_item: { text: '' } })).toBeNull();
25
- expect(parseWeChatUpdate({ text_item: { text: 'x' } })).toBeNull();
26
- });
27
- });
28
-
29
- describe('buildGetUpdatesBody', () => {
30
- it('带同步游标与 longpolling,且必须带 base_info.channel_version', () => {
31
- const body = buildGetUpdatesBody('buf-1');
32
- expect(body.get_updates_buf).toBe('buf-1');
33
- expect(body.longpolling_timeout).toBe(35000);
34
- expect(body.base_info).toEqual({ channel_version: '1.0.2' });
35
- });
36
- });
37
-
38
- describe('buildSendMessageBody', () => {
39
- it('msg 包裹 + text_item,带回话 context_token', () => {
40
- const body = buildSendMessageBody('wx-1', '回复', 'tok-1', 'client-1');
41
- expect(body.base_info).toEqual({ channel_version: '1.0.2' });
42
- expect(body.msg).toMatchObject({
43
- from_user_id: '',
44
- to_user_id: 'wx-1',
45
- client_id: 'client-1',
46
- message_type: 2,
47
- message_state: 2,
48
- context_token: 'tok-1',
49
- item_list: [{ type: 1, text_item: { text: '回复' } }],
50
- });
51
- });
52
- });
53
-
54
- describe('chunkText', () => {
55
- it('长文本按上限分段', () => {
56
- const chunks = chunkText('a'.repeat(1700), 800);
57
- expect(chunks).toHaveLength(3);
58
- expect(chunks[0].length).toBe(800);
59
- expect(chunks[2].length).toBe(100);
60
- });
61
- it('短文本单段', () => {
62
- expect(chunkText('hi', 800)).toEqual(['hi']);
63
- });
64
- });
65
-
66
- describe('buildNumberedReplyText', () => {
67
- it('生成 1/2 选项文本', () => {
68
- const text = buildNumberedReplyText('需要批准', [
69
- { id: 'approve:r1', label: '✅ 同意' },
70
- { id: 'reject:r1', label: '🚫 拒绝' },
71
- ]);
72
- expect(text).toContain('1) ✅ 同意');
73
- expect(text).toContain('回复数字选择');
74
- });
75
- it('无按钮时原样返回', () => {
76
- expect(buildNumberedReplyText('hi', [])).toBe('hi');
77
- });
78
- });
@@ -1,62 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import {
3
- buildNumberedText,
4
- decryptWeComPayload,
5
- encryptWeComPayload,
6
- matchNumberedButton,
7
- parseWeComXmlMessage,
8
- } from '../src/adapters/wecom.js';
9
-
10
- const KEY = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'; // 43 位 EncodingAESKey
11
-
12
- describe('企业微信 AES 加解密(往返)', () => {
13
- it('encrypt → decrypt 还原原文(含 receiveId)', () => {
14
- const { encrypted } = encryptWeComPayload(KEY, 'hello', 'corpid123');
15
- const out = decryptWeComPayload(KEY, encrypted);
16
- expect(out.message).toBe('hello');
17
- expect(out.receiveId).toBe('corpid123');
18
- });
19
-
20
- it('中文消息往返', () => {
21
- const { encrypted } = encryptWeComPayload(KEY, '你好,世界', 'ww1234567890');
22
- const out = decryptWeComPayload(KEY, encrypted);
23
- expect(out.message).toBe('你好,世界');
24
- expect(out.receiveId).toBe('ww1234567890');
25
- });
26
- });
27
-
28
- describe('parseWeComXmlMessage(回调 XML → NormalizedMessage)', () => {
29
- it('文本消息', () => {
30
- const xml = `<xml><ToUserName><![CDATA[ww1]]></ToUserName><FromUserName><![CDATA[user1]]></FromUserName><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[你好]]></Content></xml>`;
31
- const out = parseWeComXmlMessage(xml);
32
- expect(out).toMatchObject({ chatId: 'user1', userId: 'user1', text: '你好' });
33
- });
34
- it('非文本返回 null', () => {
35
- const xml = `<xml><FromUserName><![CDATA[u]]></FromUserName><MsgType><![CDATA[image]]></MsgType></xml>`;
36
- expect(parseWeComXmlMessage(xml)).toBeNull();
37
- });
38
- });
39
-
40
- describe('buildNumberedText(审批编号回复)', () => {
41
- it('生成 1/2 选项文本', () => {
42
- const text = buildNumberedText('需要批准', [
43
- { id: 'approve:r1', label: '同意' },
44
- { id: 'reject:r1', label: '拒绝' },
45
- ]);
46
- expect(text).toContain('1) 同意');
47
- expect(text).toContain('2) 拒绝');
48
- expect(text).toContain('回复数字选择');
49
- });
50
- it('无按钮时原样返回', () => {
51
- expect(buildNumberedText('plain', [])).toBe('plain');
52
- });
53
- it('matchNumberedButton 命中选项', () => {
54
- const buttons = [
55
- { id: 'approve:r1', label: '同意' },
56
- { id: 'reject:r1', label: '拒绝' },
57
- ];
58
- expect(matchNumberedButton('2', buttons)).toEqual(buttons[1]);
59
- expect(matchNumberedButton('3', buttons)).toBeUndefined();
60
- expect(matchNumberedButton('abc', buttons)).toBeUndefined();
61
- });
62
- });
@@ -1,138 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import {
3
- buildNativeFlowButtons,
4
- buildNumberedReply,
5
- matchNumberedReply,
6
- normalizeWhatsAppMessage,
7
- parseNativeButtonResponse,
8
- whatsappImageUrl,
9
- type RawWhatsAppMessage,
10
- } from '../src/adapters/whatsapp.js';
11
-
12
- describe('normalizeWhatsAppMessage', () => {
13
- it('文本消息 → NormalizedMessage(chatId=JID, userId=发送者)', () => {
14
- const raw = {
15
- key: { remoteJid: '60123@s.whatsapp.net', participant: undefined },
16
- message: { conversation: 'hello' },
17
- messageType: 'conversation',
18
- };
19
- const out = normalizeWhatsAppMessage(raw);
20
- expect(out?.kind).toBe('message');
21
- expect(out?.msg).toMatchObject({ chatId: '60123@s.whatsapp.net', userId: '60123@s.whatsapp.net', text: 'hello' });
22
- });
23
-
24
- it('群聊用 participant 作 userId', () => {
25
- const raw = {
26
- key: { remoteJid: 'group@g.us', participant: '60123@s.whatsapp.net' },
27
- message: { extendedTextMessage: { text: 'hi' } },
28
- messageType: 'extendedTextMessage',
29
- };
30
- const out = normalizeWhatsAppMessage(raw);
31
- expect(out?.msg.userId).toBe('60123@s.whatsapp.net');
32
- expect(out?.msg.chatId).toBe('group@g.us');
33
- });
34
-
35
- it('非文本消息返回 null', () => {
36
- expect(normalizeWhatsAppMessage({ key: { remoteJid: 'x' }, message: { imageMessage: {} } })).toBeNull();
37
- expect(normalizeWhatsAppMessage({ key: { remoteJid: 'x' }, message: {} })).toBeNull();
38
- });
39
- });
40
-
41
- describe('whatsappImageUrl / 媒体消息捕获', () => {
42
- it('imageMessage url + caption → media: { kind: "image", url };无 url 退 directPath', () => {
43
- const raw = {
44
- key: { remoteJid: '60123@s.whatsapp.net' },
45
- message: { imageMessage: { url: 'https://mmg.whatsapp.net/f/x.jpg', caption: '看图' } },
46
- messageType: 'imageMessage',
47
- };
48
- expect(whatsappImageUrl(raw)).toBe('https://mmg.whatsapp.net/f/x.jpg');
49
- expect(normalizeWhatsAppMessage(raw)).toMatchObject({
50
- kind: 'message',
51
- msg: {
52
- chatId: '60123@s.whatsapp.net',
53
- userId: '60123@s.whatsapp.net',
54
- text: '看图',
55
- media: { kind: 'image', url: 'https://mmg.whatsapp.net/f/x.jpg' },
56
- },
57
- });
58
- // directPath 兜底(M4 简化:URL 直接透传,认证头后续再补)
59
- expect(whatsappImageUrl({ message: { imageMessage: { directPath: '/d/p.jpg' } } })).toBe('/d/p.jpg');
60
- });
61
- });
62
-
63
- describe('buildNumberedReply / matchNumberedReply(审批按钮的编号文本方案)', () => {
64
- it('生成 1/2 编号选项', () => {
65
- const text = buildNumberedReply('⚠️ 需要批准:删除文件', [
66
- { id: 'approve:r1', label: '✅ 同意' },
67
- { id: 'reject:r1', label: '🚫 拒绝' },
68
- ]);
69
- expect(text).toContain('1) ✅ 同意');
70
- expect(text).toContain('2) 🚫 拒绝');
71
- });
72
-
73
- it('回复数字能匹配回按钮 id', () => {
74
- const buttons = [
75
- { id: 'approve:r1', label: '✅ 同意' },
76
- { id: 'reject:r1', label: '🚫 拒绝' },
77
- ];
78
- expect(matchNumberedReply('1', buttons)?.id).toBe('approve:r1');
79
- expect(matchNumberedReply('2', buttons)?.id).toBe('reject:r1');
80
- expect(matchNumberedReply('9', buttons)).toBeUndefined();
81
- expect(matchNumberedReply('同意', buttons)).toBeUndefined();
82
- });
83
- });
84
-
85
- describe('buildNativeFlowButtons(WhatsApp 原生交互按钮)', () => {
86
- it('按钮 → nativeFlowMessage buttons 数组', () => {
87
- const buttons = buildNativeFlowButtons([
88
- { id: 'approve:r1', label: '✅ 同意' },
89
- { id: 'reject:r1', label: '🚫 拒绝' },
90
- ]);
91
- expect(buttons).toHaveLength(2);
92
- expect(buttons[0]).toMatchObject({
93
- name: 'quick_reply',
94
- buttonParamsJson: JSON.stringify({ id: 'approve:r1', display_text: '✅ 同意' }),
95
- });
96
- expect(buttons[1]).toMatchObject({
97
- name: 'quick_reply',
98
- buttonParamsJson: JSON.stringify({ id: 'reject:r1', display_text: '🚫 拒绝' }),
99
- });
100
- });
101
-
102
- it('空数组 → 空 buttons', () => {
103
- expect(buildNativeFlowButtons([])).toEqual([]);
104
- });
105
- });
106
-
107
- describe('parseNativeButtonResponse(交互按钮响应解析)', () => {
108
- it('解析 paramsJson 中的 id', () => {
109
- const raw: RawWhatsAppMessage = {
110
- key: { remoteJid: '60123@s.whatsapp.net' },
111
- message: {
112
- interactiveResponseMessage: {
113
- nativeFlowResponseMessage: { paramsJson: JSON.stringify({ id: 'approve:r1' }) },
114
- },
115
- },
116
- messageType: 'interactiveResponseMessage',
117
- };
118
- expect(parseNativeButtonResponse(raw)).toBe('approve:r1');
119
- });
120
-
121
- it('无交互响应 / 非法 JSON / 缺 id 均返回 null', () => {
122
- expect(parseNativeButtonResponse({ key: { remoteJid: 'x' }, message: { conversation: 'hi' } })).toBeNull();
123
- expect(parseNativeButtonResponse({ key: { remoteJid: 'x' }, message: {} })).toBeNull();
124
- expect(parseNativeButtonResponse({ key: { remoteJid: 'x' } })).toBeNull();
125
- expect(
126
- parseNativeButtonResponse({
127
- key: { remoteJid: 'x' },
128
- message: { interactiveResponseMessage: { nativeFlowResponseMessage: { paramsJson: 'not-json' } } },
129
- }),
130
- ).toBeNull();
131
- expect(
132
- parseNativeButtonResponse({
133
- key: { remoteJid: 'x' },
134
- message: { interactiveResponseMessage: { nativeFlowResponseMessage: { paramsJson: JSON.stringify({ foo: 'bar' }) } } },
135
- }),
136
- ).toBeNull();
137
- });
138
- });
package/test/asr.test.ts DELETED
@@ -1,77 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest';
2
- import { createTranscriber, extensionForMime } from '../src/asr.js';
3
-
4
- describe('extensionForMime', () => {
5
- it('maps known audio mimes', () => {
6
- expect(extensionForMime('audio/ogg')).toBe('ogg');
7
- expect(extensionForMime('audio/mpeg')).toBe('mp3');
8
- expect(extensionForMime('audio/mp4')).toBe('m4a');
9
- expect(extensionForMime('audio/wav')).toBe('wav');
10
- expect(extensionForMime('audio/webm')).toBe('webm');
11
- });
12
- it('ignores parameters and case', () => {
13
- expect(extensionForMime('audio/OGG; codecs=opus')).toBe('ogg');
14
- });
15
- it('falls back for unknown or missing mime', () => {
16
- expect(extensionForMime('application/x-foo')).toBe('oga');
17
- expect(extensionForMime(undefined)).toBe('oga');
18
- expect(extensionForMime('')).toBe('oga');
19
- });
20
- });
21
-
22
- describe('createTranscriber.transcribe', () => {
23
- afterEach(() => vi.unstubAllGlobals());
24
-
25
- it('未配置 API key 时禁用且不发起请求', async () => {
26
- const fetchMock = vi.fn()
27
- vi.stubGlobal('fetch', fetchMock)
28
- const asr = createTranscriber({})
29
- expect(asr.enabled).toBe(false)
30
- expect(await asr.transcribe({ url: 'https://x/v.ogg' })).toBeNull()
31
- expect(fetchMock).not.toHaveBeenCalled()
32
- })
33
-
34
- it('下载音频并调用 OpenAI 兼容转写端点,返回文本', async () => {
35
- const fetchMock = vi.fn()
36
- .mockResolvedValueOnce({ ok: true, blob: async () => new Blob(['fake-audio']) })
37
- .mockResolvedValueOnce({ ok: true, json: async () => ({ text: ' 你好世界 ' }) })
38
- vi.stubGlobal('fetch', fetchMock)
39
- const asr = createTranscriber({ apiKey: 'sk-test', baseUrl: 'https://api.siliconflow.cn/v1', model: 'whisper-1' })
40
- expect(asr.enabled).toBe(true)
41
- const text = await asr.transcribe({ url: 'https://x/v.ogg', mime: 'audio/ogg' })
42
- expect(text).toBe('你好世界')
43
-
44
- // 第一次 fetch:下载音频;第二次:转写端点
45
- expect(fetchMock.mock.calls[0][0]).toBe('https://x/v.ogg')
46
- const [url, init] = fetchMock.mock.calls[1]
47
- expect(String(url)).toBe('https://api.siliconflow.cn/v1/audio/transcriptions')
48
- expect(init.method).toBe('POST')
49
- expect(init.headers.authorization).toBe('Bearer sk-test')
50
- expect(init.body).toBeInstanceOf(FormData)
51
- const filename = (init.body as FormData).get('file') as File
52
- expect(filename.name).toBe('voice.ogg')
53
- })
54
-
55
- it('下载失败返回 null(不抛错)', async () => {
56
- vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 403 }))
57
- const asr = createTranscriber({ apiKey: 'sk-test' })
58
- expect(await asr.transcribe({ url: 'https://x/v.ogg' })).toBeNull()
59
- })
60
-
61
- it('转写端点失败返回 null', async () => {
62
- const fetchMock = vi.fn()
63
- .mockResolvedValueOnce({ ok: true, blob: async () => new Blob(['x']) })
64
- .mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'server error' })
65
- vi.stubGlobal('fetch', fetchMock)
66
- const asr = createTranscriber({ apiKey: 'sk-test' })
67
- expect(await asr.transcribe({ url: 'https://x/v.ogg' })).toBeNull()
68
- })
69
-
70
- it('无 url 返回 null', async () => {
71
- const fetchMock = vi.fn()
72
- vi.stubGlobal('fetch', fetchMock)
73
- const asr = createTranscriber({ apiKey: 'sk-test' })
74
- expect(await asr.transcribe({ mime: 'audio/ogg' })).toBeNull()
75
- expect(fetchMock).not.toHaveBeenCalled()
76
- })
77
- })
@@ -1,43 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { parseCommand, type ParsedCommand } from '../src/commands.js';
3
-
4
- describe('parseCommand', () => {
5
- it('识别 /trace、/task、/cron、/agents、/new、/help', () => {
6
- expect(parseCommand('/trace')).toEqual({ kind: 'trace' });
7
- expect(parseCommand('/new')).toEqual({ kind: 'new' });
8
- expect(parseCommand('/agents')).toEqual({ kind: 'agents' });
9
- expect(parseCommand('/help')).toEqual({ kind: 'help' });
10
- expect(parseCommand('/task 调研竞品')).toEqual({ kind: 'task', prompt: '调研竞品' });
11
- expect(parseCommand('/cron 0 8 * * * 每日汇报')).toEqual({ kind: 'cron', schedule: '0 8 * * *', prompt: '每日汇报' });
12
- });
13
- it('识别 /crons 与 /cronrm', () => {
14
- expect(parseCommand('/crons')).toEqual({ kind: 'crons' });
15
- expect(parseCommand('/cronrm cron-1')).toEqual({ kind: 'cronrm', taskId: 'cron-1' });
16
- expect(parseCommand('/cronrm cron-123-abc')).toEqual({ kind: 'cronrm', taskId: 'cron-123-abc' });
17
- });
18
- it('非命令返回 null', () => {
19
- expect(parseCommand('hello')).toBeNull();
20
- expect(parseCommand('/unknown')).toBeNull();
21
- expect(parseCommand('/task')).toBeNull(); // 缺参数
22
- expect(parseCommand('/cronrm')).toBeNull(); // 缺任务 id
23
- });
24
- it('识别 /remember /recall /forget', () => {
25
- expect(parseCommand('/remember 用户喜欢美式咖啡')).toEqual({ kind: 'remember', text: '用户喜欢美式咖啡' });
26
- expect(parseCommand('/recall 咖啡')).toEqual({ kind: 'recall', query: '咖啡' });
27
- expect(parseCommand('/recall')).toEqual({ kind: 'recall', query: '' });
28
- expect(parseCommand('/forget abc123')).toEqual({ kind: 'forget', memoryId: 'abc123' });
29
- expect(parseCommand('/remember')).toBeNull(); // 缺内容
30
- });
31
- it('识别 /remind(相对时间与定点时间)', () => {
32
- expect(parseCommand('/remind in 10 分钟 喝水')).toEqual({ kind: 'remind', text: '喝水', inMinutes: 10, atTime: null });
33
- expect(parseCommand('/remind in 2 小时 开会')).toEqual({ kind: 'remind', text: '开会', inMinutes: 120, atTime: null });
34
- expect(parseCommand('/remind in 30 minutes 散步')).toEqual({ kind: 'remind', text: '散步', inMinutes: 30, atTime: null });
35
- expect(parseCommand('/remind in 1 day 汇报')).toEqual({ kind: 'remind', text: '汇报', inMinutes: 1440, atTime: null });
36
- expect(parseCommand('/remind at 14:30 开会')).toEqual({ kind: 'remind', text: '开会', inMinutes: null, atTime: '14:30' });
37
- });
38
- it('识别 /send 与 /status', () => {
39
- expect(parseCommand('/send /tmp/report.png')).toEqual({ kind: 'send', path: '/tmp/report.png' });
40
- expect(parseCommand('/status')).toEqual({ kind: 'status' });
41
- expect(parseCommand('/send')).toBeNull(); // 缺路径
42
- });
43
- });
@@ -1,30 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { createAdapter, parseAdapterIds, type AdapterEnv } from '../src/config.js';
3
-
4
- describe('parseAdapterIds', () => {
5
- it('逗号分隔 + 去空格 + 去空项', () => {
6
- expect(parseAdapterIds('cli, whatsapp, telegram,')).toEqual(['cli', 'whatsapp', 'telegram']);
7
- });
8
- it('缺省为 cli', () => {
9
- expect(parseAdapterIds('')).toEqual(['cli']);
10
- });
11
- });
12
-
13
- describe('createAdapter 注册表', () => {
14
- it('cli 恒可用', () => {
15
- const a = createAdapter('cli', {});
16
- expect(a.id).toBe('cli');
17
- });
18
- it('未知适配器抛错', () => {
19
- expect(() => createAdapter('nope', {})).toThrow(/unknown adapter/);
20
- });
21
- it('feishu 缺凭据抛错', () => {
22
- expect(() => createAdapter('feishu', {})).toThrow(/FEISHU_APP_ID/);
23
- });
24
- it('dingtalk 缺凭据抛错', () => {
25
- expect(() => createAdapter('dingtalk', {})).toThrow(/DINGTALK_CLIENT_ID/);
26
- });
27
- it('wecom 缺凭据抛错', () => {
28
- expect(() => createAdapter('wecom', {})).toThrow(/WECOM_CORP_ID/);
29
- });
30
- });
package/test/feed.test.ts DELETED
@@ -1,111 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import { FeedPoller, FeedStore, formatFeedItem, parseRss } from '../src/feed.js';
3
- import type { Adapter } from '../src/adapter.js';
4
-
5
- const SAMPLE_RSS = `<?xml version="1.0"?>
6
- <rss version="2.0"><channel>
7
- <title>Example Feed</title>
8
- <item>
9
- <title>First post</title>
10
- <link>https://x.com/1</link>
11
- <guid>g-1</guid>
12
- <pubDate>Mon, 18 Aug 2026 10:00:00 GMT</pubDate>
13
- </item>
14
- <item>
15
- <title>Second &amp; great post</title>
16
- <link>https://x.com/2</link>
17
- <guid>g-2</guid>
18
- </item>
19
- </channel></rss>`;
20
-
21
- describe('parseRss', () => {
22
- it('解析条目与实体解码', () => {
23
- const items = parseRss(SAMPLE_RSS);
24
- expect(items).toHaveLength(2);
25
- expect(items[0]).toMatchObject({ title: 'First post', link: 'https://x.com/1', guid: 'g-1', pubDate: 'Mon, 18 Aug 2026 10:00:00 GMT' });
26
- expect(items[1].title).toBe('Second & great post');
27
- });
28
- it('空/非法 XML 返回空', () => {
29
- expect(parseRss('')).toEqual([]);
30
- expect(parseRss('<rss></rss>')).toEqual([]);
31
- });
32
- it('CDATA 内容解码', () => {
33
- const xml = '<rss><channel><item><title><![CDATA[Hello <World>]]></title><guid>g</guid></item></channel></rss>';
34
- expect(parseRss(xml)[0].title).toBe('Hello <World>');
35
- });
36
- });
37
-
38
- describe('formatFeedItem', () => {
39
- it('带链接时输出 标题+链接', () => {
40
- expect(formatFeedItem({ title: 'A', link: 'https://a', guid: 'g' })).toContain('📰 A');
41
- expect(formatFeedItem({ title: 'A', link: 'https://a', guid: 'g' })).toContain('https://a');
42
- });
43
- });
44
-
45
- describe('FeedStore(内存模式)', () => {
46
- it('add / list / remove / updateLastGuid', () => {
47
- const store = new FeedStore();
48
- const feed = store.add('telegram', 'c1', 'https://feed.example/rss');
49
- expect(store.list()).toHaveLength(1);
50
- expect(feed.lastGuid).toBe('');
51
- store.updateLastGuid(feed.id, 'g-2');
52
- expect(store.list()[0].lastGuid).toBe('g-2');
53
- expect(store.remove(feed.id)).toBe(true);
54
- expect(store.list()).toHaveLength(0);
55
- expect(store.remove(feed.id)).toBe(false);
56
- });
57
- });
58
-
59
- describe('FeedPoller.pollOnce', () => {
60
- const fakeAdapter = { id: 'telegram', send: vi.fn(async () => {}) } as unknown as Adapter;
61
-
62
- it('首次订阅只建立游标不推送', async () => {
63
- const store = new FeedStore();
64
- const feed = store.add('telegram', 'c1', 'https://feed.example/rss');
65
- const poller = new FeedPoller(store, new Map([['telegram', fakeAdapter]]), {
66
- fetchImpl: vi.fn(async () => ({ ok: true, text: async () => SAMPLE_RSS }) as Response),
67
- });
68
- const pushed = await poller.pollOnce();
69
- expect(pushed).toBe(0);
70
- expect(store.list()[0].lastGuid).toBe('g-1');
71
- expect(fakeAdapter.send).not.toHaveBeenCalled();
72
- void feed;
73
- });
74
-
75
- it('游标之后的新条目按序推送', async () => {
76
- const store = new FeedStore();
77
- const feed = store.add('telegram', 'c1', 'https://feed.example/rss');
78
- // 首次订阅:游标建立在最新一条 g-1 上
79
- const poller1 = new FeedPoller(store, new Map([['telegram', fakeAdapter]]), {
80
- fetchImpl: vi.fn(async () => ({ ok: true, text: async () => SAMPLE_RSS }) as Response),
81
- });
82
- await poller1.pollOnce();
83
- expect(store.list()[0].lastGuid).toBe('g-1');
84
- fakeAdapter.send.mockClear();
85
- // 第二轮出现新条目 g-0(文档序最新在前)→ 只推 g-0
86
- const NEW_RSS = `<rss version="2.0"><channel><item><title>Brand new</title><link>https://x.com/0</link><guid>g-0</guid></item>${SAMPLE_RSS.slice(SAMPLE_RSS.indexOf('<item>'))}`;
87
- const poller2 = new FeedPoller(store, new Map([['telegram', fakeAdapter]]), {
88
- fetchImpl: vi.fn(async () => ({ ok: true, text: async () => NEW_RSS }) as Response),
89
- });
90
- const pushed = await poller2.pollOnce();
91
- expect(pushed).toBe(1);
92
- expect(fakeAdapter.send).toHaveBeenCalledWith('c1', expect.objectContaining({ text: expect.stringContaining('Brand new') }));
93
- expect(store.list()[0].lastGuid).toBe('g-0');
94
- });
95
-
96
- it('抓取失败不影响其他源', async () => {
97
- const store = new FeedStore();
98
- store.add('telegram', 'c1', 'https://bad.example');
99
- store.add('telegram', 'c1', 'https://good.example');
100
- const calls: string[] = [];
101
- const poller = new FeedPoller(store, new Map([['telegram', fakeAdapter]]), {
102
- fetchImpl: vi.fn(async (url: string) => {
103
- calls.push(url);
104
- if (url.includes('bad')) return { ok: false, status: 500 } as Response;
105
- return { ok: true, text: async () => SAMPLE_RSS } as Response;
106
- }),
107
- });
108
- await poller.pollOnce();
109
- expect(calls).toHaveLength(2);
110
- });
111
- });
@@ -1,79 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { MemoryStore, extractAutoMemories, formatMemories, memoryScope, searchMemories } from '../src/memory.js';
3
-
4
- describe('memoryScope', () => {
5
- it('按 platform:userId 作用域(记忆跟随用户跨频道)', () => {
6
- expect(memoryScope('telegram', 'u1')).toBe('telegram:u1');
7
- });
8
- });
9
-
10
- describe('searchMemories', () => {
11
- const entries = [
12
- { id: '1', text: '用户喜欢喝美式咖啡', createdAt: 'x' },
13
- { id: '2', text: '用户住在杭州', createdAt: 'x' },
14
- { id: '3', text: '项目用 TypeScript', createdAt: 'x' },
15
- ];
16
- it('任一关键词命中即返回', () => {
17
- expect(searchMemories(entries, '咖啡').map((e) => e.id)).toEqual(['1']);
18
- expect(searchMemories(entries, '杭州 咖啡').map((e) => e.id).sort()).toEqual(['1', '2']);
19
- });
20
- it('无匹配返回空', () => {
21
- expect(searchMemories(entries, '滑雪')).toEqual([]);
22
- });
23
- it('空查询返回全部', () => {
24
- expect(searchMemories(entries, '')).toHaveLength(3);
25
- });
26
- it('大小写不敏感', () => {
27
- expect(searchMemories(entries, 'typescript').map((e) => e.id)).toEqual(['3']);
28
- });
29
- });
30
-
31
- describe('formatMemories', () => {
32
- it('空列表返回空串', () => {
33
- expect(formatMemories([])).toBe('');
34
- });
35
- it('渲染注入块', () => {
36
- const text = formatMemories([{ id: '1', text: '用户住在杭州', createdAt: 'x' }]);
37
- expect(text).toContain('📌 相关记忆');
38
- expect(text).toContain('用户住在杭州');
39
- });
40
- });
41
-
42
- describe('MemoryStore(内存模式)', () => {
43
- it('add / list / search / remove 全流程', () => {
44
- const store = new MemoryStore(); // 无文件 = 纯内存
45
- const entry = store.add('telegram:u1', '用户喜欢喝美式咖啡');
46
- expect(store.count('telegram:u1')).toBe(1);
47
- expect(store.list('telegram:u1')[0].text).toBe('用户喜欢喝美式咖啡');
48
- expect(store.search('telegram:u1', '咖啡')).toHaveLength(1);
49
- expect(store.remove('telegram:u1', entry.id)).toBe(true);
50
- expect(store.count('telegram:u1')).toBe(0);
51
- expect(store.remove('telegram:u1', entry.id)).toBe(false);
52
- });
53
- it('不同作用域隔离', () => {
54
- const store = new MemoryStore();
55
- store.add('telegram:u1', 'A 的记忆');
56
- store.add('whatsapp:u1', 'B 的记忆');
57
- expect(store.list('telegram:u1')).toHaveLength(1);
58
- expect(store.list('whatsapp:u1')).toHaveLength(1);
59
- });
60
- });
61
-
62
- describe('extractAutoMemories', () => {
63
- it('识别自我事实(我叫/我住在/我喜欢/我的邮箱等)', () => {
64
- expect(extractAutoMemories('你好,我叫小明')).toEqual(['我叫小明']);
65
- expect(extractAutoMemories('我住在杭州')).toEqual(['我住在杭州']);
66
- expect(extractAutoMemories('我喜欢喝美式咖啡')).toEqual(['我喜欢喝美式咖啡']);
67
- expect(extractAutoMemories('我的邮箱是 a@b.com,麻烦发我')).toEqual(['我的邮箱是 a@b.com']);
68
- expect(extractAutoMemories('我的职业是产品经理')).toEqual(['我的职业是产品经理']);
69
- });
70
- it('普通消息不触发', () => {
71
- expect(extractAutoMemories('今天天气如何')).toEqual([]);
72
- expect(extractAutoMemories('帮我写个脚本')).toEqual([]);
73
- });
74
- it('同一消息多模式只取各自匹配', () => {
75
- const facts = extractAutoMemories('我叫小红,我住在上海');
76
- expect(facts).toContain('我叫小红');
77
- expect(facts).toContain('我住在上海');
78
- });
79
- });