@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/package.json +31 -0
- package/src/adapter.ts +26 -0
- package/src/adapters/cli.ts +37 -0
- package/src/adapters/dingtalk.ts +111 -0
- package/src/adapters/discord.ts +117 -0
- package/src/adapters/feishu.ts +126 -0
- package/src/adapters/slack.ts +105 -0
- package/src/adapters/telegram.ts +112 -0
- package/src/adapters/wecom.ts +187 -0
- package/src/adapters/whatsapp.ts +231 -0
- package/src/commands.ts +32 -0
- package/src/config.ts +90 -0
- package/src/index.ts +220 -0
- package/src/session.ts +17 -0
- package/src/status.ts +64 -0
- package/src/trajectory.ts +45 -0
- package/test/adapters.dingtalk.test.ts +25 -0
- package/test/adapters.discord.test.ts +41 -0
- package/test/adapters.feishu.test.ts +30 -0
- package/test/adapters.slack.test.ts +45 -0
- package/test/adapters.telegram.test.ts +37 -0
- package/test/adapters.wecom.test.ts +62 -0
- package/test/adapters.whatsapp.test.ts +138 -0
- package/test/commands.test.ts +18 -0
- package/test/config.test.ts +30 -0
- package/test/multi.test.ts +86 -0
- package/test/outbound.test.ts +29 -0
- package/test/session.test.ts +21 -0
- package/test/status.test.ts +41 -0
- package/test/streaming.test.ts +162 -0
- package/test/trajectory.test.ts +58 -0
- package/tsconfig.json +5 -0
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dsh-overdrive/gateway",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "tsc"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@dsh-overdrive/sdk": "0.1.0",
|
|
10
|
+
"@larksuiteoapi/node-sdk": "^1.50.0",
|
|
11
|
+
"@slack/bolt": "^3.0.0",
|
|
12
|
+
"@whiskeysockets/baileys": "^6.0.1",
|
|
13
|
+
"dingtalk-stream-sdk-nodejs": "^2.0.4",
|
|
14
|
+
"discord.js": "^14.27.0",
|
|
15
|
+
"grammy": "^1.45.0",
|
|
16
|
+
"pino": "^9.0.0",
|
|
17
|
+
"qrcode-terminal": "^0.12.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/qrcode-terminal": "^0.12.2"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"description": "dsh-overdrive: 多平台消息网关(WhatsApp/Telegram/Discord/Slack/飞书/钉钉/企业微信)",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/temotee2103/dsh-overdrive.git"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
export interface OutboundPayload {
|
|
11
|
+
text: string;
|
|
12
|
+
buttons?: OutboundButton[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 平台适配器契约:M2/M3 的 WhatsApp/Telegram/… 都实现它。 */
|
|
16
|
+
export interface Adapter {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
connect(): Promise<void>;
|
|
19
|
+
send(chatId: string, payload: OutboundPayload): Promise<void>;
|
|
20
|
+
/** 可选:平台"正在输入"指示(Telegram/WhatsApp 实现,其余默认无操作)。 */
|
|
21
|
+
sendTyping?(chatId: string): Promise<void>;
|
|
22
|
+
/** 可选:连接状态(供控制台)。 */
|
|
23
|
+
status?(): { connected: boolean };
|
|
24
|
+
onMessage(cb: (msg: NormalizedMessage) => void): void;
|
|
25
|
+
onReply(cb: (buttonId: string) => void): void;
|
|
26
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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) => 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]);
|
|
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) => void): void { this.replyCb = cb; }
|
|
35
|
+
/** CLI 是本地进程内适配器:恒为已连接。 */
|
|
36
|
+
status(): { connected: boolean } { return { connected: true }; }
|
|
37
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
2
|
+
import { DWClient, TOPIC_ROBOT, type RobotMessage } from 'dingtalk-stream-sdk-nodejs';
|
|
3
|
+
|
|
4
|
+
// dingtalk-stream-sdk-nodejs@2.0.4 实测:exports 提供 DWClient + TOPIC_ROBOT
|
|
5
|
+
// (/v1.0/im/bot/messages/get);回调 registerCallbackListener(TOPIC_ROBOT, (msg) => …),
|
|
6
|
+
// msg.data 是 RobotMessage 的 JSON 字符串;回复直接 POST 消息内的 sessionWebhook(无需 access_token)。
|
|
7
|
+
|
|
8
|
+
// ── 纯函数 ────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface ParsedRobotMessage {
|
|
11
|
+
chatId: string;
|
|
12
|
+
userId: string;
|
|
13
|
+
text: string;
|
|
14
|
+
sessionWebhook: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseBotMessage(data: RobotMessage): ParsedRobotMessage | null {
|
|
18
|
+
if (data.msgtype !== 'text' || !data.text?.content) return null;
|
|
19
|
+
if (!data.conversationId || !data.senderStaffId || !data.sessionWebhook) return null;
|
|
20
|
+
return {
|
|
21
|
+
chatId: data.conversationId,
|
|
22
|
+
userId: data.senderStaffId,
|
|
23
|
+
text: data.text.content,
|
|
24
|
+
sessionWebhook: data.sessionWebhook,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildReplyBody(text: string): { msgtype: 'text'; text: { content: string } } {
|
|
29
|
+
return { msgtype: 'text', text: { content: text } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildNumberedText(text: string, buttons: OutboundButton[]): string {
|
|
33
|
+
if (buttons.length === 0) return text;
|
|
34
|
+
const options = buttons.map((b, i) => `${i + 1}) ${b.label}`).join('\n');
|
|
35
|
+
return `${text}\n\n${options}\n\n回复数字选择。`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function matchNumberedButton(text: string, buttons: OutboundButton[]): OutboundButton | undefined {
|
|
39
|
+
const n = Number(text.trim());
|
|
40
|
+
if (!Number.isInteger(n) || n < 1 || n > buttons.length) return undefined;
|
|
41
|
+
return buttons[n - 1];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── 适配器 ────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export interface DingTalkAdapterOptions {
|
|
47
|
+
clientId: string;
|
|
48
|
+
clientSecret: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class DingTalkAdapter implements Adapter {
|
|
52
|
+
readonly id = 'dingtalk';
|
|
53
|
+
private client?: DWClient;
|
|
54
|
+
private connected = false;
|
|
55
|
+
private messageCb?: (msg: NormalizedMessage) => void;
|
|
56
|
+
private replyCb?: (buttonId: string) => void;
|
|
57
|
+
private readonly pendingButtons = new Map<string, OutboundButton[]>();
|
|
58
|
+
/** conversationId → 最近的 sessionWebhook(回复通道,过期由钉钉侧管理) */
|
|
59
|
+
private readonly webhooks = new Map<string, string>();
|
|
60
|
+
|
|
61
|
+
constructor(private readonly opts: DingTalkAdapterOptions) {}
|
|
62
|
+
|
|
63
|
+
async connect(): Promise<void> {
|
|
64
|
+
const client = new DWClient({ clientId: this.opts.clientId, clientSecret: this.opts.clientSecret });
|
|
65
|
+
this.client = client;
|
|
66
|
+
client.registerCallbackListener(TOPIC_ROBOT, (msg) => {
|
|
67
|
+
let data: RobotMessage;
|
|
68
|
+
try {
|
|
69
|
+
data = JSON.parse(msg.data) as RobotMessage;
|
|
70
|
+
} catch {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const parsed = parseBotMessage(data);
|
|
74
|
+
if (!parsed) return;
|
|
75
|
+
this.webhooks.set(parsed.chatId, parsed.sessionWebhook);
|
|
76
|
+
const pending = this.pendingButtons.get(parsed.chatId);
|
|
77
|
+
if (pending) {
|
|
78
|
+
const button = matchNumberedButton(parsed.text, pending);
|
|
79
|
+
if (button) {
|
|
80
|
+
this.pendingButtons.delete(parsed.chatId);
|
|
81
|
+
this.replyCb?.(button.id);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
this.messageCb?.({ chatId: parsed.chatId, userId: parsed.userId, text: parsed.text });
|
|
86
|
+
});
|
|
87
|
+
await client.connect();
|
|
88
|
+
this.connected = true;
|
|
89
|
+
console.log('[dingtalk] 钉钉 Stream 已连接');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async send(chatId: string, payload: OutboundPayload): Promise<void> {
|
|
93
|
+
const webhook = this.webhooks.get(chatId);
|
|
94
|
+
if (!webhook) throw new Error(`钉钉会话 ${chatId} 无可用 sessionWebhook(先让用户发一条消息)`);
|
|
95
|
+
if (payload.buttons?.length) this.pendingButtons.set(chatId, payload.buttons);
|
|
96
|
+
const text = buildNumberedText(payload.text, payload.buttons ?? []);
|
|
97
|
+
const res = await fetch(webhook, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'content-type': 'application/json' },
|
|
100
|
+
body: JSON.stringify(buildReplyBody(text)),
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
const body = await res.text();
|
|
104
|
+
throw new Error(`钉钉回发失败 ${res.status}: ${body.slice(0, 200)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
109
|
+
onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
|
|
110
|
+
status(): { connected: boolean } { return { connected: this.connected }; }
|
|
111
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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) => 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
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async send(chatId: string, payload: OutboundPayload): Promise<void> {
|
|
97
|
+
const channel = await this.client.channels.fetch(chatId);
|
|
98
|
+
if (!channel || !('send' in channel)) {
|
|
99
|
+
console.error(`[discord] 无法向 ${chatId} 发送(channel 不可用)`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (payload.buttons?.length) {
|
|
103
|
+
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
104
|
+
payload.buttons.map((b) =>
|
|
105
|
+
new ButtonBuilder().setCustomId(b.id).setLabel(b.label).setStyle(ButtonStyle.Primary),
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
await (channel as { send: (o: unknown) => Promise<unknown> }).send({ content: payload.text, components: [row] });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
await (channel as { send: (o: unknown) => Promise<unknown> }).send(payload.text);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
115
|
+
onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
|
|
116
|
+
status(): { connected: boolean } { return { connected: this.connected }; }
|
|
117
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import lark from '@larksuiteoapi/node-sdk';
|
|
2
|
+
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
3
|
+
|
|
4
|
+
// @larksuiteoapi/node-sdk 是 CommonJS 包(main=lib/index.js,无 "type":"module"):
|
|
5
|
+
// Node 原生 ESM 下必须 default 导入后解构(同 M2b 的 @slack/bolt 处理)。
|
|
6
|
+
const { Client, WSClient, EventDispatcher } = lark;
|
|
7
|
+
|
|
8
|
+
// ── 纯函数 ────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface FeishuReceivePayload {
|
|
11
|
+
event?: {
|
|
12
|
+
message?: {
|
|
13
|
+
message_id?: string;
|
|
14
|
+
chat_id?: string;
|
|
15
|
+
message_type?: string;
|
|
16
|
+
content?: string;
|
|
17
|
+
};
|
|
18
|
+
sender?: { sender_id?: { open_id?: string } };
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseFeishuTextMessage(payload: FeishuReceivePayload): NormalizedMessage | null {
|
|
23
|
+
const message = payload.event?.message;
|
|
24
|
+
const sender = payload.event?.sender?.sender_id?.open_id;
|
|
25
|
+
if (!message?.chat_id || !sender) return null;
|
|
26
|
+
if (message.message_type !== 'text') return null;
|
|
27
|
+
let text = '';
|
|
28
|
+
try {
|
|
29
|
+
text = (JSON.parse(message.content ?? '{}') as { text?: string }).text ?? '';
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (!text) return null;
|
|
34
|
+
return { chatId: message.chat_id, userId: sender, text };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildNumberedText(text: string, buttons: OutboundButton[]): string {
|
|
38
|
+
if (buttons.length === 0) return text;
|
|
39
|
+
const options = buttons.map((b, i) => `${i + 1}) ${b.label}`).join('\n');
|
|
40
|
+
return `${text}\n\n${options}\n\n回复数字选择。`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function matchNumberedButton(text: string, buttons: OutboundButton[]): OutboundButton | undefined {
|
|
44
|
+
const n = Number(text.trim());
|
|
45
|
+
if (!Number.isInteger(n) || n < 1 || n > buttons.length) return undefined;
|
|
46
|
+
return buttons[n - 1];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── 适配器 ────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export interface FeishuAdapterOptions {
|
|
52
|
+
appId: string;
|
|
53
|
+
appSecret: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class FeishuAdapter implements Adapter {
|
|
57
|
+
readonly id = 'feishu';
|
|
58
|
+
private readonly client: InstanceType<typeof Client>;
|
|
59
|
+
private ws?: InstanceType<typeof WSClient>;
|
|
60
|
+
private connected = false;
|
|
61
|
+
private messageCb?: (msg: NormalizedMessage) => void;
|
|
62
|
+
private replyCb?: (buttonId: string) => void;
|
|
63
|
+
private readonly pendingButtons = new Map<string, OutboundButton[]>();
|
|
64
|
+
/** chatId → 最近一条入站消息的 message_id(send 优先 reply,缺失则 create 兜底) */
|
|
65
|
+
private readonly lastMessageIds = new Map<string, string>();
|
|
66
|
+
|
|
67
|
+
constructor(private readonly opts: FeishuAdapterOptions) {
|
|
68
|
+
this.client = new Client({ appId: opts.appId, appSecret: opts.appSecret });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async connect(): Promise<void> {
|
|
72
|
+
const dispatcher = new EventDispatcher({}).register({
|
|
73
|
+
'im.message.receive_v1': async (data: FeishuReceivePayload) => {
|
|
74
|
+
const message = data.event?.message;
|
|
75
|
+
if (message?.chat_id && message.message_id) {
|
|
76
|
+
this.lastMessageIds.set(message.chat_id, message.message_id);
|
|
77
|
+
}
|
|
78
|
+
const normalized = parseFeishuTextMessage(data);
|
|
79
|
+
if (!normalized) return;
|
|
80
|
+
const chatId = normalized.chatId;
|
|
81
|
+
const pending = this.pendingButtons.get(chatId);
|
|
82
|
+
if (pending) {
|
|
83
|
+
const button = matchNumberedButton(normalized.text, pending);
|
|
84
|
+
if (button) {
|
|
85
|
+
this.pendingButtons.delete(chatId);
|
|
86
|
+
this.replyCb?.(button.id);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
this.messageCb?.(normalized);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
this.ws = new WSClient({
|
|
94
|
+
appId: this.opts.appId,
|
|
95
|
+
appSecret: this.opts.appSecret,
|
|
96
|
+
loggerLevel: lark.LoggerLevel.error,
|
|
97
|
+
});
|
|
98
|
+
await this.ws.start({ eventDispatcher: dispatcher });
|
|
99
|
+
this.connected = true;
|
|
100
|
+
console.log('[feishu] 飞书长连接已建立');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async send(chatId: string, payload: OutboundPayload): Promise<void> {
|
|
104
|
+
if (payload.buttons?.length) this.pendingButtons.set(chatId, payload.buttons);
|
|
105
|
+
const text = buildNumberedText(payload.text, payload.buttons ?? []);
|
|
106
|
+
const content = JSON.stringify({ text });
|
|
107
|
+
const messageId = this.lastMessageIds.get(chatId);
|
|
108
|
+
if (messageId) {
|
|
109
|
+
// 有最近入站消息:im.message.reply(path=message_id)回复原消息
|
|
110
|
+
await this.client.im.message.reply({
|
|
111
|
+
path: { message_id: messageId },
|
|
112
|
+
data: { msg_type: 'text', content },
|
|
113
|
+
});
|
|
114
|
+
} else {
|
|
115
|
+
// 无入站消息(主动下发):im.message.create 按 receive_id=chat_id 发送
|
|
116
|
+
await this.client.im.message.create({
|
|
117
|
+
params: { receive_id_type: 'chat_id' },
|
|
118
|
+
data: { receive_id: chatId, msg_type: 'text', content },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
124
|
+
onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
|
|
125
|
+
status(): { connected: boolean } { return { connected: this.connected }; }
|
|
126
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import Bolt from '@slack/bolt';
|
|
2
|
+
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
3
|
+
|
|
4
|
+
// @slack/bolt 是 CommonJS 包:Node 原生 ESM 下 `import { App }`(命名导入)会因
|
|
5
|
+
// cjs-module-lexer 无法识别其导出而失败(App 为 undefined / 静态导入抛 SyntaxError),
|
|
6
|
+
// 必须 default 导入后解构。
|
|
7
|
+
const { App } = Bolt;
|
|
8
|
+
|
|
9
|
+
// ── 纯函数 ────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export interface RawSlackMessage {
|
|
12
|
+
channel?: string;
|
|
13
|
+
user?: string;
|
|
14
|
+
text?: string;
|
|
15
|
+
subtype?: string;
|
|
16
|
+
bot_id?: string;
|
|
17
|
+
files?: Array<{ url_private?: string; mimetype?: string }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 纯函数:取第一条文件的私有 URL(files[0].url_private);无文件返回 undefined。 */
|
|
21
|
+
export function slackFileUrl(raw: RawSlackMessage): string | undefined {
|
|
22
|
+
return raw.files?.[0]?.url_private;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type MediaKind = 'voice' | 'image' | 'video' | 'file';
|
|
26
|
+
|
|
27
|
+
function mediaKindFromMime(mime?: string): MediaKind {
|
|
28
|
+
if (mime?.startsWith('image/')) return 'image';
|
|
29
|
+
if (mime?.startsWith('audio/')) return 'voice';
|
|
30
|
+
if (mime?.startsWith('video/')) return 'video';
|
|
31
|
+
return 'file';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function normalizeSlackMessage(raw: RawSlackMessage): NormalizedMessage | null {
|
|
35
|
+
if (!raw.channel || !raw.user || raw.subtype === 'bot_message' || raw.bot_id) return null;
|
|
36
|
+
const text = raw.text ?? '';
|
|
37
|
+
const url = slackFileUrl(raw);
|
|
38
|
+
if (!text && !url) return null;
|
|
39
|
+
const out: NormalizedMessage = { chatId: raw.channel, userId: raw.user, text };
|
|
40
|
+
if (url) out.media = { kind: mediaKindFromMime(raw.files?.[0]?.mimetype), url };
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function slackBlocks(text: string, buttons: OutboundButton[]): unknown[] {
|
|
45
|
+
const blocks: unknown[] = [{ type: 'section', text: { type: 'mrkdwn', text } }];
|
|
46
|
+
if (buttons.length > 0) {
|
|
47
|
+
blocks.push({
|
|
48
|
+
type: 'actions',
|
|
49
|
+
elements: buttons.map((b) => ({
|
|
50
|
+
type: 'button',
|
|
51
|
+
value: b.id,
|
|
52
|
+
text: { type: 'plain_text', text: b.label },
|
|
53
|
+
})),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return blocks;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── 适配器 ────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export interface SlackAdapterOptions {
|
|
62
|
+
botToken: string;
|
|
63
|
+
appToken: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class SlackAdapter implements Adapter {
|
|
67
|
+
readonly id = 'slack';
|
|
68
|
+
// 解构出的 App 只有值绑定(无类型绑定),实例类型需用 InstanceType<typeof App>
|
|
69
|
+
private readonly app: InstanceType<typeof App>;
|
|
70
|
+
private connected = false;
|
|
71
|
+
private messageCb?: (msg: NormalizedMessage) => void;
|
|
72
|
+
private replyCb?: (buttonId: string) => void;
|
|
73
|
+
|
|
74
|
+
constructor(opts: SlackAdapterOptions) {
|
|
75
|
+
this.app = new App({ token: opts.botToken, appToken: opts.appToken, socketMode: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async connect(): Promise<void> {
|
|
79
|
+
this.app.message(async ({ message }) => {
|
|
80
|
+
const msg = normalizeSlackMessage(message as RawSlackMessage);
|
|
81
|
+
if (msg) this.messageCb?.(msg);
|
|
82
|
+
});
|
|
83
|
+
this.app.action(/^approve:|^reject:/, async ({ ack, body, respond }) => {
|
|
84
|
+
await ack();
|
|
85
|
+
const action = (body as { actions?: Array<{ value?: string }> }).actions?.[0];
|
|
86
|
+
if (action?.value) this.replyCb?.(action.value);
|
|
87
|
+
await respond({ text: '处理中…', replace_original: false }).catch(() => undefined);
|
|
88
|
+
});
|
|
89
|
+
await this.app.start(0); // Socket Mode 不需要端口;start(0) 仅建立连接
|
|
90
|
+
this.connected = true;
|
|
91
|
+
console.log('[slack] 已连接 Slack(Socket Mode)');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async send(chatId: string, payload: OutboundPayload): Promise<void> {
|
|
95
|
+
await this.app.client.chat.postMessage({
|
|
96
|
+
channel: chatId,
|
|
97
|
+
text: payload.text,
|
|
98
|
+
blocks: slackBlocks(payload.text, payload.buttons ?? []) as never,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
103
|
+
onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
|
|
104
|
+
status(): { connected: boolean } { return { connected: this.connected }; }
|
|
105
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Bot, InlineKeyboard } from 'grammy';
|
|
2
|
+
import type { Adapter, NormalizedMessage, OutboundButton, OutboundPayload } from '../adapter.js';
|
|
3
|
+
|
|
4
|
+
// ── 纯函数 ────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface RawTelegramMessage {
|
|
7
|
+
chat?: { id?: number | string };
|
|
8
|
+
from?: { id?: number | string };
|
|
9
|
+
message?: {
|
|
10
|
+
text?: string;
|
|
11
|
+
caption?: string;
|
|
12
|
+
photo?: Array<{ file_id?: string }>;
|
|
13
|
+
voice?: { file_id?: string };
|
|
14
|
+
audio?: { file_id?: string };
|
|
15
|
+
video?: { file_id?: string };
|
|
16
|
+
document?: { file_id?: string };
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 纯函数:取 photo 数组最后一张(最大尺寸)的 file_id;无图返回 undefined。 */
|
|
21
|
+
export function telegramPhotoFileId(photo: Array<{ file_id?: string }>): string | undefined {
|
|
22
|
+
return photo[photo.length - 1]?.file_id;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 纯函数:Telegram 文件下载 URL 模板。file_path 需 getFile(file_id) 换取(真实调用在 adapter 薄层)。 */
|
|
26
|
+
export function telegramImageUrl(token: string, filePath: string): string {
|
|
27
|
+
return `https://api.telegram.org/file/bot${token}/${filePath}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeTelegramMessage(raw: RawTelegramMessage): NormalizedMessage | null {
|
|
31
|
+
if (raw.chat?.id === undefined || raw.from?.id === undefined) return null;
|
|
32
|
+
const msg = raw.message;
|
|
33
|
+
if (!msg) return null;
|
|
34
|
+
const text = msg.text ?? msg.caption ?? '';
|
|
35
|
+
let media: NormalizedMessage['media'];
|
|
36
|
+
if (msg.photo?.length) media = { kind: 'image' }; // url 由 adapter getFile 薄层填充
|
|
37
|
+
else if (msg.voice || msg.audio) media = { kind: 'voice' };
|
|
38
|
+
else if (msg.video) media = { kind: 'video' };
|
|
39
|
+
else if (msg.document) media = { kind: 'file' };
|
|
40
|
+
if (!text && !media) return null;
|
|
41
|
+
const out: NormalizedMessage = { chatId: String(raw.chat.id), userId: String(raw.from.id), text };
|
|
42
|
+
if (media) out.media = media;
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function buttonRows(buttons: OutboundButton[]): Array<[string, string]> {
|
|
47
|
+
return buttons.map((b) => [b.label, b.id]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── 适配器 ────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export interface TelegramAdapterOptions { token: string; }
|
|
53
|
+
|
|
54
|
+
export class TelegramAdapter implements Adapter {
|
|
55
|
+
readonly id = 'telegram';
|
|
56
|
+
private readonly bot: Bot;
|
|
57
|
+
private readonly token: string;
|
|
58
|
+
private connected = false;
|
|
59
|
+
private messageCb?: (msg: NormalizedMessage) => void;
|
|
60
|
+
private replyCb?: (buttonId: string) => void;
|
|
61
|
+
|
|
62
|
+
constructor(opts: TelegramAdapterOptions) {
|
|
63
|
+
this.token = opts.token;
|
|
64
|
+
this.bot = new Bot(opts.token);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async connect(): Promise<void> {
|
|
68
|
+
await this.bot.api.getMe(); // 校验 token
|
|
69
|
+
this.connected = true;
|
|
70
|
+
this.bot.on('message', (ctx) => {
|
|
71
|
+
console.log(`[telegram] 收到更新: chat=${ctx.chat?.id} from=${ctx.from?.id} text="${ctx.message?.text?.slice(0, 40) ?? ''}"`);
|
|
72
|
+
void this.handleMessage(ctx as never);
|
|
73
|
+
});
|
|
74
|
+
this.bot.on('callback_query:data', async (ctx) => {
|
|
75
|
+
const data = ctx.callbackQuery.data;
|
|
76
|
+
await ctx.answerCallbackQuery().catch(() => undefined);
|
|
77
|
+
this.replyCb?.(data);
|
|
78
|
+
});
|
|
79
|
+
this.bot.catch((err) => console.error('[telegram]', err));
|
|
80
|
+
void this.bot.start(); // 长轮询(自托管无需 webhook)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 薄层:photo → getFile(file_id) 换 file_path → 下载 URL 填充 msg.media.url(纯函数只做模板)。 */
|
|
84
|
+
private async handleMessage(raw: RawTelegramMessage): Promise<void> {
|
|
85
|
+
const msg = normalizeTelegramMessage(raw);
|
|
86
|
+
if (!msg) return;
|
|
87
|
+
const fileId = msg.media?.kind === 'image' ? telegramPhotoFileId(raw.message?.photo ?? []) : undefined;
|
|
88
|
+
if (fileId) {
|
|
89
|
+
try {
|
|
90
|
+
const file = await this.bot.api.getFile(fileId);
|
|
91
|
+
if (file.file_path) msg.media!.url = telegramImageUrl(this.token, file.file_path);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('[telegram] getFile 失败:', error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
this.messageCb?.(msg);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async send(chatId: string, payload: OutboundPayload): Promise<void> {
|
|
100
|
+
if (payload.buttons?.length) {
|
|
101
|
+
const kb = new InlineKeyboard();
|
|
102
|
+
for (const [label, id] of buttonRows(payload.buttons)) kb.text(label, id);
|
|
103
|
+
await this.bot.api.sendMessage(chatId, payload.text, { reply_markup: kb });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
await this.bot.api.sendMessage(chatId, payload.text);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
110
|
+
onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
|
|
111
|
+
status(): { connected: boolean } { return { connected: this.connected }; }
|
|
112
|
+
}
|