@wenbin_wb/dsh-bridge 2.3.2 → 2.4.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/README.en.md +41 -1
- package/README.md +41 -1
- package/client/client.js +102 -45
- package/client/index.js +103 -46
- package/docs/telegram-usage.md +89 -0
- package/lib/feishu/gateway.js +166 -7
- package/lib/feishu/node.js +55 -10
- package/lib/index.js +56 -1
- package/lib/platform/base.js +13 -0
- package/lib/platform/conversation-bridge.js +32 -4
- package/lib/qq/gateway.js +94 -14
- package/lib/qq/node.js +49 -9
- package/lib/telegram/gateway.js +619 -0
- package/lib/telegram/index.js +210 -0
- package/lib/telegram/node.js +342 -0
- package/lib/wechat/gateway.js +54 -0
- package/lib/wechat/node.js +1 -0
- package/package.json +4 -3
package/client/index.js
CHANGED
|
@@ -343,16 +343,18 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
343
343
|
setCfgDraft({
|
|
344
344
|
digestIntervalSec: String(platform.config.digestIntervalSec ?? 300),
|
|
345
345
|
approvalTimeoutSec: String(platform.config.approvalTimeoutSec ?? 600),
|
|
346
|
-
maxMessageChars: String(platform.config.maxMessageChars ?? 2000),
|
|
346
|
+
maxMessageChars: String(platform.config.maxMessageChars ?? (platformId === 'telegram' ? 4096 : 2000)),
|
|
347
347
|
sendChunkDelayMs: String(platform.config.sendChunkDelayMs ?? 1500),
|
|
348
348
|
appId: platform.config.appId ?? '',
|
|
349
349
|
// Secret 不由后端回传;空值表示沿用已保存密钥
|
|
350
350
|
clientSecret: '',
|
|
351
351
|
appSecret: '',
|
|
352
352
|
domain: platform.config.domain ?? 'feishu',
|
|
353
|
+
botToken: '',
|
|
354
|
+
proxy: platform.config.proxy ?? '',
|
|
353
355
|
});
|
|
354
356
|
}
|
|
355
|
-
}, [platform?.config]);
|
|
357
|
+
}, [platform?.config, platformId]);
|
|
356
358
|
|
|
357
359
|
// 向上传递连接状态(供平台列表卡片绿点使用)
|
|
358
360
|
React.useEffect(() => {
|
|
@@ -424,10 +426,10 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
424
426
|
...d,
|
|
425
427
|
digestIntervalSec: '300',
|
|
426
428
|
approvalTimeoutSec: '600',
|
|
427
|
-
maxMessageChars: '2000',
|
|
429
|
+
maxMessageChars: platformId === 'telegram' ? '4096' : '2000',
|
|
428
430
|
sendChunkDelayMs: '1500',
|
|
429
431
|
}));
|
|
430
|
-
}, []);
|
|
432
|
+
}, [platformId]);
|
|
431
433
|
|
|
432
434
|
// 高级设置保存
|
|
433
435
|
const saveConfig = React.useCallback(async () => {
|
|
@@ -438,7 +440,7 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
438
440
|
maxMessageChars: Number(cfgDraft.maxMessageChars),
|
|
439
441
|
sendChunkDelayMs: Number(cfgDraft.sendChunkDelayMs),
|
|
440
442
|
};
|
|
441
|
-
// QQ / 飞书 平台额外携带凭证
|
|
443
|
+
// QQ / 飞书 / Telegram 平台额外携带凭证
|
|
442
444
|
if (platformId === 'qq') {
|
|
443
445
|
payload.appId = cfgDraft.appId.trim();
|
|
444
446
|
payload.clientSecret = cfgDraft.clientSecret.trim();
|
|
@@ -446,6 +448,9 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
446
448
|
payload.appId = cfgDraft.appId.trim();
|
|
447
449
|
payload.appSecret = cfgDraft.appSecret.trim();
|
|
448
450
|
payload.domain = cfgDraft.domain || 'feishu';
|
|
451
|
+
} else if (platformId === 'telegram') {
|
|
452
|
+
payload.botToken = cfgDraft.botToken.trim();
|
|
453
|
+
payload.proxy = cfgDraft.proxy.trim();
|
|
449
454
|
}
|
|
450
455
|
await act(BRIDGE_ENDPOINTS.platformSetConfig, payload);
|
|
451
456
|
}, [act, cfgDraft, platformId]);
|
|
@@ -461,6 +466,10 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
461
466
|
(platformId === 'feishu' && (
|
|
462
467
|
cfgDraft.appId !== (platform.config.appId ?? '') ||
|
|
463
468
|
cfgDraft.appSecret !== (platform.config.appSecret ?? '')
|
|
469
|
+
)) ||
|
|
470
|
+
(platformId === 'telegram' && (
|
|
471
|
+
cfgDraft.botToken !== '' ||
|
|
472
|
+
cfgDraft.proxy !== (platform.config.proxy ?? '')
|
|
464
473
|
))
|
|
465
474
|
);
|
|
466
475
|
|
|
@@ -523,6 +532,16 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
523
532
|
target: '_blank', rel: 'noopener noreferrer',
|
|
524
533
|
style: s.btnGhost,
|
|
525
534
|
}, '🌐 飞书开放平台'),
|
|
535
|
+
platformId === 'telegram' && React.createElement('a', {
|
|
536
|
+
href: 'https://github.com/wenbin-wb/dsh-bridge/blob/main/docs/telegram-usage.md',
|
|
537
|
+
target: '_blank', rel: 'noopener noreferrer',
|
|
538
|
+
style: s.btnGhost,
|
|
539
|
+
}, '📖 Telegram 使用说明'),
|
|
540
|
+
platformId === 'telegram' && React.createElement('a', {
|
|
541
|
+
href: 'https://t.me/BotFather',
|
|
542
|
+
target: '_blank', rel: 'noopener noreferrer',
|
|
543
|
+
style: s.btnGhost,
|
|
544
|
+
}, '🌐 @BotFather 申请 Bot'),
|
|
526
545
|
React.createElement('button', {
|
|
527
546
|
style: s.btnGhost,
|
|
528
547
|
onClick: () => setShowHelp(v => !v),
|
|
@@ -643,8 +662,8 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
643
662
|
title: '清除登录凭证,下次需重新配置',
|
|
644
663
|
}, '解绑账号'),
|
|
645
664
|
),
|
|
646
|
-
//
|
|
647
|
-
platformId === 'feishu' && platform.botQr && React.createElement('div', {
|
|
665
|
+
// 飞书 / Telegram 扫码直达对话引导卡片
|
|
666
|
+
(platformId === 'feishu' || platformId === 'telegram') && platform.botQr && React.createElement('div', {
|
|
648
667
|
style: {
|
|
649
668
|
marginTop: 12,
|
|
650
669
|
padding: 12,
|
|
@@ -657,11 +676,11 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
657
676
|
flexWrap: 'wrap',
|
|
658
677
|
},
|
|
659
678
|
},
|
|
660
|
-
React.createElement('img', { src: platform.botQr, alt:
|
|
679
|
+
React.createElement('img', { src: platform.botQr, alt: `${platformName} Bot QR`, style: { width: 110, height: 110, borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', padding: 4, background: '#fff' } }),
|
|
661
680
|
React.createElement('div', { style: { flex: 1, minWidth: 160 } },
|
|
662
|
-
React.createElement('div', { style: { ...s.label, fontSize: 13, fontWeight: 600 } },
|
|
681
|
+
React.createElement('div', { style: { ...s.label, fontSize: 13, fontWeight: 600 } }, `📱 手机 ${platformName} 扫码直达对话`),
|
|
663
682
|
React.createElement('div', { style: { ...s.muted, fontSize: 12, marginTop: 4, lineHeight: 1.5 } },
|
|
664
|
-
|
|
683
|
+
`用 ${platformName} 扫描左侧二维码,立即打开与 Bot 对话;发送首条消息自动完成白名单授权。`
|
|
665
684
|
),
|
|
666
685
|
platform.botLink && React.createElement('div', { style: { display: 'flex', gap: 8, marginTop: 8 } },
|
|
667
686
|
React.createElement('a', {
|
|
@@ -669,13 +688,13 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
669
688
|
target: '_blank',
|
|
670
689
|
rel: 'noopener noreferrer',
|
|
671
690
|
style: { ...s.btnGhost, textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 10px', fontSize: 12 },
|
|
672
|
-
},
|
|
691
|
+
}, `在 ${platformName} 客户端打开 ↗`),
|
|
673
692
|
),
|
|
674
693
|
),
|
|
675
694
|
),
|
|
676
695
|
),
|
|
677
696
|
|
|
678
|
-
// 未配置 / 登录中:表单(QQ /
|
|
697
|
+
// 未配置 / 登录中:表单(QQ / 飞书 / Telegram)或二维码(微信)
|
|
679
698
|
(!platform?.configured || showQr) && React.createElement('div', { style: s.block },
|
|
680
699
|
showQr && login.qr
|
|
681
700
|
? React.createElement('div', null,
|
|
@@ -687,7 +706,7 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
687
706
|
),
|
|
688
707
|
login.error && React.createElement('div', { style: { ...s.muted, marginTop: 4, color: 'var(--dsw-alias-state-warn-primary,#92400e)' } }, login.error),
|
|
689
708
|
)
|
|
690
|
-
: (platformId === 'qq' || platformId === 'feishu')
|
|
709
|
+
: (platformId === 'qq' || platformId === 'feishu' || platformId === 'telegram')
|
|
691
710
|
? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, marginTop: 4 } },
|
|
692
711
|
platformId === 'feishu' && React.createElement('div', {
|
|
693
712
|
style: {
|
|
@@ -704,40 +723,76 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
704
723
|
React.createElement('code', { style: { ...s.code, fontSize: 11, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', padding: '2px 4px', borderRadius: 4 } }, 'npx feishu-bot-bootstrap'),
|
|
705
724
|
React.createElement('span', { style: s.muted }, ' 手机扫码一键自动创建应用并输出凭证;或在下方手动填入凭证。')
|
|
706
725
|
),
|
|
707
|
-
|
|
708
|
-
React.createElement(
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
726
|
+
platformId === 'telegram'
|
|
727
|
+
? React.createElement(React.Fragment, null,
|
|
728
|
+
React.createElement('div', null,
|
|
729
|
+
React.createElement('div', { style: { ...s.muted, marginBottom: 4 } }, 'Bot Token — Telegram @BotFather 下发的机器人 Token'),
|
|
730
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
731
|
+
React.createElement('input', {
|
|
732
|
+
style: { ...s.input, flex: 1 },
|
|
733
|
+
type: showSecret ? 'text' : 'password',
|
|
734
|
+
placeholder: '请输入 Telegram Bot Token (如 123456789:ABCdef...)',
|
|
735
|
+
value: cfgDraft?.botToken ?? '',
|
|
736
|
+
onChange: (e) => setCfgDraft(d => ({ ...d, botToken: e.target.value })),
|
|
737
|
+
}),
|
|
738
|
+
React.createElement('button', {
|
|
739
|
+
style: { ...s.btnGhost, height: 32, padding: '0 10px', fontSize: 13, flexShrink: 0 },
|
|
740
|
+
onClick: () => setShowSecret(v => !v),
|
|
741
|
+
type: 'button',
|
|
742
|
+
title: showSecret ? '隐藏密钥' : '显示明文',
|
|
743
|
+
}, showSecret ? '🙈 隐藏' : '👁️ 显示'),
|
|
744
|
+
),
|
|
745
|
+
),
|
|
746
|
+
React.createElement('div', null,
|
|
747
|
+
React.createElement('div', { style: { ...s.muted, marginBottom: 4 } }, '网络代理 (可选) — 支持国内 HTTP / HTTPS 代理'),
|
|
748
|
+
React.createElement('input', {
|
|
749
|
+
style: { ...s.input, width: '100%' },
|
|
750
|
+
placeholder: '可选,例如 http://127.0.0.1:7890(为空则直连或读取环境变量)',
|
|
751
|
+
value: cfgDraft?.proxy ?? '',
|
|
752
|
+
onChange: (e) => setCfgDraft(d => ({ ...d, proxy: e.target.value })),
|
|
753
|
+
}),
|
|
754
|
+
),
|
|
755
|
+
)
|
|
756
|
+
: React.createElement(React.Fragment, null,
|
|
757
|
+
React.createElement('div', null,
|
|
758
|
+
React.createElement('div', { style: { ...s.muted, marginBottom: 4 } },
|
|
759
|
+
platformId === 'qq' ? 'AppID — QQ 开放平台机器人应用 ID' : 'App ID — 飞书开放平台自建应用 ID (cli_xxx)'
|
|
760
|
+
),
|
|
761
|
+
React.createElement('input', {
|
|
762
|
+
style: { ...s.input, width: '100%' },
|
|
763
|
+
placeholder: platformId === 'qq' ? '请输入 AppID' : '请输入 App ID (如 cli_a1b2c3d4...)',
|
|
764
|
+
value: cfgDraft?.appId ?? '',
|
|
765
|
+
onChange: (e) => setCfgDraft(d => ({ ...d, appId: e.target.value })),
|
|
766
|
+
}),
|
|
767
|
+
),
|
|
768
|
+
React.createElement('div', null,
|
|
769
|
+
React.createElement('div', { style: { ...s.muted, marginBottom: 4 } },
|
|
770
|
+
platformId === 'qq' ? 'ClientSecret — QQ 开放平台机器人密钥' : 'App Secret — 飞书开放平台应用密钥'
|
|
771
|
+
),
|
|
772
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
773
|
+
React.createElement('input', {
|
|
774
|
+
style: { ...s.input, flex: 1 },
|
|
775
|
+
type: showSecret ? 'text' : 'password',
|
|
776
|
+
placeholder: platformId === 'qq' ? '请输入 ClientSecret' : '请输入 App Secret',
|
|
777
|
+
value: platformId === 'qq' ? (cfgDraft?.clientSecret ?? '') : (cfgDraft?.appSecret ?? ''),
|
|
778
|
+
onChange: (e) => setCfgDraft(d => platformId === 'qq' ? ({ ...d, clientSecret: e.target.value }) : ({ ...d, appSecret: e.target.value })),
|
|
779
|
+
}),
|
|
780
|
+
React.createElement('button', {
|
|
781
|
+
style: { ...s.btnGhost, height: 32, padding: '0 10px', fontSize: 13, flexShrink: 0 },
|
|
782
|
+
onClick: () => setShowSecret(v => !v),
|
|
783
|
+
type: 'button',
|
|
784
|
+
title: showSecret ? '隐藏密钥' : '显示明文',
|
|
785
|
+
}, showSecret ? '🙈 隐藏' : '👁️ 显示'),
|
|
786
|
+
),
|
|
787
|
+
),
|
|
788
|
+
),
|
|
738
789
|
React.createElement('div', null,
|
|
739
790
|
React.createElement('a', {
|
|
740
|
-
href: platformId === 'qq'
|
|
791
|
+
href: platformId === 'qq'
|
|
792
|
+
? 'https://bot.q.qq.com/wiki/develop/api-v2/'
|
|
793
|
+
: platformId === 'feishu'
|
|
794
|
+
? 'https://open.feishu.cn/app'
|
|
795
|
+
: 'https://t.me/BotFather',
|
|
741
796
|
target: '_blank', rel: 'noopener noreferrer',
|
|
742
797
|
style: s.btnLink,
|
|
743
798
|
}, platformId === 'qq' ? '📖 前往 QQ 开放平台申请机器人' : '📖 前往飞书开放平台创建企业自建应用'),
|
|
@@ -746,7 +801,9 @@ function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatu
|
|
|
746
801
|
React.createElement('button', {
|
|
747
802
|
style: { ...s.btnPri, opacity: busy ? 0.5 : 1 },
|
|
748
803
|
onClick: saveConfig,
|
|
749
|
-
disabled: busy ||
|
|
804
|
+
disabled: busy || (platformId === 'telegram'
|
|
805
|
+
? !cfgDraft?.botToken?.trim()
|
|
806
|
+
: (!cfgDraft?.appId?.trim() || (platformId === 'qq' ? !cfgDraft?.clientSecret?.trim() : !cfgDraft?.appSecret?.trim()))),
|
|
750
807
|
}, busy ? '保存中…' : '保存并连接'),
|
|
751
808
|
login.phase === 'error' && React.createElement('div', { style: { ...s.muted, fontSize: 12 } }, login.error ?? '连接失败'),
|
|
752
809
|
),
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Telegram 机器人接入指南
|
|
2
|
+
|
|
3
|
+
> 通过官方 Telegram Bot API,将本地 DeepSeek Harness 接入 Telegram,支持单聊与群聊、原生快捷指令菜单、交互卡片按键、增量打字机流式输出与权限审批。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 🌟 核心优势
|
|
8
|
+
|
|
9
|
+
- **100% 免公网 IP / 免 Webhook**:基于 Telegram 官方 Long Polling(长轮询)机制,本地电脑或内网服务器即可直连 Telegram Bot API。
|
|
10
|
+
- **零依赖原生代理支持**:内置极简 HTTP/HTTPS CONNECT 隧道代理客户端,国内网络环境下仅需填入代理地址(如 `http://127.0.0.1:7890`)即可极速通信。
|
|
11
|
+
- **增量打字机流式输出**:接入轮次实时生命周期,通过 `editMessageText` 实现平滑打字机流式输出,单条气泡原地更新,告别刷屏。
|
|
12
|
+
- **原生快捷指令菜单 (`Menu` 按键)**:自动通过 `setMyCommands` 与 `setChatMenuButton` 注册全范围指令,输入 `/` 或点击左下角菜单即可一键直达。
|
|
13
|
+
- **原生交互卡片与审批按键**:审批请求下发 Inline Keyboard 按键 `[✓ 批准执行]` / `[✕ 拒绝执行]`,一键点击即时响应。
|
|
14
|
+
- **多工作区与会话持久化**:支持 `/sessions` 查看会话列表、`/use <序号>` 切换、`/workspaces` 调度工作区,重启 DSH 后白名单与会话不丢失。
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 🛠️ 第一步:在 Telegram 中创建机器人并获取 Token
|
|
19
|
+
|
|
20
|
+
1. 打开 Telegram,搜索官方机器人管理号 [@BotFather](https://t.me/BotFather);
|
|
21
|
+
2. 点击底部 `Start` 或发送 `/newbot` 指令;
|
|
22
|
+
3. 根据提示依次输入:
|
|
23
|
+
- **机器人昵称**(如 `My DSH Agent`);
|
|
24
|
+
- **机器人用户名**(必须以 `bot` 结尾,如 `my_dsh_agent_bot`);
|
|
25
|
+
4. 创建成功后,@BotFather 会返回一行 **HTTP API Token**(格式如 `123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ`)。
|
|
26
|
+
|
|
27
|
+
> 💡 **(可选)在 BotFather 中固化快捷指令**:
|
|
28
|
+
> 向 @BotFather 发送 `/setcommands`,选择你的机器人,直接复制并粘贴下方内容发送:
|
|
29
|
+
> ```text
|
|
30
|
+
> new - 新建会话并开始执行 (/new <提示词>)
|
|
31
|
+
> sessions - 列出所有会话列表与切换
|
|
32
|
+
> use - 切换活动会话 (/use <N>)
|
|
33
|
+
> workspaces - 列出本地所有可用工作区
|
|
34
|
+
> status - 查看 Agent 状态与会话摘要
|
|
35
|
+
> stop - 停止当前正在运行的任务
|
|
36
|
+
> end - 结束当前活动会话
|
|
37
|
+
> help - 显示快捷按键与完整帮助
|
|
38
|
+
> ```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## ⚙️ 第二步:在 DSH Bridge 中配置与连接
|
|
43
|
+
|
|
44
|
+
1. 打开 DSH Web 设置页「**远程访问**」→「**IM 机器人**」;
|
|
45
|
+
2. 在平台选择栏中点击「**Telegram**」卡片;
|
|
46
|
+
3. 填入刚才获取的 **Bot Token**;
|
|
47
|
+
4. **网络代理(可选)**:
|
|
48
|
+
- 若在中国大陆地区服务器或个人电脑上运行,填入本地代理地址,如 `http://127.0.0.1:7890`(支持 Clash / v2ray / Squid 等 HTTP/HTTPS 代理);
|
|
49
|
+
- 亦可直接在系统环境变量中设置 `HTTPS_PROXY=http://127.0.0.1:7890`;
|
|
50
|
+
5. 点击「**保存并连接**」。
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 📱 第三步:扫码与自动白名单授权
|
|
55
|
+
|
|
56
|
+
1. 连接成功后,面板将展示当前机器人的二维码与 `https://t.me/<username>` 直达链接;
|
|
57
|
+
2. 用手机 Telegram 扫描二维码或点击链接打开与机器人的对话;
|
|
58
|
+
3. 发送第一条消息(如 `/help` 或 `你好`),系统将**自动将你的 Telegram 账号加入白名单**并持久化;
|
|
59
|
+
4. 之后即可在 Telegram 里随时随地向 Agent 下达任务指令。
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 💬 常用指令与卡片交互
|
|
64
|
+
|
|
65
|
+
| 指令 | 说明 | 交互支持 |
|
|
66
|
+
| :--- | :--- | :--- |
|
|
67
|
+
| *(普通文本)* | 发送给当前活动 Agent 执行任务 | 实时打字机流式输出 |
|
|
68
|
+
| `/new <提示词>` | 在当前工作区新建会话并立即执行 | 启动全新轮次 |
|
|
69
|
+
| `/new <提示词> @N` | 在指定工作区序号新建会话 | 多工作区调度 |
|
|
70
|
+
| `/sessions`(或 `/list`) | 查看所有会话列表 | 附带一键切换按键 |
|
|
71
|
+
| `/use N`(或 `/resume N`) | 切换活动会话至序号 N | 快速切换上下文 |
|
|
72
|
+
| `/workspaces` | 列出本地所有可用项目工作区 | 查看工作区路径 |
|
|
73
|
+
| `/status` | 查看当前 Agent 运行状态看板 | 附带刷新/停止/结束按键 |
|
|
74
|
+
| `/stop` | 强制停止当前正在运行的 Agent 任务 | 即刻中断执行 |
|
|
75
|
+
| `/end` | 结束当前活动会话 | 挂载快捷开始按键 |
|
|
76
|
+
| `/yes` `/no`(或 `1`/`2`) | 响应权限审批请求 | 支持直接点击卡片按钮 |
|
|
77
|
+
| `/help` | 查看快捷按键与完整使用帮助 | 挂载全套功能导航按键 |
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 🛡️ 多模态与安全说明
|
|
82
|
+
|
|
83
|
+
1. **图片与文件双向传输**:
|
|
84
|
+
- 直接向 Telegram 机器人发送图片或文档,系统自动保存到本地并注入提示词供 Agent 解析;
|
|
85
|
+
- Agent 任务执行中生成的图片、文档等产物,在轮次结束时会自动推送回 Telegram 聊天。
|
|
86
|
+
2. **安全白名单拦截**:
|
|
87
|
+
- 仅白名单内的用户消息会被放行给 Agent;非白名单用户发送的消息会被直接忽略,绝不消耗 Token 或喂给模型。
|
|
88
|
+
3. **敏感操作审批**:
|
|
89
|
+
- 当 Agent 尝试执行系统命令或敏感文件读写时,Telegram 会自动下发 `[✓ 批准执行]` / `[✕ 拒绝执行]` 交互按键,10 分钟未处理自动超时拒绝。
|
package/lib/feishu/gateway.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Official SDK: @larksuiteoapi/node-sdk
|
|
3
3
|
// Reference: https://open.feishu.cn/document/home/event-subscription-via-websocket/overview
|
|
4
4
|
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
5
7
|
import { Service } from '@deepseek-ai/cordis'
|
|
6
8
|
import * as Lark from '@larksuiteoapi/node-sdk'
|
|
7
9
|
|
|
@@ -144,6 +146,7 @@ export class FeishuGateway extends Service {
|
|
|
144
146
|
// 5. 启动 WebSocket 长连接
|
|
145
147
|
await this.wsClient.start({ eventDispatcher: this.eventDispatcher })
|
|
146
148
|
this.setStatus('online')
|
|
149
|
+
this._startWatchdog()
|
|
147
150
|
this.logger.info?.('[dsh-bridge feishu] WebSocket connected to Feishu Open Platform')
|
|
148
151
|
return true
|
|
149
152
|
} catch (err) {
|
|
@@ -158,8 +161,28 @@ export class FeishuGateway extends Service {
|
|
|
158
161
|
return this._startingPromise
|
|
159
162
|
}
|
|
160
163
|
|
|
164
|
+
_startWatchdog() {
|
|
165
|
+
this._stopWatchdog()
|
|
166
|
+
this._watchdogTimer = setInterval(async () => {
|
|
167
|
+
if (this.status === 'online' && !this._closing && this.configured && this.client) {
|
|
168
|
+
try {
|
|
169
|
+
await this.client.bot.v3.bot.get({}).catch(() => null)
|
|
170
|
+
} catch (err) {
|
|
171
|
+
this.logger?.warn?.('[dsh-bridge feishu] liveness probe warning: %s', err?.message ?? err)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}, 60_000)
|
|
175
|
+
if (typeof this._watchdogTimer?.unref === 'function') this._watchdogTimer.unref()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_stopWatchdog() {
|
|
179
|
+
if (this._watchdogTimer) clearInterval(this._watchdogTimer)
|
|
180
|
+
this._watchdogTimer = null
|
|
181
|
+
}
|
|
182
|
+
|
|
161
183
|
async stop() {
|
|
162
184
|
this._closing = true
|
|
185
|
+
this._stopWatchdog()
|
|
163
186
|
try {
|
|
164
187
|
if (this.wsClient) {
|
|
165
188
|
const wsInstance = this.wsClient.wsConfig?.getWSInstance?.()
|
|
@@ -199,15 +222,16 @@ export class FeishuGateway extends Service {
|
|
|
199
222
|
const chatType = message.chat_type || 'p2p' // 'p2p' or 'group'
|
|
200
223
|
const isGroup = chatType === 'group'
|
|
201
224
|
|
|
202
|
-
//
|
|
225
|
+
// 解析消息体(文本 / 图片 / 文件 / 音频 / 视频)
|
|
203
226
|
let text = ''
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
text = message.content || ''
|
|
227
|
+
let contentObj = {}
|
|
228
|
+
try {
|
|
229
|
+
contentObj = JSON.parse(message.content || '{}')
|
|
230
|
+
if (message.message_type === 'text') {
|
|
231
|
+
text = contentObj.text || ''
|
|
210
232
|
}
|
|
233
|
+
} catch {
|
|
234
|
+
text = message.content || ''
|
|
211
235
|
}
|
|
212
236
|
|
|
213
237
|
// 群聊中剥离机器人 @ 占位符(例如 @_user_1 等)
|
|
@@ -226,12 +250,54 @@ export class FeishuGateway extends Service {
|
|
|
226
250
|
isGroup,
|
|
227
251
|
messageId,
|
|
228
252
|
text,
|
|
253
|
+
contentObj,
|
|
229
254
|
messageType: message.message_type,
|
|
230
255
|
raw: data,
|
|
231
256
|
createTime: Number(message.create_time || Date.now()),
|
|
232
257
|
})
|
|
233
258
|
}
|
|
234
259
|
|
|
260
|
+
/**
|
|
261
|
+
* 下载消息中的资源文件(图片/文件/音频/媒体)
|
|
262
|
+
* @param {object} opts
|
|
263
|
+
* @param {string} opts.messageId 消息 ID
|
|
264
|
+
* @param {string} opts.fileKey 资源 Key
|
|
265
|
+
* @param {string} [opts.type] 'file' | 'image'
|
|
266
|
+
* @returns {Promise<Buffer|null>}
|
|
267
|
+
*/
|
|
268
|
+
async downloadMessageResource({ messageId, fileKey, type = 'file' }) {
|
|
269
|
+
if (!this.client) throw new Error('Feishu client not initialized')
|
|
270
|
+
try {
|
|
271
|
+
const resp = await this.client.im.messageResource.get({
|
|
272
|
+
path: {
|
|
273
|
+
message_id: messageId,
|
|
274
|
+
file_key: fileKey,
|
|
275
|
+
},
|
|
276
|
+
params: {
|
|
277
|
+
type: type === 'image' ? 'image' : 'file',
|
|
278
|
+
},
|
|
279
|
+
})
|
|
280
|
+
if (Buffer.isBuffer(resp)) return resp
|
|
281
|
+
if (resp && typeof resp.pipe === 'function') {
|
|
282
|
+
const chunks = []
|
|
283
|
+
for await (const chunk of resp) chunks.push(Buffer.from(chunk))
|
|
284
|
+
return Buffer.concat(chunks)
|
|
285
|
+
}
|
|
286
|
+
if (resp?.data) {
|
|
287
|
+
if (Buffer.isBuffer(resp.data)) return resp.data
|
|
288
|
+
if (resp.data.pipe) {
|
|
289
|
+
const chunks = []
|
|
290
|
+
for await (const chunk of resp.data) chunks.push(Buffer.from(chunk))
|
|
291
|
+
return Buffer.concat(chunks)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return null
|
|
295
|
+
} catch (err) {
|
|
296
|
+
this.logger?.warn?.('[dsh-bridge feishu] downloadMessageResource error: %s', err?.message ?? err)
|
|
297
|
+
return null
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
235
301
|
async _handleCardActionTrigger(data) {
|
|
236
302
|
const action = data?.action || {}
|
|
237
303
|
const operator = data?.operator || {}
|
|
@@ -382,6 +448,99 @@ export class FeishuGateway extends Service {
|
|
|
382
448
|
}
|
|
383
449
|
}
|
|
384
450
|
|
|
451
|
+
/**
|
|
452
|
+
* 上传并发送本地图片
|
|
453
|
+
*/
|
|
454
|
+
async sendLocalImage(receiveId, filePath, opts = {}) {
|
|
455
|
+
if (!this.client || !receiveId || !filePath) return null
|
|
456
|
+
let receiveIdType = opts.receiveIdType
|
|
457
|
+
if (!receiveIdType) {
|
|
458
|
+
if (receiveId.startsWith('ou_')) receiveIdType = 'open_id'
|
|
459
|
+
else if (receiveId.startsWith('oc_')) receiveIdType = 'chat_id'
|
|
460
|
+
else if (opts.isGroup) receiveIdType = 'chat_id'
|
|
461
|
+
else receiveIdType = 'open_id'
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
const imageStream = fs.createReadStream(filePath)
|
|
466
|
+
const uploadRes = await this.client.im.v1.image.create({
|
|
467
|
+
data: {
|
|
468
|
+
image_type: 'message',
|
|
469
|
+
image: imageStream,
|
|
470
|
+
},
|
|
471
|
+
})
|
|
472
|
+
const imageKey = uploadRes?.image_key
|
|
473
|
+
if (!imageKey) throw new Error('Feishu image upload returned no image_key')
|
|
474
|
+
|
|
475
|
+
const res = await this.client.im.v1.message.create({
|
|
476
|
+
params: { receive_id_type: receiveIdType },
|
|
477
|
+
data: {
|
|
478
|
+
receive_id: receiveId,
|
|
479
|
+
msg_type: 'image',
|
|
480
|
+
content: JSON.stringify({ image_key: imageKey }),
|
|
481
|
+
},
|
|
482
|
+
})
|
|
483
|
+
return res?.data
|
|
484
|
+
} catch (err) {
|
|
485
|
+
this.logger.warn?.(`[dsh-bridge feishu] sendLocalImage error (${receiveId}, ${filePath}):`, err?.message ?? err)
|
|
486
|
+
return null
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* 上传并发送本地文件
|
|
492
|
+
*/
|
|
493
|
+
async sendLocalFile(receiveId, filePath, opts = {}) {
|
|
494
|
+
if (!this.client || !receiveId || !filePath) return null
|
|
495
|
+
let receiveIdType = opts.receiveIdType
|
|
496
|
+
if (!receiveIdType) {
|
|
497
|
+
if (receiveId.startsWith('ou_')) receiveIdType = 'open_id'
|
|
498
|
+
else if (receiveId.startsWith('oc_')) receiveIdType = 'chat_id'
|
|
499
|
+
else if (opts.isGroup) receiveIdType = 'chat_id'
|
|
500
|
+
else receiveIdType = 'open_id'
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
const fileName = path.basename(filePath)
|
|
505
|
+
const fileStream = fs.createReadStream(filePath)
|
|
506
|
+
const uploadRes = await this.client.im.v1.file.create({
|
|
507
|
+
data: {
|
|
508
|
+
file_type: 'stream',
|
|
509
|
+
file_name: fileName,
|
|
510
|
+
file: fileStream,
|
|
511
|
+
},
|
|
512
|
+
})
|
|
513
|
+
const fileKey = uploadRes?.file_key
|
|
514
|
+
if (!fileKey) throw new Error('Feishu file upload returned no file_key')
|
|
515
|
+
|
|
516
|
+
const res = await this.client.im.v1.message.create({
|
|
517
|
+
params: { receive_id_type: receiveIdType },
|
|
518
|
+
data: {
|
|
519
|
+
receive_id: receiveId,
|
|
520
|
+
msg_type: 'file',
|
|
521
|
+
content: JSON.stringify({ file_key: fileKey }),
|
|
522
|
+
},
|
|
523
|
+
})
|
|
524
|
+
return res?.data
|
|
525
|
+
} catch (err) {
|
|
526
|
+
this.logger.warn?.(`[dsh-bridge feishu] sendLocalFile error (${receiveId}, ${filePath}):`, err?.message ?? err)
|
|
527
|
+
return null
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* 发送本地媒体文件(自动识别图片/文档)
|
|
533
|
+
*/
|
|
534
|
+
async sendMediaFile(receiveId, filePath, opts = {}) {
|
|
535
|
+
if (!fs.existsSync(filePath)) return null
|
|
536
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
537
|
+
const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)
|
|
538
|
+
if (isImage) {
|
|
539
|
+
return this.sendLocalImage(receiveId, filePath, opts)
|
|
540
|
+
}
|
|
541
|
+
return this.sendLocalFile(receiveId, filePath, opts)
|
|
542
|
+
}
|
|
543
|
+
|
|
385
544
|
// 释放资源
|
|
386
545
|
dispose() {
|
|
387
546
|
void this.stop()
|