@dsh-overdrive/gateway 0.1.0

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/src/index.ts ADDED
@@ -0,0 +1,220 @@
1
+ import { GatewayClient, type ServerEvent } from '@dsh-overdrive/sdk';
2
+ import type { Adapter, OutboundPayload } from './adapter.js';
3
+ import { Allowlist, buildSessionKey } from './session.js';
4
+ import { adapterEnvFromProcess, createAdapter, parseAdapterIds } from './config.js';
5
+ import { CliAdapter } from './adapters/cli.js';
6
+ import { parseCommand, HELP_TEXT, type ParsedCommand } from './commands.js';
7
+ import { TrajectoryAggregator, formatTrajectorySummary } from './trajectory.js';
8
+ import { createStatusServer } from './status.js';
9
+
10
+ /**
11
+ * message.delta → 打字指示去重:同一 turn 内首个 delta 触发一次 typing,
12
+ * complete 后复位,下一 turn 可再次触发。纯逻辑,便于单测。
13
+ */
14
+ export class DeltaTracker {
15
+ private readonly fired = new Set<string>();
16
+
17
+ onDelta(sessionId: string, fireTyping: () => void): void {
18
+ if (this.fired.has(sessionId)) return;
19
+ this.fired.add(sessionId);
20
+ fireTyping();
21
+ }
22
+
23
+ /** turn 结束(message.complete / error)时复位,允许下一 turn 再次触发 typing。 */
24
+ onComplete(sessionId: string): void {
25
+ this.fired.delete(sessionId);
26
+ }
27
+ }
28
+
29
+ /** 事件 → 平台输出。纯函数,便于单测。返回 null 表示该事件不产出消息。 */
30
+ export function planOutbound(ev: ServerEvent): { payload: OutboundPayload } | null {
31
+ switch (ev.type) {
32
+ case 'message.complete':
33
+ return { payload: { text: ev.text } };
34
+ case 'message.delta':
35
+ return null; // 流式渲染走 sendTyping(见 wireAdapter),不产出文本
36
+ case 'trajectory.step': {
37
+ const icon = ev.step.kind === 'tool' ? '🛠️' : ev.step.kind === 'subagent' ? '🤖' : '🧠';
38
+ return { payload: { text: `${icon} ${ev.step.label}` } };
39
+ }
40
+ case 'trajectory.summary':
41
+ return { payload: { text: formatTrajectorySummary(ev.steps) } };
42
+ case 'approval.request':
43
+ return {
44
+ payload: {
45
+ text: `⚠️ 需要批准:${ev.summary}(${Math.round(ev.timeoutMs / 1000)}s 内有效)`,
46
+ buttons: [
47
+ { id: `approve:${ev.reqId}`, label: '✅ 同意' },
48
+ { id: `reject:${ev.reqId}`, label: '🚫 拒绝' },
49
+ ],
50
+ },
51
+ };
52
+ case 'agent.status':
53
+ return ev.status === 'subagent-spawned'
54
+ ? { payload: { text: '🤖 派生子任务…' } }
55
+ : null;
56
+ case 'task.done':
57
+ return { payload: { text: ev.ok ? `✅ 任务完成 ${ev.taskId}` : `❌ 任务失败 ${ev.taskId}` } };
58
+ case 'error':
59
+ return { payload: { text: `❌ 出错了:${ev.message}` } };
60
+ default:
61
+ return null;
62
+ }
63
+ }
64
+
65
+ export interface WireOptions {
66
+ allowlist: string[];
67
+ }
68
+
69
+ /** 命令面分发:/trace /new /task /cron /agents /help(M4)。 */
70
+ async function handleCommand(
71
+ adapter: Adapter,
72
+ client: GatewayClient,
73
+ command: ParsedCommand,
74
+ sessionId: string,
75
+ chatId: string,
76
+ aggregator: TrajectoryAggregator,
77
+ ): Promise<void> {
78
+ switch (command.kind) {
79
+ case 'trace': {
80
+ const summary = aggregator.recentSummary(sessionId);
81
+ await adapter.send(chatId, { text: summary ?? '暂无轨迹(尚未运行或已重置)。' });
82
+ return;
83
+ }
84
+ case 'new': {
85
+ await client.resetSession(sessionId);
86
+ await adapter.send(chatId, { text: '🆕 会话已重置' });
87
+ return;
88
+ }
89
+ case 'task': {
90
+ await client.createTask({ sessionId, kind: 'subagent', prompt: command.prompt });
91
+ await adapter.send(chatId, { text: '🤖 子任务已派出' });
92
+ return;
93
+ }
94
+ case 'cron': {
95
+ await client.createTask({ sessionId, kind: 'cron', prompt: command.prompt, schedule: command.schedule });
96
+ await adapter.send(chatId, { text: '⏰ 定时任务已注册' });
97
+ return;
98
+ }
99
+ case 'agents': {
100
+ await adapter.send(chatId, { text: '(M4 简化)子任务状态由 agent 汇报,/task 派发' });
101
+ return;
102
+ }
103
+ case 'help': {
104
+ await adapter.send(chatId, { text: HELP_TEXT });
105
+ return;
106
+ }
107
+ }
108
+ }
109
+
110
+ /** 单个适配器的接线:白名单 → 命令面/upsert → sendMessage;按钮 → resolveApproval;事件流经轨迹聚合。 */
111
+ export async function wireAdapter(
112
+ adapter: Adapter,
113
+ client: GatewayClient,
114
+ opts: WireOptions,
115
+ ): Promise<void> {
116
+ const allow = new Allowlist(opts.allowlist);
117
+ const chatIds = new Map<string, string>();
118
+ const aggregator = new TrajectoryAggregator();
119
+ const deltas = new DeltaTracker();
120
+
121
+ adapter.onMessage(async (msg) => {
122
+ const key = buildSessionKey(adapter.id, msg);
123
+ console.log(`[gateway][${adapter.id}] 收到消息 chat=${msg.chatId} user=${msg.userId} text="${msg.text.slice(0, 40)}"`);
124
+ try {
125
+ if (!allow.allows(key)) {
126
+ await adapter.send(msg.chatId, { text: '⛔ 你不在白名单里。' });
127
+ return;
128
+ }
129
+ chatIds.set(key, msg.chatId);
130
+
131
+ const command = parseCommand(msg.text);
132
+ if (command) {
133
+ console.log(`[gateway][${adapter.id}] 命令: ${JSON.stringify(command)}`);
134
+ await handleCommand(adapter, client, command, key, msg.chatId, aggregator);
135
+ return;
136
+ }
137
+
138
+ await client.upsertSession({ platform: adapter.id, channel: msg.chatId, user: msg.userId });
139
+ console.log(`[gateway][${adapter.id}] upsertSession OK -> ${key}`);
140
+ await client.sendMessage(key, { text: msg.text, media: msg.media });
141
+ console.log(`[gateway][${adapter.id}] sendMessage OK`);
142
+ } catch (error) {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ console.error(`[gateway][${adapter.id}] 处理失败: ${message}`);
145
+ await adapter.send(msg.chatId, { text: `❌ 出错了:${message}` }).catch(() => undefined);
146
+ }
147
+ });
148
+
149
+ adapter.onReply(async (buttonId) => {
150
+ try {
151
+ const idx = buttonId.indexOf(':');
152
+ if (idx < 0) return;
153
+ const action = buttonId.slice(0, idx) as 'approve' | 'reject';
154
+ const reqId = buttonId.slice(idx + 1);
155
+ if ((action === 'approve' || action === 'reject') && reqId) {
156
+ await client.resolveApproval(reqId, action);
157
+ }
158
+ } catch (error) {
159
+ console.error(`[gateway] resolveApproval 失败: ${error instanceof Error ? error.message : String(error)}`);
160
+ }
161
+ });
162
+
163
+ const chatIdFor = (sessionId: string): string => {
164
+ const known = chatIds.get(sessionId);
165
+ if (known) return known;
166
+ return sessionId.split(':')[1] ?? sessionId;
167
+ };
168
+
169
+ await client.connect((ev) => {
170
+ // 流式渲染:delta → 打字指示(去重),complete/error → 复位(下一 turn 可再触发)
171
+ if (ev.type === 'message.delta') {
172
+ deltas.onDelta(ev.sessionId, () => void adapter.sendTyping?.(chatIdFor(ev.sessionId)));
173
+ } else if (ev.type === 'message.complete' || ev.type === 'error') {
174
+ deltas.onComplete(ev.sessionId);
175
+ }
176
+
177
+ // 轨迹聚合:trajectory.step 攒批,idle 时产出 trajectory.summary(减少刷屏)
178
+ aggregator.onEvent(ev, (out) => {
179
+ const planned = planOutbound(out);
180
+ if (!planned) return;
181
+ const chatId = chatIdFor(out.sessionId);
182
+ console.log(`[gateway][${adapter.id}] 事件 ${out.type} -> 发送到 ${chatId}`);
183
+ void adapter.send(chatId, planned.payload).then(
184
+ () => console.log(`[gateway][${adapter.id}] 已发送 ${out.type} 到 ${chatId}`),
185
+ (error) => console.error(`[gateway][${adapter.id}] 发送失败 ${out.type}: ${error instanceof Error ? error.message : String(error)}`),
186
+ );
187
+ });
188
+ });
189
+ }
190
+
191
+ async function main(): Promise<void> {
192
+ const dshBaseUrl = process.env.DSH_BASE_URL ?? 'http://127.0.0.1:3191';
193
+ const dshToken = process.env.DSH_TOKEN ?? 'dev-token';
194
+ const allowlist = (process.env.ALLOWLIST ?? '')
195
+ .split(',').map((s) => s.trim()).filter(Boolean);
196
+ const adapterIds = parseAdapterIds(process.env.GATEWAY_ADAPTERS ?? 'cli');
197
+ const env = adapterEnvFromProcess();
198
+
199
+ const client = new GatewayClient(dshBaseUrl, dshToken);
200
+ await client.health(); // 确认 DSH 侧(或 mock)活着
201
+
202
+ const adapters: Adapter[] = adapterIds.map((id) => createAdapter(id, env));
203
+ for (const adapter of adapters) {
204
+ await adapter.connect();
205
+ await wireAdapter(adapter, client, { allowlist });
206
+ console.log(`[gateway] ${adapter.id} 适配器已就绪`);
207
+ }
208
+
209
+ const consolePort = Number(process.env.GATEWAY_CONSOLE_PORT ?? 3190);
210
+ const status = createStatusServer({ adapters, client, version: '0.1.0' });
211
+ await status.listen(consolePort);
212
+ console.log(`[gateway] 控制台 http://0.0.0.0:${consolePort}/`);
213
+
214
+ process.stdout.write(`[gateway] 就绪(适配器: ${adapterIds.join(', ')})。Ctrl+C 退出。\n`);
215
+ }
216
+
217
+ if (process.argv[1]?.endsWith('index.js')) void main();
218
+
219
+ // 保留 CLI 的直接导入(E2E 用)
220
+ export { CliAdapter };
package/src/session.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { sessionKey } from '@dsh-overdrive/sdk';
2
+
3
+ export function buildSessionKey(
4
+ adapterId: string,
5
+ msg: { chatId: string; userId: string },
6
+ ): string {
7
+ return sessionKey(adapterId, msg.chatId, msg.userId);
8
+ }
9
+
10
+ /** 空列表 = 开发模式放行所有;生产环境必须显式配置。 */
11
+ export class Allowlist {
12
+ constructor(private readonly entries: string[]) {}
13
+
14
+ allows(key: string): boolean {
15
+ return this.entries.length === 0 || this.entries.includes(key);
16
+ }
17
+ }
package/src/status.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { createServer, type Server } from 'node:http';
3
+ import { fileURLToPath } from 'node:url';
4
+ import type { Adapter } from './adapter.js';
5
+ import type { GatewayClient } from '@dsh-overdrive/sdk';
6
+
7
+ export interface StatusServerOptions {
8
+ adapters: Adapter[];
9
+ client: GatewayClient;
10
+ version: string;
11
+ }
12
+
13
+ /**
14
+ * 健康控制台:GET / 与 /console 返回静态页,GET /api/status 返回 DSH 健康 + 适配器状态。
15
+ *
16
+ * console.html 读取路径说明(与 dist 产物核对过):
17
+ * - src 运行(vitest):import.meta.url = packages/gateway/src/status.ts → ../../web/console.html = packages/web/console.html
18
+ * - dist 运行(node packages/gateway/dist/index.js):import.meta.url = packages/gateway/dist/status.js → ../../web/console.html = packages/web/console.html
19
+ * 两种形态下 `../../web/console.html` 均解析到 packages/web/console.html(plan 中的
20
+ * ../../../web/console.html 会多上一级到仓库根目录,不正确,已修正)。
21
+ */
22
+ export function createStatusServer(opts: StatusServerOptions): {
23
+ server: Server;
24
+ listen(port: number): Promise<number>;
25
+ close(): Promise<void>;
26
+ } {
27
+ const http = createServer(async (req, res) => {
28
+ const url = new URL(req.url ?? '/', 'http://localhost');
29
+ if (url.pathname === '/api/status') {
30
+ let dsh: { status: string } | { error: string };
31
+ try {
32
+ dsh = await opts.client.health();
33
+ } catch (error) {
34
+ dsh = { error: error instanceof Error ? error.message : String(error) };
35
+ }
36
+ const adapters = opts.adapters.map((a) => ({
37
+ id: a.id,
38
+ connected: a.status?.().connected ?? null,
39
+ }));
40
+ res.writeHead(200, { 'content-type': 'application/json' });
41
+ res.end(JSON.stringify({ version: opts.version, dsh, adapters }));
42
+ return;
43
+ }
44
+ if (url.pathname === '/' || url.pathname === '/console') {
45
+ const html = await readFile(
46
+ fileURLToPath(new URL('../../web/console.html', import.meta.url)),
47
+ 'utf8',
48
+ ).catch(() => '<h1>console.html not found</h1>');
49
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
50
+ res.end(html);
51
+ return;
52
+ }
53
+ res.writeHead(404, { 'content-type': 'text/plain' });
54
+ res.end('not found');
55
+ });
56
+ return {
57
+ server: http,
58
+ listen: (port: number) =>
59
+ new Promise<number>((resolve) =>
60
+ http.listen(port, '0.0.0.0', () => resolve((http.address() as { port: number }).port)),
61
+ ),
62
+ close: () => new Promise<void>((resolve) => http.close(() => resolve())),
63
+ };
64
+ }
@@ -0,0 +1,45 @@
1
+ import type { ServerEvent, TrajectoryStep } from '@dsh-overdrive/sdk';
2
+
3
+ /** turn 级轨迹聚合:收集 trajectory.step,turn/end(idle)时产出 trajectory.summary 摘要卡片。 */
4
+ export class TrajectoryAggregator {
5
+ private readonly buffer = new Map<string, TrajectoryStep[]>();
6
+ private readonly summaries = new Map<string, string>();
7
+
8
+ onEvent(ev: ServerEvent, emit: (ev: ServerEvent) => void): void {
9
+ if (ev.type === 'agent.status' && ev.status === 'idle') {
10
+ const steps = this.buffer.get(ev.sessionId) ?? [];
11
+ this.buffer.delete(ev.sessionId);
12
+ if (steps.length > 0) {
13
+ this.summaries.set(ev.sessionId, formatTrajectorySummary(steps));
14
+ emit({ type: 'trajectory.summary', sessionId: ev.sessionId, ts: Date.now(), steps });
15
+ }
16
+ emit(ev);
17
+ return;
18
+ }
19
+ if (ev.type === 'agent.status' && ev.status === 'busy') {
20
+ this.buffer.set(ev.sessionId, []);
21
+ emit(ev);
22
+ return;
23
+ }
24
+ if (ev.type === 'trajectory.step') {
25
+ const list = this.buffer.get(ev.sessionId);
26
+ if (list) list.push(ev.step);
27
+ else this.buffer.set(ev.sessionId, [ev.step]);
28
+ return; // 单步不实时推,等摘要(减少刷屏)
29
+ }
30
+ emit(ev);
31
+ }
32
+
33
+ /** 最近一次 turn 的轨迹摘要文本(/trace 命令用),无则 null。 */
34
+ recentSummary(sessionId: string): string | null {
35
+ return this.summaries.get(sessionId) ?? null;
36
+ }
37
+ }
38
+
39
+ export function formatTrajectorySummary(steps: TrajectoryStep[]): string {
40
+ const lines = steps.map((s) => {
41
+ const icon = s.kind === 'tool' ? '🛠️' : s.kind === 'subagent' ? '🤖' : '🧠';
42
+ return `${icon} ${s.label}`;
43
+ });
44
+ return `📋 轨迹(${lines.length} 步)\n${lines.join('\n')}`;
45
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildReplyBody, parseBotMessage } from '../src/adapters/dingtalk.js';
3
+
4
+ describe('parseBotMessage(RobotMessage → NormalizedMessage)', () => {
5
+ it('文本消息', () => {
6
+ const data = {
7
+ conversationId: 'cid1',
8
+ senderStaffId: 'u1',
9
+ msgtype: 'text',
10
+ text: { content: 'hello' },
11
+ sessionWebhook: 'https://hook.dingtalk.com/x',
12
+ };
13
+ const out = parseBotMessage(data);
14
+ expect(out).toMatchObject({ chatId: 'cid1', userId: 'u1', text: 'hello' });
15
+ });
16
+ it('非文本返回 null', () => {
17
+ expect(parseBotMessage({ conversationId: 'c', msgtype: 'picture' })).toBeNull();
18
+ });
19
+ });
20
+
21
+ describe('buildReplyBody(sessionWebhook 回发载荷)', () => {
22
+ it('文本消息体', () => {
23
+ expect(buildReplyBody('hi')).toEqual({ msgtype: 'text', text: { content: 'hi' } });
24
+ });
25
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { discordAttachmentUrl, discordComponents, normalizeDiscordMessage } from '../src/adapters/discord.js';
3
+
4
+ describe('normalizeDiscordMessage', () => {
5
+ it('文本消息 → NormalizedMessage', () => {
6
+ const raw = { channelId: '111', author: { id: '222', bot: false }, content: 'hello' };
7
+ const out = normalizeDiscordMessage(raw);
8
+ expect(out).toMatchObject({ chatId: '111', userId: '222', text: 'hello' });
9
+ });
10
+ it('bot 消息返回 null', () => {
11
+ expect(normalizeDiscordMessage({ channelId: '1', author: { id: '2', bot: true }, content: 'x' })).toBeNull();
12
+ });
13
+ it('含附件 → media: { kind: "image", url }(纯函数 discordAttachmentUrl 取第一条)', () => {
14
+ const raw = {
15
+ channelId: '111',
16
+ author: { id: '222', bot: false },
17
+ content: '',
18
+ attachments: [{ url: 'https://cdn.discordapp.com/a.png', contentType: 'image/png' }],
19
+ };
20
+ expect(discordAttachmentUrl(raw)).toBe('https://cdn.discordapp.com/a.png');
21
+ expect(normalizeDiscordMessage(raw)).toMatchObject({
22
+ chatId: '111', userId: '222', text: '', media: { kind: 'image', url: 'https://cdn.discordapp.com/a.png' },
23
+ });
24
+ });
25
+ });
26
+
27
+ describe('discordComponents(按钮 action row 数据)', () => {
28
+ it('按钮 → discord components 结构', () => {
29
+ const comps = discordComponents([
30
+ { id: 'approve:r1', label: '✅ 同意' },
31
+ { id: 'reject:r1', label: '🚫 拒绝' },
32
+ ]);
33
+ expect(comps).toEqual([{
34
+ type: 1,
35
+ components: [
36
+ { type: 2, custom_id: 'approve:r1', label: '✅ 同意', style: 1 },
37
+ { type: 2, custom_id: 'reject:r1', label: '🚫 拒绝', style: 1 },
38
+ ],
39
+ }]);
40
+ });
41
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildNumberedText, parseFeishuTextMessage } from '../src/adapters/feishu.js';
3
+
4
+ describe('parseFeishuTextMessage(im.message.receive_v1 载荷 → NormalizedMessage)', () => {
5
+ it('文本私聊消息', () => {
6
+ const data = {
7
+ event: {
8
+ message: { message_id: 'om_1', chat_id: 'oc_1', message_type: 'text', content: JSON.stringify({ text: 'hello' }) },
9
+ sender: { sender_id: { open_id: 'ou_1' } },
10
+ },
11
+ };
12
+ const out = parseFeishuTextMessage(data);
13
+ expect(out).toMatchObject({ chatId: 'oc_1', userId: 'ou_1', text: 'hello' });
14
+ });
15
+ it('非文本消息返回 null', () => {
16
+ const data = { event: { message: { message_type: 'image', content: '{}' }, sender: { sender_id: { open_id: 'ou_1' } } } };
17
+ expect(parseFeishuTextMessage(data)).toBeNull();
18
+ });
19
+ });
20
+
21
+ describe('buildNumberedText(审批编号回复)', () => {
22
+ it('生成 1/2 选项文本', () => {
23
+ const text = buildNumberedText('需要批准', [
24
+ { id: 'approve:r1', label: '✅ 同意' },
25
+ { id: 'reject:r1', label: '🚫 拒绝' },
26
+ ]);
27
+ expect(text).toContain('1) ✅ 同意');
28
+ expect(text).toContain('2) 🚫 拒绝');
29
+ });
30
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { normalizeSlackMessage, slackBlocks, slackFileUrl } from '../src/adapters/slack.js';
3
+
4
+ describe('normalizeSlackMessage', () => {
5
+ it('文本消息 → NormalizedMessage', () => {
6
+ const raw = { channel: 'C123', user: 'U456', text: 'hello', subtype: undefined };
7
+ const out = normalizeSlackMessage(raw);
8
+ expect(out).toMatchObject({ chatId: 'C123', userId: 'U456', text: 'hello' });
9
+ });
10
+ it('bot 自己的消息(subtype=bot_message)返回 null', () => {
11
+ expect(normalizeSlackMessage({ channel: 'C1', user: 'U2', text: 'x', subtype: 'bot_message' })).toBeNull();
12
+ });
13
+ it('含文件 → media: { kind: "image", url }(纯函数 slackFileUrl 取 files[0].url_private)', () => {
14
+ const raw = {
15
+ channel: 'C1',
16
+ user: 'U2',
17
+ text: '',
18
+ files: [{ url_private: 'https://files.slack.com/files/x.png', mimetype: 'image/png' }],
19
+ };
20
+ expect(slackFileUrl(raw)).toBe('https://files.slack.com/files/x.png');
21
+ expect(normalizeSlackMessage(raw)).toMatchObject({
22
+ chatId: 'C1', userId: 'U2', text: '', media: { kind: 'image', url: 'https://files.slack.com/files/x.png' },
23
+ });
24
+ });
25
+ });
26
+
27
+ describe('slackBlocks', () => {
28
+ it('纯文本 → 一个 section', () => {
29
+ const blocks = slackBlocks('hi', []);
30
+ expect(blocks).toEqual([{ type: 'section', text: { type: 'mrkdwn', text: 'hi' } }]);
31
+ });
32
+ it('带按钮 → section + actions', () => {
33
+ const blocks = slackBlocks('需要批准', [
34
+ { id: 'approve:r1', label: '✅ 同意' },
35
+ { id: 'reject:r1', label: '🚫 拒绝' },
36
+ ]);
37
+ expect(blocks[1]).toMatchObject({
38
+ type: 'actions',
39
+ elements: [
40
+ { type: 'button', value: 'approve:r1', text: { type: 'plain_text', text: '✅ 同意' } },
41
+ { type: 'button', value: 'reject:r1', text: { type: 'plain_text', text: '🚫 拒绝' } },
42
+ ],
43
+ });
44
+ });
45
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buttonRows, normalizeTelegramMessage, telegramImageUrl, telegramPhotoFileId } from '../src/adapters/telegram.js';
3
+
4
+ describe('normalizeTelegramMessage', () => {
5
+ it('文本消息 → NormalizedMessage(chatId/userId 字符串化)', () => {
6
+ const ctx = { chat: { id: 12345 }, from: { id: 678 }, message: { text: 'hello' } };
7
+ const out = normalizeTelegramMessage(ctx as never);
8
+ expect(out).toMatchObject({ chatId: '12345', userId: '678', text: 'hello' });
9
+ });
10
+ it('无文本返回 null', () => {
11
+ expect(normalizeTelegramMessage({ chat: { id: 1 }, from: { id: 2 }, message: { photo: [] } } as never)).toBeNull();
12
+ });
13
+ it('含 photo 的消息 → media: { kind: "image" },file_id → 下载 URL 模板(真实 getFile 在 adapter)', () => {
14
+ const ctx = {
15
+ chat: { id: 1 },
16
+ from: { id: 2 },
17
+ message: { photo: [{ file_id: 'small' }, { file_id: 'large' }] },
18
+ };
19
+ expect(normalizeTelegramMessage(ctx as never)).toMatchObject({
20
+ chatId: '1', userId: '2', text: '', media: { kind: 'image' },
21
+ });
22
+ expect(telegramPhotoFileId(ctx.message.photo)).toBe('large');
23
+ expect(telegramImageUrl('SECRET', 'photos/file_10.jpg')).toBe('https://api.telegram.org/file/botSECRET/photos/file_10.jpg');
24
+ });
25
+ });
26
+
27
+ describe('buttonRows(InlineKeyboard 数据)', () => {
28
+ it('按钮 → [label, id] 行', () => {
29
+ expect(buttonRows([
30
+ { id: 'approve:r1', label: '✅ 同意' },
31
+ { id: 'reject:r1', label: '🚫 拒绝' },
32
+ ])).toEqual([
33
+ ['✅ 同意', 'approve:r1'],
34
+ ['🚫 拒绝', 'reject:r1'],
35
+ ]);
36
+ });
37
+ });
@@ -0,0 +1,62 @@
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
+ });