@dsh-overdrive/gateway 0.1.4 → 0.1.6
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/adapter.d.ts +7 -1
- package/dist/adapters/cli.d.ts +4 -1
- package/dist/adapters/cli.js +1 -1
- package/dist/adapters/cli.js.map +1 -1
- package/dist/adapters/dingtalk.d.ts +13 -5
- package/dist/adapters/dingtalk.js +27 -15
- package/dist/adapters/dingtalk.js.map +1 -1
- package/dist/adapters/discord.d.ts +4 -1
- package/dist/adapters/discord.js +4 -1
- package/dist/adapters/discord.js.map +1 -1
- package/dist/adapters/feishu.d.ts +4 -1
- package/dist/adapters/feishu.js +9 -3
- package/dist/adapters/feishu.js.map +1 -1
- package/dist/adapters/slack.d.ts +4 -1
- package/dist/adapters/slack.js +8 -3
- package/dist/adapters/slack.js.map +1 -1
- package/dist/adapters/telegram.d.ts +4 -1
- package/dist/adapters/telegram.js +5 -1
- package/dist/adapters/telegram.js.map +1 -1
- package/dist/adapters/wechat.d.ts +63 -0
- package/dist/adapters/wechat.js +231 -0
- package/dist/adapters/wechat.js.map +1 -0
- package/dist/adapters/wecom.d.ts +4 -1
- package/dist/adapters/wecom.js +1 -1
- package/dist/adapters/wecom.js.map +1 -1
- package/dist/adapters/whatsapp.d.ts +4 -1
- package/dist/adapters/whatsapp.js +8 -2
- package/dist/adapters/whatsapp.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +20 -5
- package/dist/index.js.map +1 -1
- package/dist/session.d.ts +6 -2
- package/dist/session.js +8 -3
- package/dist/session.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter.ts +8 -1
- package/src/adapters/cli.ts +3 -3
- package/src/adapters/dingtalk.ts +37 -17
- package/src/adapters/discord.ts +6 -3
- package/src/adapters/feishu.ts +16 -5
- package/src/adapters/slack.ts +14 -4
- package/src/adapters/telegram.ts +7 -3
- package/src/adapters/wechat.ts +247 -0
- package/src/adapters/wecom.ts +3 -3
- package/src/adapters/whatsapp.ts +10 -4
- package/src/config.ts +8 -0
- package/src/index.ts +22 -5
- package/src/session.ts +9 -3
- package/test/adapters.dingtalk.test.ts +11 -5
- package/test/adapters.wechat.test.ts +78 -0
- package/test/multi.test.ts +29 -8
- package/test/session.test.ts +7 -2
- package/test/streaming.test.ts +162 -162
package/src/index.ts
CHANGED
|
@@ -65,6 +65,8 @@ export function planOutbound(ev: ServerEvent): { payload: OutboundPayload } | nu
|
|
|
65
65
|
|
|
66
66
|
export interface WireOptions {
|
|
67
67
|
allowlist: string[];
|
|
68
|
+
/** 开发逃生口:ALLOW_ALL=1 跳过白名单(生产勿开)。 */
|
|
69
|
+
allowAll?: boolean;
|
|
68
70
|
/** ASR 转写器;配置了 API key 时启用,语音消息转成文本再发给 agent。 */
|
|
69
71
|
asr?: AsrTranscriber;
|
|
70
72
|
}
|
|
@@ -102,7 +104,12 @@ async function handleCommand(
|
|
|
102
104
|
case 'crons': {
|
|
103
105
|
const res = await client.listTasks();
|
|
104
106
|
const text = res.tasks.length
|
|
105
|
-
? res.tasks.map((task) =>
|
|
107
|
+
? res.tasks.map((task) => {
|
|
108
|
+
const next = task.nextRunAt
|
|
109
|
+
? new Date(task.nextRunAt).toLocaleString('zh-CN', { hour12: false })
|
|
110
|
+
: '(无下次触发)';
|
|
111
|
+
return `- \`${task.id}\` ${task.schedule} — ${task.prompt}(下次 ${next})`;
|
|
112
|
+
}).join('\n')
|
|
106
113
|
: '暂无定时任务。';
|
|
107
114
|
await adapter.send(chatId, { text: `⏰ 定时任务(${res.tasks.length}):\n${text}` });
|
|
108
115
|
return;
|
|
@@ -131,7 +138,7 @@ export async function wireAdapter(
|
|
|
131
138
|
client: GatewayClient,
|
|
132
139
|
opts: WireOptions,
|
|
133
140
|
): Promise<void> {
|
|
134
|
-
const allow = new Allowlist(opts.allowlist);
|
|
141
|
+
const allow = new Allowlist(opts.allowlist, opts.allowAll);
|
|
135
142
|
const chatIds = new Map<string, string>();
|
|
136
143
|
const aggregator = new TrajectoryAggregator();
|
|
137
144
|
const deltas = new DeltaTracker();
|
|
@@ -174,13 +181,22 @@ export async function wireAdapter(
|
|
|
174
181
|
}
|
|
175
182
|
});
|
|
176
183
|
|
|
177
|
-
adapter.onReply(async (buttonId) => {
|
|
184
|
+
adapter.onReply(async (buttonId, sender) => {
|
|
178
185
|
try {
|
|
179
186
|
const idx = buttonId.indexOf(':');
|
|
180
187
|
if (idx < 0) return;
|
|
181
188
|
const action = buttonId.slice(0, idx) as 'approve' | 'reject';
|
|
182
189
|
const reqId = buttonId.slice(idx + 1);
|
|
183
190
|
if ((action === 'approve' || action === 'reject') && reqId) {
|
|
191
|
+
// 安全边界:审批按钮必须校验点击者(维护者评审指出:未授权用户可代授权)
|
|
192
|
+
const senderKey = buildSessionKey(adapter.id, { chatId: sender.chatId, userId: sender.userId });
|
|
193
|
+
if (!allow.allows(senderKey)) {
|
|
194
|
+
console.warn(`[gateway][${adapter.id}] 按钮点击者不在白名单,拒绝批准: ${senderKey}`);
|
|
195
|
+
if (sender.chatId) {
|
|
196
|
+
await adapter.send(sender.chatId, { text: '⛔ 你不在白名单里,不能批准该操作。' }).catch(() => undefined);
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
184
200
|
await client.resolveApproval(reqId, action);
|
|
185
201
|
}
|
|
186
202
|
} catch (error) {
|
|
@@ -218,9 +234,10 @@ export async function wireAdapter(
|
|
|
218
234
|
|
|
219
235
|
async function main(): Promise<void> {
|
|
220
236
|
const dshBaseUrl = process.env.DSH_BASE_URL ?? 'http://127.0.0.1:3191';
|
|
221
|
-
const dshToken = process.env.DSH_TOKEN ?? 'dev-token';
|
|
237
|
+
const dshToken = process.env.DSH_OVERDRIVE_TOKEN ?? process.env.DSH_TOKEN ?? 'dev-token';
|
|
222
238
|
const allowlist = (process.env.ALLOWLIST ?? '')
|
|
223
239
|
.split(',').map((s) => s.trim()).filter(Boolean);
|
|
240
|
+
const allowAll = process.env.ALLOW_ALL === '1';
|
|
224
241
|
const adapterIds = parseAdapterIds(process.env.GATEWAY_ADAPTERS ?? 'cli');
|
|
225
242
|
const env = adapterEnvFromProcess();
|
|
226
243
|
const asr = createTranscriber({
|
|
@@ -236,7 +253,7 @@ async function main(): Promise<void> {
|
|
|
236
253
|
const adapters: Adapter[] = adapterIds.map((id) => createAdapter(id, env));
|
|
237
254
|
for (const adapter of adapters) {
|
|
238
255
|
await adapter.connect();
|
|
239
|
-
await wireAdapter(adapter, client, { allowlist, asr });
|
|
256
|
+
await wireAdapter(adapter, client, { allowlist, allowAll, asr });
|
|
240
257
|
console.log(`[gateway] ${adapter.id} 适配器已就绪`);
|
|
241
258
|
}
|
|
242
259
|
|
package/src/session.ts
CHANGED
|
@@ -7,11 +7,17 @@ export function buildSessionKey(
|
|
|
7
7
|
return sessionKey(adapterId, msg.chatId, msg.userId);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* 白名单:默认 fail-closed —— 只有显式配置了条目才放行;
|
|
12
|
+
* 开发环境可用 ALLOW_ALL=1 显式放行所有(比空列表隐式放行安全得多)。
|
|
13
|
+
*/
|
|
11
14
|
export class Allowlist {
|
|
12
|
-
constructor(
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly entries: string[],
|
|
17
|
+
private readonly allowAll = false,
|
|
18
|
+
) {}
|
|
13
19
|
|
|
14
20
|
allows(key: string): boolean {
|
|
15
|
-
return this.entries.length
|
|
21
|
+
return this.allowAll || (this.entries.length > 0 && this.entries.includes(key));
|
|
16
22
|
}
|
|
17
23
|
}
|
|
@@ -37,13 +37,19 @@ describe('buildActionCard(钉钉 actionCard)', () => {
|
|
|
37
37
|
});
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
describe('parseCardCallback(TOPIC_CARD 回调载荷 → 按钮 id
|
|
40
|
+
describe('parseCardCallback(TOPIC_CARD 回调载荷 → 按钮 id + 身份)', () => {
|
|
41
41
|
it('识别 cardCallbackData 字段', () => {
|
|
42
|
-
expect(parseCardCallback({ cardPrivateData: { cardCallbackData: '{"action":"approve","reqId":"r1"}' } })).
|
|
42
|
+
expect(parseCardCallback({ cardPrivateData: { cardCallbackData: '{"action":"approve","reqId":"r1"}' } })).toMatchObject({ buttonId: 'approve:r1' });
|
|
43
43
|
});
|
|
44
44
|
it('识别 params / cardActionData 字段与嵌套结构', () => {
|
|
45
|
-
expect(parseCardCallback({ cardPrivateData: { params: '{"action":"reject","reqId":"r9"}' } })).
|
|
46
|
-
expect(parseCardCallback({ a: { b: { cardActionData: '{"action":"approve","reqId":"x"}' } } })).
|
|
45
|
+
expect(parseCardCallback({ cardPrivateData: { params: '{"action":"reject","reqId":"r9"}' } })).toMatchObject({ buttonId: 'reject:r9' });
|
|
46
|
+
expect(parseCardCallback({ a: { b: { cardActionData: '{"action":"approve","reqId":"x"}' } } })).toMatchObject({ buttonId: 'approve:x' });
|
|
47
|
+
});
|
|
48
|
+
it('带回会话与用户身份(用于白名单校验)', () => {
|
|
49
|
+
expect(parseCardCallback({
|
|
50
|
+
cardPrivateData: { cardCallbackData: '{"action":"approve","reqId":"r1"}', userId: 'u1' },
|
|
51
|
+
conversationId: 'cid1',
|
|
52
|
+
})).toEqual({ buttonId: 'approve:r1', chatId: 'cid1', userId: 'u1' });
|
|
47
53
|
});
|
|
48
54
|
it('非法载荷返回 null', () => {
|
|
49
55
|
expect(parseCardCallback(null)).toBeNull();
|
|
@@ -53,6 +59,6 @@ describe('parseCardCallback(TOPIC_CARD 回调载荷 → 按钮 id)', () => {
|
|
|
53
59
|
});
|
|
54
60
|
it('buttonCallbackData 与 parseCardCallback 往返一致', () => {
|
|
55
61
|
const data = buttonCallbackData({ id: 'reject:r7', label: 'x' });
|
|
56
|
-
expect(parseCardCallback({ cardPrivateData: { cardCallbackData: data } })).
|
|
62
|
+
expect(parseCardCallback({ cardPrivateData: { cardCallbackData: data } })).toMatchObject({ buttonId: 'reject:r7' });
|
|
57
63
|
});
|
|
58
64
|
});
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
});
|
package/test/multi.test.ts
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { wireAdapter } from '../src/index.js';
|
|
3
3
|
import { GatewayClient } from '@dsh-overdrive/sdk';
|
|
4
|
-
import type { Adapter, NormalizedMessage, OutboundPayload } from '../src/adapter.js';
|
|
4
|
+
import type { Adapter, NormalizedMessage, OutboundPayload, ReplySender } from '../src/adapter.js';
|
|
5
5
|
|
|
6
6
|
/** 可编程 FakeAdapter:验证 wiring 逻辑(白名单/会话键/错误兜底)。 */
|
|
7
7
|
class FakeAdapter implements Adapter {
|
|
8
8
|
readonly id: string;
|
|
9
9
|
readonly sent: Array<{ chatId: string; payload: OutboundPayload }> = [];
|
|
10
10
|
private messageCb?: (msg: NormalizedMessage) => Promise<void> | void;
|
|
11
|
-
private replyCb?: (buttonId: string) => Promise<void> | void;
|
|
11
|
+
private replyCb?: (buttonId: string, sender: ReplySender) => Promise<void> | void;
|
|
12
12
|
constructor(id: string) { this.id = id; }
|
|
13
13
|
async connect(): Promise<void> {}
|
|
14
14
|
async send(chatId: string, payload: OutboundPayload): Promise<void> { this.sent.push({ chatId, payload }); }
|
|
15
15
|
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
16
|
-
onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
|
|
16
|
+
onReply(cb: (buttonId: string, sender: ReplySender) => void): void { this.replyCb = cb; }
|
|
17
17
|
/** 测试助手:返回处理器 Promise,测试 await 以确保 async 接线(upsert→sendMessage / catch 兜底)跑完 */
|
|
18
18
|
emit(msg: NormalizedMessage): Promise<void> | void { return this.messageCb?.(msg); }
|
|
19
|
-
click(buttonId: string): Promise<void> | void {
|
|
19
|
+
click(buttonId: string, sender: ReplySender = { chatId: 'C1', userId: 'U1' }): Promise<void> | void {
|
|
20
|
+
return this.replyCb?.(buttonId, sender);
|
|
21
|
+
}
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/** 假 DSH:记录调用。 */
|
|
@@ -63,22 +65,41 @@ describe('wireAdapter(多适配器装配核心)', () => {
|
|
|
63
65
|
expect(messages).toEqual([{ sessionId: 'telegram:111:222', text: 'hello' }]);
|
|
64
66
|
});
|
|
65
67
|
|
|
66
|
-
it('
|
|
68
|
+
it('按钮点击(白名单内)→ resolveApproval', async () => {
|
|
67
69
|
const adapter = new FakeAdapter('discord');
|
|
68
70
|
const { client, approvals } = fakeClient();
|
|
69
|
-
await wireAdapter(adapter, client, { allowlist: [] });
|
|
71
|
+
await wireAdapter(adapter, client, { allowlist: ['discord:C1:U1'] });
|
|
70
72
|
|
|
71
|
-
await adapter.click('approve:r1');
|
|
73
|
+
await adapter.click('approve:r1', { chatId: 'C1', userId: 'U1' });
|
|
72
74
|
expect(approvals).toEqual([{ reqId: 'r1', decision: 'approve' }]);
|
|
73
75
|
});
|
|
74
76
|
|
|
77
|
+
it('按钮点击(白名单外)→ 拒绝批准并回错误文本', async () => {
|
|
78
|
+
const adapter = new FakeAdapter('discord');
|
|
79
|
+
const { client, approvals } = fakeClient();
|
|
80
|
+
await wireAdapter(adapter, client, { allowlist: ['discord:C1:U1'] });
|
|
81
|
+
|
|
82
|
+
await adapter.click('approve:r1', { chatId: 'C1', userId: 'EVIL' });
|
|
83
|
+
expect(approvals).toEqual([]); // 未授权用户不能批准
|
|
84
|
+
expect(adapter.sent[0].payload.text).toContain('⛔');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('空白名单 fail-closed:点击一律拒绝', async () => {
|
|
88
|
+
const adapter = new FakeAdapter('telegram');
|
|
89
|
+
const { client, approvals } = fakeClient();
|
|
90
|
+
await wireAdapter(adapter, client, { allowlist: [] });
|
|
91
|
+
|
|
92
|
+
await adapter.click('reject:r1', { chatId: 'C1', userId: 'U1' });
|
|
93
|
+
expect(approvals).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
75
96
|
it('DSH 调用失败 → 回错误文本(不崩溃)', async () => {
|
|
76
97
|
const adapter = new FakeAdapter('slack');
|
|
77
98
|
const client = {
|
|
78
99
|
upsertSession: async () => { throw new Error('dsh down'); },
|
|
79
100
|
connect: async () => () => undefined,
|
|
80
101
|
} as unknown as GatewayClient;
|
|
81
|
-
await wireAdapter(adapter, client, { allowlist: [] });
|
|
102
|
+
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
82
103
|
|
|
83
104
|
await adapter.emit({ chatId: 'C1', userId: 'U1', text: 'hi' });
|
|
84
105
|
expect(adapter.sent[0].payload.text).toContain('❌');
|
package/test/session.test.ts
CHANGED
|
@@ -8,9 +8,9 @@ describe('buildSessionKey', () => {
|
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
describe('Allowlist', () => {
|
|
11
|
-
it('
|
|
11
|
+
it('空列表 fail-closed:拒绝所有(生产默认)', () => {
|
|
12
12
|
const allow = new Allowlist([]);
|
|
13
|
-
expect(allow.allows('anything:any:any')).toBe(
|
|
13
|
+
expect(allow.allows('anything:any:any')).toBe(false);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
it('非空列表只放行白名单条目', () => {
|
|
@@ -18,4 +18,9 @@ describe('Allowlist', () => {
|
|
|
18
18
|
expect(allow.allows('whatsapp:60123:60123')).toBe(true);
|
|
19
19
|
expect(allow.allows('whatsapp:99999:99999')).toBe(false);
|
|
20
20
|
});
|
|
21
|
+
|
|
22
|
+
it('ALLOW_ALL 显式放行所有(开发逃生口)', () => {
|
|
23
|
+
const allow = new Allowlist([], true);
|
|
24
|
+
expect(allow.allows('anything:any:any')).toBe(true);
|
|
25
|
+
});
|
|
21
26
|
});
|
package/test/streaming.test.ts
CHANGED
|
@@ -1,162 +1,162 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { DeltaTracker, planOutbound, wireAdapter } from '../src/index.js';
|
|
3
|
-
import { HELP_TEXT } from '../src/commands.js';
|
|
4
|
-
import { GatewayClient, type ServerEvent } from '@dsh-overdrive/sdk';
|
|
5
|
-
import type { Adapter, NormalizedMessage, OutboundPayload } from '../src/adapter.js';
|
|
6
|
-
|
|
7
|
-
describe('DeltaTracker(message.delta → 打字指示,complete → 终稿)', () => {
|
|
8
|
-
it('首个 delta 触发 typing,重复 delta 不重复触发', () => {
|
|
9
|
-
const t = new DeltaTracker();
|
|
10
|
-
const typings: string[] = [];
|
|
11
|
-
const outputs: string[] = [];
|
|
12
|
-
t.onDelta('s1', () => typings.push('s1'));
|
|
13
|
-
t.onDelta('s1', () => typings.push('s1'));
|
|
14
|
-
expect(typings).toEqual(['s1']);
|
|
15
|
-
void outputs;
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('complete 后同会话下一个 turn 的 delta 可再次触发 typing', () => {
|
|
19
|
-
const t = new DeltaTracker();
|
|
20
|
-
const typings: string[] = [];
|
|
21
|
-
t.onDelta('s1', () => typings.push('s1'));
|
|
22
|
-
t.onComplete('s1');
|
|
23
|
-
t.onDelta('s1', () => typings.push('s1'));
|
|
24
|
-
expect(typings).toEqual(['s1', 's1']);
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe('planOutbound(trajectory.summary 摘要卡片渲染)', () => {
|
|
29
|
-
it('trajectory.summary → formatTrajectorySummary 文本', () => {
|
|
30
|
-
const ev: ServerEvent = {
|
|
31
|
-
type: 'trajectory.summary', sessionId: 'cli:cli:local', ts: 1,
|
|
32
|
-
steps: [{ kind: 'thought', label: '分析' }, { kind: 'tool', label: 'bash' }],
|
|
33
|
-
};
|
|
34
|
-
const out = planOutbound(ev)!;
|
|
35
|
-
expect(out.payload.text).toContain('📋 轨迹(2 步)');
|
|
36
|
-
expect(out.payload.text).toContain('🧠 分析');
|
|
37
|
-
expect(out.payload.text).toContain('🛠️ bash');
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
/** 可编程 FakeAdapter(带 sendTyping 探测)。 */
|
|
42
|
-
class FakeAdapter implements Adapter {
|
|
43
|
-
readonly id: string;
|
|
44
|
-
readonly sent: Array<{ chatId: string; payload: OutboundPayload }> = [];
|
|
45
|
-
readonly typings: string[] = [];
|
|
46
|
-
private messageCb?: (msg: NormalizedMessage) => Promise<void> | void;
|
|
47
|
-
private replyCb?: (buttonId: string) => Promise<void> | void;
|
|
48
|
-
constructor(id: string) { this.id = id; }
|
|
49
|
-
async connect(): Promise<void> {}
|
|
50
|
-
async send(chatId: string, payload: OutboundPayload): Promise<void> { this.sent.push({ chatId, payload }); }
|
|
51
|
-
async sendTyping(chatId: string): Promise<void> { this.typings.push(chatId); }
|
|
52
|
-
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
53
|
-
onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
|
|
54
|
-
emit(msg: NormalizedMessage): Promise<void> | void { return this.messageCb?.(msg); }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** 假 DSH 客户端:可编程事件推流 + 记录调用。 */
|
|
58
|
-
function fakeClient() {
|
|
59
|
-
const createTasks: Array<{ sessionId: string; kind: string; prompt: string; schedule?: string }> = [];
|
|
60
|
-
const resets: string[] = [];
|
|
61
|
-
let eventCb: ((ev: ServerEvent) => void) | undefined;
|
|
62
|
-
const client = {
|
|
63
|
-
upsertSession: async (req: { platform: string; channel: string; user: string }) =>
|
|
64
|
-
({ sessionId: `${req.platform}:${req.channel}:${req.user}` }),
|
|
65
|
-
sendMessage: async () => ({ runId: 'r1' }),
|
|
66
|
-
resolveApproval: async () => ({ ok: true }),
|
|
67
|
-
createTask: async (req: { sessionId: string; kind: 'subagent' | 'cron'; prompt: string; schedule?: string }) => {
|
|
68
|
-
createTasks.push(req);
|
|
69
|
-
return { taskId: 't1' };
|
|
70
|
-
},
|
|
71
|
-
resetSession: async (sessionId: string) => { resets.push(sessionId); return { ok: true }; },
|
|
72
|
-
connect: async (cb: (ev: ServerEvent) => void) => { eventCb = cb; return () => undefined; },
|
|
73
|
-
};
|
|
74
|
-
const push = (ev: ServerEvent): void => eventCb?.(ev);
|
|
75
|
-
return { client, createTasks, resets, push } as unknown as {
|
|
76
|
-
client: GatewayClient; createTasks: typeof createTasks; resets: string[];
|
|
77
|
-
push: (ev: ServerEvent) => void;
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
describe('wireAdapter(命令分发 + delta 打字指示 + 轨迹聚合接线)', () => {
|
|
82
|
-
it('message.delta → sendTyping 一次;complete 后下一 turn 再触发', async () => {
|
|
83
|
-
const adapter = new FakeAdapter('cli');
|
|
84
|
-
const { client, push } = fakeClient();
|
|
85
|
-
await wireAdapter(adapter, client, { allowlist: [] });
|
|
86
|
-
|
|
87
|
-
push({ type: 'message.delta', sessionId: 'cli:cli:local', ts: 1, text: '…' });
|
|
88
|
-
push({ type: 'message.delta', sessionId: 'cli:cli:local', ts: 2, text: '…' });
|
|
89
|
-
expect(adapter.typings).toEqual(['cli']); // sendTyping 目标是 chatId(无消息时回退到 channel)
|
|
90
|
-
expect(adapter.sent).toHaveLength(0); // delta 不产出文本
|
|
91
|
-
|
|
92
|
-
push({ type: 'message.complete', sessionId: 'cli:cli:local', ts: 3, text: '结果' });
|
|
93
|
-
push({ type: 'message.delta', sessionId: 'cli:cli:local', ts: 4, text: '…' });
|
|
94
|
-
expect(adapter.typings).toEqual(['cli', 'cli']);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('trajectory.step 不实时输出,idle 时以 trajectory.summary 摘要输出', async () => {
|
|
98
|
-
const adapter = new FakeAdapter('cli');
|
|
99
|
-
const { client, push } = fakeClient();
|
|
100
|
-
await wireAdapter(adapter, client, { allowlist: [] });
|
|
101
|
-
|
|
102
|
-
push({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 1, status: 'busy' });
|
|
103
|
-
push({ type: 'trajectory.step', sessionId: 'cli:cli:local', ts: 2, step: { kind: 'thought', label: '分析' } });
|
|
104
|
-
push({ type: 'trajectory.step', sessionId: 'cli:cli:local', ts: 3, step: { kind: 'tool', label: 'bash' } });
|
|
105
|
-
expect(adapter.sent).toHaveLength(0); // 单步不推
|
|
106
|
-
|
|
107
|
-
push({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 4, status: 'idle' });
|
|
108
|
-
const summary = adapter.sent.find((s) => s.payload.text.includes('📋 轨迹'));
|
|
109
|
-
expect(summary?.payload.text).toContain('🧠 分析');
|
|
110
|
-
expect(summary?.payload.text).toContain('🛠️ bash');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('/help → HELP_TEXT 原样输出', async () => {
|
|
114
|
-
const adapter = new FakeAdapter('cli');
|
|
115
|
-
const { client } = fakeClient();
|
|
116
|
-
await wireAdapter(adapter, client, { allowlist: [] });
|
|
117
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/help' });
|
|
118
|
-
expect(adapter.sent[0].payload.text).toBe(HELP_TEXT);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('/task 派子任务并回执;/cron 注册定时任务并回执', async () => {
|
|
122
|
-
const adapter = new FakeAdapter('cli');
|
|
123
|
-
const { client, createTasks } = fakeClient();
|
|
124
|
-
await wireAdapter(adapter, client, { allowlist: [] });
|
|
125
|
-
|
|
126
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/task 调研竞品' });
|
|
127
|
-
expect(createTasks).toContainEqual({ sessionId: 'cli:cli:local', kind: 'subagent', prompt: '调研竞品' });
|
|
128
|
-
expect(adapter.sent.at(-1)!.payload.text).toContain('🤖 子任务已派出');
|
|
129
|
-
|
|
130
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/cron 0 8 * * * 每日汇报' });
|
|
131
|
-
expect(createTasks).toContainEqual({ sessionId: 'cli:cli:local', kind: 'cron', prompt: '每日汇报', schedule: '0 8 * * *' });
|
|
132
|
-
expect(adapter.sent.at(-1)!.payload.text).toContain('⏰ 定时任务已注册');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('/new 走 resetSession 端点并回执;/trace 显示最近摘要,无则提示', async () => {
|
|
136
|
-
const adapter = new FakeAdapter('cli');
|
|
137
|
-
const { client, resets, push } = fakeClient();
|
|
138
|
-
await wireAdapter(adapter, client, { allowlist: [] });
|
|
139
|
-
|
|
140
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/trace' });
|
|
141
|
-
expect(adapter.sent.at(-1)!.payload.text).toContain('暂无轨迹');
|
|
142
|
-
|
|
143
|
-
push({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 1, status: 'busy' });
|
|
144
|
-
push({ type: 'trajectory.step', sessionId: 'cli:cli:local', ts: 2, step: { kind: 'thought', label: '分析' } });
|
|
145
|
-
push({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 3, status: 'idle' });
|
|
146
|
-
|
|
147
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/trace' });
|
|
148
|
-
expect(adapter.sent.at(-1)!.payload.text).toContain('🧠 分析');
|
|
149
|
-
|
|
150
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/new' });
|
|
151
|
-
expect(resets).toEqual(['cli:cli:local']);
|
|
152
|
-
expect(adapter.sent.at(-1)!.payload.text).toContain('🆕 会话已重置');
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('/agents 返回简化回执', async () => {
|
|
156
|
-
const adapter = new FakeAdapter('cli');
|
|
157
|
-
const { client } = fakeClient();
|
|
158
|
-
await wireAdapter(adapter, client, { allowlist: [] });
|
|
159
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/agents' });
|
|
160
|
-
expect(adapter.sent[0].payload.text).toContain('/task 派发');
|
|
161
|
-
});
|
|
162
|
-
});
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DeltaTracker, planOutbound, wireAdapter } from '../src/index.js';
|
|
3
|
+
import { HELP_TEXT } from '../src/commands.js';
|
|
4
|
+
import { GatewayClient, type ServerEvent } from '@dsh-overdrive/sdk';
|
|
5
|
+
import type { Adapter, NormalizedMessage, OutboundPayload } from '../src/adapter.js';
|
|
6
|
+
|
|
7
|
+
describe('DeltaTracker(message.delta → 打字指示,complete → 终稿)', () => {
|
|
8
|
+
it('首个 delta 触发 typing,重复 delta 不重复触发', () => {
|
|
9
|
+
const t = new DeltaTracker();
|
|
10
|
+
const typings: string[] = [];
|
|
11
|
+
const outputs: string[] = [];
|
|
12
|
+
t.onDelta('s1', () => typings.push('s1'));
|
|
13
|
+
t.onDelta('s1', () => typings.push('s1'));
|
|
14
|
+
expect(typings).toEqual(['s1']);
|
|
15
|
+
void outputs;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('complete 后同会话下一个 turn 的 delta 可再次触发 typing', () => {
|
|
19
|
+
const t = new DeltaTracker();
|
|
20
|
+
const typings: string[] = [];
|
|
21
|
+
t.onDelta('s1', () => typings.push('s1'));
|
|
22
|
+
t.onComplete('s1');
|
|
23
|
+
t.onDelta('s1', () => typings.push('s1'));
|
|
24
|
+
expect(typings).toEqual(['s1', 's1']);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('planOutbound(trajectory.summary 摘要卡片渲染)', () => {
|
|
29
|
+
it('trajectory.summary → formatTrajectorySummary 文本', () => {
|
|
30
|
+
const ev: ServerEvent = {
|
|
31
|
+
type: 'trajectory.summary', sessionId: 'cli:cli:local', ts: 1,
|
|
32
|
+
steps: [{ kind: 'thought', label: '分析' }, { kind: 'tool', label: 'bash' }],
|
|
33
|
+
};
|
|
34
|
+
const out = planOutbound(ev)!;
|
|
35
|
+
expect(out.payload.text).toContain('📋 轨迹(2 步)');
|
|
36
|
+
expect(out.payload.text).toContain('🧠 分析');
|
|
37
|
+
expect(out.payload.text).toContain('🛠️ bash');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** 可编程 FakeAdapter(带 sendTyping 探测)。 */
|
|
42
|
+
class FakeAdapter implements Adapter {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly sent: Array<{ chatId: string; payload: OutboundPayload }> = [];
|
|
45
|
+
readonly typings: string[] = [];
|
|
46
|
+
private messageCb?: (msg: NormalizedMessage) => Promise<void> | void;
|
|
47
|
+
private replyCb?: (buttonId: string) => Promise<void> | void;
|
|
48
|
+
constructor(id: string) { this.id = id; }
|
|
49
|
+
async connect(): Promise<void> {}
|
|
50
|
+
async send(chatId: string, payload: OutboundPayload): Promise<void> { this.sent.push({ chatId, payload }); }
|
|
51
|
+
async sendTyping(chatId: string): Promise<void> { this.typings.push(chatId); }
|
|
52
|
+
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
53
|
+
onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
|
|
54
|
+
emit(msg: NormalizedMessage): Promise<void> | void { return this.messageCb?.(msg); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 假 DSH 客户端:可编程事件推流 + 记录调用。 */
|
|
58
|
+
function fakeClient() {
|
|
59
|
+
const createTasks: Array<{ sessionId: string; kind: string; prompt: string; schedule?: string }> = [];
|
|
60
|
+
const resets: string[] = [];
|
|
61
|
+
let eventCb: ((ev: ServerEvent) => void) | undefined;
|
|
62
|
+
const client = {
|
|
63
|
+
upsertSession: async (req: { platform: string; channel: string; user: string }) =>
|
|
64
|
+
({ sessionId: `${req.platform}:${req.channel}:${req.user}` }),
|
|
65
|
+
sendMessage: async () => ({ runId: 'r1' }),
|
|
66
|
+
resolveApproval: async () => ({ ok: true }),
|
|
67
|
+
createTask: async (req: { sessionId: string; kind: 'subagent' | 'cron'; prompt: string; schedule?: string }) => {
|
|
68
|
+
createTasks.push(req);
|
|
69
|
+
return { taskId: 't1' };
|
|
70
|
+
},
|
|
71
|
+
resetSession: async (sessionId: string) => { resets.push(sessionId); return { ok: true }; },
|
|
72
|
+
connect: async (cb: (ev: ServerEvent) => void) => { eventCb = cb; return () => undefined; },
|
|
73
|
+
};
|
|
74
|
+
const push = (ev: ServerEvent): void => eventCb?.(ev);
|
|
75
|
+
return { client, createTasks, resets, push } as unknown as {
|
|
76
|
+
client: GatewayClient; createTasks: typeof createTasks; resets: string[];
|
|
77
|
+
push: (ev: ServerEvent) => void;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('wireAdapter(命令分发 + delta 打字指示 + 轨迹聚合接线)', () => {
|
|
82
|
+
it('message.delta → sendTyping 一次;complete 后下一 turn 再触发', async () => {
|
|
83
|
+
const adapter = new FakeAdapter('cli');
|
|
84
|
+
const { client, push } = fakeClient();
|
|
85
|
+
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
86
|
+
|
|
87
|
+
push({ type: 'message.delta', sessionId: 'cli:cli:local', ts: 1, text: '…' });
|
|
88
|
+
push({ type: 'message.delta', sessionId: 'cli:cli:local', ts: 2, text: '…' });
|
|
89
|
+
expect(adapter.typings).toEqual(['cli']); // sendTyping 目标是 chatId(无消息时回退到 channel)
|
|
90
|
+
expect(adapter.sent).toHaveLength(0); // delta 不产出文本
|
|
91
|
+
|
|
92
|
+
push({ type: 'message.complete', sessionId: 'cli:cli:local', ts: 3, text: '结果' });
|
|
93
|
+
push({ type: 'message.delta', sessionId: 'cli:cli:local', ts: 4, text: '…' });
|
|
94
|
+
expect(adapter.typings).toEqual(['cli', 'cli']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('trajectory.step 不实时输出,idle 时以 trajectory.summary 摘要输出', async () => {
|
|
98
|
+
const adapter = new FakeAdapter('cli');
|
|
99
|
+
const { client, push } = fakeClient();
|
|
100
|
+
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
101
|
+
|
|
102
|
+
push({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 1, status: 'busy' });
|
|
103
|
+
push({ type: 'trajectory.step', sessionId: 'cli:cli:local', ts: 2, step: { kind: 'thought', label: '分析' } });
|
|
104
|
+
push({ type: 'trajectory.step', sessionId: 'cli:cli:local', ts: 3, step: { kind: 'tool', label: 'bash' } });
|
|
105
|
+
expect(adapter.sent).toHaveLength(0); // 单步不推
|
|
106
|
+
|
|
107
|
+
push({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 4, status: 'idle' });
|
|
108
|
+
const summary = adapter.sent.find((s) => s.payload.text.includes('📋 轨迹'));
|
|
109
|
+
expect(summary?.payload.text).toContain('🧠 分析');
|
|
110
|
+
expect(summary?.payload.text).toContain('🛠️ bash');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('/help → HELP_TEXT 原样输出', async () => {
|
|
114
|
+
const adapter = new FakeAdapter('cli');
|
|
115
|
+
const { client } = fakeClient();
|
|
116
|
+
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
117
|
+
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/help' });
|
|
118
|
+
expect(adapter.sent[0].payload.text).toBe(HELP_TEXT);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('/task 派子任务并回执;/cron 注册定时任务并回执', async () => {
|
|
122
|
+
const adapter = new FakeAdapter('cli');
|
|
123
|
+
const { client, createTasks } = fakeClient();
|
|
124
|
+
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
125
|
+
|
|
126
|
+
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/task 调研竞品' });
|
|
127
|
+
expect(createTasks).toContainEqual({ sessionId: 'cli:cli:local', kind: 'subagent', prompt: '调研竞品' });
|
|
128
|
+
expect(adapter.sent.at(-1)!.payload.text).toContain('🤖 子任务已派出');
|
|
129
|
+
|
|
130
|
+
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/cron 0 8 * * * 每日汇报' });
|
|
131
|
+
expect(createTasks).toContainEqual({ sessionId: 'cli:cli:local', kind: 'cron', prompt: '每日汇报', schedule: '0 8 * * *' });
|
|
132
|
+
expect(adapter.sent.at(-1)!.payload.text).toContain('⏰ 定时任务已注册');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('/new 走 resetSession 端点并回执;/trace 显示最近摘要,无则提示', async () => {
|
|
136
|
+
const adapter = new FakeAdapter('cli');
|
|
137
|
+
const { client, resets, push } = fakeClient();
|
|
138
|
+
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
139
|
+
|
|
140
|
+
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/trace' });
|
|
141
|
+
expect(adapter.sent.at(-1)!.payload.text).toContain('暂无轨迹');
|
|
142
|
+
|
|
143
|
+
push({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 1, status: 'busy' });
|
|
144
|
+
push({ type: 'trajectory.step', sessionId: 'cli:cli:local', ts: 2, step: { kind: 'thought', label: '分析' } });
|
|
145
|
+
push({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 3, status: 'idle' });
|
|
146
|
+
|
|
147
|
+
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/trace' });
|
|
148
|
+
expect(adapter.sent.at(-1)!.payload.text).toContain('🧠 分析');
|
|
149
|
+
|
|
150
|
+
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/new' });
|
|
151
|
+
expect(resets).toEqual(['cli:cli:local']);
|
|
152
|
+
expect(adapter.sent.at(-1)!.payload.text).toContain('🆕 会话已重置');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('/agents 返回简化回执', async () => {
|
|
156
|
+
const adapter = new FakeAdapter('cli');
|
|
157
|
+
const { client } = fakeClient();
|
|
158
|
+
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
159
|
+
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/agents' });
|
|
160
|
+
expect(adapter.sent[0].payload.text).toContain('/task 派发');
|
|
161
|
+
});
|
|
162
|
+
});
|