@zzzpyyy/dsh-bot-feishu 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +83 -2
- package/compatibility.json +12 -0
- package/cordis.patch.example.yml +14 -0
- package/cordis.patch.yml +3 -0
- package/domain-OHZklmI3.js +520 -0
- package/feishu.js +925 -0
- package/package.json +52 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 飞书机器人接入
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DSH Bot 0.5.0 的「IM 接入 → 飞书」配置面板支持为每个 Bot 单独创建并绑定一个飞书机器人,也可以绑定已有应用。如流入口、凭据和会话继续保留。飞书插件是独立可选包 `@zzzpyyy/dsh-bot-feishu@0.1.0`,搭配 DSH `0.1.5-rc.2` 和 `@zzzpyyy/dsh-bot@0.5.0`。
|
|
4
|
+
|
|
5
|
+
同一个 DSH Bot 可以同时启用飞书和如流绑定。在「IM 接入」的两个平台页分别选择同一个 Bot 并完成配置即可,不需要复制 Bot。两边的凭据、准入名单和启停开关独立;停用一个平台不影响另一个平台。消息记录、模型对话上下文和回复目的地按平台及具体聊天隔离,不自动同步或转发;私聊与群聊也分别保存。同名群不会跨平台合并。共用的是 Bot 指令、模型和工具配置;工具可访问的文件、外部服务及主动写入的记忆仍由部署配置决定,不属于会话隔离。
|
|
6
|
+
|
|
7
|
+
## 一个 Bot,四种调用入口
|
|
8
|
+
|
|
9
|
+
四个入口使用同一个 DSH Bot 的身份、指令和原生执行预设(模型、工具、技能及审批),回复返回发起请求的聊天。
|
|
10
|
+
|
|
11
|
+
飞书机器人直接发送回复正文,不额外添加 DSH 内部的 `@botId` 行;发送者名称和头像标明回复身份。模型正文中的正常 @ 文本原样保留。
|
|
12
|
+
|
|
13
|
+
| 入口 | 触发方式 | 回复位置 |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| Web Bot 单聊 | 打开该 Bot,直接发消息 | 当前 Web 单聊 |
|
|
16
|
+
| Web 群聊 | 将该 Bot 加入群,发送 `@botId 问题` | 当前 Web 群聊 |
|
|
17
|
+
| 飞书 Bot 单聊 | 私聊绑定到该 Bot 的飞书机器人 | 当前飞书单聊 |
|
|
18
|
+
| 飞书群聊 | 将机器人加入允许的群,用飞书原生 @ 选择该机器人后发消息 | 当前飞书群聊 |
|
|
19
|
+
|
|
20
|
+
Bot 的身份和能力共用,聊天上下文按入口和聊天分别保存;Web 私聊内容不会自动带入飞书或群聊。同一飞书群在 DSH 中只显示一个群聊,成员共享公开消息,各 Bot 保留自己的原生 Session;飞书私聊仍按应用隔离。群里存在多个机器人时,只执行被真正 @ 到的机器人;同一条消息 @ 多个 Bot 时,DSH 只记录一条用户消息,并分别展示各 Bot 的回复。文本中手打同名字符串不能替代飞书的原生 @。审批仍在 DSH 完成。
|
|
21
|
+
|
|
22
|
+
飞书连接已启用仍不代表所有群都开放。进入 **IM 接入 → 飞书 → 选择 Bot → 允许接入的群 Chat ID**,添加目标群的 `oc_…` 并保存;空名单不会响应群消息。手动接入已有群时,需要分别把群 ID 填入各 Bot 的配置;通过一键建群创建的新群会自动配置所选 Bot。私聊用户也必须在该应用的允许用户名单中。
|
|
23
|
+
|
|
24
|
+
## 扫码创建并绑定
|
|
25
|
+
|
|
26
|
+
1. 打开 **DSH Bots → IM 接入 → 飞书**,选择要接入的 Bot。
|
|
27
|
+
2. 点击 **扫码创建并绑定**,使用飞书扫描二维码,或打开页面中的官方授权链接。
|
|
28
|
+
3. 在飞书确认应用名称、租户和申请权限。此步骤需要你的授权;不是后台静默创建。
|
|
29
|
+
4. 返回 DSH。App ID 和 App Secret 会由官方 SDK 返回,凭据直接写入 DSH 凭据库,页面不回显密钥。扫码用户的当前应用 Open ID 会作为默认私聊允许用户和管理员;不同应用的 Open ID 不能互换。
|
|
30
|
+
5. 核对绑定结果,点击 **验证机器人**,再点击 **启用连接**。新绑定默认停用,确保先检查配置。
|
|
31
|
+
|
|
32
|
+
新应用可能仍需发布或租户管理员审批。出现“暂未就绪”时,凭据已保留,去飞书开发者后台完成发布,然后再次验证即可,不要重复创建。
|
|
33
|
+
|
|
34
|
+
## 绑定已有机器人
|
|
35
|
+
|
|
36
|
+
选择对应 Bot,点击 **绑定已有机器人**,填写 App ID(`cli_` 开头)、App Secret、允许私聊用户(该应用的 `ou_` Open ID)和管理员。平台可选飞书或 Lark。点击 **验证并保存**,通过后启用连接。
|
|
37
|
+
|
|
38
|
+
已保存密钥留空表示保留原值。一个 App ID 只能绑定一个 DSH Bot,防止两个 Bot 争抢同一应用事件。已有绑定不能直接换应用;为新应用创建一个新的 DSH Bot。
|
|
39
|
+
|
|
40
|
+
## 消息、权限和群聊
|
|
41
|
+
|
|
42
|
+
连接使用飞书官方长连接,不需要公网回调地址。应用需启用机器人能力,在「事件与回调」使用长连接,并订阅 `im.message.receive_v1`。接入申请以下应用权限:
|
|
43
|
+
|
|
44
|
+
- `im:message.p2p_msg:readonly`:接收用户私聊。
|
|
45
|
+
- `im:message.group_at_msg:readonly`:接收群中 @机器人的消息。
|
|
46
|
+
- `im:message:send_as_bot`:以机器人身份发送回复。
|
|
47
|
+
- `im:chat:create`:创建飞书群;已有应用可点击页面中的「开通建群权限」补齐。
|
|
48
|
+
|
|
49
|
+
官方创建页的 `addons` 能力仍有灰度限制;若没有采用请求的配置,可能展示默认模板。以实际授权页为准,核对权限,并在开发者后台补齐消息事件、长连接和发布状态。不要同时在其他程序运行同一应用的接收连接。
|
|
50
|
+
|
|
51
|
+
私聊只接受允许用户;群聊只接受允许群 ID(`oc_`)且明确 @当前机器人的文字消息。空名单表示不允许。将机器人加入已有飞书群后,把群 ID 填入该 Bot 的允许群列表并保存。支持文字回复及 `/help`、`/status`、`/stop`;审批仍在 DSH 中处理。
|
|
52
|
+
|
|
53
|
+
## 一键创建飞书群聊
|
|
54
|
+
|
|
55
|
+
在 **IM 接入 → 飞书 → 选择已启用的 Bot → 新建飞书群聊** 填写群名,勾选 1–6 个同企业、同平台且已启用的机器人,然后点击「创建」。普通「新建群聊」窗口也可以勾选「同时创建飞书群聊」。DSH 使用指定的建群机器人创建私有群,自动加入群主及所选机器人、补充各 Bot 的允许群列表,并创建对话记录。建群机器人需要 `im:chat:create`;其他被邀请的机器人只需原有消息权限。
|
|
56
|
+
|
|
57
|
+
群主默认是建群机器人配置的第一位管理员,没有管理员时使用第一位允许私聊用户。高级设置可以填写该应用对应的群主 Open ID;不同应用的用户 Open ID 不能互换。群里用飞书原生 @ 选择机器人即可调用它。DSH 中以群名显示一个共享群聊,包含所有接入成员及其回复。此入口创建飞书群及其记录,不额外创建独立 Web 群。
|
|
58
|
+
|
|
59
|
+
升级后会自动合并旧版按 Bot 拆开的同群记录,保留原生 Session、回复和待发队列;重复的同一条群消息合并为一条。首次继续执行时,各 Bot 会获得有上限的共享公开历史。原来保存的群聊入口仍可跳转到合并后的群。DSH 内可查看记录、停止响应和打开执行会话;发消息及 @ 操作在飞书完成。
|
|
60
|
+
|
|
61
|
+
如果建群被明确拒绝(例如缺少 `im:chat:create`),弹窗会保留群名和成员并恢复编辑;可以从弹窗打开当前应用的权限页,或改用曾成功建群的机器人。已关闭的失败请求可在「IM 接入 → 飞书」群聊列表点击「修改后重试」恢复草稿。切换建群机器人会清空原应用的群主 Open ID,改用新机器人的默认群主。结果尚未确认或群已创建但接入未完成时,配置继续锁定,点击「继续完成」恢复同一次请求。
|
|
62
|
+
|
|
63
|
+
建群记录先持久化请求 ID,再调用[飞书创建群 API](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/chat/create.md)。失败时从飞书页面的群列表点击「继续完成」:已保存群 ID 的请求只补齐本地接入,未知结果在原请求的 9 小时内使用相同 UUID 和群主重试(飞书去重期限为 10 小时);到期后须先确认远端结果,已有群可手动接入。服务重启不会自动重发建群。原有如流建群功能继续可用。
|
|
64
|
+
|
|
65
|
+
## 取消、重启和重试
|
|
66
|
+
|
|
67
|
+
取消只停止等待授权,不能撤销已经在飞书确认创建的应用。授权中服务重启后,不会自动重试创建;先检查飞书后台,若已存在应用,使用「绑定已有机器人」。二维码过期后可重新发起扫码。创建结果或本地保存失败也不会自动创建第二个应用。
|
|
68
|
+
|
|
69
|
+
聊天接收、回复队列和去重记录保存在 DSH 原生存储。连接恢复可继续已接收任务。发送使用稳定的 UUID;飞书服务的去重有效期有限,长时间不确定的网络故障无法保证远端永不重复。
|
|
70
|
+
|
|
71
|
+
## 安装与开发
|
|
72
|
+
|
|
73
|
+
在兼容的 DSH `0.1.5-rc.2` Web profile 中,先安装主包,再安装飞书插件:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
dsh plugin --profile web add @zzzpyyy/dsh-bot@0.5.0
|
|
77
|
+
dsh plugin --profile web add @zzzpyyy/dsh-bot-feishu@0.1.0
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Desktop 可在插件管理器中按相同顺序安装这两个精确版本。包使用 `next` 标签;使用与现有服务相同的 profile 和数据目录。升级前备份数据并停止 Host,升级后重启,再从「IM 接入 → 飞书」检查连接状态。
|
|
81
|
+
|
|
82
|
+
构建后运行 `npm run pack:feishu`,得到独立伴随包。该包固定依赖公开的 `@larksuiteoapi/node-sdk@1.75.0` 和 `qrcode@1.5.4`。通过 DSH 原生插件包安装机制加载;Web 使用包内 `cordis.patch.yml`,与基础插件的 patch 一起放在命令行子命令之前。默认只启用管理页,不创建应用、不连接未启用的绑定。可调队列、重试与 HTTP 超时见 [示例配置](https://github.com/zzzpyyy/dsh-bot/blob/main/feishu/cordis.patch.example.yml)。
|
|
83
|
+
|
|
84
|
+
官方依据:[SDK 应用注册](https://github.com/larksuite/node-sdk#app-registration)、[飞书官方扫码创建指南](https://www.feishu.cn/content/article/7613711414611463386)、[实现调研](https://github.com/zzzpyyy/dsh-bot/blob/main/docs/feishu-provisioning-research.zh.md)。
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Install the companion; no robot or authorization starts until requested in the page.
|
|
2
|
+
- id: bot-feishu
|
|
3
|
+
disabled: false
|
|
4
|
+
config:
|
|
5
|
+
endpointTimeoutMs: 15000
|
|
6
|
+
pollMs: 1000
|
|
7
|
+
maxPending: 200
|
|
8
|
+
maxQueuePerRoom: 20
|
|
9
|
+
maxConcurrentRooms: 4
|
|
10
|
+
maxReceipts: 100000
|
|
11
|
+
maxOutbox: 10000
|
|
12
|
+
chunkSize: 3000
|
|
13
|
+
retryMs: 5000
|
|
14
|
+
maxRetryMs: 120000
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { brandString } from "@deepseek-ai/dsh-brand";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineDomain } from "@deepseek-ai/dsh-storage-domain";
|
|
4
|
+
//#region lib/types/infoflow/messages.js
|
|
5
|
+
/** Decode the SDK's external wire payloads without coercing unsafe numeric IDs. */
|
|
6
|
+
const record = z.record(z.string(), z.unknown());
|
|
7
|
+
function object(value) {
|
|
8
|
+
return record.safeParse(value).data ?? {};
|
|
9
|
+
}
|
|
10
|
+
function id(value) {
|
|
11
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
12
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return String(value);
|
|
13
|
+
}
|
|
14
|
+
function text(value) {
|
|
15
|
+
return typeof value === "string" ? value : "";
|
|
16
|
+
}
|
|
17
|
+
/** Match an exact robot AT; name-only and @everyone events cannot start a turn.
|
|
18
|
+
* @param event - SDK event name.
|
|
19
|
+
* @param value - untrusted SDK envelope.
|
|
20
|
+
* @param accountId - configured application ID.
|
|
21
|
+
* @param robotId - configured group mention identity.
|
|
22
|
+
* @returns a text message or null for irrelevant/unsupported events.
|
|
23
|
+
*/
|
|
24
|
+
function decodeMessage(event, value, accountId, robotId) {
|
|
25
|
+
const raw = object(object(object(value).data).raw);
|
|
26
|
+
if (event === "private.text" || event === "private.markdown") {
|
|
27
|
+
const sender = id(raw.FromUserId ?? raw.fromUserId);
|
|
28
|
+
const receipt = id(raw.MsgId ?? raw.msgId);
|
|
29
|
+
const content = text(raw.Content ?? raw.content ?? raw.Text ?? raw.text).trim();
|
|
30
|
+
if (!sender || sender === robotId || !receipt || !content) return null;
|
|
31
|
+
return {
|
|
32
|
+
conversation: {
|
|
33
|
+
provider: "infoflow",
|
|
34
|
+
accountId,
|
|
35
|
+
kind: "private",
|
|
36
|
+
id: sender
|
|
37
|
+
},
|
|
38
|
+
message: {
|
|
39
|
+
id: receipt,
|
|
40
|
+
sender: {
|
|
41
|
+
id: sender,
|
|
42
|
+
name: sender
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
text: content
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (![
|
|
49
|
+
"group.text",
|
|
50
|
+
"group.markdown",
|
|
51
|
+
"group.mixed"
|
|
52
|
+
].includes(event)) return null;
|
|
53
|
+
if (raw.eventtype !== void 0 && raw.eventtype !== "MESSAGE_RECEIVE") return null;
|
|
54
|
+
const group = id(raw.groupid ?? raw.groupId);
|
|
55
|
+
const message = object(raw.message);
|
|
56
|
+
const header = object(message.header);
|
|
57
|
+
if (header.role === "robot") return null;
|
|
58
|
+
const sender = id(header.fromuserid ?? header.fromUserId ?? raw.fromuserid);
|
|
59
|
+
const receipt = id(header.messageid ?? raw.messageid);
|
|
60
|
+
const blocks = Array.isArray(message.body) ? message.body.map(object) : [];
|
|
61
|
+
if (!group || !sender || sender === robotId || !receipt || !blocks.some((block) => text(block.type).toUpperCase() === "AT" && id(block.robotid) === robotId)) return null;
|
|
62
|
+
const content = blocks.flatMap((block) => {
|
|
63
|
+
const type = text(block.type).toUpperCase();
|
|
64
|
+
if ([
|
|
65
|
+
"TEXT",
|
|
66
|
+
"MD",
|
|
67
|
+
"MARKDOWN"
|
|
68
|
+
].includes(type)) return [text(block.content ?? block.text)];
|
|
69
|
+
if (type === "LINK") return [text(block.label ?? block.url ?? block.href)];
|
|
70
|
+
return [];
|
|
71
|
+
}).join("\n").trim();
|
|
72
|
+
if (!content) return null;
|
|
73
|
+
return {
|
|
74
|
+
conversation: {
|
|
75
|
+
provider: "infoflow",
|
|
76
|
+
accountId,
|
|
77
|
+
kind: "group",
|
|
78
|
+
id: group
|
|
79
|
+
},
|
|
80
|
+
message: {
|
|
81
|
+
id: receipt,
|
|
82
|
+
sender: {
|
|
83
|
+
id: sender,
|
|
84
|
+
name: text(header.fromusername) || sender
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
text: content
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Unambiguous key for receipts and per-conversation ordering.
|
|
91
|
+
* @param conversation - stable external identity.
|
|
92
|
+
* @returns serialized tuple, with no numeric conversion.
|
|
93
|
+
*/
|
|
94
|
+
function conversationKey(conversation) {
|
|
95
|
+
return JSON.stringify([
|
|
96
|
+
conversation.provider,
|
|
97
|
+
conversation.accountId,
|
|
98
|
+
conversation.kind,
|
|
99
|
+
conversation.id
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
/** Split at Unicode code points, retaining exact content for retry.
|
|
103
|
+
* @param content - outbound text.
|
|
104
|
+
* @param limit - configured platform payload limit in code points.
|
|
105
|
+
* @returns ordered nonempty parts.
|
|
106
|
+
*/
|
|
107
|
+
function splitMessage(content, limit) {
|
|
108
|
+
const points = Array.from(content);
|
|
109
|
+
const chunks = [];
|
|
110
|
+
for (let i = 0; i < points.length; i += limit) chunks.push(points.slice(i, i + limit).join(""));
|
|
111
|
+
return chunks;
|
|
112
|
+
}
|
|
113
|
+
/** Exact platform mentions route a shared group receipt across robot connections. */
|
|
114
|
+
function mentionedRobots(value) {
|
|
115
|
+
const body = object(object(object(object(value).data).raw).message).body;
|
|
116
|
+
return Array.isArray(body) ? body.map(object).filter((block) => text(block.type).toUpperCase() === "AT").map((block) => id(block.robotid)).filter((value) => value !== void 0) : [];
|
|
117
|
+
}
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region lib/types/infoflow/bridge.js
|
|
120
|
+
/** One configured robot. All persistence writes are serialized, including SDK callbacks. */
|
|
121
|
+
var InfoFlowBridge = class {
|
|
122
|
+
bots;
|
|
123
|
+
store;
|
|
124
|
+
sender;
|
|
125
|
+
config;
|
|
126
|
+
report;
|
|
127
|
+
state;
|
|
128
|
+
tail = Promise.resolve();
|
|
129
|
+
operations = Promise.resolve();
|
|
130
|
+
worker;
|
|
131
|
+
dirty = false;
|
|
132
|
+
closed = false;
|
|
133
|
+
/** @param bots - same Host's Bot owner. @param store - durable native domain. @param sender - SDK adapter.
|
|
134
|
+
* @param config - validated deployment settings. @param report - contained error reporting, without message bodies or secrets.
|
|
135
|
+
*/
|
|
136
|
+
constructor(bots, store, sender, config, report) {
|
|
137
|
+
this.bots = bots;
|
|
138
|
+
this.store = store;
|
|
139
|
+
this.sender = sender;
|
|
140
|
+
this.config = config;
|
|
141
|
+
this.report = report;
|
|
142
|
+
this.state = structuredClone(store.get());
|
|
143
|
+
}
|
|
144
|
+
change(action) {
|
|
145
|
+
const operation = this.tail.then(async () => {
|
|
146
|
+
const draft = structuredClone(this.state);
|
|
147
|
+
const result = action(draft);
|
|
148
|
+
await this.store.set(draft);
|
|
149
|
+
this.state = draft;
|
|
150
|
+
return result;
|
|
151
|
+
});
|
|
152
|
+
this.tail = operation.catch(() => {});
|
|
153
|
+
return operation;
|
|
154
|
+
}
|
|
155
|
+
exclusive(action) {
|
|
156
|
+
const operation = this.operations.then(action);
|
|
157
|
+
this.operations = operation.catch(() => {});
|
|
158
|
+
return operation;
|
|
159
|
+
}
|
|
160
|
+
room(input) {
|
|
161
|
+
return this.bots.snapshot().rooms.find((room) => room.external && (conversationKey(room.external) === conversationKey(input.conversation) || room.external.provider === "feishu" && input.conversation.provider === "feishu" && room.external.kind === "group" && input.conversation.kind === "group" && room.external.id === input.conversation.id));
|
|
162
|
+
}
|
|
163
|
+
enqueue(state, key, input, content, botId = input.targets?.[0] ?? this.config.defaultBot) {
|
|
164
|
+
const parts = splitMessage(content, this.config.chunkSize);
|
|
165
|
+
if (state.outbox.length + parts.length > this.config.maxOutbox) throw new Error("External chat outbox is full; delivery must recover before accepting more results");
|
|
166
|
+
for (const [index, text] of parts.entries()) state.outbox.push({
|
|
167
|
+
id: `${key}:${index}`,
|
|
168
|
+
conversation: input.conversation,
|
|
169
|
+
user: input.message.sender.id,
|
|
170
|
+
botId,
|
|
171
|
+
text,
|
|
172
|
+
attempts: 0,
|
|
173
|
+
nextAt: 0
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/** Persist authorization-approved input and its receipt in the same write.
|
|
177
|
+
* @param input - validated SDK text event.
|
|
178
|
+
* @returns accepted, duplicate, or ignored; a full queue throws before claiming the receipt.
|
|
179
|
+
*/
|
|
180
|
+
async receive(input) {
|
|
181
|
+
return this.exclusive(() => this.admit(input));
|
|
182
|
+
}
|
|
183
|
+
async admit(input) {
|
|
184
|
+
if (this.closed) throw new Error("External chat is stopping");
|
|
185
|
+
const { conversation, message, text } = input;
|
|
186
|
+
if (conversation.provider !== (this.config.provider ?? "infoflow") || conversation.accountId !== this.config.accountId) return "ignored";
|
|
187
|
+
if (conversation.kind === "private" ? !this.config.allowUsers.includes(message.sender.id) : !(conversation.provider === "infoflow" && this.config.allowAllGroups === true) && !this.config.allowGroups.includes(conversation.id)) return "ignored";
|
|
188
|
+
const key = JSON.stringify([conversationKey(conversation), message.id]);
|
|
189
|
+
const outcome = await this.change((state) => {
|
|
190
|
+
if (state.receipts.includes(key)) return "duplicate";
|
|
191
|
+
if (state.receipts.length >= this.config.maxReceipts) throw new Error("External chat receipt capacity reached; raise maxReceipts before continuing");
|
|
192
|
+
state.receipts.push(key);
|
|
193
|
+
const room = this.room(input);
|
|
194
|
+
const command = text.trim().split(/\s+/)[0];
|
|
195
|
+
if (text.length > 32e3) {
|
|
196
|
+
this.enqueue(state, key, input, "消息过长,请缩短到 32000 字符以内。");
|
|
197
|
+
return "accepted";
|
|
198
|
+
}
|
|
199
|
+
if (command.startsWith("/")) {
|
|
200
|
+
if (command === "/help") this.enqueue(state, key, input, "直接发送文字开始对话。群内先 @对应机器人。\n/bots 查看 Bot\n/status 查看当前会话\n/stop 停止自己在当前会话的请求(管理员可停止全部)\n审批与执行详情请由部署者在 DSH 中处理。");
|
|
201
|
+
else if (command === "/bots") this.enqueue(state, key, input, this.bots.snapshot().bots.filter((bot) => this.config.botIds.includes(bot.id)).map((bot) => `@${bot.id} ${bot.name}`).join("\n"));
|
|
202
|
+
else if (command === "/status") {
|
|
203
|
+
const pending = state.pending.filter((item) => conversationKey(item.input.conversation) === conversationKey(conversation));
|
|
204
|
+
const active = room?.messages.filter((item) => item.status === "running") ?? [];
|
|
205
|
+
this.enqueue(state, key, input, `排队:${pending.filter((item) => !item.submitted && !item.stop).length};执行中:${active.length}。\n${active.length ? "执行进度与人工审批请在 DSH 的对应外部聊天中打开执行会话查看。" : "当前没有正在执行的 Bot。"}`);
|
|
206
|
+
} else if (command === "/stop") {
|
|
207
|
+
const admin = this.config.admins.includes(message.sender.id);
|
|
208
|
+
const mayStop = (sender) => admin || sender === message.sender.id;
|
|
209
|
+
const canceled = state.pending.filter((item) => !item.submitted && !item.stop && conversationKey(item.input.conversation) === conversationKey(conversation) && mayStop(item.input.message.sender.id) && !room?.messages.some((message) => message.external?.id === item.input.message.id));
|
|
210
|
+
for (const item of canceled) this.enqueue(state, `${item.key}:canceled`, item.input, "请求已在开始执行前取消。");
|
|
211
|
+
state.pending = state.pending.filter((item) => !canceled.includes(item));
|
|
212
|
+
const shared = conversation.provider === "feishu" && conversation.kind === "group";
|
|
213
|
+
const attemptIds = room?.messages.filter((item) => {
|
|
214
|
+
if (item.status !== "running" || !item.replyTo || shared && (!item.botId || !this.config.botIds.includes(item.botId))) return false;
|
|
215
|
+
const external = room.messages.find((parent) => parent.id === item.replyTo)?.external;
|
|
216
|
+
return mayStop((shared ? external?.sources?.find((source) => source.accountId === this.config.accountId)?.senderId : external?.sender.id) ?? "");
|
|
217
|
+
}).map((item) => item.id) ?? [];
|
|
218
|
+
if (room && attemptIds.length) state.pending.push({
|
|
219
|
+
key,
|
|
220
|
+
input,
|
|
221
|
+
binding: this.binding(input),
|
|
222
|
+
stop: {
|
|
223
|
+
roomId: room.id,
|
|
224
|
+
attemptIds
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
else this.enqueue(state, key, input, canceled.length ? "已取消排队中的请求。" : "当前没有你可以停止的请求。");
|
|
228
|
+
} else this.enqueue(state, key, input, "不支持此命令。发送 /help 查看用法。");
|
|
229
|
+
return "accepted";
|
|
230
|
+
}
|
|
231
|
+
if (state.pending.length >= this.config.maxPending || state.pending.filter((item) => conversationKey(item.input.conversation) === conversationKey(conversation)).length >= this.config.maxQueuePerRoom) throw new Error("External chat request queue is full; retry later");
|
|
232
|
+
state.pending.push({
|
|
233
|
+
key,
|
|
234
|
+
input,
|
|
235
|
+
binding: this.binding(input)
|
|
236
|
+
});
|
|
237
|
+
this.enqueue(state, `${key}:received`, input, "已接收,按当前聊天的顺序处理。");
|
|
238
|
+
return "accepted";
|
|
239
|
+
});
|
|
240
|
+
this.wake();
|
|
241
|
+
return outcome;
|
|
242
|
+
}
|
|
243
|
+
binding(input) {
|
|
244
|
+
const privateChat = input.conversation.kind === "private";
|
|
245
|
+
return {
|
|
246
|
+
conversation: input.conversation,
|
|
247
|
+
title: `${input.conversation.provider === "feishu" ? "飞书" : "如流"} · ${privateChat ? input.message.sender.name : input.conversation.id}`,
|
|
248
|
+
botIds: privateChat ? [this.config.defaultBot] : this.config.botIds,
|
|
249
|
+
defaultBot: this.config.defaultBot
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** Coalesce feed, timer, and inbound callbacks into one worker.
|
|
253
|
+
* @returns completion of currently signaled work; retry deadlines never busy-loop.
|
|
254
|
+
*/
|
|
255
|
+
wake() {
|
|
256
|
+
if (this.closed) return Promise.resolve();
|
|
257
|
+
this.dirty = true;
|
|
258
|
+
this.worker ??= this.work().finally(() => {
|
|
259
|
+
this.worker = void 0;
|
|
260
|
+
if (this.dirty && !this.closed) this.wake();
|
|
261
|
+
});
|
|
262
|
+
return this.worker;
|
|
263
|
+
}
|
|
264
|
+
async work() {
|
|
265
|
+
while (this.dirty && !this.closed) {
|
|
266
|
+
this.dirty = false;
|
|
267
|
+
try {
|
|
268
|
+
await this.exclusive(() => this.dispatch());
|
|
269
|
+
} catch {
|
|
270
|
+
this.report("External chat processing failed; durable work is retained for the next retry");
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
await this.deliver();
|
|
274
|
+
} catch {
|
|
275
|
+
this.report("External chat delivery settlement failed; durable replies are retained");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async finish(item, content) {
|
|
280
|
+
await this.change((state) => {
|
|
281
|
+
if (!state.pending.some((current) => current.key === item.key)) return;
|
|
282
|
+
this.enqueue(state, `${item.key}:result`, item.input, content);
|
|
283
|
+
state.pending = state.pending.filter((current) => current.key !== item.key);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
async dispatch() {
|
|
287
|
+
for (const item of [...this.state.pending].filter((item) => item.stop)) {
|
|
288
|
+
if (this.closed) return;
|
|
289
|
+
if (item.input.conversation.accountId !== this.config.accountId) continue;
|
|
290
|
+
for (const messageId of item.stop.attemptIds) await this.bots.stop({
|
|
291
|
+
roomId: item.stop.roomId,
|
|
292
|
+
messageId
|
|
293
|
+
});
|
|
294
|
+
await this.finish(item, "已处理停止请求,请在 DSH 中查看执行记录。");
|
|
295
|
+
}
|
|
296
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
297
|
+
for (const item of [...this.state.pending].filter((item) => !item.stop)) {
|
|
298
|
+
if (this.closed) return;
|
|
299
|
+
if (item.input.conversation.accountId !== this.config.accountId) continue;
|
|
300
|
+
if (item.submitted) {
|
|
301
|
+
const room = this.bots.snapshot().rooms.find((room) => room.id === item.submitted.roomId || room.aliases?.includes(item.submitted.roomId));
|
|
302
|
+
if (!room) throw new Error("Submitted external room is missing");
|
|
303
|
+
const responses = item.submitted.attemptIds.map((id) => room.messages.find((message) => message.id === id));
|
|
304
|
+
if (responses.some((message) => !message)) throw new Error("Submitted external response is missing");
|
|
305
|
+
if (responses.every((message) => message.status !== "running")) await this.change((state) => {
|
|
306
|
+
for (const message of responses) {
|
|
307
|
+
const response = message;
|
|
308
|
+
const content = response.status === "complete" ? response.text || "本次执行完成,没有文字回复。" : response.status === "interrupted" ? "执行已中断,未自动重跑。请部署者在 DSH 中检查执行记录。" : response.status === "canceled" ? "执行已停止。" : "执行失败,请部署者在 DSH 中检查执行记录。";
|
|
309
|
+
const text = this.config.labelReplies ? `@${response.botId}\n${content}` : content;
|
|
310
|
+
this.enqueue(state, `${item.key}:result:${response.id}`, item.input, text, response.botId ?? this.config.defaultBot);
|
|
311
|
+
}
|
|
312
|
+
state.pending = state.pending.filter((current) => current.key !== item.key);
|
|
313
|
+
});
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const key = conversationKey(item.input.conversation);
|
|
317
|
+
if (blocked.has(key)) continue;
|
|
318
|
+
blocked.add(key);
|
|
319
|
+
if (this.bots.snapshot().rooms.filter((room) => room.external && room.messages.some((message) => message.status === "running")).length >= this.config.maxConcurrentRooms) continue;
|
|
320
|
+
const known = this.bots.snapshot().bots.map((bot) => bot.id);
|
|
321
|
+
if (item.binding.botIds.some((id) => !known.includes(id))) {
|
|
322
|
+
await this.finish(item, "配置的 Bot 不存在,请部署者检查机器人配置。");
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const room = await this.bots.ensureExternalRoom(item.binding);
|
|
327
|
+
const accepted = await this.bots.submitExternalMessage({
|
|
328
|
+
roomId: room.id,
|
|
329
|
+
text: item.input.text,
|
|
330
|
+
external: item.input.message,
|
|
331
|
+
sourceAccountId: item.input.conversation.accountId,
|
|
332
|
+
...item.input.targets ? { targets: item.input.targets } : {}
|
|
333
|
+
});
|
|
334
|
+
if (accepted.status === "rejected") {
|
|
335
|
+
await this.finish(item, accepted.reason === "room-full" ? "该聊天的历史容量已满,请部署者调整 DSH Bot 的 maxMessages 配置。" : "消息无法提交,请检查文字和 @Bot 是否属于当前聊天。");
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (accepted.status === "accepted") await this.change((state) => {
|
|
339
|
+
const current = state.pending.find((current) => current.key === item.key);
|
|
340
|
+
if (current) current.submitted = {
|
|
341
|
+
roomId: room.id,
|
|
342
|
+
attemptIds: accepted.attemptIds
|
|
343
|
+
};
|
|
344
|
+
});
|
|
345
|
+
} catch {
|
|
346
|
+
this.report("External chat admission failed; check Bot configuration/storage and retained queue");
|
|
347
|
+
await this.change((state) => {
|
|
348
|
+
const notice = `${item.key}:admission-error`;
|
|
349
|
+
if (state.notices.includes(notice)) return;
|
|
350
|
+
this.enqueue(state, notice, item.input, "请求暂未提交,请部署者检查 Bot 配置或存储。消息已保留,系统会重试。");
|
|
351
|
+
state.notices.push(notice);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async deliver() {
|
|
357
|
+
const blockedRecipients = /* @__PURE__ */ new Set();
|
|
358
|
+
for (const delivery of [...this.state.outbox]) {
|
|
359
|
+
if (this.closed) return;
|
|
360
|
+
if (delivery.conversation.accountId !== this.config.accountId) {
|
|
361
|
+
this.report("Retained delivery belongs to another External chat account; restore the original configuration");
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const key = conversationKey(delivery.conversation);
|
|
365
|
+
if (blockedRecipients.has(key)) continue;
|
|
366
|
+
if (delivery.nextAt > Date.now()) {
|
|
367
|
+
blockedRecipients.add(key);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
await this.sender.send(delivery);
|
|
372
|
+
} catch {
|
|
373
|
+
blockedRecipients.add(key);
|
|
374
|
+
await this.change((state) => {
|
|
375
|
+
const current = state.outbox.find((item) => item.id === delivery.id);
|
|
376
|
+
if (current) {
|
|
377
|
+
current.attempts++;
|
|
378
|
+
current.nextAt = Date.now() + Math.min(this.config.maxRetryMs, this.config.retryMs * 2 ** Math.min(current.attempts - 1, 20));
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
this.report("External chat send failed; the reply remains queued");
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
await this.change((state) => {
|
|
385
|
+
state.outbox = state.outbox.filter((item) => item.id !== delivery.id);
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** Notify the original destination about native human interaction without answering it.
|
|
390
|
+
* @param sessionId - native Session requesting attention.
|
|
391
|
+
* @param noticeId - stable approval/tool identifier for deduplication.
|
|
392
|
+
*/
|
|
393
|
+
async attention(sessionId, noticeId) {
|
|
394
|
+
if (this.closed) return;
|
|
395
|
+
const room = this.bots.snapshot().rooms.find((room) => room.members.some((member) => member.sessionId === sessionId));
|
|
396
|
+
if (!room?.external || room.external.provider !== (this.config.provider ?? "infoflow")) return;
|
|
397
|
+
const shared = room.external.provider === "feishu" && room.external.kind === "group";
|
|
398
|
+
if (!shared && room.external.accountId !== this.config.accountId) return;
|
|
399
|
+
const member = room.members.find((member) => member.sessionId === sessionId);
|
|
400
|
+
if (shared && !this.config.botIds.includes(member.botId)) return;
|
|
401
|
+
const attempt = room.messages.find((message) => message.botId === member.botId && message.status === "running");
|
|
402
|
+
if (!attempt) return;
|
|
403
|
+
const parent = room.messages.find((message) => message.id === attempt.replyTo);
|
|
404
|
+
const senderId = parent?.external?.sources?.find((source) => source.accountId === this.config.accountId)?.senderId ?? parent?.external?.sender.id;
|
|
405
|
+
const input = this.state.pending.find((item) => item.submitted?.attemptIds.includes(attempt.id))?.input ?? (parent?.external && senderId ? {
|
|
406
|
+
conversation: {
|
|
407
|
+
...room.external,
|
|
408
|
+
accountId: this.config.accountId
|
|
409
|
+
},
|
|
410
|
+
message: {
|
|
411
|
+
...parent.external,
|
|
412
|
+
sender: {
|
|
413
|
+
...parent.external.sender,
|
|
414
|
+
id: senderId
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
text: parent.text,
|
|
418
|
+
targets: [member.botId]
|
|
419
|
+
} : void 0);
|
|
420
|
+
if (!input) return;
|
|
421
|
+
const key = `${sessionId}:${attempt.id}:${noticeId}`;
|
|
422
|
+
await this.change((state) => {
|
|
423
|
+
if (state.notices.includes(key)) return;
|
|
424
|
+
this.enqueue(state, `attention:${key}`, input, `@${member.botId} 请求了人工处理。请部署者在 DSH 对应外部聊天中打开执行会话。`);
|
|
425
|
+
state.notices.push(key);
|
|
426
|
+
});
|
|
427
|
+
this.wake();
|
|
428
|
+
}
|
|
429
|
+
/** Stop taking work and await in-flight persistence/sending; Bot execution remains owned by DSH. */
|
|
430
|
+
async close() {
|
|
431
|
+
this.closed = true;
|
|
432
|
+
await this.worker;
|
|
433
|
+
await this.operations;
|
|
434
|
+
await this.tail;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region lib/types/infoflow/domain.js
|
|
439
|
+
/** Connector-owned durable admission queue and outbound delivery records. */
|
|
440
|
+
const conversation = z.object({
|
|
441
|
+
provider: z.enum(["infoflow", "feishu"]),
|
|
442
|
+
accountId: z.string().min(1),
|
|
443
|
+
kind: z.enum(["private", "group"]),
|
|
444
|
+
id: z.string().min(1)
|
|
445
|
+
});
|
|
446
|
+
const message = z.object({
|
|
447
|
+
id: z.string().min(1),
|
|
448
|
+
sender: z.object({
|
|
449
|
+
id: z.string().min(1),
|
|
450
|
+
name: z.string()
|
|
451
|
+
})
|
|
452
|
+
});
|
|
453
|
+
const attempts = z.object({
|
|
454
|
+
roomId: z.string().transform(brandString),
|
|
455
|
+
attemptIds: z.array(z.string().transform(brandString))
|
|
456
|
+
});
|
|
457
|
+
const bridgeSchema = z.object({
|
|
458
|
+
receipts: z.array(z.string()),
|
|
459
|
+
notices: z.array(z.string()),
|
|
460
|
+
pending: z.array(z.object({
|
|
461
|
+
key: z.string(),
|
|
462
|
+
input: z.object({
|
|
463
|
+
conversation,
|
|
464
|
+
message,
|
|
465
|
+
text: z.string(),
|
|
466
|
+
targets: z.array(z.string().transform(brandString)).optional()
|
|
467
|
+
}),
|
|
468
|
+
binding: z.object({
|
|
469
|
+
conversation,
|
|
470
|
+
title: z.string(),
|
|
471
|
+
botIds: z.array(z.string().transform(brandString)),
|
|
472
|
+
defaultBot: z.string().transform(brandString)
|
|
473
|
+
}),
|
|
474
|
+
submitted: attempts.optional(),
|
|
475
|
+
stop: attempts.optional()
|
|
476
|
+
})),
|
|
477
|
+
outbox: z.array(z.object({
|
|
478
|
+
id: z.string(),
|
|
479
|
+
conversation,
|
|
480
|
+
user: z.string(),
|
|
481
|
+
text: z.string(),
|
|
482
|
+
attempts: z.number().int().nonnegative(),
|
|
483
|
+
nextAt: z.number(),
|
|
484
|
+
botId: z.string().transform(brandString).optional()
|
|
485
|
+
}))
|
|
486
|
+
});
|
|
487
|
+
/** Separate authoritative state; no SDK credentials or native execution logs are copied here. */
|
|
488
|
+
const infoflowDomain = defineDomain({
|
|
489
|
+
name: "bot_infoflow",
|
|
490
|
+
version: 1,
|
|
491
|
+
tables: {},
|
|
492
|
+
global: {
|
|
493
|
+
schema: bridgeSchema,
|
|
494
|
+
initial: {
|
|
495
|
+
receipts: [],
|
|
496
|
+
pending: [],
|
|
497
|
+
outbox: [],
|
|
498
|
+
notices: []
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
/** Each managed robot or shared group owns an independent queue. */
|
|
503
|
+
function bridgeDomain(name) {
|
|
504
|
+
return defineDomain({
|
|
505
|
+
name,
|
|
506
|
+
version: 1,
|
|
507
|
+
tables: {},
|
|
508
|
+
global: {
|
|
509
|
+
schema: bridgeSchema,
|
|
510
|
+
initial: {
|
|
511
|
+
receipts: [],
|
|
512
|
+
pending: [],
|
|
513
|
+
outbox: [],
|
|
514
|
+
notices: []
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
//#endregion
|
|
520
|
+
export { mentionedRobots as a, decodeMessage as i, infoflowDomain as n, InfoFlowBridge as r, bridgeDomain as t };
|