dsh-im-union 0.4.8 → 0.5.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/CHANGELOG.md +70 -0
- package/client.js +2 -1
- package/dsh.plugin.json +1 -1
- package/lib/channels/feishu.d.ts +73 -4
- package/lib/channels/feishu.js +306 -16
- package/lib/channels/meta.js +6 -1
- package/lib/channels/qq-group-admin.d.ts +35 -0
- package/lib/channels/qq-group-admin.js +43 -0
- package/lib/channels/qq.d.ts +102 -9
- package/lib/channels/qq.js +694 -62
- package/lib/channels/types.d.ts +5 -2
- package/lib/client.js +2 -1
- package/lib/engine/account-manager.d.ts +8 -0
- package/lib/engine/account-manager.js +15 -1
- package/lib/engine/commands.d.ts +3 -1
- package/lib/engine/commands.js +8 -1
- package/lib/engine/gateway.d.ts +16 -0
- package/lib/engine/gateway.js +164 -3
- package/lib/engine/interactions.d.ts +7 -0
- package/lib/engine/interactions.js +48 -15
- package/package.json +1 -1
- package/src/channels/feishu.ts +323 -17
- package/src/channels/meta.ts +6 -1
- package/src/channels/qq-group-admin.ts +54 -0
- package/src/channels/qq.ts +726 -57
- package/src/channels/types.ts +3 -1
- package/src/engine/account-manager.ts +20 -3
- package/src/engine/commands.ts +9 -0
- package/src/engine/gateway.ts +175 -3
- package/src/engine/interactions.ts +48 -17
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,76 @@
|
|
|
2
2
|
|
|
3
3
|
本项目版本号遵循语义化版本;发布面与宿主兼容矩阵见 `scripts/hosts.mjs`(README 管理块同源)。
|
|
4
4
|
|
|
5
|
+
## 0.5.0(QQ 渠道能力补齐:出站图片 / Markdown / 入站图片文件 / 审批按钮 / 群管理)
|
|
6
|
+
|
|
7
|
+
对照 `_ref-im/dsh-qqbot`(同一套 QQ 官方 api-v2)逐面解锁 `src/channels/qq.ts`,遵守净室 / 零运行时依赖 / D7 纪律。
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **出站图片 `caps.fileOut` + `sendFile`**:上传换 file_info → msg_type=7 携带 **`media: { file_info }`**(体形按官方 `@tencent-connect/qqbot-nodejs` 1.0.5 `sendMediaMessage` 对账,非旧式 `image: file_info`);上传采用**双形态递降**:先官方 SDK 小文件形态(JSON + base64 `file_data`,`file_type=1`、`srv_send_msg=false`),被平台确定性拒绝再回退官方文档记载的 multipart/form-data 形态一次(网络不确定绝不重试,D7 免白占配额);群/单聊分 `/files` 端点,窗口内带 msg_id/seq;一举激活网关 SVG→PNG 附图链、产物回传与 `/export`。非图片 mime(zip/文件)优雅拒绝(官方 file_type=4 未开放);304080/40093002 确定性转译
|
|
12
|
+
- **Markdown 出站 msg_type=2**:正文含 `` 经宿主 `workspaceFiles` 受管读(workspaceRoot 限定,适配器不碰盘)取字节上传、回替 `qqbot-media://` url;非 QQ/无本地图/读失败/上传失败均原样保留
|
|
14
|
+
- **入站图片/文件 `caps.imageIn/fileIn`**:事件 `attachments[].url`(rkey 时效)即时下载填 `images[]/files[]`(含 mimeType);群聊图片 content_type 实测为 'file' → 兼用 URL 形态判定;无 arrayBuffer/非 2xx/空字节按占位不丢事件;入站派发改串行链保序(异步下载不打乱消息顺序)
|
|
15
|
+
- **审批/提问按钮 `askChoices`(keyboard)**:msg_type=2 + **`keyboard: { content: { rows } }`**(官方 InlineKeyboard 包装层,少一层平台不渲染),action.type=1 回传;收到 INTERACTION_CREATE 后**best-effort `PUT /interactions/{id} {code:0}` 上确认**再结算;D5 仅接受发起者点按;`config.keyboard=on` 才订阅 INTERACTION intent 并置 `nativeChoices=true`(默认关=零风险,未配置回退数字菜单)
|
|
16
|
+
- **语音入站免解码**:优先取事件原生 `asr_refer_text` 作正文,无则占位;不引入 silk 解码依赖
|
|
17
|
+
- **QQ 群管理**:新建 `src/channels/qq-group-admin.ts`(FEATURE_GATES 门控 + 端点/错误码人话映射),QqAdapter 暴露入群申请列表/审批、群禁言查询/设置、消息撤回;经 `/qq` 命令面可达,仅群聊、作用于本群、需 `config.groupAdminUsers` 授权名单(空=命令惰性拒绝)+ 机器人为群管理员
|
|
18
|
+
|
|
19
|
+
### Notes
|
|
20
|
+
|
|
21
|
+
- 撤回/编辑 `editStream`、原生流式 `nativeStream` 本轮不做(协议语义未逐面核实、与 dsh-qqbot 打平)
|
|
22
|
+
- 全量 753 单测绿(QQ +8);真机农场冒烟需 QQ 凭据待验
|
|
23
|
+
|
|
24
|
+
## 0.4.12(飞书审批卡片:权限 ask 走按钮确认)
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **飞书 `askChoices` 实装(本插件第一个真按钮面渠道)**:宿主审批(T1.9)不只能回「1/2/c」文本,而是直接收到一张带「允许一次 / 拒绝」按钮的卡片,点按钮即定夺。`caps.nativeChoices` 由 false 改 true(此前所有渠道都是 false 或缺面,飞书是第一个把这条分支跑通的)
|
|
29
|
+
- **协议事实(真机实测,写进代码注释)**:飞书 2.0 schema 的 `body.elements` **已不支持 `action` 标签**(`230099 / 200861 unsupported tag action`),按钮卡片必须走 1.0 形态(顶层 `elements` + `action`/`button`);而内联 `interactive` 消息不碰 cardkit 实体面,**不需要 `cardkit:card:write`**(与流式卡片不同一条权限链)
|
|
30
|
+
- **回传走同一条长连接**:`EventDispatcher` 注册 `card.action.trigger`(官方 SDK 1.76 自带该分发 + 按 chatId 去重/加锁/排队),无需另开 webhook;回包给 `toast` 让用户看到「已记录:拒绝」
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **按钮期间用户打字会被当成新任务丢给模型**:原 `handleApproval` 的按钮分支是直接 `await adapter.askChoices(...)`,期间**不登记 pending**,所以用户习以为常地回一句「可以」会落进会话队列。现改为**按钮 + 文本双路竞速**(`approvalViaButtons`):卡片在位时「1/2/c」同样定夺,先到为准
|
|
35
|
+
- **按钮面无渠道侧超时**:适配器 `askChoices` 自带 5 分钟上限(与 D5 对齐)+ `signal` abort 即收;`disconnect()` 释放所有在位卡片(空答=取消,fail-closed)
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- `askChoices` 契约加第 6 个可选参 `opts.ownerUserId`:卡片回传带的是点击者身份,需比对发起者才能守 D5(**只有发起者能定夺**,他人点击不改判,只回 toast 说明)
|
|
40
|
+
- 飞书 `multi=true` 明确拒接(卡片无多选原语):抛错后 broker 回落文本菜单,不造假成单选
|
|
41
|
+
- 迟到的重复点按(卡还在、pending 已清)一律忽略;审批卡按 `message_id` 键控,同聊天先后两张卡不会串台
|
|
42
|
+
|
|
43
|
+
## 0.4.11(回合内「处理中」信号 — 引擎接线 + 飞书表情落地)
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- **引擎侧第一次真正调 `setTyping`**:`runTurn` 在 prompt 前发回合开始信号(`caps.typing` 位 + `setTyping` 方法双门控)。此前 Discord/Telegram/Matrix/QQ/微信 六个适配器都实现了 `setTyping`,但引擎侧**零调用点**——它们全是死代码;这次一并激活
|
|
48
|
+
- **飞书「处理中」落地为表情回应**:飞书无 typing API(客户端不暴露),实测 `POST/DELETE /open-apis/im/v1/messages/{id}/reactions` 全链 `code 0`,因此在用户本条入站消息上贴 `OnIt`(官方「需处理/进行中」语义):回合开始贴、回复/卡片落定即删、没删成 2 分钟 TTL 自清;同一聊天只留一个;表情接口缺权/限频/消息被撕一律静默,不阻断回合
|
|
49
|
+
- 飞书 `caps.typing` / `caps.statusReaction` 由 false 改 true(声明=真实现:两个位同一个落地物)
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- 入站接收时记住 `chatId → message_id`(状态位锚点);状态位仅沾在用户那条消息上,不额外发一条「思考中…」占位消息,不污染会话历史
|
|
54
|
+
|
|
55
|
+
## 0.4.10(多回合接续:同一聊天不得被劈成两条轨迹)
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- **飞书第二回合另起会话,轨迹分叉(真机 desktop 实证)**:`apply` 期会话后端是 `none`,首回合入站时 `ensureSessionId` 直接返回隐式 id(`im:union:feishu:dm:oc_…`)——宿主按该 id 落了盘(目录 `im~003Aunion…`);惰性后端随后升级 `native`,第二回合就调 `createSession(hint)`,而 desktop 线 `sessionController.create` 面缺位 → 回退 `agents.create`(不收 sessionId)→ 宿主生成 `session-uuid`,`router.bind` 改绑到 UUID。结果同一聊天被劈成两段:首段在 `im:union:` 目录(进「频道」分组),第二段在 `session-uuid` 目录(落「未分组」),jsonl 各写各的
|
|
60
|
+
- **修法(先查后建)**:`ensureSessionId` 在 `createSession` 之前先查宿主会话列表,已有该 `im:union:` id 则直接接续(不 create、不重复应用账号偏好——存量会话不动);命中后进进程内缓存,后续回合不重复查。查询面异常/缺位按「未存在」处理,仍走 create 兜底
|
|
61
|
+
- **可操作的排障日志**:宿主改派成非 `im:` 前缀 id(会落「未分组」)时,除既有「请求 id → 宿主实际 id」info,另打一条 warn 给出 `/session <原隐式 id>` 的接回手段;不再只留一条 id 差异让人猜
|
|
62
|
+
|
|
63
|
+
## 0.4.9(飞书诊断假红收口 + 可选密钥不再算缺失)
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **飞书「凭据无效」假红(真机 desktop 实证)**:`diagnose` 凭据位探的是 `GET /open-apis/bot/v2/info`——该路径不存在(服务端回 `404 page not found` 纯文本,SDK 对非 2xx 直接抛),于是任何正确凭据都落 catch 分支报「凭据或网络无效」。现改探 `POST /open-apis/auth/v3/tenant_access_token/internal`(唯一对 appSecret 做真裁决的面:错 secret → 10014),并把「机器人能力」拆成独立位 `bot-ability`(`GET /open-apis/bot/v3/info`:有 bot → code 0;无 bot → 11205)——bot 面本身不校验 secret(错 secret 同样回 11205),探它只能验能力不能验凭据
|
|
68
|
+
- **「长连接通过」不再被误读为可收发**:`ws-long-connection` 置 passed 时附提示「长连接已建立(不能据此推定机器人能力/消息权限已开通)」——无机器人能力时事件不投递、消息发不出,与长连接握手是两件事
|
|
69
|
+
- **可选密钥被算成「凭据缺失」**:`listPublic` 对 meta 里所有 `secret:true` 字段一律下发 `configured`,设置页据此把未入库的**可选**键(飞书 `dashScopeKey` 语音、微信 `botToken` 扫码自动写)标成红徽标「凭据缺失:…」,观感上等同连接被阻断。现 `secretFields` 随字段下发 `required`,客户端只对 `required!==false` 的缺失出红徽标
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
|
|
73
|
+
- 诊断 hint 不再一律「凭据或网络无效」:网络类错误回「网络不可达:<message>」,其余回「凭据探测失败:<message>」(只取 error.message,不透传 axios config——那里带着 Authorization 与请求体)
|
|
74
|
+
|
|
5
75
|
## 0.4.8(宿主枚举补票:0.2.1-rc.3)
|
|
6
76
|
|
|
7
77
|
### Changed
|
package/client.js
CHANGED
|
@@ -785,7 +785,8 @@ select.diu-input { appearance: auto; }
|
|
|
785
785
|
|
|
786
786
|
var missingSecrets = [];
|
|
787
787
|
(row.secretFields || []).forEach(function (sf) {
|
|
788
|
-
|
|
788
|
+
// 可选密钥(语音 DashScope Key / 扫码 botToken)未入库不算缺失:不进红徽标。
|
|
789
|
+
if (!sf.configured && sf.required !== false) missingSecrets.push(sf.key);
|
|
789
790
|
});
|
|
790
791
|
var badge = row.paused
|
|
791
792
|
? h(Tag, { tone: 'Muted' }, t('accounts.state.paused'))
|
package/dsh.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-im-union",
|
|
3
3
|
"name": "dsh-im-union",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"description": {
|
|
6
6
|
"en": "Union IM bridge for DeepSeek Harness: multi-account bots across messaging platforms, each chat bound to its own session, with cross-host-version compatibility (0.1.2-rc.1 .. 0.2.1-rc.3).",
|
|
7
7
|
"zh": "DeepSeek Harness 的 IM 联合桥:多平台多账号机器人、每个聊天绑定独立会话,跨宿主版本兼容(0.1.2-rc.1 至 0.2.1-rc.3)。"
|
package/lib/channels/feishu.d.ts
CHANGED
|
@@ -63,6 +63,25 @@ interface FeishuUploadResult {
|
|
|
63
63
|
interface FeishuResourceResult {
|
|
64
64
|
getReadableStream?: () => AsyncIterable<Uint8Array>;
|
|
65
65
|
}
|
|
66
|
+
/** 卡片回传事件(card.action.trigger)归一后的形——SDK 长连接与 webhook 两形态共用。 */
|
|
67
|
+
export interface FeishuCardAction {
|
|
68
|
+
messageId: string;
|
|
69
|
+
chatId: string;
|
|
70
|
+
operator: {
|
|
71
|
+
openId?: string;
|
|
72
|
+
userId?: string;
|
|
73
|
+
};
|
|
74
|
+
action: {
|
|
75
|
+
value?: Record<string, unknown>;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 从 card.action.trigger 负载取关键字段。两种形态都要接:
|
|
80
|
+
* - 长连接/webhook 原始体:`{schema:'2.0', header:{event_type}, event:{context, operator, action}}`;
|
|
81
|
+
* - SDK 安全管道 normalize 后的形:`{messageId, chatId, operator:{openId}, action:{value}}`。
|
|
82
|
+
* 缺关键字段回 undefined(调用方丢弃,不猜)。
|
|
83
|
+
*/
|
|
84
|
+
export declare function extractCardAction(payload: any): FeishuCardAction | undefined;
|
|
66
85
|
export interface LarkLike {
|
|
67
86
|
WSClient: new (options: {
|
|
68
87
|
appId: string;
|
|
@@ -73,7 +92,7 @@ export interface LarkLike {
|
|
|
73
92
|
}) => Promise<void> | void;
|
|
74
93
|
};
|
|
75
94
|
EventDispatcher: new (options: Record<string, unknown>) => {
|
|
76
|
-
register: (handlers: Record<string, (data:
|
|
95
|
+
register: (handlers: Record<string, (data: any) => unknown>) => unknown;
|
|
77
96
|
};
|
|
78
97
|
Client: new (options: {
|
|
79
98
|
appId: string;
|
|
@@ -202,13 +221,22 @@ export declare class FeishuAdapter implements ChannelAdapter {
|
|
|
202
221
|
/** 语音档位(未开启=undefined:入站只留占位,零外部调用)。 */
|
|
203
222
|
private readonly voice;
|
|
204
223
|
private tenantToken;
|
|
224
|
+
/** chatId → 最近一条入站消息 id(typing 状态位挂在用户那条消息上)。 */
|
|
225
|
+
private readonly lastInboundMessageId;
|
|
226
|
+
/** chatId → 在位「处理中」表情(回复落地/超时回收)。 */
|
|
227
|
+
private readonly typingReactions;
|
|
205
228
|
private listeners;
|
|
206
229
|
private client;
|
|
207
230
|
private wsClient;
|
|
208
231
|
private lark;
|
|
209
232
|
private readonly log;
|
|
210
233
|
/** 事件处理器表(测试注入口:直接驱动 receive handler)。 */
|
|
211
|
-
handlers: Record<string, (data:
|
|
234
|
+
handlers: Record<string, (data: any) => unknown> | undefined;
|
|
235
|
+
/**
|
|
236
|
+
* 在位的选项卡:message_id → 待答。
|
|
237
|
+
* 键用 message_id 而非 chatId:一个聊天可能先后出多张卡,旧卡迟到点击不得误判新卡。
|
|
238
|
+
*/
|
|
239
|
+
private readonly pendingChoices;
|
|
212
240
|
constructor(options: FeishuAdapterOptions);
|
|
213
241
|
onMessage(listener: MessageListener): () => void;
|
|
214
242
|
private requireLark;
|
|
@@ -219,6 +247,33 @@ export declare class FeishuAdapter implements ChannelAdapter {
|
|
|
219
247
|
/** 权限类错误码 → 可读引导(不透传内部错误体)。 */
|
|
220
248
|
private translateApiError;
|
|
221
249
|
reply(address: ChatAddress, text: string): Promise<Receipt>;
|
|
250
|
+
/**
|
|
251
|
+
* 回合开始「处理中」信号(引擎在 prompt 前调用)。飞书无 typing API,落地为
|
|
252
|
+
* 给用户本条入站消息加 OnIt 表情;同一聊天只留一个,回复落地/2 分钟超时自清。
|
|
253
|
+
* 任一环节失败一律静默——这是增强项,不得阻断回合。
|
|
254
|
+
*/
|
|
255
|
+
setTyping(address: ChatAddress): Promise<void>;
|
|
256
|
+
/** 回收「处理中」表情(重复调用幂等)。 */
|
|
257
|
+
private clearTyping;
|
|
258
|
+
/**
|
|
259
|
+
* 审批/选项确认卡片(T1.9 按钮面)。
|
|
260
|
+
* 协议事实(真机实测):2.0 schema 的 `body.elements` 已不支持 `action` 标签
|
|
261
|
+
* (230099 / 200861 unsupported tag action),按钮只能走 1.0 形态(顶层 elements);
|
|
262
|
+
* 内联 interactive 消息不碰 cardkit 实体面,因此不需要卡片实体权限。
|
|
263
|
+
* multi=true 不接(卡片无多选原语):抛错由 broker 回落文本菜单,不造假。
|
|
264
|
+
*/
|
|
265
|
+
askChoices(address: ChatAddress, question: string, options: readonly string[], multi: boolean, signal?: AbortSignal, opts?: {
|
|
266
|
+
ownerUserId?: string;
|
|
267
|
+
}): Promise<{
|
|
268
|
+
answer: string;
|
|
269
|
+
uncertain: boolean;
|
|
270
|
+
}>;
|
|
271
|
+
/**
|
|
272
|
+
* 卡片按钮点击回传(card.action.trigger,长连接同一条通道)。
|
|
273
|
+
* D5 纪律:只有发起者能定夺,他人点击不改判;超时/pending 已清则忽略。
|
|
274
|
+
* 回包给 toast(飞书卡片回调响应),让用户看到「已记录」。
|
|
275
|
+
*/
|
|
276
|
+
private handleCardAction;
|
|
222
277
|
/**
|
|
223
278
|
* 出站文件(T2.4):im.v1.file.create(file_type=stream)→ im.v1.message.create
|
|
224
279
|
* (msg_type=file, content.file_key)。限额:引擎 FILE_BUDGET 在上游已裁(20 MiB ≤ 4),
|
|
@@ -257,12 +312,26 @@ export declare class FeishuAdapter implements ChannelAdapter {
|
|
|
257
312
|
speak(address: ChatAddress, text: string, _signal?: AbortSignal): Promise<Receipt>;
|
|
258
313
|
/** tenant_access_token 自管(提前 60s 刷新;公开协议 auth/v3/internal)。 */
|
|
259
314
|
private token;
|
|
315
|
+
/** 通用 REST(带 tenant token):「处理中」表情等非卡片面共用。 */
|
|
316
|
+
private rest;
|
|
260
317
|
private cardApi;
|
|
261
318
|
/** 开流:建卡片实体(streaming_mode 开)→ interactive 消息引用 card_id。 */
|
|
262
319
|
beginStream(address: ChatAddress): Promise<import('./types.js').ReplyStream>;
|
|
263
320
|
private createCardMessage;
|
|
264
321
|
authorize(): boolean | undefined;
|
|
265
322
|
diagnose(): Promise<ChannelDiagnosisCheck[]>;
|
|
323
|
+
/**
|
|
324
|
+
* 凭据探测:auth/v3/tenant_access_token 换 token(唯一对 appSecret 做真裁决的面)。
|
|
325
|
+
* 曾探 `/open-apis/bot/v2/info`——该路径不存在(服务端 404 纯文本、SDK 直接抛),
|
|
326
|
+
* 任何凭据都落「凭据或网络无效」;且 bot 面本身不校验 secret(错 secret 同样回
|
|
327
|
+
* 11205),探它只能验能力不能验凭据——故拆成凭据位与机器人能力位两条。
|
|
328
|
+
*/
|
|
329
|
+
private credentialsCheck;
|
|
330
|
+
/**
|
|
331
|
+
* 机器人能力探测:GET /open-apis/bot/v3/info(有 bot → code 0;无 bot → 11205)。
|
|
332
|
+
* 这是「长连接通了却收不到消息」的判定位:无机器人能力时事件不投递、消息发不出。
|
|
333
|
+
*/
|
|
334
|
+
private botAbilityCheck;
|
|
266
335
|
/**
|
|
267
336
|
* 语音档位诊断(T3.5 三态):
|
|
268
337
|
* - 未启用(voiceEnabled 非 'true')= unverified——能力存在但未开通,不谎报;
|
|
@@ -271,8 +340,8 @@ export declare class FeishuAdapter implements ChannelAdapter {
|
|
|
271
340
|
* 未注入探测面时不真起进程(禁真实副作用),诚实报 unverified。
|
|
272
341
|
*/
|
|
273
342
|
private voiceCheck;
|
|
274
|
-
/**
|
|
275
|
-
injectEvent(eventName: string, payload:
|
|
343
|
+
/** 测试/诊断驱动:模拟一条事件(与 SDK handler 同一入口);回传 handler 的响应体。 */
|
|
344
|
+
injectEvent(eventName: string, payload: unknown): Promise<unknown>;
|
|
276
345
|
}
|
|
277
346
|
/**
|
|
278
347
|
* 工厂挂 registry(懒加载动态 import SDK 面)。语音档位全配置驱动、默认关:
|
package/lib/channels/feishu.js
CHANGED
|
@@ -3,12 +3,63 @@ const MAX_MESSAGE_LENGTH = 4000;
|
|
|
3
3
|
const OPEN_BASE = 'https://open.feishu.cn';
|
|
4
4
|
const STREAM_ELEMENT_ID = 'imu_stream';
|
|
5
5
|
const CARD_UPDATE_THROTTLE_MS = 1_500;
|
|
6
|
+
/**
|
|
7
|
+
* 回合内「处理中」信号:飞书无 typing API(客户端不暴露),用**入站消息的表情回应**
|
|
8
|
+
* 当状态位——实测 POST/DELETE /im/v1/messages/{id}/reactions 全链 code 0。
|
|
9
|
+
* OnIt = 官方「需处理/进行中」语义;回复落地或超时即删,不污染历史。
|
|
10
|
+
*/
|
|
11
|
+
const TYPING_EMOJI = 'OnIt';
|
|
12
|
+
const TYPING_TTL_MS = 120_000;
|
|
13
|
+
/**
|
|
14
|
+
* 卡片按钮回传(审批确认)等待上限:与宿主审批红线 D5 的 5 分钟对齐。
|
|
15
|
+
* 适配器自己必须定时:broker 的按钮分支是直接 `await askChoices(...)`,
|
|
16
|
+
* 不挂它自己的超时——没人点又不能永久悬挂。
|
|
17
|
+
*/
|
|
18
|
+
const CHOICE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
6
19
|
/** 开发者后台「批量导入权限」模板(权限补全引导, diagnose 附送)。 */
|
|
7
20
|
export const FEISHU_PERMISSION_TEMPLATE = JSON.stringify({
|
|
8
21
|
scopes: ['im:message', 'im:message:send_as_bot', 'im:chat', 'im:resource', 'cardkit:card:write'],
|
|
9
22
|
}, null, 2);
|
|
10
23
|
/** 无权限类平台错误码(发消息/读事件常见)。 */
|
|
11
24
|
const PERMISSION_ERROR_CODES = new Set([99991672, 2300024, 230027, 99991668]);
|
|
25
|
+
/** 凭据类错误码:auth/v3/tenant_access_token 对 appId/appSecret 的直接裁决。 */
|
|
26
|
+
const CREDENTIAL_ERROR_CODES = new Set([10003, 10013, 10014]);
|
|
27
|
+
/** 机器人能力未开通(bot/v3/info 回 11205,im 面回 232025)。 */
|
|
28
|
+
const BOT_ABILITY_MISSING_CODES = new Set([11205, 232025]);
|
|
29
|
+
/**
|
|
30
|
+
* 从 card.action.trigger 负载取关键字段。两种形态都要接:
|
|
31
|
+
* - 长连接/webhook 原始体:`{schema:'2.0', header:{event_type}, event:{context, operator, action}}`;
|
|
32
|
+
* - SDK 安全管道 normalize 后的形:`{messageId, chatId, operator:{openId}, action:{value}}`。
|
|
33
|
+
* 缺关键字段回 undefined(调用方丢弃,不猜)。
|
|
34
|
+
*/
|
|
35
|
+
export function extractCardAction(payload) {
|
|
36
|
+
if (payload === null || typeof payload !== 'object')
|
|
37
|
+
return undefined;
|
|
38
|
+
const nested = payload.event !== null && typeof payload.event === 'object' ? payload.event : payload;
|
|
39
|
+
const ctx = nested.context !== null && typeof nested.context === 'object' ? nested.context : {};
|
|
40
|
+
const messageId = typeof nested.messageId === 'string' ? nested.messageId
|
|
41
|
+
: typeof ctx.open_message_id === 'string' ? ctx.open_message_id
|
|
42
|
+
: typeof nested.open_message_id === 'string' ? nested.open_message_id : undefined;
|
|
43
|
+
const chatId = typeof nested.chatId === 'string' ? nested.chatId
|
|
44
|
+
: typeof ctx.open_chat_id === 'string' ? ctx.open_chat_id
|
|
45
|
+
: typeof nested.open_chat_id === 'string' ? nested.open_chat_id : undefined;
|
|
46
|
+
if (messageId === undefined || chatId === undefined)
|
|
47
|
+
return undefined;
|
|
48
|
+
const operator = (nested.operator ?? {});
|
|
49
|
+
const action = (nested.action ?? {});
|
|
50
|
+
const value = action.value !== null && typeof action.value === 'object' ? action.value : undefined;
|
|
51
|
+
return {
|
|
52
|
+
messageId,
|
|
53
|
+
chatId,
|
|
54
|
+
operator: {
|
|
55
|
+
...(typeof operator.openId === 'string' ? { openId: operator.openId } : {}),
|
|
56
|
+
...(typeof operator.open_id === 'string' ? { openId: operator.open_id } : {}),
|
|
57
|
+
...(typeof operator.userId === 'string' ? { userId: operator.userId } : {}),
|
|
58
|
+
...(typeof operator.user_id === 'string' ? { userId: operator.user_id } : {}),
|
|
59
|
+
},
|
|
60
|
+
action: { ...(value === undefined ? {} : { value }) },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
12
63
|
/** 网络类错误判定(结果不明)——与确定性平台拒收分档。 */
|
|
13
64
|
function isNetworkLike(error) {
|
|
14
65
|
return error instanceof Error && /网络|timeout|timed out|unreachable|socket|ECONN|fetch/i.test(error.message);
|
|
@@ -83,6 +134,10 @@ export class FeishuAdapter {
|
|
|
83
134
|
/** 语音档位(未开启=undefined:入站只留占位,零外部调用)。 */
|
|
84
135
|
voice;
|
|
85
136
|
tenantToken;
|
|
137
|
+
/** chatId → 最近一条入站消息 id(typing 状态位挂在用户那条消息上)。 */
|
|
138
|
+
lastInboundMessageId = new Map();
|
|
139
|
+
/** chatId → 在位「处理中」表情(回复落地/超时回收)。 */
|
|
140
|
+
typingReactions = new Map();
|
|
86
141
|
listeners = [];
|
|
87
142
|
client;
|
|
88
143
|
wsClient;
|
|
@@ -90,6 +145,11 @@ export class FeishuAdapter {
|
|
|
90
145
|
log;
|
|
91
146
|
/** 事件处理器表(测试注入口:直接驱动 receive handler)。 */
|
|
92
147
|
handlers;
|
|
148
|
+
/**
|
|
149
|
+
* 在位的选项卡:message_id → 待答。
|
|
150
|
+
* 键用 message_id 而非 chatId:一个聊天可能先后出多张卡,旧卡迟到点击不得误判新卡。
|
|
151
|
+
*/
|
|
152
|
+
pendingChoices = new Map();
|
|
93
153
|
constructor(options) {
|
|
94
154
|
this.options = options;
|
|
95
155
|
if (typeof options.appId !== 'string' || options.appId === '' || typeof options.appSecret !== 'string' || options.appSecret === '') {
|
|
@@ -123,9 +183,10 @@ export class FeishuAdapter {
|
|
|
123
183
|
this.caps = {
|
|
124
184
|
editStream: false,
|
|
125
185
|
nativeStream: this.cardMode === 'card',
|
|
126
|
-
nativeChoices:
|
|
127
|
-
|
|
128
|
-
|
|
186
|
+
nativeChoices: true,
|
|
187
|
+
// typing 落地=表情回应(无平台 typing API);两者同源,故一起声明。
|
|
188
|
+
statusReaction: true,
|
|
189
|
+
typing: true,
|
|
129
190
|
imageIn: false,
|
|
130
191
|
fileIn: true,
|
|
131
192
|
fileOut: true,
|
|
@@ -156,6 +217,10 @@ export class FeishuAdapter {
|
|
|
156
217
|
const message = normalizeFeishuMessage(data);
|
|
157
218
|
if (message === undefined)
|
|
158
219
|
return;
|
|
220
|
+
// 「处理中」状态位锚点:后面 setTyping 用它给用户这条消息贴 OnIt。
|
|
221
|
+
const inboundId = data?.message?.message_id;
|
|
222
|
+
if (typeof inboundId === 'string' && inboundId !== '')
|
|
223
|
+
this.lastInboundMessageId.set(message.address.chatId, inboundId);
|
|
159
224
|
// 入站文件下载(T2.4):失败降级为无附件,不丢整条。
|
|
160
225
|
await this.fillFiles(message, data?.message);
|
|
161
226
|
// 入站语音转写(T3.5):失败回占位文案,同样不丢整条。
|
|
@@ -163,6 +228,8 @@ export class FeishuAdapter {
|
|
|
163
228
|
for (const listener of this.listeners)
|
|
164
229
|
listener(message);
|
|
165
230
|
},
|
|
231
|
+
// 卡片按钮回传(审批确认):长连接下飞书把 card.action.trigger 推同一条连接。
|
|
232
|
+
'card.action.trigger': async (data) => this.handleCardAction(data),
|
|
166
233
|
};
|
|
167
234
|
const dispatcher = new lark.EventDispatcher({}).register(this.handlers);
|
|
168
235
|
await this.wsClient.start({ eventDispatcher: dispatcher });
|
|
@@ -171,6 +238,12 @@ export class FeishuAdapter {
|
|
|
171
238
|
async disconnect() {
|
|
172
239
|
this.connected = false;
|
|
173
240
|
this.wsClient = undefined;
|
|
241
|
+
// 拆除即释放所有在位选项卡(空答=取消,fail-closed,不伪装批准)。
|
|
242
|
+
for (const [, pending] of [...this.pendingChoices]) {
|
|
243
|
+
clearTimeout(pending.timer);
|
|
244
|
+
pending.resolve('');
|
|
245
|
+
}
|
|
246
|
+
this.pendingChoices.clear();
|
|
174
247
|
}
|
|
175
248
|
async sendMessagePayload(address, msgType, content) {
|
|
176
249
|
const client = this.client;
|
|
@@ -213,8 +286,147 @@ export class FeishuAdapter {
|
|
|
213
286
|
return new Error(`feishu: 平台拒绝请求(code ${code})`);
|
|
214
287
|
}
|
|
215
288
|
async reply(address, text) {
|
|
289
|
+
// 回复即收轮:先回收「处理中」状态位(失败不影响投递)。
|
|
290
|
+
void this.clearTyping(address);
|
|
216
291
|
return this.createMessage(address, text);
|
|
217
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* 回合开始「处理中」信号(引擎在 prompt 前调用)。飞书无 typing API,落地为
|
|
295
|
+
* 给用户本条入站消息加 OnIt 表情;同一聊天只留一个,回复落地/2 分钟超时自清。
|
|
296
|
+
* 任一环节失败一律静默——这是增强项,不得阻断回合。
|
|
297
|
+
*/
|
|
298
|
+
async setTyping(address) {
|
|
299
|
+
const messageId = this.lastInboundMessageId.get(address.chatId);
|
|
300
|
+
if (messageId === undefined)
|
|
301
|
+
return;
|
|
302
|
+
await this.clearTyping(address);
|
|
303
|
+
try {
|
|
304
|
+
const data = await this.rest('POST', `/open-apis/im/v1/messages/${messageId}/reactions`, {
|
|
305
|
+
reaction_type: { emoji_type: TYPING_EMOJI },
|
|
306
|
+
});
|
|
307
|
+
const reactionId = data.reaction_id;
|
|
308
|
+
if (typeof reactionId !== 'string' || reactionId === '')
|
|
309
|
+
return;
|
|
310
|
+
this.typingReactions.set(address.chatId, { messageId, reactionId });
|
|
311
|
+
const timer = setTimeout(() => void this.clearTyping(address), TYPING_TTL_MS);
|
|
312
|
+
timer.unref?.();
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// 表情权限缺位/限频:静默,不影响回合。
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** 回收「处理中」表情(重复调用幂等)。 */
|
|
319
|
+
async clearTyping(address) {
|
|
320
|
+
const pending = this.typingReactions.get(address.chatId);
|
|
321
|
+
if (pending === undefined)
|
|
322
|
+
return;
|
|
323
|
+
this.typingReactions.delete(address.chatId);
|
|
324
|
+
try {
|
|
325
|
+
await this.rest('DELETE', `/open-apis/im/v1/messages/${pending.messageId}/reactions/${pending.reactionId}`);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// 消息被撤回/表情已被手动移除:无需处理。
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* 审批/选项确认卡片(T1.9 按钮面)。
|
|
333
|
+
* 协议事实(真机实测):2.0 schema 的 `body.elements` 已不支持 `action` 标签
|
|
334
|
+
* (230099 / 200861 unsupported tag action),按钮只能走 1.0 形态(顶层 elements);
|
|
335
|
+
* 内联 interactive 消息不碰 cardkit 实体面,因此不需要卡片实体权限。
|
|
336
|
+
* multi=true 不接(卡片无多选原语):抛错由 broker 回落文本菜单,不造假。
|
|
337
|
+
*/
|
|
338
|
+
async askChoices(address, question, options, multi, signal, opts) {
|
|
339
|
+
const client = this.client;
|
|
340
|
+
if (client === undefined)
|
|
341
|
+
throw new Error('feishu: 客户端未初始化(先 connect)');
|
|
342
|
+
if (multi)
|
|
343
|
+
throw new Error('feishu: 多选无卡片原语(回落文本菜单)');
|
|
344
|
+
const labels = options.map((option) => String(option)).filter((label) => label !== '');
|
|
345
|
+
if (labels.length === 0)
|
|
346
|
+
throw new Error('feishu: 选项为空');
|
|
347
|
+
const card = {
|
|
348
|
+
config: { wide_screen_mode: true },
|
|
349
|
+
header: { title: { tag: 'plain_text', content: '需要确认' } },
|
|
350
|
+
elements: [
|
|
351
|
+
{ tag: 'div', text: { tag: 'lark_md', content: question } },
|
|
352
|
+
{
|
|
353
|
+
tag: 'action',
|
|
354
|
+
actions: labels.map((label, index) => ({
|
|
355
|
+
tag: 'button',
|
|
356
|
+
text: { tag: 'plain_text', content: label },
|
|
357
|
+
type: index === 0 ? 'primary' : 'default',
|
|
358
|
+
value: { imu: 'choice', label },
|
|
359
|
+
})),
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
};
|
|
363
|
+
const response = await client.im.message.create({
|
|
364
|
+
params: { receive_id_type: 'chat_id' },
|
|
365
|
+
data: { receive_id: address.chatId, msg_type: 'interactive', content: JSON.stringify(card) },
|
|
366
|
+
});
|
|
367
|
+
if (typeof response?.code === 'number' && response.code !== 0)
|
|
368
|
+
throw this.translateApiError(response.code, response.msg);
|
|
369
|
+
const messageId = typeof response?.message_id === 'string' ? response.message_id : undefined;
|
|
370
|
+
if (messageId === undefined)
|
|
371
|
+
throw new Error('feishu: 交互卡片未返回 message_id');
|
|
372
|
+
return new Promise((resolveOuter) => {
|
|
373
|
+
let done = false;
|
|
374
|
+
const onAbort = () => settle('');
|
|
375
|
+
const settle = (answer) => {
|
|
376
|
+
if (done)
|
|
377
|
+
return;
|
|
378
|
+
done = true;
|
|
379
|
+
const held = this.pendingChoices.get(messageId);
|
|
380
|
+
if (held !== undefined) {
|
|
381
|
+
clearTimeout(held.timer);
|
|
382
|
+
this.pendingChoices.delete(messageId);
|
|
383
|
+
}
|
|
384
|
+
if (signal !== undefined)
|
|
385
|
+
signal.removeEventListener('abort', onAbort);
|
|
386
|
+
// 卡片已确定发出,后续任何结果(点/超时/取消/撤回)都是确定性的。
|
|
387
|
+
resolveOuter({ answer, uncertain: false });
|
|
388
|
+
};
|
|
389
|
+
if (signal !== undefined) {
|
|
390
|
+
if (signal.aborted) {
|
|
391
|
+
settle('');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
395
|
+
}
|
|
396
|
+
const timer = setTimeout(() => settle(''), CHOICE_TIMEOUT_MS);
|
|
397
|
+
timer.unref?.();
|
|
398
|
+
this.pendingChoices.set(messageId, {
|
|
399
|
+
labels,
|
|
400
|
+
ownerUserId: opts?.ownerUserId,
|
|
401
|
+
resolve: settle,
|
|
402
|
+
timer,
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* 卡片按钮点击回传(card.action.trigger,长连接同一条通道)。
|
|
408
|
+
* D5 纪律:只有发起者能定夺,他人点击不改判;超时/pending 已清则忽略。
|
|
409
|
+
* 回包给 toast(飞书卡片回调响应),让用户看到「已记录」。
|
|
410
|
+
*/
|
|
411
|
+
async handleCardAction(payload) {
|
|
412
|
+
const evt = extractCardAction(payload);
|
|
413
|
+
if (evt === undefined)
|
|
414
|
+
return undefined;
|
|
415
|
+
const pending = this.pendingChoices.get(evt.messageId);
|
|
416
|
+
if (pending === undefined)
|
|
417
|
+
return undefined;
|
|
418
|
+
const owner = pending.ownerUserId;
|
|
419
|
+
if (owner !== undefined && evt.operator.openId !== owner && evt.operator.userId !== owner) {
|
|
420
|
+
return { toast: { type: 'info', content: '该请求只接受发起者的答复' } };
|
|
421
|
+
}
|
|
422
|
+
const raw = evt.action.value?.label;
|
|
423
|
+
const label = typeof raw === 'string' ? raw : '';
|
|
424
|
+
if (!pending.labels.includes(label)) {
|
|
425
|
+
return { toast: { type: 'error', content: '未能识别该按钮,请用文本回复' } };
|
|
426
|
+
}
|
|
427
|
+
pending.resolve(label);
|
|
428
|
+
return { toast: { type: 'success', content: `已记录:${label}` } };
|
|
429
|
+
}
|
|
218
430
|
/**
|
|
219
431
|
* 出站文件(T2.4):im.v1.file.create(file_type=stream)→ im.v1.message.create
|
|
220
432
|
* (msg_type=file, content.file_key)。限额:引擎 FILE_BUDGET 在上游已裁(20 MiB ≤ 4),
|
|
@@ -453,6 +665,21 @@ export class FeishuAdapter {
|
|
|
453
665
|
this.tenantToken = { value: json.tenant_access_token, expireAtMs: Date.now() + Number(json.expire ?? 3600) * 1000 };
|
|
454
666
|
return this.tenantToken.value;
|
|
455
667
|
}
|
|
668
|
+
/** 通用 REST(带 tenant token):「处理中」表情等非卡片面共用。 */
|
|
669
|
+
async rest(method, path, body) {
|
|
670
|
+
const token = await this.token();
|
|
671
|
+
const response = await this.fetchImpl(`${OPEN_BASE}${path}`, {
|
|
672
|
+
method,
|
|
673
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
674
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
675
|
+
});
|
|
676
|
+
const json = await response.json();
|
|
677
|
+
if (!response.ok || (typeof json.code === 'number' && json.code !== 0)) {
|
|
678
|
+
const code = Number(json.code ?? response.status);
|
|
679
|
+
throw new Error(`feishu: 请求失败(code ${code}:${String(json.msg ?? '')})`);
|
|
680
|
+
}
|
|
681
|
+
return (json.data ?? {});
|
|
682
|
+
}
|
|
456
683
|
async cardApi(method, path, body) {
|
|
457
684
|
const token = await this.token();
|
|
458
685
|
const response = await this.fetchImpl(`${OPEN_BASE}${path}`, {
|
|
@@ -517,6 +744,8 @@ export class FeishuAdapter {
|
|
|
517
744
|
await self.cardApi('PATCH', `/open-apis/cardkit/v1/cards/${cardId}/settings`, {
|
|
518
745
|
config: { streaming_mode: false },
|
|
519
746
|
}).catch((error) => self.log(`feishu: 关闭流式模式失败:${error.message}`));
|
|
747
|
+
// 卡片已落定 = 回合可见结果到位,「处理中」表情该收了。
|
|
748
|
+
await self.clearTyping(address);
|
|
520
749
|
return { platformMessageId: sent, uncertain: false };
|
|
521
750
|
},
|
|
522
751
|
abort() {
|
|
@@ -541,21 +770,14 @@ export class FeishuAdapter {
|
|
|
541
770
|
}
|
|
542
771
|
async diagnose() {
|
|
543
772
|
const checks = [];
|
|
544
|
-
|
|
545
|
-
if (this.client === undefined)
|
|
546
|
-
throw new Error('未连接');
|
|
547
|
-
const response = await this.client.request({ method: 'GET', url: '/open-apis/bot/v2/info' });
|
|
548
|
-
const ok = response?.code === undefined || response.code === 0;
|
|
549
|
-
checks.push({ name: 'credentials-valid', status: ok ? 'passed' : 'failed', hint: ok ? undefined : `code ${response?.code}` });
|
|
550
|
-
}
|
|
551
|
-
catch (error) {
|
|
552
|
-
checks.push({ name: 'credentials-valid', status: 'failed', hint: '凭据或网络无效,请检查 appId/appSecret' });
|
|
553
|
-
}
|
|
773
|
+
checks.push(await this.credentialsCheck());
|
|
554
774
|
checks.push({
|
|
555
775
|
name: 'ws-long-connection',
|
|
556
776
|
status: this.connected ? 'passed' : 'unverified',
|
|
557
|
-
|
|
777
|
+
// 长连接只证明握手成功:事件真投递还需机器人能力 + receive_v1 订阅。
|
|
778
|
+
hint: this.connected ? '长连接已建立(不能据此推定机器人能力/消息权限已开通)' : '未连接',
|
|
558
779
|
});
|
|
780
|
+
checks.push(await this.botAbilityCheck());
|
|
559
781
|
checks.push({
|
|
560
782
|
name: 'permission-im-message',
|
|
561
783
|
status: 'unverified',
|
|
@@ -576,6 +798,74 @@ export class FeishuAdapter {
|
|
|
576
798
|
checks.push(await this.voiceCheck());
|
|
577
799
|
return checks;
|
|
578
800
|
}
|
|
801
|
+
/**
|
|
802
|
+
* 凭据探测:auth/v3/tenant_access_token 换 token(唯一对 appSecret 做真裁决的面)。
|
|
803
|
+
* 曾探 `/open-apis/bot/v2/info`——该路径不存在(服务端 404 纯文本、SDK 直接抛),
|
|
804
|
+
* 任何凭据都落「凭据或网络无效」;且 bot 面本身不校验 secret(错 secret 同样回
|
|
805
|
+
* 11205),探它只能验能力不能验凭据——故拆成凭据位与机器人能力位两条。
|
|
806
|
+
*/
|
|
807
|
+
async credentialsCheck() {
|
|
808
|
+
const client = this.client;
|
|
809
|
+
if (client === undefined)
|
|
810
|
+
return { name: 'credentials-valid', status: 'failed', hint: '未连接:先连接后再检查' };
|
|
811
|
+
try {
|
|
812
|
+
const response = await client.request({
|
|
813
|
+
method: 'POST',
|
|
814
|
+
url: '/open-apis/auth/v3/tenant_access_token/internal',
|
|
815
|
+
data: { app_id: this.options.appId, app_secret: this.options.appSecret },
|
|
816
|
+
});
|
|
817
|
+
const code = response?.code;
|
|
818
|
+
if (code === undefined || code === 0) {
|
|
819
|
+
return typeof response?.tenant_access_token === 'string'
|
|
820
|
+
? { name: 'credentials-valid', status: 'passed' }
|
|
821
|
+
: { name: 'credentials-valid', status: 'unverified', hint: 'code 0 但未回 tenant_access_token' };
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
name: 'credentials-valid',
|
|
825
|
+
status: 'failed',
|
|
826
|
+
hint: CREDENTIAL_ERROR_CODES.has(code)
|
|
827
|
+
? `appId/appSecret 无效(code ${code}):核对凭据库条目`
|
|
828
|
+
: `凭据探测被拒(code ${code})`,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
catch (error) {
|
|
832
|
+
// 只取 message(不透传 error 体:axios config 里带着 Authorization/body)。
|
|
833
|
+
const message = error?.message ?? String(error);
|
|
834
|
+
return {
|
|
835
|
+
name: 'credentials-valid',
|
|
836
|
+
status: 'failed',
|
|
837
|
+
hint: isNetworkLike(error) ? `网络不可达:${message}` : `凭据探测失败:${message}`,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* 机器人能力探测:GET /open-apis/bot/v3/info(有 bot → code 0;无 bot → 11205)。
|
|
843
|
+
* 这是「长连接通了却收不到消息」的判定位:无机器人能力时事件不投递、消息发不出。
|
|
844
|
+
*/
|
|
845
|
+
async botAbilityCheck() {
|
|
846
|
+
const client = this.client;
|
|
847
|
+
if (client === undefined)
|
|
848
|
+
return { name: 'bot-ability', status: 'unverified', hint: '未连接:先连接后再检查' };
|
|
849
|
+
try {
|
|
850
|
+
const response = await client.request({ method: 'GET', url: '/open-apis/bot/v3/info' });
|
|
851
|
+
const code = response?.code;
|
|
852
|
+
if (code === undefined || code === 0) {
|
|
853
|
+
return { name: 'bot-ability', status: 'passed', hint: '机器人能力已开通;收发消息仍受 im:message 权限约束' };
|
|
854
|
+
}
|
|
855
|
+
if (BOT_ABILITY_MISSING_CODES.has(code)) {
|
|
856
|
+
return {
|
|
857
|
+
name: 'bot-ability',
|
|
858
|
+
status: 'failed',
|
|
859
|
+
hint: '应用未开通「机器人」能力(code 11205):开发者后台「添加应用能力 → 机器人」并发布版本,否则长连接通也收不到消息事件、发不出消息',
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
return { name: 'bot-ability', status: 'failed', hint: `机器人能力探测被拒(code ${code})` };
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
const message = error?.message ?? String(error);
|
|
866
|
+
return { name: 'bot-ability', status: 'unverified', hint: `机器人能力探测未完成:${message}` };
|
|
867
|
+
}
|
|
868
|
+
}
|
|
579
869
|
/**
|
|
580
870
|
* 语音档位诊断(T3.5 三态):
|
|
581
871
|
* - 未启用(voiceEnabled 非 'true')= unverified——能力存在但未开通,不谎报;
|
|
@@ -603,12 +893,12 @@ export class FeishuAdapter {
|
|
|
603
893
|
? { name: 'voice-pipeline', status: 'passed', hint: `ffmpeg 到位(${String(probe.version ?? '')});ASR/TTS 需首次实调用实证权限与配额` }
|
|
604
894
|
: { name: 'voice-pipeline', status: 'failed', hint: `语音探测失败:${voiceFailureText(probe.failure)}(内部:${String(probe.failure?.message ?? '')})` };
|
|
605
895
|
}
|
|
606
|
-
/**
|
|
896
|
+
/** 测试/诊断驱动:模拟一条事件(与 SDK handler 同一入口);回传 handler 的响应体。 */
|
|
607
897
|
async injectEvent(eventName, payload) {
|
|
608
898
|
const handler = this.handlers?.[eventName];
|
|
609
899
|
if (handler === undefined)
|
|
610
900
|
throw new Error(`feishu: 未注册事件 ${eventName}`);
|
|
611
|
-
await handler(payload);
|
|
901
|
+
return await handler(payload);
|
|
612
902
|
}
|
|
613
903
|
}
|
|
614
904
|
class FeishuUncertainError extends Error {
|