@dsh-overdrive/gateway 0.3.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/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 -98
- package/src/config.ts +0 -104
- package/src/feed.ts +0 -190
- package/src/index.ts +0 -510
- package/src/memory.ts +0 -176
- package/src/mention.ts +0 -51
- package/src/pending-buttons.ts +0 -65
- package/src/session.ts +0 -23
- package/src/setup.ts +0 -252
- package/src/status.ts +0 -63
- package/src/text.ts +0 -32
- 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 -53
- package/test/config.test.ts +0 -30
- package/test/feed.test.ts +0 -111
- package/test/memory.test.ts +0 -79
- package/test/mention.test.ts +0 -54
- package/test/multi.test.ts +0 -284
- package/test/outbound.test.ts +0 -29
- package/test/pending-buttons.test.ts +0 -100
- package/test/session.test.ts +0 -26
- package/test/status.test.ts +0 -41
- package/test/streaming.test.ts +0 -162
- package/test/text.test.ts +0 -20
- package/test/trajectory.test.ts +0 -58
- package/tsconfig.json +0 -5
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
|
-
});
|
package/test/mention.test.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { isGroupChat, isMentioned, shouldRespond } from '../src/mention.js';
|
|
3
|
-
|
|
4
|
-
describe('isGroupChat', () => {
|
|
5
|
-
it('telegram 负 ID 为群', () => {
|
|
6
|
-
expect(isGroupChat('telegram', '-1001234567890')).toBe(true);
|
|
7
|
-
expect(isGroupChat('telegram', '-12345')).toBe(true);
|
|
8
|
-
expect(isGroupChat('telegram', '12345')).toBe(false);
|
|
9
|
-
});
|
|
10
|
-
it('whatsapp @g.us 为群', () => {
|
|
11
|
-
expect(isGroupChat('whatsapp', '123@ g.us'.replace(' ', ''))).toBe(true);
|
|
12
|
-
expect(isGroupChat('whatsapp', '123@s.whatsapp.net')).toBe(false);
|
|
13
|
-
});
|
|
14
|
-
it('slack D 开头为私聊', () => {
|
|
15
|
-
expect(isGroupChat('slack', 'D123')).toBe(false);
|
|
16
|
-
expect(isGroupChat('slack', 'C123')).toBe(true);
|
|
17
|
-
});
|
|
18
|
-
it('无法判定的平台默认视为私聊', () => {
|
|
19
|
-
expect(isGroupChat('discord', 'any')).toBe(false);
|
|
20
|
-
expect(isGroupChat('feishu', 'oc_1')).toBe(false);
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
describe('isMentioned', () => {
|
|
25
|
-
it('telegram/whatsapp @身份', () => {
|
|
26
|
-
expect(isMentioned('telegram', '@mybot 你好', 'mybot')).toBe(true);
|
|
27
|
-
expect(isMentioned('telegram', '你好', 'mybot')).toBe(false);
|
|
28
|
-
expect(isMentioned('whatsapp', '@8613800000000 hi', '8613800000000')).toBe(true);
|
|
29
|
-
});
|
|
30
|
-
it('discord/slack <@ID>', () => {
|
|
31
|
-
expect(isMentioned('discord', '<@123456> hello', '123456')).toBe(true);
|
|
32
|
-
expect(isMentioned('slack', 'hello <@U123>', 'U123')).toBe(true);
|
|
33
|
-
expect(isMentioned('discord', 'hello', '123456')).toBe(false);
|
|
34
|
-
});
|
|
35
|
-
it('无法检测的平台视为已提及', () => {
|
|
36
|
-
expect(isMentioned('feishu', '随便', 'x')).toBe(true);
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe('shouldRespond', () => {
|
|
41
|
-
const msg = (text: string) => ({ chatId: '-100123', userId: 'u', text });
|
|
42
|
-
it('未开启提及模式 → 始终响应', () => {
|
|
43
|
-
expect(shouldRespond('telegram', msg('随便'), { requireMention: false, botIdentity: 'b' })).toBe(true);
|
|
44
|
-
});
|
|
45
|
-
it('群聊未提及 → 不响应;提及 → 响应', () => {
|
|
46
|
-
const policy = { requireMention: true, botIdentity: 'mybot' };
|
|
47
|
-
expect(shouldRespond('telegram', msg('你好'), policy)).toBe(false);
|
|
48
|
-
expect(shouldRespond('telegram', msg('@mybot 你好'), policy)).toBe(true);
|
|
49
|
-
});
|
|
50
|
-
it('私聊始终响应', () => {
|
|
51
|
-
const policy = { requireMention: true, botIdentity: 'mybot' };
|
|
52
|
-
expect(shouldRespond('telegram', { chatId: '12345', userId: 'u', text: '你好' }, policy)).toBe(true);
|
|
53
|
-
});
|
|
54
|
-
});
|
package/test/multi.test.ts
DELETED
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { mediaKindFromPath, remindSchedule, wireAdapter } from '../src/index.js';
|
|
3
|
-
import { GatewayClient, type ServerEvent } from '@dsh-overdrive/sdk';
|
|
4
|
-
import { MemoryStore, TopicStore } from '../src/memory.js';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { tmpdir } from 'node:os';
|
|
7
|
-
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
-
import type { Adapter, NormalizedMessage, OutboundPayload, ReplySender } from '../src/adapter.js';
|
|
9
|
-
|
|
10
|
-
/** 可编程 FakeAdapter:验证 wiring 逻辑(白名单/会话键/错误兜底)。 */
|
|
11
|
-
class FakeAdapter implements Adapter {
|
|
12
|
-
readonly id: string;
|
|
13
|
-
readonly sent: Array<{ chatId: string; payload: OutboundPayload }> = [];
|
|
14
|
-
private messageCb?: (msg: NormalizedMessage) => Promise<void> | void;
|
|
15
|
-
private replyCb?: (buttonId: string, sender: ReplySender) => Promise<void> | void;
|
|
16
|
-
constructor(id: string) { this.id = id; }
|
|
17
|
-
async connect(): Promise<void> {}
|
|
18
|
-
async send(chatId: string, payload: OutboundPayload): Promise<void> { this.sent.push({ chatId, payload }); }
|
|
19
|
-
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
20
|
-
onReply(cb: (buttonId: string, sender: ReplySender) => void): void { this.replyCb = cb; }
|
|
21
|
-
/** 测试助手:返回处理器 Promise,测试 await 以确保 async 接线(upsert→sendMessage / catch 兜底)跑完 */
|
|
22
|
-
emit(msg: NormalizedMessage): Promise<void> | void { return this.messageCb?.(msg); }
|
|
23
|
-
click(buttonId: string, sender: ReplySender = { chatId: 'C1', userId: 'U1' }): Promise<void> | void {
|
|
24
|
-
return this.replyCb?.(buttonId, sender);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** 假 DSH:记录调用。 */
|
|
29
|
-
function fakeClient() {
|
|
30
|
-
const upserts: Array<{ platform: string; channel: string; user: string }> = [];
|
|
31
|
-
const messages: Array<{ sessionId: string; text: string }> = [];
|
|
32
|
-
const approvals: Array<{ reqId: string; decision: string }> = [];
|
|
33
|
-
const client = {
|
|
34
|
-
upsertSession: async (req: { platform: string; channel: string; user: string }) => {
|
|
35
|
-
upserts.push(req);
|
|
36
|
-
return { sessionId: `${req.platform}:${req.channel}:${req.user}` };
|
|
37
|
-
},
|
|
38
|
-
sendMessage: async (sessionId: string, req: { text: string }) => {
|
|
39
|
-
messages.push({ sessionId, text: req.text });
|
|
40
|
-
return { runId: 'r1' };
|
|
41
|
-
},
|
|
42
|
-
resolveApproval: async (reqId: string, decision: 'approve' | 'reject') => {
|
|
43
|
-
approvals.push({ reqId, decision });
|
|
44
|
-
return { ok: true };
|
|
45
|
-
},
|
|
46
|
-
listTasks: async () => ({ tasks: [] }),
|
|
47
|
-
// wireAdapter 末尾会订阅事件流(client.connect),假客户端需实现完整接口
|
|
48
|
-
connect: async () => () => undefined,
|
|
49
|
-
} as unknown as GatewayClient;
|
|
50
|
-
return { client, upserts, messages, approvals };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
describe('wireAdapter(多适配器装配核心)', () => {
|
|
54
|
-
it('白名单拦截并回错误文本', async () => {
|
|
55
|
-
const adapter = new FakeAdapter('telegram');
|
|
56
|
-
const { client } = fakeClient();
|
|
57
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'] });
|
|
58
|
-
|
|
59
|
-
await adapter.emit({ chatId: '999', userId: '999', text: 'hi' });
|
|
60
|
-
expect(adapter.sent[0].payload.text).toContain('⛔');
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('白名单内消息 → upsert + sendMessage', async () => {
|
|
64
|
-
const adapter = new FakeAdapter('telegram');
|
|
65
|
-
const { client, upserts, messages } = fakeClient();
|
|
66
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'] });
|
|
67
|
-
|
|
68
|
-
await adapter.emit({ chatId: '111', userId: '222', text: 'hello' });
|
|
69
|
-
expect(upserts).toEqual([{ platform: 'telegram', channel: '111', user: '222' }]);
|
|
70
|
-
expect(messages).toEqual([{ sessionId: 'telegram:111:222', text: 'hello' }]);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('按钮点击(白名单内)→ resolveApproval', async () => {
|
|
74
|
-
const adapter = new FakeAdapter('discord');
|
|
75
|
-
const { client, approvals } = fakeClient();
|
|
76
|
-
await wireAdapter(adapter, client, { allowlist: ['discord:C1:U1'] });
|
|
77
|
-
|
|
78
|
-
await adapter.click('approve:r1', { chatId: 'C1', userId: 'U1' });
|
|
79
|
-
expect(approvals).toEqual([{ reqId: 'r1', decision: 'approve' }]);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('按钮点击(白名单外)→ 拒绝批准并回错误文本', async () => {
|
|
83
|
-
const adapter = new FakeAdapter('discord');
|
|
84
|
-
const { client, approvals } = fakeClient();
|
|
85
|
-
await wireAdapter(adapter, client, { allowlist: ['discord:C1:U1'] });
|
|
86
|
-
|
|
87
|
-
await adapter.click('approve:r1', { chatId: 'C1', userId: 'EVIL' });
|
|
88
|
-
expect(approvals).toEqual([]); // 未授权用户不能批准
|
|
89
|
-
expect(adapter.sent[0].payload.text).toContain('⛔');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('空白名单 fail-closed:点击一律拒绝', async () => {
|
|
93
|
-
const adapter = new FakeAdapter('telegram');
|
|
94
|
-
const { client, approvals } = fakeClient();
|
|
95
|
-
await wireAdapter(adapter, client, { allowlist: [] });
|
|
96
|
-
|
|
97
|
-
await adapter.click('reject:r1', { chatId: 'C1', userId: 'U1' });
|
|
98
|
-
expect(approvals).toEqual([]);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('DSH 调用失败 → 回错误文本(不崩溃)', async () => {
|
|
102
|
-
const adapter = new FakeAdapter('slack');
|
|
103
|
-
const client = {
|
|
104
|
-
upsertSession: async () => { throw new Error('dsh down'); },
|
|
105
|
-
connect: async () => () => undefined,
|
|
106
|
-
} as unknown as GatewayClient;
|
|
107
|
-
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
108
|
-
|
|
109
|
-
await adapter.emit({ chatId: 'C1', userId: 'U1', text: 'hi' });
|
|
110
|
-
expect(adapter.sent[0].payload.text).toContain('❌');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('相关记忆自动注入到发给 agent 的文本(OpenClaw 式)', async () => {
|
|
114
|
-
const adapter = new FakeAdapter('telegram');
|
|
115
|
-
const { client, messages } = fakeClient();
|
|
116
|
-
const memory = new MemoryStore();
|
|
117
|
-
memory.add('telegram:222', '用户喜欢美式咖啡');
|
|
118
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
|
|
119
|
-
|
|
120
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '帮我点一杯咖啡' });
|
|
121
|
-
expect(messages[0].text).toContain('帮我点一杯咖啡');
|
|
122
|
-
expect(messages[0].text).toContain('📌 相关记忆');
|
|
123
|
-
expect(messages[0].text).toContain('用户喜欢美式咖啡');
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('无相关记忆时不注入', async () => {
|
|
127
|
-
const adapter = new FakeAdapter('telegram');
|
|
128
|
-
const { client, messages } = fakeClient();
|
|
129
|
-
const memory = new MemoryStore();
|
|
130
|
-
memory.add('telegram:222', '用户喜欢美式咖啡');
|
|
131
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
|
|
132
|
-
|
|
133
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '今天天气如何' });
|
|
134
|
-
expect(messages[0].text).toBe('今天天气如何');
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('/remember 命令写入记忆并回执', async () => {
|
|
138
|
-
const adapter = new FakeAdapter('telegram');
|
|
139
|
-
const { client } = fakeClient();
|
|
140
|
-
const memory = new MemoryStore();
|
|
141
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
|
|
142
|
-
|
|
143
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '/remember 用户住在杭州' });
|
|
144
|
-
expect(memory.count('telegram:222')).toBe(1);
|
|
145
|
-
expect(adapter.sent[0].payload.text).toContain('已记住');
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('自动记忆:自我事实消息自动沉淀', async () => {
|
|
149
|
-
const adapter = new FakeAdapter('telegram');
|
|
150
|
-
const { client } = fakeClient();
|
|
151
|
-
const memory = new MemoryStore();
|
|
152
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
|
|
153
|
-
|
|
154
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '你好,我叫小明,我住在杭州' });
|
|
155
|
-
expect(memory.count('telegram:222')).toBe(2);
|
|
156
|
-
expect(memory.list('telegram:222').map((e) => e.text)).toContain('我叫小明');
|
|
157
|
-
expect(memory.list('telegram:222').map((e) => e.text)).toContain('我住在杭州');
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('persona:每条消息前置人设', async () => {
|
|
161
|
-
const adapter = new FakeAdapter('telegram');
|
|
162
|
-
const { client, messages } = fakeClient();
|
|
163
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], persona: '你是一个毒舌助理' });
|
|
164
|
-
|
|
165
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '你好' });
|
|
166
|
-
expect(messages[0].text).toBe('【人设】你是一个毒舌助理\n你好');
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('file.created 事件:agent 产出的文件自动发回聊天并清理临时文件', async () => {
|
|
170
|
-
const adapter = new FakeAdapter('telegram');
|
|
171
|
-
let onEvent: ((ev: ServerEvent) => void) | undefined;
|
|
172
|
-
const client = {
|
|
173
|
-
upsertSession: async () => ({ sessionId: 'telegram:111:222' }),
|
|
174
|
-
connect: async (cb: (ev: ServerEvent) => void) => { onEvent = cb; return () => {}; },
|
|
175
|
-
} as unknown as GatewayClient;
|
|
176
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'] });
|
|
177
|
-
|
|
178
|
-
const bytes = Buffer.from('fake-image-bytes');
|
|
179
|
-
onEvent!({
|
|
180
|
-
type: 'file.created', sessionId: 'telegram:111:222', ts: Date.now(),
|
|
181
|
-
name: 'chart.png', kind: 'image', data: bytes.toString('base64'),
|
|
182
|
-
});
|
|
183
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
184
|
-
expect(adapter.sent[0].payload.text).toContain('chart.png');
|
|
185
|
-
expect(adapter.sent[0].payload.media).toMatchObject({ kind: 'image', caption: 'chart.png' });
|
|
186
|
-
expect(existsSync(adapter.sent[0].payload.media!.path)).toBe(false); // 临时文件已清理
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('群聊提及模式:群聊未提及不响应,提及才响应', async () => {
|
|
190
|
-
const adapter = new FakeAdapter('telegram');
|
|
191
|
-
const { client, messages } = fakeClient();
|
|
192
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:-100123:222'], requireMention: true, botIdentity: 'mybot' });
|
|
193
|
-
|
|
194
|
-
await adapter.emit({ chatId: '-100123', userId: '222', text: '你好' }); // 群聊(负 ID)未提及
|
|
195
|
-
expect(messages).toHaveLength(0);
|
|
196
|
-
await adapter.emit({ chatId: '-100123', userId: '222', text: '@mybot 你好' }); // 提及
|
|
197
|
-
expect(messages).toHaveLength(1);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('/context 设置/清除会话主题并注入到消息', async () => {
|
|
201
|
-
const adapter = new FakeAdapter('telegram');
|
|
202
|
-
const { client, messages } = fakeClient();
|
|
203
|
-
const topics = new TopicStore();
|
|
204
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], topics });
|
|
205
|
-
|
|
206
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '/context 项目重构' });
|
|
207
|
-
expect(adapter.sent[0].payload.text).toContain('已绑定');
|
|
208
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '帮我写周报' });
|
|
209
|
-
expect(messages[0].text).toBe('【会话主题】项目重构\n帮我写周报');
|
|
210
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '/context off' });
|
|
211
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '你好' });
|
|
212
|
-
expect(messages[1].text).toBe('你好');
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('/send <path> 读取文件并以媒体载荷发送', async () => {
|
|
216
|
-
const adapter = new FakeAdapter('telegram');
|
|
217
|
-
const { client } = fakeClient();
|
|
218
|
-
const tmp = join(tmpdir(), `dsh-send-test-${Date.now()}.png`);
|
|
219
|
-
writeFileSync(tmp, Buffer.from([1, 2, 3]));
|
|
220
|
-
try {
|
|
221
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'] });
|
|
222
|
-
await adapter.emit({ chatId: '111', userId: '222', text: `/send ${tmp}` });
|
|
223
|
-
expect(adapter.sent[0].payload.media).toMatchObject({ kind: 'image', path: tmp });
|
|
224
|
-
expect(adapter.sent[0].payload.text).toContain('📎');
|
|
225
|
-
} finally {
|
|
226
|
-
rmSync(tmp, { force: true });
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it('/send 不存在的文件回错误文本', async () => {
|
|
231
|
-
const adapter = new FakeAdapter('telegram');
|
|
232
|
-
const { client } = fakeClient();
|
|
233
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'] });
|
|
234
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '/send /no/such/file.png' });
|
|
235
|
-
expect(adapter.sent[0].payload.text).toContain('❌');
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it('/status 返回适配器/记忆/定时任务概览', async () => {
|
|
239
|
-
const adapter = new FakeAdapter('telegram');
|
|
240
|
-
const { client } = fakeClient();
|
|
241
|
-
const memory = new MemoryStore();
|
|
242
|
-
memory.add('telegram:222', 'x');
|
|
243
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
|
|
244
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '/status' });
|
|
245
|
-
expect(adapter.sent[0].payload.text).toContain('📊 状态');
|
|
246
|
-
expect(adapter.sent[0].payload.text).toContain('telegram');
|
|
247
|
-
expect(adapter.sent[0].payload.text).toContain('记忆: 1 条');
|
|
248
|
-
});
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
describe('remindSchedule', () => {
|
|
252
|
-
it('相对分钟 → cron 5 字段', () => {
|
|
253
|
-
const now = new Date('2026-08-20T10:05:00');
|
|
254
|
-
expect(remindSchedule(10, null, now)).toBe('15 10 20 8 *');
|
|
255
|
-
});
|
|
256
|
-
it('定点时间 → cron;已过则推到明天', () => {
|
|
257
|
-
const now = new Date('2026-08-20T10:05:00');
|
|
258
|
-
expect(remindSchedule(0, '14:30', now)).toBe('30 14 20 8 *');
|
|
259
|
-
expect(remindSchedule(0, '09:00', now)).toBe('0 9 21 8 *');
|
|
260
|
-
});
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
describe('mediaKindFromPath', () => {
|
|
264
|
-
it('图片/语音/其他文件分类', () => {
|
|
265
|
-
expect(mediaKindFromPath('a.png')).toBe('image');
|
|
266
|
-
expect(mediaKindFromPath('b.JPG')).toBe('image');
|
|
267
|
-
expect(mediaKindFromPath('c.webp')).toBe('image');
|
|
268
|
-
expect(mediaKindFromPath('v.ogg')).toBe('voice');
|
|
269
|
-
expect(mediaKindFromPath('r.pdf')).toBe('file');
|
|
270
|
-
expect(mediaKindFromPath('x.zip')).toBe('file');
|
|
271
|
-
});
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
describe('remindSchedule', () => {
|
|
275
|
-
it('相对分钟 → cron 5 字段', () => {
|
|
276
|
-
const now = new Date('2026-08-20T10:05:00');
|
|
277
|
-
expect(remindSchedule(10, null, now)).toBe('15 10 20 8 *');
|
|
278
|
-
});
|
|
279
|
-
it('定点时间 → cron;已过则推到明天', () => {
|
|
280
|
-
const now = new Date('2026-08-20T10:05:00');
|
|
281
|
-
expect(remindSchedule(0, '14:30', now)).toBe('30 14 20 8 *');
|
|
282
|
-
expect(remindSchedule(0, '09:00', now)).toBe('0 9 21 8 *');
|
|
283
|
-
});
|
|
284
|
-
});
|
package/test/outbound.test.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { planOutbound } from '../src/index.js';
|
|
3
|
-
import type { ServerEvent } from '@dsh-overdrive/sdk';
|
|
4
|
-
|
|
5
|
-
describe('planOutbound(事件 → 平台输出)', () => {
|
|
6
|
-
it('message.complete → 纯文本', () => {
|
|
7
|
-
const ev: ServerEvent = { type: 'message.complete', sessionId: 'cli:cli:local', ts: 1, text: '结果' };
|
|
8
|
-
expect(planOutbound(ev)?.payload).toEqual({ text: '结果' });
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('trajectory.step → 带图标的轨迹行', () => {
|
|
12
|
-
const ev: ServerEvent = { type: 'trajectory.step', sessionId: 'cli:cli:local', ts: 1, step: { kind: 'tool', label: 'grep' } };
|
|
13
|
-
expect(planOutbound(ev)?.payload.text).toBe('🛠️ grep');
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it('approval.request → 文本 + 同意/拒绝两个按钮', () => {
|
|
17
|
-
const ev: ServerEvent = { type: 'approval.request', sessionId: 'cli:cli:local', ts: 1, reqId: 'r1', summary: '删除文件', timeoutMs: 60000 };
|
|
18
|
-
const out = planOutbound(ev)!;
|
|
19
|
-
expect(out.payload.text).toContain('删除文件');
|
|
20
|
-
expect(out.payload.buttons).toHaveLength(2);
|
|
21
|
-
expect(out.payload.buttons![0].id).toBe('approve:r1');
|
|
22
|
-
expect(out.payload.buttons![1].id).toBe('reject:r1');
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('message.delta 不输出(MVP 等 complete)', () => {
|
|
26
|
-
const ev: ServerEvent = { type: 'message.delta', sessionId: 'cli:cli:local', ts: 1, text: '…' };
|
|
27
|
-
expect(planOutbound(ev)).toBeNull();
|
|
28
|
-
});
|
|
29
|
-
});
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { PendingButtons, PENDING_BUTTONS_TTL_MS } from '../src/pending-buttons.js';
|
|
3
|
-
|
|
4
|
-
describe('PendingButtons', () => {
|
|
5
|
-
it('数字回复命中并消费', () => {
|
|
6
|
-
const pb = new PendingButtons();
|
|
7
|
-
pb.set('chat-1', [
|
|
8
|
-
{ id: 'approve:1', label: '同意' },
|
|
9
|
-
{ id: 'reject:1', label: '拒绝' },
|
|
10
|
-
]);
|
|
11
|
-
expect(pb.match('chat-1', '2')).toEqual({ id: 'reject:1', label: '拒绝' });
|
|
12
|
-
expect(pb.match('chat-1', '1')).toBeUndefined(); // 已消费
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('非数字 / 越界不消费,按钮仍有效', () => {
|
|
16
|
-
const pb = new PendingButtons();
|
|
17
|
-
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
18
|
-
expect(pb.match('chat-1', 'hello')).toBeUndefined();
|
|
19
|
-
expect(pb.match('chat-1', '5')).toBeUndefined();
|
|
20
|
-
expect(pb.match('chat-1', '1')).toEqual({ id: 'a', label: '同意' });
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('过期后自动失效(不吞后续数字消息)', () => {
|
|
24
|
-
vi.useFakeTimers();
|
|
25
|
-
try {
|
|
26
|
-
const pb = new PendingButtons(1000);
|
|
27
|
-
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
28
|
-
vi.advanceTimersByTime(1001);
|
|
29
|
-
expect(pb.match('chat-1', '1')).toBeUndefined();
|
|
30
|
-
} finally {
|
|
31
|
-
vi.useRealTimers();
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('TTL 内不失效', () => {
|
|
36
|
-
vi.useFakeTimers();
|
|
37
|
-
try {
|
|
38
|
-
const pb = new PendingButtons(PENDING_BUTTONS_TTL_MS);
|
|
39
|
-
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
40
|
-
vi.advanceTimersByTime(PENDING_BUTTONS_TTL_MS - 1);
|
|
41
|
-
expect(pb.match('chat-1', '1')).toEqual({ id: 'a', label: '同意' });
|
|
42
|
-
} finally {
|
|
43
|
-
vi.useRealTimers();
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('consume 删除 pending(原生按钮点击)', () => {
|
|
48
|
-
const pb = new PendingButtons();
|
|
49
|
-
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
50
|
-
pb.consume('chat-1');
|
|
51
|
-
expect(pb.match('chat-1', '1')).toBeUndefined();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('不同 chat 互不影响', () => {
|
|
55
|
-
const pb = new PendingButtons();
|
|
56
|
-
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
57
|
-
pb.set('chat-2', [{ id: 'b', label: '拒绝' }]);
|
|
58
|
-
expect(pb.match('chat-2', '1')).toEqual({ id: 'b', label: '拒绝' });
|
|
59
|
-
expect(pb.match('chat-1', '1')).toEqual({ id: 'a', label: '同意' });
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('文字审批关键词:批准/同意/yes → approve 按钮', () => {
|
|
63
|
-
const pb = new PendingButtons();
|
|
64
|
-
pb.set('chat-1', [
|
|
65
|
-
{ id: 'approve:r1', label: '✅ 同意' },
|
|
66
|
-
{ id: 'reject:r1', label: '🚫 拒绝' },
|
|
67
|
-
]);
|
|
68
|
-
expect(pb.match('chat-1', '批准')).toEqual({ id: 'approve:r1', label: '✅ 同意' });
|
|
69
|
-
pb.set('chat-1', [
|
|
70
|
-
{ id: 'approve:r2', label: '✅ 同意' },
|
|
71
|
-
{ id: 'reject:r2', label: '🚫 拒绝' },
|
|
72
|
-
]);
|
|
73
|
-
expect(pb.match('chat-1', 'yes')).toEqual({ id: 'approve:r2', label: '✅ 同意' });
|
|
74
|
-
pb.set('chat-1', [
|
|
75
|
-
{ id: 'approve:r3', label: '✅ 同意' },
|
|
76
|
-
{ id: 'reject:r3', label: '🚫 拒绝' },
|
|
77
|
-
]);
|
|
78
|
-
expect(pb.match('chat-1', '同意 好的')).toEqual({ id: 'approve:r3', label: '✅ 同意' });
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('文字审批关键词:拒绝/no → reject 按钮', () => {
|
|
82
|
-
const pb = new PendingButtons();
|
|
83
|
-
pb.set('chat-1', [
|
|
84
|
-
{ id: 'approve:r1', label: '✅ 同意' },
|
|
85
|
-
{ id: 'reject:r1', label: '🚫 拒绝' },
|
|
86
|
-
]);
|
|
87
|
-
expect(pb.match('chat-1', '拒绝')).toEqual({ id: 'reject:r1', label: '🚫 拒绝' });
|
|
88
|
-
expect(pb.match('chat-1', 'no')).toBeUndefined(); // 已消费
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('关键词审批与数字互斥、无关文本不命中', () => {
|
|
92
|
-
const pb = new PendingButtons();
|
|
93
|
-
pb.set('chat-1', [
|
|
94
|
-
{ id: 'approve:r1', label: '✅ 同意' },
|
|
95
|
-
{ id: 'reject:r1', label: '🚫 拒绝' },
|
|
96
|
-
]);
|
|
97
|
-
expect(pb.match('chat-1', '拒绝批准')).toBeUndefined(); // 复合词不命中关键词
|
|
98
|
-
expect(pb.match('chat-1', '2')).toEqual({ id: 'reject:r1', label: '🚫 拒绝' }); // 数字仍可用
|
|
99
|
-
});
|
|
100
|
-
});
|