@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.
- package/dist/commands.d.ts +5 -0
- package/dist/commands.js +14 -4
- package/dist/commands.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +57 -8
- package/dist/index.js.map +1 -1
- package/dist/memory.d.ts +10 -0
- package/dist/memory.js +43 -0
- package/dist/memory.js.map +1 -1
- package/dist/mention.d.ts +13 -0
- package/dist/mention.js +43 -0
- package/dist/mention.js.map +1 -0
- package/dist/pending-buttons.d.ts +5 -1
- package/dist/pending-buttons.js +26 -8
- package/dist/pending-buttons.js.map +1 -1
- package/dist/text.d.ts +2 -0
- package/dist/text.js +24 -0
- package/dist/text.js.map +1 -0
- package/package.json +9 -4
- package/src/adapter.ts +0 -42
- package/src/adapters/cli.ts +0 -37
- package/src/adapters/dingtalk.ts +0 -206
- package/src/adapters/discord.ts +0 -127
- package/src/adapters/feishu.ts +0 -224
- package/src/adapters/slack.ts +0 -123
- package/src/adapters/telegram.ts +0 -142
- package/src/adapters/wechat.ts +0 -247
- package/src/adapters/wecom.ts +0 -218
- package/src/adapters/whatsapp.ts +0 -249
- package/src/asr.ts +0 -83
- package/src/commands.ts +0 -89
- package/src/config.ts +0 -104
- package/src/feed.ts +0 -190
- package/src/index.ts +0 -456
- package/src/memory.ts +0 -133
- package/src/pending-buttons.ts +0 -45
- package/src/session.ts +0 -23
- package/src/setup.ts +0 -252
- package/src/status.ts +0 -63
- package/src/trajectory.ts +0 -45
- package/test/adapters.dingtalk.test.ts +0 -64
- package/test/adapters.discord.test.ts +0 -41
- package/test/adapters.feishu.test.ts +0 -66
- package/test/adapters.slack.test.ts +0 -45
- package/test/adapters.telegram.test.ts +0 -37
- package/test/adapters.wechat.test.ts +0 -78
- package/test/adapters.wecom.test.ts +0 -62
- package/test/adapters.whatsapp.test.ts +0 -138
- package/test/asr.test.ts +0 -77
- package/test/commands.test.ts +0 -43
- package/test/config.test.ts +0 -30
- package/test/feed.test.ts +0 -111
- package/test/memory.test.ts +0 -79
- package/test/multi.test.ts +0 -258
- package/test/outbound.test.ts +0 -29
- package/test/pending-buttons.test.ts +0 -61
- package/test/session.test.ts +0 -26
- package/test/status.test.ts +0 -41
- package/test/streaming.test.ts +0 -162
- package/test/trajectory.test.ts +0 -58
- 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
|
-
})
|
package/test/commands.test.ts
DELETED
|
@@ -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
|
-
});
|
package/test/config.test.ts
DELETED
|
@@ -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 & 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
|
-
});
|
package/test/memory.test.ts
DELETED
|
@@ -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
|
-
});
|