@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.
Files changed (47) hide show
  1. package/package.json +9 -4
  2. package/src/adapter.ts +0 -42
  3. package/src/adapters/cli.ts +0 -37
  4. package/src/adapters/dingtalk.ts +0 -206
  5. package/src/adapters/discord.ts +0 -127
  6. package/src/adapters/feishu.ts +0 -224
  7. package/src/adapters/slack.ts +0 -123
  8. package/src/adapters/telegram.ts +0 -142
  9. package/src/adapters/wechat.ts +0 -247
  10. package/src/adapters/wecom.ts +0 -218
  11. package/src/adapters/whatsapp.ts +0 -249
  12. package/src/asr.ts +0 -83
  13. package/src/commands.ts +0 -98
  14. package/src/config.ts +0 -104
  15. package/src/feed.ts +0 -190
  16. package/src/index.ts +0 -510
  17. package/src/memory.ts +0 -176
  18. package/src/mention.ts +0 -51
  19. package/src/pending-buttons.ts +0 -65
  20. package/src/session.ts +0 -23
  21. package/src/setup.ts +0 -252
  22. package/src/status.ts +0 -63
  23. package/src/text.ts +0 -32
  24. package/src/trajectory.ts +0 -45
  25. package/test/adapters.dingtalk.test.ts +0 -64
  26. package/test/adapters.discord.test.ts +0 -41
  27. package/test/adapters.feishu.test.ts +0 -66
  28. package/test/adapters.slack.test.ts +0 -45
  29. package/test/adapters.telegram.test.ts +0 -37
  30. package/test/adapters.wechat.test.ts +0 -78
  31. package/test/adapters.wecom.test.ts +0 -62
  32. package/test/adapters.whatsapp.test.ts +0 -138
  33. package/test/asr.test.ts +0 -77
  34. package/test/commands.test.ts +0 -53
  35. package/test/config.test.ts +0 -30
  36. package/test/feed.test.ts +0 -111
  37. package/test/memory.test.ts +0 -79
  38. package/test/mention.test.ts +0 -54
  39. package/test/multi.test.ts +0 -284
  40. package/test/outbound.test.ts +0 -29
  41. package/test/pending-buttons.test.ts +0 -100
  42. package/test/session.test.ts +0 -26
  43. package/test/status.test.ts +0 -41
  44. package/test/streaming.test.ts +0 -162
  45. package/test/text.test.ts +0 -20
  46. package/test/trajectory.test.ts +0 -58
  47. package/tsconfig.json +0 -5
package/package.json CHANGED
@@ -1,16 +1,21 @@
1
- {
1
+ {
2
2
  "name": "@dsh-overdrive/gateway",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
- "build": "tsc"
6
+ "build": "tsc",
7
+ "prepack": "npm run build"
7
8
  },
8
9
  "bin": {
9
10
  "dsh-overdrive-gateway": "dist/index.js",
10
11
  "dsh-overdrive-setup": "dist/setup.js"
11
12
  },
13
+ "files": [
14
+ "dist",
15
+ "web"
16
+ ],
12
17
  "dependencies": {
13
- "@dsh-overdrive/sdk": "0.1.2",
18
+ "@dsh-overdrive/sdk": "^0.1.2",
14
19
  "@larksuiteoapi/node-sdk": "^1.50.0",
15
20
  "@slack/bolt": "^3.0.0",
16
21
  "@whiskeysockets/baileys": "^6.0.1",
package/src/adapter.ts DELETED
@@ -1,42 +0,0 @@
1
- export interface NormalizedMessage {
2
- chatId: string;
3
- userId: string;
4
- text: string;
5
- media?: { kind: 'voice' | 'image' | 'video' | 'file'; url?: string; mime?: string; caption?: string };
6
- }
7
-
8
- export interface OutboundButton { id: string; label: string; }
9
-
10
- /** 出站媒体(/send 等):path 为本地文件路径。 */
11
- export interface OutboundMedia {
12
- kind: 'image' | 'file' | 'voice';
13
- path: string;
14
- caption?: string;
15
- }
16
-
17
- export interface OutboundPayload {
18
- text: string;
19
- buttons?: OutboundButton[];
20
- /** 可选:随消息发送的本地媒体文件;不支持媒体的适配器忽略并只发文本。 */
21
- media?: OutboundMedia;
22
- }
23
-
24
- /** 按钮回执的点击者身份(用于白名单校验)。chatId 在个别平台回调中可能缺失,缺失时按未授权处理(fail-closed)。 */
25
- export interface ReplySender {
26
- chatId: string;
27
- userId: string;
28
- }
29
-
30
- /** 平台适配器契约:M2/M3 的 WhatsApp/Telegram/… 都实现它。 */
31
- export interface Adapter {
32
- readonly id: string;
33
- connect(): Promise<void>;
34
- send(chatId: string, payload: OutboundPayload): Promise<void>;
35
- /** 可选:平台"正在输入"指示(Telegram/WhatsApp 实现,其余默认无操作)。 */
36
- sendTyping?(chatId: string): Promise<void>;
37
- /** 可选:连接状态(供控制台)。 */
38
- status?(): { connected: boolean };
39
- onMessage(cb: (msg: NormalizedMessage) => void): void;
40
- /** 按钮点击回执:buttonId + 点击者身份。身份缺失即传空字符串,由上层按未授权处理。 */
41
- onReply(cb: (buttonId: string, sender: ReplySender) => void): void;
42
- }
@@ -1,37 +0,0 @@
1
- import { createInterface } from 'node:readline';
2
- import type { Adapter, NormalizedMessage, OutboundPayload } from '../adapter.js';
3
-
4
- /** 本地命令行适配器:M1 用于验证全链路,也是 M2+ 平台适配器的样板。 */
5
- export class CliAdapter implements Adapter {
6
- readonly id = 'cli';
7
- private messageCb?: (msg: NormalizedMessage) => void;
8
- private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
9
- private rl?: ReturnType<typeof createInterface>;
10
-
11
- async connect(): Promise<void> {
12
- this.rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false });
13
- this.rl.on('line', (line) => {
14
- const trimmed = line.trim();
15
- if (!trimmed) return;
16
- const btn = trimmed.match(/^\/btn\s+(\S+)$/i);
17
- if (btn) {
18
- this.replyCb?.(btn[1], { chatId: 'cli', userId: 'local' });
19
- return;
20
- }
21
- this.messageCb?.({ chatId: 'cli', userId: 'local', text: trimmed });
22
- });
23
- }
24
-
25
- async send(_chatId: string, payload: OutboundPayload): Promise<void> {
26
- const lines = [payload.text];
27
- for (const b of payload.buttons ?? []) {
28
- lines.push(` [按钮] ${b.label} → 输入 /btn ${b.id}`);
29
- }
30
- process.stdout.write(lines.join('\n') + '\n');
31
- }
32
-
33
- onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
34
- onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
35
- /** CLI 是本地进程内适配器:恒为已连接。 */
36
- status(): { connected: boolean } { return { connected: true }; }
37
- }
@@ -1,206 +0,0 @@
1
- import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
2
- import { PendingButtons } from '../pending-buttons.js';
3
- import { DWClient, TOPIC_CARD, TOPIC_ROBOT, type RobotMessage } from 'dingtalk-stream-sdk-nodejs';
4
-
5
- // dingtalk-stream-sdk-nodejs@2.0.4 实测:exports 提供 DWClient + TOPIC_ROBOT
6
- // (/v1.0/im/bot/messages/get);回调 registerCallbackListener(TOPIC_ROBOT, (msg) => …),
7
- // msg.data 是 RobotMessage 的 JSON 字符串;回复直接 POST 消息内的 sessionWebhook(无需 access_token)。
8
-
9
- // ── 纯函数 ────────────────────────────────────────────────────
10
-
11
- export interface ParsedRobotMessage {
12
- chatId: string;
13
- userId: string;
14
- text: string;
15
- sessionWebhook: string;
16
- }
17
-
18
- export function parseBotMessage(data: RobotMessage): ParsedRobotMessage | null {
19
- if (data.msgtype !== 'text' || !data.text?.content) return null;
20
- if (!data.conversationId || !data.senderStaffId || !data.sessionWebhook) return null;
21
- return {
22
- chatId: data.conversationId,
23
- userId: data.senderStaffId,
24
- text: data.text.content,
25
- sessionWebhook: data.sessionWebhook,
26
- };
27
- }
28
-
29
- export function buildReplyBody(text: string): { msgtype: 'text'; text: { content: string } } {
30
- return { msgtype: 'text', text: { content: text } };
31
- }
32
-
33
- export function buildNumberedText(text: string, buttons: OutboundButton[]): string {
34
- if (buttons.length === 0) return text;
35
- const options = buttons.map((b, i) => `${i + 1}) ${b.label}`).join('\n');
36
- return `${text}\n\n${options}\n\n回复数字选择。`;
37
- }
38
-
39
- export function matchNumberedButton(text: string, buttons: OutboundButton[]): OutboundButton | undefined {
40
- const n = Number(text.trim());
41
- if (!Number.isInteger(n) || n < 1 || n > buttons.length) return undefined;
42
- return buttons[n - 1];
43
- }
44
-
45
- /** 按钮 id("approve:<reqId>" / "reject:<reqId>")→ 卡片回调载荷 JSON 字符串。 */
46
- export function buttonCallbackData(button: OutboundButton): string {
47
- const idx = button.id.indexOf(':');
48
- return JSON.stringify({
49
- action: idx >= 0 ? button.id.slice(0, idx) : button.id,
50
- reqId: idx >= 0 ? button.id.slice(idx + 1) : '',
51
- });
52
- }
53
-
54
- /** 钉钉 actionCard 消息体(Roadmap v0.2)。按钮回调经 TOPIC_CARD 走 Stream 返回。 */
55
- export function buildActionCard(text: string, buttons: OutboundButton[]): {
56
- msgtype: 'actionCard';
57
- actionCard: { title: string; text: string; btnOrientation: string; btns: Array<{ title: string; actionURL: string }> };
58
- } {
59
- return {
60
- msgtype: 'actionCard',
61
- actionCard: {
62
- title: '需要批准',
63
- text,
64
- btnOrientation: '1',
65
- btns: buttons.map((b) => ({
66
- title: b.label,
67
- actionURL: `dingtalk://dingtalkclient/action/openapp?cardCallbackData=${encodeURIComponent(buttonCallbackData(b))}`,
68
- })),
69
- },
70
- };
71
- }
72
-
73
- export interface CardCallbackResult {
74
- buttonId: string;
75
- chatId?: string;
76
- userId?: string;
77
- }
78
-
79
- /**
80
- * 钉钉卡片回调载荷 → { buttonId, chatId?, userId? }。
81
- * Stream 模式下回调 JSON 的字段名(cardCallbackData / params / cardActionData,以及会话/用户字段)
82
- * 在不同卡片版本有差异,这里做多路径深度兜底解析;真机验证后可按实际字段收敛。找不到返回 null。
83
- */
84
- export function parseCardCallback(raw: unknown): CardCallbackResult | null {
85
- let buttonId: string | null = null;
86
- let chatId: string | undefined;
87
- let userId: string | undefined;
88
-
89
- const visit = (obj: unknown, depth: number): void => {
90
- if (depth > 5 || !obj || typeof obj !== 'object') return;
91
- for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
92
- if (!buttonId && typeof value === 'string' && (key === 'cardCallbackData' || key === 'params' || key === 'cardActionData')) {
93
- try {
94
- const parsed = JSON.parse(value) as { action?: string; reqId?: string };
95
- if ((parsed.action === 'approve' || parsed.action === 'reject') && typeof parsed.reqId === 'string' && parsed.reqId) {
96
- buttonId = `${parsed.action}:${parsed.reqId}`;
97
- }
98
- } catch {
99
- /* 该字段不是 JSON 载荷,继续往下找 */
100
- }
101
- }
102
- if (!chatId && typeof value === 'string' && (key === 'conversationId' || key === 'conversation_id') && value) {
103
- chatId = value;
104
- }
105
- if (!userId && typeof value === 'string' && (key === 'senderStaffId' || key === 'senderId' || key === 'userid' || key === 'userId') && value) {
106
- userId = value;
107
- }
108
- if (typeof value === 'object') visit(value, depth + 1);
109
- }
110
- };
111
- visit(raw, 0);
112
- return buttonId ? { buttonId, chatId, userId } : null;
113
- }
114
-
115
- // ── 适配器 ────────────────────────────────────────────────────
116
-
117
- export interface DingTalkAdapterOptions {
118
- clientId: string;
119
- clientSecret: string;
120
- }
121
-
122
- export class DingTalkAdapter implements Adapter {
123
- readonly id = 'dingtalk';
124
- private client?: DWClient;
125
- private connected = false;
126
- private messageCb?: (msg: NormalizedMessage) => void;
127
- private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
128
- private readonly pendingButtons = new PendingButtons();
129
- /** conversationId → 最近的 sessionWebhook(回复通道,过期由钉钉侧管理) */
130
- private readonly webhooks = new Map<string, string>();
131
-
132
- constructor(private readonly opts: DingTalkAdapterOptions) {}
133
-
134
- async connect(): Promise<void> {
135
- const client = new DWClient({ clientId: this.opts.clientId, clientSecret: this.opts.clientSecret });
136
- this.client = client;
137
- client.registerCallbackListener(TOPIC_ROBOT, (msg) => {
138
- let data: RobotMessage;
139
- try {
140
- data = JSON.parse(msg.data) as RobotMessage;
141
- } catch {
142
- return;
143
- }
144
- const parsed = parseBotMessage(data);
145
- if (!parsed) return;
146
- this.webhooks.set(parsed.chatId, parsed.sessionWebhook);
147
- const button = this.pendingButtons.match(parsed.chatId, parsed.text);
148
- if (button) {
149
- this.replyCb?.(button.id, { chatId: parsed.chatId, userId: parsed.userId });
150
- return;
151
- }
152
- this.messageCb?.({ chatId: parsed.chatId, userId: parsed.userId, text: parsed.text });
153
- });
154
- // 原生 actionCard 按钮回调(Stream 模式,Roadmap v0.2)
155
- client.registerCallbackListener(TOPIC_CARD, (msg) => {
156
- let data: unknown;
157
- try {
158
- data = JSON.parse(msg.data) as unknown;
159
- } catch {
160
- return;
161
- }
162
- const result = parseCardCallback(data);
163
- if (result) {
164
- this.replyCb?.(result.buttonId, {
165
- chatId: result.chatId ?? result.userId ?? '',
166
- userId: result.userId ?? '',
167
- });
168
- }
169
- });
170
- await client.connect();
171
- this.connected = true;
172
- console.log('[dingtalk] 钉钉 Stream 已连接');
173
- }
174
-
175
- async send(chatId: string, payload: OutboundPayload): Promise<void> {
176
- const webhook = this.webhooks.get(chatId);
177
- if (!webhook) throw new Error(`钉钉会话 ${chatId} 无可用 sessionWebhook(先让用户发一条消息)`);
178
- if (payload.buttons?.length) {
179
- this.pendingButtons.set(chatId, payload.buttons); // 卡片之外仍支持编号回复兜底
180
- const res = await fetch(webhook, {
181
- method: 'POST',
182
- headers: { 'content-type': 'application/json' },
183
- body: JSON.stringify(buildActionCard(payload.text, payload.buttons)),
184
- });
185
- if (!res.ok) {
186
- const body = await res.text();
187
- throw new Error(`钉钉回发失败 ${res.status}: ${body.slice(0, 200)}`);
188
- }
189
- return;
190
- }
191
- const text = buildNumberedText(payload.text, payload.buttons ?? []);
192
- const res = await fetch(webhook, {
193
- method: 'POST',
194
- headers: { 'content-type': 'application/json' },
195
- body: JSON.stringify(buildReplyBody(text)),
196
- });
197
- if (!res.ok) {
198
- const body = await res.text();
199
- throw new Error(`钉钉回发失败 ${res.status}: ${body.slice(0, 200)}`);
200
- }
201
- }
202
-
203
- onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
204
- onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
205
- status(): { connected: boolean } { return { connected: this.connected }; }
206
- }
@@ -1,127 +0,0 @@
1
- import {
2
- ActionRowBuilder,
3
- ButtonBuilder,
4
- ButtonStyle,
5
- Client,
6
- Events,
7
- GatewayIntentBits,
8
- type ButtonInteraction,
9
- type Message,
10
- } from 'discord.js';
11
- import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
12
-
13
- // ── 纯函数 ────────────────────────────────────────────────────
14
-
15
- export interface RawDiscordMessage {
16
- channelId?: string;
17
- author?: { id?: string; bot?: boolean };
18
- content?: string;
19
- attachments?: Array<{ url?: string; contentType?: string }>;
20
- }
21
-
22
- /** 纯函数:取第一条附件的下载 URL(attachments.first().url);无附件返回 undefined。 */
23
- export function discordAttachmentUrl(raw: RawDiscordMessage): string | undefined {
24
- return raw.attachments?.[0]?.url;
25
- }
26
-
27
- type MediaKind = 'voice' | 'image' | 'video' | 'file';
28
-
29
- function mediaKindFromMime(mime?: string): MediaKind {
30
- if (mime?.startsWith('image/')) return 'image';
31
- if (mime?.startsWith('audio/')) return 'voice';
32
- if (mime?.startsWith('video/')) return 'video';
33
- return 'file';
34
- }
35
-
36
- export function normalizeDiscordMessage(raw: RawDiscordMessage): NormalizedMessage | null {
37
- if (!raw.channelId || !raw.author?.id || raw.author.bot) return null;
38
- const text = raw.content ?? '';
39
- const url = discordAttachmentUrl(raw);
40
- if (!text && !url) return null;
41
- const out: NormalizedMessage = { chatId: raw.channelId, userId: raw.author.id, text };
42
- if (url) out.media = { kind: mediaKindFromMime(raw.attachments?.[0]?.contentType), url };
43
- return out;
44
- }
45
-
46
- export function discordComponents(buttons: OutboundButton[]): Array<{ type: 1; components: unknown[] }> {
47
- return [{
48
- type: 1,
49
- components: buttons.map((b) => ({
50
- type: 2,
51
- custom_id: b.id,
52
- label: b.label,
53
- style: 1, // ButtonStyle.Primary
54
- })),
55
- }];
56
- }
57
-
58
- // ── 适配器 ────────────────────────────────────────────────────
59
-
60
- export interface DiscordAdapterOptions { token: string; }
61
-
62
- export class DiscordAdapter implements Adapter {
63
- readonly id = 'discord';
64
- private readonly client: Client;
65
- private connected = false;
66
- private messageCb?: (msg: NormalizedMessage) => void;
67
- private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
68
-
69
- constructor(opts: DiscordAdapterOptions) {
70
- this.client = new Client({
71
- intents: [
72
- GatewayIntentBits.Guilds,
73
- GatewayIntentBits.GuildMessages,
74
- GatewayIntentBits.DirectMessages,
75
- GatewayIntentBits.MessageContent,
76
- ],
77
- });
78
- void opts.token;
79
- this.client.login(opts.token).catch((e) => console.error('[discord] 登录失败:', e));
80
- }
81
-
82
- async connect(): Promise<void> {
83
- this.client.once(Events.ClientReady, () => { this.connected = true; console.log('[discord] 已连接 Discord'); });
84
- this.client.on(Events.MessageCreate, (m: Message) => {
85
- const msg = normalizeDiscordMessage(m as never);
86
- if (msg) this.messageCb?.(msg);
87
- });
88
- this.client.on(Events.InteractionCreate, async (interaction) => {
89
- if (!interaction.isButton()) return;
90
- const button = interaction as ButtonInteraction;
91
- await button.deferUpdate().catch(() => undefined);
92
- this.replyCb?.(button.customId, {
93
- chatId: button.channelId,
94
- userId: button.user.id,
95
- });
96
- });
97
- }
98
-
99
- async send(chatId: string, payload: OutboundPayload): Promise<void> {
100
- const channel = await this.client.channels.fetch(chatId);
101
- if (!channel || !('send' in channel)) {
102
- console.error(`[discord] 无法向 ${chatId} 发送(channel 不可用)`);
103
- return;
104
- }
105
- if (payload.media) {
106
- await (channel as { send: (o: unknown) => Promise<unknown> }).send({
107
- content: payload.text,
108
- files: [payload.media.path],
109
- });
110
- return;
111
- }
112
- if (payload.buttons?.length) {
113
- const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
114
- payload.buttons.map((b) =>
115
- new ButtonBuilder().setCustomId(b.id).setLabel(b.label).setStyle(ButtonStyle.Primary),
116
- ),
117
- );
118
- await (channel as { send: (o: unknown) => Promise<unknown> }).send({ content: payload.text, components: [row] });
119
- return;
120
- }
121
- await (channel as { send: (o: unknown) => Promise<unknown> }).send(payload.text);
122
- }
123
-
124
- onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
125
- onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
126
- status(): { connected: boolean } { return { connected: this.connected }; }
127
- }
@@ -1,224 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import lark from '@larksuiteoapi/node-sdk';
3
- import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
4
- import { PendingButtons } from '../pending-buttons.js';
5
-
6
- // @larksuiteoapi/node-sdk 是 CommonJS 包(main=lib/index.js,无 "type":"module"):
7
- // Node 原生 ESM 下必须 default 导入后解构(同 M2b 的 @slack/bolt 处理)。
8
- const { Client, WSClient, EventDispatcher } = lark;
9
-
10
- // ── 纯函数 ────────────────────────────────────────────────────
11
-
12
- export interface FeishuReceivePayload {
13
- event?: {
14
- message?: {
15
- message_id?: string;
16
- chat_id?: string;
17
- message_type?: string;
18
- content?: string;
19
- };
20
- sender?: { sender_id?: { open_id?: string } };
21
- };
22
- }
23
-
24
- export function parseFeishuTextMessage(payload: FeishuReceivePayload): NormalizedMessage | null {
25
- const message = payload.event?.message;
26
- const sender = payload.event?.sender?.sender_id?.open_id;
27
- if (!message?.chat_id || !sender) return null;
28
- if (message.message_type !== 'text') return null;
29
- let text = '';
30
- try {
31
- text = (JSON.parse(message.content ?? '{}') as { text?: string }).text ?? '';
32
- } catch {
33
- return null;
34
- }
35
- if (!text) return null;
36
- return { chatId: message.chat_id, userId: sender, text };
37
- }
38
-
39
- export function buildNumberedText(text: string, buttons: OutboundButton[]): string {
40
- if (buttons.length === 0) return text;
41
- const options = buttons.map((b, i) => `${i + 1}) ${b.label}`).join('\n');
42
- return `${text}\n\n${options}\n\n回复数字选择。`;
43
- }
44
-
45
- export function matchNumberedButton(text: string, buttons: OutboundButton[]): OutboundButton | undefined {
46
- const n = Number(text.trim());
47
- if (!Number.isInteger(n) || n < 1 || n > buttons.length) return undefined;
48
- return buttons[n - 1];
49
- }
50
-
51
- /** 按钮 id("approve:<reqId>" / "reject:<reqId>")→ 卡片按钮 value。 */
52
- export function buttonValue(button: OutboundButton): { action: string; reqId: string } {
53
- const idx = button.id.indexOf(':');
54
- return {
55
- action: idx >= 0 ? button.id.slice(0, idx) : button.id,
56
- reqId: idx >= 0 ? button.id.slice(idx + 1) : '',
57
- };
58
- }
59
-
60
- /** 交互卡片 JSON(msg_type: interactive)。原生按钮点击走 card.action.trigger 回调。 */
61
- export function buildApprovalCard(text: string, buttons: OutboundButton[]): string {
62
- const actions = buttons.map((b) => ({
63
- tag: 'button',
64
- text: { tag: 'plain_text', content: b.label },
65
- type: b.id.startsWith('approve:') ? 'primary' : 'default',
66
- value: buttonValue(b),
67
- }));
68
- const card = {
69
- config: { wide_screen_mode: true },
70
- header: { title: { tag: 'plain_text', content: text.slice(0, 60) }, template: 'blue' },
71
- elements: [
72
- { tag: 'div', text: { tag: 'lark_md', content: text } },
73
- { tag: 'action', actions },
74
- ],
75
- };
76
- return JSON.stringify(card);
77
- }
78
-
79
- /** 卡片回调 value → 按钮 id("approve:<reqId>");缺字段返回 null。 */
80
- export function cardActionToButtonId(value: unknown): string | null {
81
- if (!value || typeof value !== 'object') return null;
82
- const { action, reqId } = value as { action?: unknown; reqId?: unknown };
83
- if ((action !== 'approve' && action !== 'reject') || typeof reqId !== 'string' || !reqId) return null;
84
- return `${action}:${reqId}`;
85
- }
86
-
87
- // ── 适配器 ────────────────────────────────────────────────────
88
-
89
- export interface FeishuAdapterOptions {
90
- appId: string;
91
- appSecret: string;
92
- }
93
-
94
- export class FeishuAdapter implements Adapter {
95
- readonly id = 'feishu';
96
- private readonly client: InstanceType<typeof Client>;
97
- private ws?: InstanceType<typeof WSClient>;
98
- private connected = false;
99
- private messageCb?: (msg: NormalizedMessage) => void;
100
- private replyCb?: (buttonId: string, sender: { chatId: string; userId: string }) => void;
101
- private readonly pendingButtons = new PendingButtons();
102
- /** chatId → 最近一条入站消息的 message_id(send 优先 reply,缺失则 create 兜底) */
103
- private readonly lastMessageIds = new Map<string, string>();
104
-
105
- constructor(private readonly opts: FeishuAdapterOptions) {
106
- this.client = new Client({ appId: opts.appId, appSecret: opts.appSecret });
107
- }
108
-
109
- async connect(): Promise<void> {
110
- const dispatcher = new EventDispatcher({}).register({
111
- 'im.message.receive_v1': async (data: FeishuReceivePayload) => {
112
- const message = data.event?.message;
113
- if (message?.chat_id && message.message_id) {
114
- this.lastMessageIds.set(message.chat_id, message.message_id);
115
- }
116
- const normalized = parseFeishuTextMessage(data);
117
- if (!normalized) return;
118
- const chatId = normalized.chatId;
119
- const button = this.pendingButtons.match(chatId, normalized.text);
120
- if (button) {
121
- this.replyCb?.(button.id, { chatId, userId: normalized.userId });
122
- return;
123
- }
124
- this.messageCb?.(normalized);
125
- },
126
- // 原生交互卡片按钮回调 → 审批应答(Roadmap v0.2)
127
- // 载荷字段(operator.open_id / context.open_chat_id)取自官方卡片回调事件;
128
- // 个别版本字段名可能不同 —— 拿不到身份时上层按未授权处理(fail-closed),编号回复兜底不受影响。
129
- 'card.action.trigger': async (data: {
130
- action?: { value?: unknown };
131
- operator?: { open_id?: string };
132
- context?: { open_chat_id?: string };
133
- }) => {
134
- const buttonId = cardActionToButtonId(data?.action?.value);
135
- if (buttonId) {
136
- this.replyCb?.(buttonId, {
137
- chatId: data?.context?.open_chat_id ?? '',
138
- userId: data?.operator?.open_id ?? '',
139
- });
140
- }
141
- },
142
- });
143
- this.ws = new WSClient({
144
- appId: this.opts.appId,
145
- appSecret: this.opts.appSecret,
146
- loggerLevel: lark.LoggerLevel.error,
147
- });
148
- await this.ws.start({ eventDispatcher: dispatcher });
149
- this.connected = true;
150
- console.log('[feishu] 飞书长连接已建立');
151
- }
152
-
153
- async send(chatId: string, payload: OutboundPayload): Promise<void> {
154
- if (payload.media) {
155
- // 媒体发送:上传 → 换取 image_key / file_key → 发消息;失败降级为文本路径
156
- try {
157
- const buf = readFileSync(payload.media.path);
158
- const messageId = this.lastMessageIds.get(chatId);
159
- if (payload.media.kind === 'image') {
160
- const uploaded = await this.client.im.image.create({
161
- data: { image_type: 'message', image: buf },
162
- });
163
- const content = JSON.stringify({ image_key: uploaded?.image_key ?? '' });
164
- if (messageId) {
165
- await this.client.im.message.reply({ path: { message_id: messageId }, data: { msg_type: 'image', content } });
166
- } else {
167
- await this.client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: chatId, msg_type: 'image', content } });
168
- }
169
- return;
170
- }
171
- const uploaded = await this.client.im.file.create({
172
- data: { file_type: 'stream', file_name: payload.media.caption ?? payload.media.path.split('/').pop() ?? 'file', file: buf },
173
- });
174
- const content = JSON.stringify({ file_key: uploaded?.file_key ?? '' });
175
- if (messageId) {
176
- await this.client.im.message.reply({ path: { message_id: messageId }, data: { msg_type: 'file', content } });
177
- } else {
178
- await this.client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: chatId, msg_type: 'file', content } });
179
- }
180
- return;
181
- } catch (error) {
182
- console.warn(`[feishu] 媒体上传失败,降级为文本: ${error instanceof Error ? error.message : String(error)}`);
183
- // 继续走文本发送(含 📎 路径)
184
- }
185
- }
186
- if (payload.buttons?.length) {
187
- this.pendingButtons.set(chatId, payload.buttons); // 卡片之外仍支持编号回复兜底
188
- const content = buildApprovalCard(payload.text, payload.buttons);
189
- const messageId = this.lastMessageIds.get(chatId);
190
- if (messageId) {
191
- await this.client.im.message.reply({
192
- path: { message_id: messageId },
193
- data: { msg_type: 'interactive', content },
194
- });
195
- } else {
196
- await this.client.im.message.create({
197
- params: { receive_id_type: 'chat_id' },
198
- data: { receive_id: chatId, msg_type: 'interactive', content },
199
- });
200
- }
201
- return;
202
- }
203
- const text = buildNumberedText(payload.text, payload.buttons ?? []);
204
- const content = JSON.stringify({ text });
205
- const messageId = this.lastMessageIds.get(chatId);
206
- if (messageId) {
207
- // 有最近入站消息:im.message.reply(path=message_id)回复原消息
208
- await this.client.im.message.reply({
209
- path: { message_id: messageId },
210
- data: { msg_type: 'text', content },
211
- });
212
- } else {
213
- // 无入站消息(主动下发):im.message.create 按 receive_id=chat_id 发送
214
- await this.client.im.message.create({
215
- params: { receive_id_type: 'chat_id' },
216
- data: { receive_id: chatId, msg_type: 'text', content },
217
- });
218
- }
219
- }
220
-
221
- onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
222
- onReply(cb: (buttonId: string, sender: { chatId: string; userId: string }) => void): void { this.replyCb = cb; }
223
- status(): { connected: boolean } { return { connected: this.connected }; }
224
- }