@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/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
- // 飞书专属:手机扫码直达 Bot 对话
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: 'Feishu Bot QR', style: { width: 110, height: 110, borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', padding: 4, background: '#fff' } }),
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
- '用飞书手机 App 扫描左侧二维码,立即打开与 Bot 对话;发送首条消息自动完成白名单授权。'
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
- React.createElement('div', null,
708
- React.createElement('div', { style: { ...s.muted, marginBottom: 4 } },
709
- platformId === 'qq' ? 'AppID — QQ 开放平台机器人应用 ID' : 'App ID — 飞书开放平台自建应用 ID (cli_xxx)'
710
- ),
711
- React.createElement('input', {
712
- style: { ...s.input, width: '100%' },
713
- placeholder: platformId === 'qq' ? '请输入 AppID' : '请输入 App ID (如 cli_a1b2c3d4...)',
714
- value: cfgDraft?.appId ?? '',
715
- onChange: (e) => setCfgDraft(d => ({ ...d, appId: e.target.value })),
716
- }),
717
- ),
718
- React.createElement('div', null,
719
- React.createElement('div', { style: { ...s.muted, marginBottom: 4 } },
720
- platformId === 'qq' ? 'ClientSecret QQ 开放平台机器人密钥' : 'App Secret 飞书开放平台应用密钥'
721
- ),
722
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
723
- React.createElement('input', {
724
- style: { ...s.input, flex: 1 },
725
- type: showSecret ? 'text' : 'password',
726
- placeholder: platformId === 'qq' ? '请输入 ClientSecret' : '请输入 App Secret',
727
- value: platformId === 'qq' ? (cfgDraft?.clientSecret ?? '') : (cfgDraft?.appSecret ?? ''),
728
- onChange: (e) => setCfgDraft(d => platformId === 'qq' ? ({ ...d, clientSecret: e.target.value }) : ({ ...d, appSecret: e.target.value })),
729
- }),
730
- React.createElement('button', {
731
- style: { ...s.btnGhost, height: 32, padding: '0 10px', fontSize: 13, flexShrink: 0 },
732
- onClick: () => setShowSecret(v => !v),
733
- type: 'button',
734
- title: showSecret ? '隐藏密钥' : '显示明文',
735
- }, showSecret ? '🙈 隐藏' : '👁️ 显示'),
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' ? 'https://bot.q.qq.com/wiki/develop/api-v2/' : 'https://open.feishu.cn/app',
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 || !cfgDraft?.appId?.trim() || (platformId === 'qq' ? !cfgDraft?.clientSecret?.trim() : !cfgDraft?.appSecret?.trim()),
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 分钟未处理自动超时拒绝。
@@ -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
- if (message.message_type === 'text') {
205
- try {
206
- const parsed = JSON.parse(message.content || '{}')
207
- text = parsed.text || ''
208
- } catch {
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()