chore: initial commit

This commit is contained in:
2026-10-22 17:07:08 +08:00
commit 0acef64f48
200 changed files with 35546 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Dependencies and generated output
node_modules/
dist/
coverage/
*.tsbuildinfo
# Local configuration and runtime data
/config.json
/data/bot/
.env
.env.*
!.env.example
!.env.sample
*.local
logs/
*.log
# Local tooling and editor files
.idea/
.codex/
.design-review/
.DS_Store
*.swp
*.swo
+795
View File
@@ -0,0 +1,795 @@
# Bot 转发通道完整实现计划
本文用于把工作分配给多个 Agent 分步实现。本文是设计及任务约束,不代表功能已经实现。
工作区根目录:`/home/wupeng/Project/mf_v3`。
## 1. 目标与边界
用户在 Web 请求连接某个 Bot 的转发通道,主服务器鉴权并确认 Node 在线后,命令 Node 连接转发服务器。Node 成功入房并准备好请求处理后,主服务器返回 Web 连接地址与 Web 专用凭证。Web 经转发服务器查询状态、订阅事件及执行白名单交互。
必须满足:
- 任意 `bot_status` 均允许申请转发通道,包括 `offline`、`ready`、`connecting`、`online`、`disconnecting`、`reconnecting`。
- 不存在 BotContext 也允许建立通道,查询结果为 `offline`、`context_exists: false`。
- Node 必须是主服务器当前已认证、在线且能接收命令的节点会话。
- 建立或恢复转发连接不得调用 `start_bot_context()`,不得重置 Minecraft 自动重连次数。
- `stop_bot` 只销毁 BotContext,转发通道保持,前端收到 `offline`。
- 再次启动 Bot 后,通道自动绑定新 BotContext,无需重新连接 Web Socket。
- Node 和 Web 使用不同角色凭证;Web 不能伪装 Node,不能向其他 Web 广播。
- 命令响应只返回请求方;只允许白名单事件和业务方法。
- 每一个连接、入房、请求及关闭过程都有超时、取消及身份检查。
- 本次实现统一升级协议,不增加旧协议兼容分支。
- Bot 游戏状态、Node 在线状态、转发连接状态分别维护,禁止互相覆盖。
首版转发支持:快照、日志读取、事件订阅、取消订阅、在线聊天。启动、停止、配置修改继续走主服务器现有接口,不在转发层复制一套生命周期入口。
## 2. 当前源码问题与处理归属
以下是设计时确认的源码问题,实施前应重新核对文件现状和行号。
| 问题 | 位置 | 归属任务 |
| --- | --- | --- |
| handler 参数 `socket` 与局部变量重复,缺少 `io` 导入,动态加载可能导致 Node 启动失败 | `node_server/event_handler/bot/forward_bot.ts` | D |
| 分发器忽略所有 `forward_` 开头事件,导致 `forward_bot` 被静默丢弃 | `node_server/event_handler/init.ts` | D |
| HTTP 转发路由缺少开头 `/`,校验后没有响应及命令流程 | `main_server/router/node/action.ts` | E |
| 转发房间不区分 Node/Web,任意事件广播给所有其他成员 | `forward_server/index.ts` | B |
| 将原始 ACK 函数传给房间广播,实际变成广播 ACK,语义和超时错误 | `forward_server/index.ts` | B |
| token 只有 `node_id`、`bot_id`,没有角色、有效期及会话作用域 | `forward_server/types.ts` 和 token 工具 | A、B、E |
| 把转发服务启动 ID 当作执行 Node ID,内置服务 `-1` 与实际节点不匹配 | `forward_server/index.ts`、`main_server/index.ts` | B、G |
| 重复加入新房间后没有离开旧房间 | `forward_server/index.ts` | B |
| 服务单例并发初始化、监听端口错误、关闭清理和单例复位不完整 | `forward_server/index.ts` | G |
| 转发服务注册表不清理死连接,重复注册可能断开共享 Node 控制 Socket | `main_server/runtime/forward_server/socket.ts` | G |
| BotContext 注册表缺少查询、创建、移除通知;直接绑定一个 Bot 实例无法跨重建保持 | `node_server/bot/instance/index.ts` | C |
| `BotContext.onAny()` 没有对应解绑入口;原始事件可能包含完整 Bot 对象 | `node_server/bot/instance/bot_instance_init.ts` | C、D |
| 跨域、公开地址、TLS 和反向代理未形成明确配置 | 转发服务启动配置 | G、F |
AES-GCM 解密已有完整性校验,不应把问题误写成“token 没有验签”。问题是缺少声明验证、角色隔离、到期和撤销机制。没有证据说明当前公开 HTTP DTO 已泄漏转发密钥,新接口仍必须显式投影公开字段。
## 3. 推荐端到端时序
```mermaid
sequenceDiagram
participant W as Web
participant M as Main
participant N as Node
participant F as Forward
W->>M: POST /api/user/node/forward_bot {bot_id}
M->>M: 用户/Bot归属/节点与转发权限/当前Node会话
M->>F: forward_open_session
F-->>M: 控制ACK
M->>N: forward_bot(作用域、地址、Node凭证、截止时间)
N->>F: Socket.IO connect + join_room(node)
F-->>N: join ACK
N->>N: 注册请求处理及Bot上下文订阅
N-->>M: joined:true ACK
M->>M: 再次核对当前Node会话及Bot归属/分配
M-->>W: 公开URL/path/namespace + Web凭证
W->>F: connect + join_room(web)
F-->>W: join ACK + node_ready
W->>F: relay_request(bot.snapshot)
F->>N: relay_request(内部关联ID)
N-->>F: 定向ACK
F-->>W: 定向ACK
N-->>F: bot_event
F-->>W: 已授权且已订阅的Web
```
`forward_open_session` 由已经认证的主服务器与转发服务控制通道发送。不能由 Web Socket 调用。
## 4. 任务 A:协议约定与各项目本地实现清单
### 4.1 文件所有权
不新增根目录共享协议目录或公共包,也不要求各项目跨目录引用类型、校验器或 token 工具。每个项目使用自己的类型、工具和测试目录,沿用现有结构。
本文件是多 Agent 协作时的通信协议约定,不是运行时依赖。相同协议类型允许在各项目本地定义,但事件名、字段、错误码、时间单位和验证行为必须一致。
建议落点及文件所有权:
| 项目 | 本地类型与校验 | 凭证工具 | 实现 owner |
| --- | --- | --- | --- |
| `forward_server` | 沿用 `types.ts`,校验与常量放本项目工具目录 | 沿用 `utils/token_utils.ts`,负责验证 | B;启动校验由 G 接入 |
| `node_server` | 在现有 `types/`、`utils/` 中定义节点命令、ACK 和事件 DTO | 节点转发客户端只使用 Main 下发的 token,不为远程转发服务签发凭证 | D;C 提供观察事件接口 |
| `main_server` | 在现有 `types/`、`utils/` 中定义控制协议和 HTTP DTO | 沿用 `utils/token_utils.ts`,负责签发 | E;控制接入由 G 负责 |
| `main_server_web` | 在现有类型/API目录中定义浏览器所需类型和响应检查 | 不包含密钥、签发或服务端解密工具 | F |
A 只整理并冻结文档中的协议、字段归属、校验规则及测试向量,不提前创建或占用 B/D/E/F 负责的生产文件。各 Agent 只实现本项目实际使用的部分,不必复制无关服务端类型。
不得为此引入跨项目源码别名、新 workspace 包、代码生成或改变项目构建结构。协议校验不依赖数据库、BotContext 或 UI 框架;服务端密钥工具禁止进入前端 bundle。
协议变更先更新本文件并通知受影响的 owner,再分别同步各项目本地实现。H 通过共同的合法/非法消息样例、token 测试向量及跨服务联调验证一致性,不能仅凭本地 TypeScript 类型检查判断网络协议兼容。
### 4.2 身份与时间约定
| 字段 | 定义 |
| --- | --- |
| `forward_id` | 转发服务 ID,非负安全整数,内置服务为 `0` |
| `node_id`、`bot_id`、`user_id` | 正安全整数 |
| `node_session_id` | Main 当前认证的 Node 会话标识,不由浏览器提供 |
| `forward_session_id` | Main 分配的转发通道 UUID,独立于 Socket.IO `socket.id` |
| `grant_id` | 单个浏览器授权的 UUID,不同标签页独立 |
| `request_id` | 一次业务请求的 UUID |
| `context_generation` | 每次 BotContext 创建的新 UUID;无上下文时为 `null` |
| `stream_id` | 节点事件流 UUID;Node 重启或流重建时改变 |
| `seq` | 同一 `stream_id` 内递增的安全整数 |
| `*_at`、`*_before` | Unix 毫秒时间戳 |
Bot 状态类型不包含 `disposed`。销毁是内部终止标记,公开状态为 `offline`。
### 4.3 Socket ACK
```ts
type ForwardAck<T> =
| {status: 'success'; data: T}
| {status: 'error'; data: {
code: ForwardErrorCode;
message: string;
retryable: boolean;
}};
type ForwardErrorCode =
| 'INVALID_REQUEST' | 'UNAUTHORIZED' | 'FORBIDDEN'
| 'NODE_OFFLINE' | 'RELAY_UNAVAILABLE' | 'SESSION_STALE'
| 'TOKEN_EXPIRED' | 'TIMEOUT' | 'CANCELLED'
| 'NO_CONTEXT' | 'BOT_NOT_ONLINE' | 'RATE_LIMITED'
| 'RESYNC_REQUIRED' | 'INTERNAL_ERROR';
```
转发业务统一采用这个 ACK;现有 `register_node` 等控制协议不在 A 中整体重写。HTTP 层沿用项目响应包装,并显式映射错误,不调用只适配旧数字 `data` 的辅助函数来解析新 ACK。
### 4.4 Main → Node 命令
事件 `forward_bot`:
```ts
interface ForwardBotCommand {
request_id: string;
forward_session_id: string;
node_session_id: string;
forward_id: number;
node_id: number;
bot_id: number;
user_id: number;
deadline_at: number;
relay: {
url: string;
path: string;
namespace: string;
token: string;
};
}
interface ForwardBotReady {
request_id: string;
forward_session_id: string;
node_session_id: string;
forward_id: number;
node_id: number;
bot_id: number;
joined: true;
}
```
`ForwardAck<ForwardBotReady>` 成功表示 Node 已入房、业务请求处理器已安装、上下文观察已建立;不要求存在 BotContext,也不代表 Minecraft 在线。
### 4.5 入房及续期
```ts
interface JoinRoomRequest {token: string}
interface JoinRoomReady {
forward_session_id: string;
forward_id: number;
node_id: number;
bot_id: number;
role: 'node' | 'web';
node_ready: boolean;
expires_at: number;
}
```
事件 `join_room` 使用 `ForwardAck<JoinRoomReady>`。作用域全部从受保护 token 取得,不相信客户端另传的房间名、角色和 Bot ID。
事件 `reauth` 接收 `{token: string}`,返回同一作用域的更新凭证结果。续期不得改变角色、Bot、Node、会话或 Web `grant_id`;变更这些字段必须重新建立授权。
### 4.6 Token 声明
```ts
interface RelayClaims {
v: 1;
forward_id: number;
forward_session_id: string;
node_session_id: string;
node_id: number;
bot_id: number;
role: 'node' | 'web';
user_id?: number;
grant_id?: string;
permissions: RelayMethod[];
issued_at: number;
join_before: number;
expires_at: number;
jti: string;
}
```
默认策略:新 token 在 60 秒内可用于入房或 reauth,连接授权最长 5 分钟,到期前 60 秒续期。时间参数集中定义,可配置;所有端使用相同毫秒单位,允许有限时钟偏差。
验证顺序:大小与格式 → GCM 完整性 → JSON 字段 → 版本/服务 audience → 时间 → 控制通道登记的有效会话 → 角色/权限/浏览器 grant。
Node token 不带 Web grant;Web token 必须带 `user_id`、`grant_id`。Main 与指定转发服务共享服务端密钥,密钥不得进入 Node 命令、公开响应或日志;Node 只获得受限 token,不获得转发密钥。Node 自己提供内置转发服务时,只能持有它本来配置的本地服务密钥,不能借此取得其他转发服务密钥。Node 不能仅通过解码把 token 当成已验证身份,最终有效性由转发服务 ACK 确认。
### 4.7 业务 RPC
```ts
type RelayMethod =
| 'bot.snapshot' | 'bot.logs'
| 'bot.subscribe' | 'bot.unsubscribe' | 'bot.chat';
interface RelayRequest {
request_id: string;
method: RelayMethod;
payload: unknown;
}
```
Web 发送 `relay_request` 并等待 ACK。转发服务为它分配内部关联 ID,只对当前 Node Socket 发单目标 `emitWithAck`;Node 的响应由转发服务校验后返回原 Web ACK。禁止把 Web 的 ACK 函数传进房间广播。
每个内部关联记录绑定 Web Socket、grant、Node Socket 版本及超时。Web 的 `request_id` 仅在对应 grant 内唯一,不直接作为全局 Map 键。
`bot.subscribe` 参数为 `{events: BotEventName[]}`,整体替换当前 Web 的订阅集;重复请求幂等。`bot.unsubscribe` 删除给定事件,空数组不影响其他事件。订阅默认空,Web 入房后显式设置。
`bot.logs` 参数为 `{stream_id?: string, after_seq?: number, limit: number}`,限制 1 到 200;传 after_seq 时必须携带 stream_id。流已更换或缓存不覆盖请求区间时返回 `RESYNC_REQUIRED` 或明确 `truncated: true`,不得伪装完整记录。响应包含本次 stream ID、读取水位、记录及是否截断。
`bot.chat` 参数为 `{message: string}`,限制为非空字符串和可配置长度。Node 再次检查上下文及当前实例在线;不自动重放。
### 4.8 快照及事件
```ts
interface BotSnapshot {
bot_id: number;
bot_status: BotStatus;
context_exists: boolean;
context_generation: string | null;
stream_id: string;
seq: number;
captured_at: number;
}
type BotEventName = 'status_update' | 'log' | 'message' | 'bot_error';
interface BotEvent {
forward_session_id: string;
bot_id: number;
context_generation: string | null;
stream_id: string;
seq: number;
emitted_at: number;
name: BotEventName;
payload: unknown;
}
```
`bot_event` 只允许从 Node 输入,转发给已授权、订阅相应事件的 Web。每种 `payload` 定义具体 DTO,不保留最终的任意 `unknown` 实现。
- 状态:`{status: BotStatus, context_exists: boolean}`。
- 日志:保留项目日志 ID、时间戳和样式 DTO,限制批次数及总字节数。
- 消息:文本、位置及必要的有界 JSON,禁止直接发送 ChatMessage 类实例。
- 错误:`{code?: string, message: string}`,生产环境不传完整堆栈和敏感配置。
`relay_status` 由转发服务发给 Web,包含 `node_ready` 和原因,不改变 `bot_status`。节点通道未准备好时可允许 Web 入房,但业务请求返回 `NODE_OFFLINE`,不能返回虚假成功。
### 4.9 控制通道事件
Main 与转发服务器的已认证控制通道支持:
- `forward_open_session`:登记/更新作用域、当前 Node 会话及租约,严格幂等。
- `forward_authorize_grant`:登记/延长浏览器 grant、用户身份、权限和有效期,必须在签发 Web token 并返回前完成。
- `forward_revoke_session`:关闭指定转发会话,撤销 Node/Web,失败所有等待请求。
- `forward_revoke_grant`:撤销指定浏览器授权,不关闭其他浏览器。
- `forward_session_status`:转发服务上报会话就绪/失联信息,绑定当前控制会话。
控制 payload 和 ACK 类型在 A 中一并冻结。open/renew 不得覆盖不同 Node 会话的旧作用域而不先撤销旧会话。
会话租约上限不得由数据面自行延长;Main 根据仍有效的 grant 续租。转发服务重启丢失内存授权后,Main 先重新登记 session/grant,再允许 Node/Web 恢复入房。
### 4.10 A 的验收
- 文档明确非法 ID、错误角色、过期 token、错误 audience、缺字段及超大输入的预期结果。
- 提供无生产密钥的固定 token 测试向量和篡改/跨作用域失败样例,由 B/E 在各项目测试中分别验证。
- 浏览器 bundle 不包含密钥及 Node token 工具。
- 所有 Agent 获得事件名、payload、错误码、时间单位与版本的固定清单。
- 不新增根目录共享协议包,不引入跨项目源码依赖;本地类型和校验的 owner 已明确。
## 5. 任务 B:实现转发服务器的数据路由与鉴权
### 5.1 文件所有权与依赖
依赖 A;与 G 在接口冻结后并行。
B 独占 `forward_server` 新增的 room/session/router/auth 模块及相关测试。B 不直接改启动入口;向 G 提供 `install_relay_routes(io, session_store)` 和对应清理函数,由 G 接入。
已有 `types.ts` 的旧声明由 B 按 A 冻结的文档约定更新为本项目本地类型;不得保留两套含义不同的 token 类型,也不从其他项目导入协议实现。
### 5.2 会话存储
按 `forward_session_id` 存储:
- 固定 `forward_id / node_id / bot_id / node_session_id`。
- 当前有效 Node Socket 及本地连接 generation。
- Web Socket 与 grant 的映射、订阅集。
- 会话及每个 Socket 的授权截止时间。
- 有界 pending RPC 表。
- 首个 Web 到达前、最后 Web 离开后的回收定时器。
房间名由服务端生成,建议包含 Node、Bot、会话;禁止客户端任意指定 room。可以使用独立 Node/Web 子房间,但 RPC 必须仍按单 Socket 定向。
### 5.3 入房
1. 连接后启动 10 秒未鉴权截止定时器。
2. 要求有效 ACK 回调,校验 token 和登记会话。
3. 同一 Socket 只能绑定一个不可变作用域;相同入房请求幂等返回,不累积其他房间。
4. Node 同作用域最多一条有效上行连接。新连接入房成功后才替换旧连接,并递增 generation。
5. 旧连接断开回调只能清理自己拥有的记录,不能清空新连接。
6. Web grant 和权限写入服务端 `socket.data`,不相信每次消息中的身份字段。
7. `await socket.join()` 成功后才 ACK。
8. 成功或断开时清理未鉴权定时器。
Node 尚未 ready 时 Web 可入房并收到 `node_ready: false`;HTTP 初次建立仍必须先等 Node ready。此设计处理 HTTP 返回后到 Web 入房之间 Node 又掉线的竞态。
### 5.4 RPC 路由
1. Web 必须已鉴权,检查 method 权限和对应 payload。
2. `bot.subscribe/unsubscribe` 在转发服务处理,仅修改本 Web 订阅集。
3. 其他 method 选择该会话唯一当前 Node Socket。
4. 分配内部关联 ID,保存请求方和 Node generation。
5. 对 Node 单目标调用,设置明确 timeout;Node 必须返回完整 `ForwardAck`。
6. 校验当前 Node 所有权仍一致后,把结果回给原 Web,ACK 只调用一次。
7. 超时、Web 离开、Node 替换、会话撤销时清理记录,拒绝迟到结果。
默认 RPC timeout 5 秒;限制每个 Web 的 pending 数量及速率。请求 ID 去重缓存只保留有界窗口,不能宣称跨进程“恰好一次”。有副作用请求断线后不得自动重发。
### 5.5 事件推送
- 只接受当前 Node Socket 的 `bot_event`。
- 校验 Bot、会话、事件白名单、大小和单流递增序号。
- 发给匹配订阅及权限的 Web,不广播给 Node 或其他房间。
- 非当前 Node、Web 伪造 `bot_event`、任意未知事件全部拒绝。
- 不使用 `error` 作为 Bot 业务事件名,避免与 Socket.IO 控制错误混淆。
### 5.6 授权与回收
- token 到期计时器真正断开已有 Socket;不能只在入房时检查。
- `reauth` 仅允许延长同一作用域与 grant 的授权,替换到期计时器。
- 会话撤销及 grant 撤销分别清理对应资源。
- 初次 Node ready 后 60 秒没有任何 Web 加入,回收未使用会话。
- 最后一个 Web 离开后保留 30 秒宽限期,再向 Node 通知释放并清理会话。
- 回收操作核对 generation,不能回收并发建立的新会话。
### 5.7 B 的验收
- Web A 请求不会被 Web B 接收,响应只给 A。
- Web 无法伪造 Node、越权换 Bot、调用控制事件。
- 两个 Web 使用相同 request ID 不冲突。
- Node 替换后旧 ACK、旧事件和旧 disconnect 不影响新连接。
- 重复入房不累积房间;过期、断开及回收后无遗留 timer/pending。
- 默认同步 adapter 和模拟异步 `join` 均在真实入房后 ACK。
## 6. 任务 C:节点 BotContext 注册表观察接口
### 6.1 文件所有权与依赖
依赖 A,可与 B、G 并行。
C 独占:
- `node_server/bot/instance/index.ts`。
- `node_server/bot/instance/bot_instance_init.ts` 中注册表观察所需的最小改动。
- 新增节点 Bot 快照/事件序列化模块。
- 对应测试。
不重写现有 Bot 生命周期、不修改 Minecraft 自动重连插件,不让 D 在这些文件重复实现另一套观察机制。
### 6.2 注册表 API
建议暴露:
```ts
get_bot_context(bot_id: number): bot_instance_context<any> | undefined;
observe_bot_context(bot_id: number, listener: ContextObserver): () => void;
get_bot_snapshot(bot_id: number): BotSnapshot;
```
`observe_bot_context` 注册时应提供当前上下文快照,之后提供创建、替换、状态更新及移除通知;每次通知包含 generation。
明确线性化点:创建通知在上下文可查询且状态上报绑定完成后、实际连接前发出;移除通知在该代次最终 `offline` 可观察后发出。初始化失败的短暂上下文也必须解绑,不留下幽灵订阅。
可以返回一个订阅对象同时含初始快照和 dispose,避免“先查询后注册”之间漏事件。具体 API 由 C 提前冻结给 D,不得让调用方自己轮询私有 Map。
### 6.3 事件桥
- 对稳定的 BotContext 订阅 `status_update/log/message/error`,不对某个 mineflayer Bot 长期持有引用。
- 使用明确事件名及 `off` 解绑;若使用 `onAny`,必须增加匹配的 `offAny` 或 disposer。
- 不把 `instance_created`、完整 Bot、插件状态对象、配置密钥发往转发层。
- 新上下文使用新 generation;旧代次回调立即失效。
- Node 级事件流维护 stream ID、seq 和有界日志缓存,BotContext 销毁不丢失最后 offline 事件。
- 旧日志明确标注历史 generation;状态事件不得用旧代次覆盖当前状态。
- 无上下文快照为 `offline/context_exists:false/context_generation:null`,不创建 Bot 来获取日志。
### 6.4 C 的验收
- 先订阅后启动、先启动后订阅均收到一致快照和后续事件。
- stop/start 跨上下文替换后订阅自动转移。
- Minecraft 实例重建不重复绑定、不丢失稳定上下文事件。
- 解绑后监听器计数恢复;旧回调不修改新 generation。
- JSON 序列化不会遍历 Bot、循环对象或泄漏代理密码。
- snapshot 的 seq 与事件水位一致,可供 F 解决快照竞态。
## 7. 任务 D:节点转发连接管理器与 forward_bot handler
### 7.1 文件所有权与依赖
依赖 A、C 的接口,以及 B 的入房/RPC 协议;允许先对协议桩开发。
D 独占:
- `node_server/event_handler/bot/forward_bot.ts`。
- `node_server/event_handler/init.ts` 的精确分发规则。
- 新增 `node_server/runtime/forward/` 或等效独立管理目录。
- 节点转发 RPC 处理器与测试。
`node_server/index.ts` 由 G 独占,D 提供控制连接状态 hook,由 G 调用。不要在 handler 每次执行时创建不受管理的 Socket。
### 7.2 管理器接口
```ts
ensure_connected(command: ForwardBotCommand): Promise<ForwardBotReady>;
release_session(session_id: string, reason: string): Promise<void>;
on_main_registered(node_session_id: string): Promise<void>;
on_main_disconnected(): void;
close_all(): Promise<void>;
```
按 `(forward_id,node_id,bot_id)` 去重,记录当前 forward session、连接代次、pending ensure、Socket、context disposer、定时器和租约。
会话状态:`idle / connecting / joining / ready / retry_wait / closed`,不得写入 `bot_status`。
### 7.3 ensure_connected
1. 校验命令、URL scheme/path、当前 Node ID/会话和截止时间。
2. 来源必须是主服务器当前已注册控制连接;拒绝旧连接迟到命令。
3. 同作用域同会话 ready 时幂等返回;若 token 更新,先 reauth 成功再 ACK。
4. 同会话正在初始化时共享同一个建立任务,各请求保留自己的等待截止时间。
5. 新会话替代旧会话时取消旧任务、解绑旧观察、关闭旧 Socket。
6. 创建 Socket 使用 `autoConnect:false`,先安装所有监听器,再 connect。
7. 每次 `connect` 都发送 `join_room` 并等待 ACK。
8. 校验 ACK 作用域后安装/确认 RPC 和 C 的观察桥,标记 ready。
9. 向 Main 返回对应 request ID 的成功 ACK。
10. 失败路径标准化错误,只执行一次 ACK,清理仅由本会话拥有的资源。
多个并发申请共享通道,某一个调用者超时不能直接关闭仍被其他有效申请/授权使用的通道。请求截止与会话租约分别管理,控制租约过期才能回收孤立结果。
### 7.4 Node RPC
- `bot.snapshot`:从 C 获取一致快照,Bot 离线也成功。
- `bot.logs`:读取 C 的有界日志缓存;没有上下文时仍可返回已有历史或空列表。
- `bot.chat`:校验权限和字符串,临执行前核对当前上下文 generation、实例身份和 `online`;否则返回 `NO_CONTEXT/BOT_NOT_ONLINE`。
- 未知 method 拒绝,禁止根据字符串任意调用 context 方法。
- 业务事件通过 serializer 后使用 `bot_event` 发送。
- 主服务器断开期间不接受新的有副作用操作;已有只读订阅可在租约内继续。
### 7.5 重连
- 转发连接重连独立于主服务器重连和 Minecraft 自动重连插件。
- 默认指数退避 1 秒到 30 秒,加入随机抖动,最多 8 次;参数集中配置。
- 只有入房 ACK 成功才重置次数,不以 TCP/Socket.IO connect 为准。
- 意外 `io server disconnect` 可重试;通过明确的撤销/过期/旧会话错误停止旧凭证重试。
- 每次重连必须重新入房;不依赖服务端恢复自动恢复权限。
- token 过期时等待 Main 下发新 token,不能使用旧凭证无限重试。
- 不缓存并重放 chat;状态恢复靠快照,日志按序补偿。
- 关闭/替换时先递增连接代次,使排队回调失效,再清理 timer、监听器、观察桥和 Socket。
### 7.6 handler 与分发修复
- 主控制 Socket 命名为 `main_socket`,转发客户端为 `relay_socket`。
- 修复重复声明并导入正确的 `io`。
- 将广泛的 `startsWith('forward_')` 忽略改为明确的控制事件路由,确保 `forward_bot` 可执行。
- 转发数据 Socket 使用独立白名单 handler,不把它接进主服务器命令的全量 dispatcher。
- 保持 Node 启动时所有 handler 可正常导入。
### 7.7 D 的验收
- 所有 Bot 状态及无上下文均能建立转发通道,不产生 Minecraft 连接动作。
- 两个并发 ensure 只创建一个 Socket,超时调用者不误关闭共享会话。
- 重连时重新入房但不重复绑定上下文事件。
- stop/start 只改变 Bot 快照,转发连接保持。
- 旧命令、旧 ACK、旧重试回调均不能污染新连接。
- 超时、租约到期、手动释放和进程关闭无监听器或定时器泄漏。
## 8. 任务 E:主服务器 HTTP 编排、授权、续期与撤销
### 8.1 文件所有权与依赖
依赖 A;B、D 协议冻结后可并行开发,最终集成依赖 G 的健康注册表。
E 独占:
- `main_server/router/node/action.ts`。
- 新增主服务器转发业务 service、会话与 grant 存储。
- 相关权限 helper 的必要提取及测试。
- HTTP 续期和释放接口。
E 不直接修改 G 所有的转发注册表及启动入口,使用 G 暴露的健康连接及控制命令 API。
### 8.2 初次申请
接口:`POST /api/user/node/forward_bot`,body 严格为 `{bot_id}`。
1. 检查用户会话、账号有效性和请求数据。
2. 用 `query_bot_info(user_id,bot_id)` 或等效逻辑检查 Bot 归属。
3. 检查对应 Node/团队/转发资源权限,但不检查 Bot 游戏状态、版本、代理可用性或启动队列。
4. 使用 `get_node_server_socket()` 获取当前可命令 Node,不仅看缓存 ONLINE。
5. 按 `(forward_id,node_id,bot_id,node_session_id)` 查找/创建通道,合并建立任务。
6. 分配浏览器独立 grant,不让不同标签页互相覆盖。
7. 经 G 的控制通道调用 `forward_open_session`。
8. 签发 Node token,发送 `forward_bot`,等待完整 ready ACK。
9. ACK 前后核对相同 Node 当前会话;再次检查 Bot 归属、节点/转发分配及配置版本。
10. 经控制通道登记该浏览器 grant,成功后签发 Web token,构造公开 DTO 返回。
不得把资源授权 helper 返回的完整实体序列化给浏览器,其中可能包含转发密钥。
公开 DTO:
```ts
interface ForwardAccess {
bot_id: number;
node_id: number;
forward_id: number;
forward_session_id: string;
grant_id: string;
url: string; // 浏览器可访问的公开地址
path: string;
namespace: string;
token: string; // 仅 Web 角色
expires_at: number;
}
```
### 8.3 续期与释放
- `POST /api/user/node/forward_bot/renew`,body `{bot_id,forward_session_id,grant_id}`。
- `POST /api/user/node/forward_bot/release`,body `{bot_id,forward_session_id,grant_id}`。
- 两者都验证登录会话、Bot 归属、grant 所有权及当前资源分配,不能仅凭 grant ID 操作。
- renew 先延长控制会话,再对 Node ensure/reauth 成功,登记更新后的 grant,最后签发 Web 新 token。
- renew 若发现 Node 会话/转发分配变化,返回 `SESSION_STALE`,前端重新初次申请。
- release 幂等撤销本 grant,立即通知转发层;不关闭其他 grant 使用的通道。
- 主服务器可依据有效 grant 延长 Node 会话租约,但不得绕过账号/资源复核。
- 浏览器异常关闭无法保证 release 到达,仍由租约和 B 的空闲回收兜底。
用户退出、权限撤销、Bot 删除、节点迁移、Node 控制会话替换时撤销对应 grant 或通道。首次实现只要求覆盖项目已有这些操作的入口,避免创建一个永不调用的撤销 API。
### 8.4 超时、幂等与失败
- Node 建立总预算 8 秒,Main 等待 10 秒,HTTP 总预算 12 秒,前端约 15 秒。
- 所有等待包含排队时间,不逐阶段无限叠加 timeout。
- 在 HTTP 超时后不返回凭证。迟到成功只能保留在有效租约/其他申请仍使用的共享通道内。
- 不把转发建立加入持久化的离线 Bot 停止队列。
- Node 离线与转发不可用使用不同错误。
- 沿用当前 HTTP 响应 envelope,错误码映射由 E 明确写测试并向 F 发布。
- 进程重启不假装恢复内存会话:旧租约过期/撤销,Web 重新申请。
### 8.5 E 的验收
- foreign Bot、无权限 Node、离线 Node 不能获取凭证。
- offline Bot 和无运行上下文不阻止申请。
- 非当前 Node 的成功 ACK 不被采纳。
- 等待期间 Bot 被迁移/删除或权限撤销不会返回可用旧凭证。
- 并发标签页共用上行但各有独立 grant,释放一个不影响另一个。
- 公开响应没有 Node token、密钥、内部地址或数据库实体泄漏。
## 9. 任务 F:前端转发客户端与页面接入
### 9.1 文件所有权与依赖
依赖 A、E 的 HTTP 约定及 B 的 Socket 协议。F 独占 `main_server_web` 中新增转发 API、连接管理模块、状态展示和相关测试。
不要把现有 Main Socket 和转发 Socket 混成同一个全局变量,也不要修改 Minecraft Bot 状态来表达转发断线。
### 9.2 客户端生命周期
1. 用户打开 Bot 交互页或显式连接时申请 ForwardAccess。
2. 保存页面/账号/Bot 的请求 generation,丢弃迟到的旧响应。
3. 用返回的 URL/path/namespace 创建 Socket,先安装监听器。
4. connect 后 join_room,成功后才启用交互操作。
5. 设置事件订阅,再请求 snapshot,同步完成前缓冲实时事件。
6. 账号退出、切换 Bot、离开页面时取消 pending、移除监听器、关闭 Socket,尽力 release grant。
连接状态建议:`idle / requesting / connecting / joining / syncing / ready / retrying / expired / error`。
同页多个组件共用一个会话客户端,组件卸载采用引用计数,不各建一条 Socket。不同标签页使用独立 grant。
### 9.3 快照与事件顺序
- 订阅后暂存事件,再请求快照,防止入房前首次状态丢失。
- 快照携带 stream ID 和 seq 水位,先应用快照,再应用同流中 seq 更大的缓冲事件。
- 流改变时丢弃旧流缓冲,重新同步,不能跨流比较 seq。
- context generation 改变时重置当前上下文视图,历史日志仍可按 generation 显示。
- seq 用于去重与快照水位,按事件订阅过滤后不保证连续,不能把自然跳号误判成丢包。由服务端显式 resync 通知、日志缓存截断或 `RESYNC_REQUIRED` 触发快照/日志重新同步。
- UI 不把 `node_ready:false` 显示成 Bot 已 offline,分别展示通道状态和 Bot 状态。
### 9.4 重连与凭证
- 网络断线采用有限退避,connect 后必须重新 join。
- 凭证在 expires_at 前 60 秒 renew,成功后调用 reauth,不必断开整个 Socket。
- 过期、SESSION_STALE 时重新走 HTTP 初次申请,不使用旧 token 无限重试。
- 服务端明确撤销授权时停止自动重试并提示重新授权,不能无条件重连绕过撤销。
- snapshot、日志读取可显式重试;chat 不能在 ACK 超时后自动重发。
- 切换 Bot 后旧 ACK、旧事件、旧 HTTP 响应不能更新新页面。
### 9.5 F 的验收
- Bot 离线页面可连接并展示 `context_exists:false`。
- 多标签页互不收到对方命令内容或私有响应。
- 停止/再次启动 Bot 后无需重连页面,即可显示新状态。
- 短断线、token 续期、主服务器重新分配转发地址均可恢复。
- 页面快速切换、重复挂载卸载不增加 Socket/监听器数量。
- 保持现有设计系统,桌面和移动端可用,不为此重做无关页面。
## 10. 任务 G:服务启动、控制注册表与部署配置
### 10.1 文件所有权与依赖
依赖 A,与 B/C 并行。G 是以下共享入口的唯一修改者:
- `forward_server/index.ts` 和现有控制 runtime 入口。
- `main_server/index.ts`。
- `main_server/runtime/forward_server/socket.ts` 及控制注册 handler。
- `node_server/index.ts` 中与控制认证会话及进程关闭相关的集成。
- 所需配置、配置校验和生命周期测试。
注意:`node_server/forward_server`、`main_server/forward_server` 可能引用同一份共享源码。实施前确认实际路径,不把别名当成独立文件让不同 Agent 同时编辑。
### 10.2 转发服务启动/关闭
- 使用共享初始化 Promise,避免 await 前检查单例造成并发启动。
- 等待 owned HTTP server 的 listening,处理端口占用与启动失败。
- 明确 HTTP server 所有权:内置服务借用 Main 的 HTTP server,停止转发功能不能意外关闭主站。
- 显式 stop API 清理 B 的会话、pending、监听器、定时器,再释放自有服务。
- 停止后复位单例和初始化状态,允许受控重启。
- 不依赖未保证存在的 Engine.IO server `close` 事件作为唯一清理入口。
- 启动参数明确为 forward ID,不继续使用 `-1` 充当执行 Node 身份。
- 密钥缺失时启动失败,取消已知字符串兜底。
### 10.3 Main 注册表
- 同一当前连接重复注册同一转发服务幂等成功。
- 注册准备完成后才加入健康表并 ACK,初始化失败必须返回失败。
- 保存 cleanup disposer;断开时仅删除仍由该连接持有的记录。
- 健康查询不返回已断开的 Socket。
- origin 1 借用 Node 控制 Socket,移除转发登记不能直接 disconnect 整个 Node。
- origin 0 的 EventEmitter 不能调用不存在的 disconnect。
- 替代时间戳猜测,使用明确注册 generation/节点会话所有权。
- 暴露供 E 使用的当前转发健康查询、控制命令及撤销方法。
### 10.4 控制链路恢复
- Main/Node 控制连接成功认证后,通知 D 当前 node_session_id。
- 主服务器短断线时,已有转发只读通道可存活到租约到期,禁止新有副作用请求。
- Main 重新认证产生新 Node 会话时,旧转发会话被替换/撤销,Web 重新申请。
- 内置转发使用进程内 EventEmitter 也必须遵守相同控制协议、ACK 和清理契约。
- 外部控制连接必须先认证,数据 Socket 不得调用控制事件。
Node 自带转发服务时,共用的 Main-Node 控制 Socket 需要同时路由 Node 命令和本地转发控制事件。G 与 D 采用明确事件表及独立 disposer,不得让修复 `forward_` 前缀过滤后,同一个控制事件被两套 handler 重复执行。Main 内置 EventEmitter 使用相同 request/ACK 适配,但不依赖 Socket.IO 才有的 timeout/disconnect 方法。
### 10.5 地址和部署
每个转发服务配置分别包含:
- `node_url`:Node 可访问地址。
- `public_url`:浏览器可访问地址。
- `path`、`namespace`。
- 明确允许的 Web Origin。
- TLS/反向代理配置及载荷限制。
HTTPS 页面必须使用可访问的 HTTPS/WSS 入口;Socket.IO 客户端传 HTTP(S) URL,不把 path 拼成错误 namespace。默认 polling 与 WebSocket 都应通过代理,不能以强制 WebSocket 掩盖未配置的跨域问题。
首版限定每个转发实例单进程。未来多进程需要共享唯一 Node 所有权、会话、pending 路由及 adapter;只加 Redis 房间广播不等于完成横向扩展。
### 10.6 G 的验收
- 并发启动只创建一个服务,端口占用不返回成功。
- 关闭再启动可用,借用 Main HTTP server 时不误关主站。
- 重复注册不踢掉 Node;旧 disconnect 不删除新注册。
- 内置与外部转发使用相同作用域规则。
- Node 初始化过程中 `forward_bot` handler 可正常导入。
- 浏览器默认连接方式能通过配置的公开域名和代理连接。
## 11. 任务 H:集成测试、故障注入与交付核对
### 11.1 文件所有权
H 独占跨服务集成测试目录及测试运行说明。生产文件修改归原 owner,H 发现问题后给证据和复现,不跨范围修复同一文件。
建议使用可控 Main、真实 Socket.IO relay 和模拟 BotContext,避免依赖公网 Minecraft 服务。时间相关使用可控时钟,额外保留至少一组真实网络连接测试。
### 11.2 必测矩阵
| 类别 | 场景 | 期望 |
| --- | --- | --- |
| Bot 状态 | 六种状态及无上下文 | 均允许申请,状态真实,不触发 start |
| 权限 | 未登录、他人 Bot、无 Node/转发资源权限 | 拒绝,不签发有效凭证 |
| Node | 离线、会话替换、等待期间断开 | 不返回旧连接成功 |
| 角色 | Web 伪造 Node、伪造事件、控制消息 | 拒绝 |
| 作用域 | 跨 Bot、跨 Node、跨 relay、旧 session token | 拒绝 |
| 入房 | 重复、不同 room、异步 join | 幂等或明确拒绝,ACK 时已入房 |
| RPC | 两 Web 相同请求 ID、超时、迟到 ACK | 不串响应,不泄漏 pending |
| RPC | Node 在执行期间替换 | 旧响应失效,有副作用请求不自动重放 |
| 生命周期 | Bot stop/start、实例 Minecraft 重连 | 通道保持,订阅跟随新上下文 |
| 事件 | 首次状态、快照期间事件、序号缺口 | 一致同步,不让旧状态覆盖新状态 |
| 重连 | Node-relay、Web-relay、Main-Node 分别断开 | 独立恢复,重新入房,无重复监听 |
| 凭证 | 到期前续期、已连接后到期、撤销 | 正确续期或断开,不能永久授权 |
| 并发 | 两标签页同时申请、一个超时/关闭 | 一条上行,各自授权,不误关共享连接 |
| 回收 | 浏览器从未到达、最后浏览器离开、进程退出 | timer/Socket/listener/pending 回收 |
| 部署 | 内置 relay、外部 relay、HTTPS、跨域 polling | 全流程可用 |
| 容量 | 日志洪峰、慢客户端、恶意 pending | 限流/截断明确,内存有界 |
### 11.3 检查与交付
- 各服务类型检查通过,前端构建通过。
- 单元测试、Socket.IO 集成测试通过。
- 每个 Agent 列出修改文件、协议变化、执行的命令及未覆盖风险。
- 明确区分“transport connected”“joined”“node ready”“Bot online”。
- 不把 mock 通过描述成真实生产部署已验证。
- 验证全过程不输出密钥、token、代理密码或完整用户配置。
## 12. 执行依赖与分工顺序
```mermaid
flowchart TD
A[A 协议约定冻结] --> B[B 转发数据面]
A --> C[C Bot注册表观察]
A --> G[G 启动与控制注册]
A --> E[E Main业务编排]
C --> D[D Node转发管理]
B --> D
G --> E
D --> E
E --> F[F Web接入]
B --> F
B --> H[H 集成验收]
C --> H
D --> H
E --> H
F --> H
G --> H
```
推荐批次:
1. A 先冻结协议,明确全部类型、错误和事件;B/C/G 提前检查接口是否可实现。
2. B、C、G 并行实现;E/F 可按已冻结协议开发本地桩,不自行变更契约。
3. C 的观察 API 完成后 D 实现 Node 管理器;B/G 提供真实 relay 联调。
4. E 接通授权、会话及 HTTP 全流程;F 接入真实 API。
5. H 执行故障注入和完整验收,原 owner 修复各自模块。
不要让 E 和 G 同时修改主服务器启动/注册表,也不要让 C 和 D 同时修改 BotContext 生命周期。
## 13. 可直接附给各 Agent 的工作指令
```text
以 FORWARD_IMPLEMENTATION_PLAN.md 为协议和任务边界来源。
只实现分配给你的任务及文件,不顺手重构其他模块。
先核对当前源码,保留已有用户修改。
共享 forward_server 路径可能为别名,先确认真实文件归属。
不自行更改事件名、字段、时间单位、角色、错误码和权限语义。
不新增根目录共享协议包,不跨项目引用协议源码;在自己负责的项目内定义必要类型和校验。
契约有问题时给出具体冲突,由协调 Agent 更新本文,再由受影响 owner 同步本地实现。
所有异步操作考虑超时、取消、旧会话和回收。
提供真实验收证据,不以 Socket connect 代替入房成功。
不得把转发重连实现成重新启动 Bot。
完成后报告:修改文件、接口、测试、依赖、未完成项和联调注意事项。
不要提交 Git commit,除非用户另有明确要求。
```
协调 Agent 负责协议冻结、文件所有权、集成顺序和最终验证;各 Agent 的局部测试通过不自动代表端到端交付完成。
+250
View File
@@ -0,0 +1,250 @@
import {randomUUID} from 'node:crypto';
import {constants} from 'node:fs';
import {lstat, open, opendir, realpath} from 'node:fs/promises';
import type {BigIntStats} from 'node:fs';
import path from 'node:path';
import {performance} from 'node:perf_hooks';
import {get_project_root_path} from '../../utils/path_utils.ts';
import {exact, ForwardFailure, integer, uuid} from '../../utils/forward_protocol.ts';
export type DataFileEntry = {type: 'directory'; name: string; children: DataFileEntry[]}
| {type: 'file'; name: string; id: string; size: number; modified_at: number};
export interface DataFileList {
listing_id: string;
expires_at: number;
partial: boolean;
children: DataFileEntry[];
}
export interface FileData {
id: string;
name: string;
encoding: 'base64';
offset: number;
next_offset: number;
total_bytes: number;
eof: boolean;
data: string;
}
export interface BotFileServiceOptions {
/** Trusted server-side override of the bot data root, primarily for tests. */
root?: string;
now?: () => number;
}
type FileRecord = {parts: string[]; stat: BigIntStats; expires_at: number; append: boolean};
const TTL = 300_000;
const CHUNK = 262_144;
const MAX_IDS = 8000;
const MAX_ENTRIES = 2000;
const MAX_BYTES = 512 * 1024;
const identity = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino
&& a.birthtimeNs === b.birthtimeNs;
const missing = (error: unknown) => ['ENOENT', 'ENOTDIR', 'ELOOP'].includes((error as NodeJS.ErrnoException)?.code ?? '');
export class BotFileService {
private readonly root: string;
private readonly now: () => number;
private readonly files = new Map<string, FileRecord>();
private disposed = false;
private listing = false;
private reads = 0;
constructor(bot_id: number, options: BotFileServiceOptions = {}) {
if (!integer(bot_id, 1)) throw new ForwardFailure('INVALID_REQUEST');
this.root = path.resolve(options.root ?? path.join(get_project_root_path(), 'data', 'bot', String(bot_id)));
this.now = options.now ?? Date.now;
}
dispose(): void {
this.disposed = true;
this.files.clear();
}
private check(deadline: number): void {
if (this.disposed) throw new ForwardFailure('CANCELLED');
if (!integer(deadline, 1)) throw new ForwardFailure('INVALID_REQUEST');
if (this.now() >= deadline) throw new ForwardFailure('TIMEOUT');
}
private prune(): void {
for (const [id, record] of this.files) {
if (record.expires_at <= this.now()) this.files.delete(id);
}
}
// Check every ancestor, including ancestors above the configured root. These
// checks narrow parent replacement races; they do not make path lookup atomic.
private async safe(parts: string[], deadline: number): Promise<BigIntStats> {
const target = path.join(this.root, ...parts);
const relative = path.relative(this.root, target);
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new ForwardFailure('RESYNC_REQUIRED');
}
const parsed = path.parse(target);
let current = parsed.root;
let stat = await lstat(current, {bigint: true});
for (const component of target.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
this.check(deadline);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new ForwardFailure('RESYNC_REQUIRED');
current = path.join(current, component);
stat = await lstat(current, {bigint: true});
this.check(deadline);
if (stat.isSymbolicLink()) throw new ForwardFailure('RESYNC_REQUIRED');
}
const resolved = await realpath(target);
this.check(deadline);
if (resolved !== target) throw new ForwardFailure('RESYNC_REQUIRED');
return stat;
}
async list(deadline_at: number, expires_at?: number): Promise<DataFileList> {
this.check(deadline_at);
if (expires_at !== undefined && (!integer(expires_at, 1) || expires_at <= this.now()))
throw new ForwardFailure('TOKEN_EXPIRED');
if (this.listing) throw new ForwardFailure('RATE_LIMITED');
this.listing = true;
const pending = new Map<string, FileRecord>();
const result: DataFileList = {listing_id: randomUUID(), expires_at: Math.min(this.now() + TTL, expires_at ?? Infinity),
partial: false, children: []};
let entries = 0;
let bytes = Buffer.byteLength(JSON.stringify(result));
const started = performance.now();
const budget = () => {
this.check(deadline_at);
if (performance.now() - started >= 2000) result.partial = true;
return !result.partial;
};
try {
this.prune();
const rootStat = await this.safe([], deadline_at);
if (!rootStat.isDirectory()) throw new ForwardFailure('RESYNC_REQUIRED');
const visit = async (parts: string[], children: DataFileEntry[], depth: number): Promise<void> => {
if (!budget()) return;
const before = await this.safe(parts, deadline_at);
if (!before.isDirectory()) throw new ForwardFailure('RESYNC_REQUIRED');
const names: string[] = [];
const directory = await opendir(path.join(this.root, ...parts));
try {
while (budget()) {
const entry = await directory.read();
this.check(deadline_at);
if (!entry) break;
if (names.length >= MAX_ENTRIES) { result.partial = true; break; }
names.push(entry.name);
}
} finally { await directory.close(); }
const after = await this.safe(parts, deadline_at);
if (!identity(before, after)) throw new ForwardFailure('RESYNC_REQUIRED');
// A bounded directory read may have hit its limit; still emit its
// collected entries, then stop further traversal.
const stopped = result.partial;
result.partial = false;
for (const name of names.sort()) {
if (!budget()) break;
if (entries >= MAX_ENTRIES) { result.partial = true; break; }
if (depth >= 32) { result.partial = true; break; }
const childParts = [...parts, name];
let stat: BigIntStats;
try { stat = await lstat(path.join(this.root, ...childParts), {bigint: true}); }
catch {
this.check(deadline_at);
result.partial = true;
break;
}
this.check(deadline_at);
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) continue;
if (stat.isFile() && (stat.size > BigInt(Number.MAX_SAFE_INTEGER)
|| this.files.size + pending.size >= MAX_IDS)) { result.partial = true; break; }
const entry: DataFileEntry = stat.isDirectory()
? {type: 'directory', name, children: []}
: {type: 'file', name, id: randomUUID(), size: Number(stat.size), modified_at: Number(stat.mtimeMs)};
const cost = Buffer.byteLength(JSON.stringify(entry)) + 1;
if (bytes + cost > MAX_BYTES) { result.partial = true; break; }
bytes += cost;
entries++;
children.push(entry);
if (entry.type === 'file') pending.set(entry.id, {parts: childParts, stat,
expires_at: result.expires_at, append: childParts.length === 2
&& childParts[0] === 'log' && name.endsWith('.log')});
else {
try { await visit(childParts, entry.children, depth + 1); }
catch (error) {
this.check(deadline_at);
if (error instanceof ForwardFailure) throw error;
result.partial = true;
}
}
if (result.partial) break;
}
result.partial ||= stopped;
};
await visit([], result.children, 0);
const finalRoot = await this.safe([], deadline_at);
if (!identity(rootStat, finalRoot)) throw new ForwardFailure('RESYNC_REQUIRED');
this.check(deadline_at);
for (const [id, record] of pending) this.files.set(id, record);
return result;
} catch (error) {
this.check(deadline_at);
if (missing(error) && result.children.length === 0 && pending.size === 0) {
// Only a genuinely absent root is an empty listing.
try { await lstat(this.root); }
catch (rootError) {
this.check(deadline_at);
if ((rootError as NodeJS.ErrnoException).code === 'ENOENT') return result;
}
}
throw error instanceof ForwardFailure ? error : new ForwardFailure('INTERNAL_ERROR');
} finally { this.listing = false; }
}
async read(payload: {id: string; offset?: number; length?: number}, deadline_at: number): Promise<FileData> {
this.check(deadline_at);
if (!exact(payload, ['id'], ['offset', 'length']) || !uuid(payload.id)
|| (payload.offset !== undefined && !integer(payload.offset))
|| (payload.length !== undefined && (!integer(payload.length, 1) || payload.length > CHUNK))) {
throw new ForwardFailure('INVALID_REQUEST');
}
this.prune();
const record = this.files.get(payload.id);
if (!record) throw new ForwardFailure('INVALID_REQUEST');
const offset = payload.offset ?? 0;
const size = Number(record.stat.size);
if (offset > size) throw new ForwardFailure('INVALID_REQUEST');
if (this.reads >= 4) throw new ForwardFailure('RATE_LIMITED');
this.reads++;
const validate = (stat: BigIntStats) => {
if (!stat.isFile() || !identity(record.stat, stat) || (record.append
? stat.size < record.stat.size
: stat.size !== record.stat.size || stat.mtimeNs !== record.stat.mtimeNs
|| stat.ctimeNs !== record.stat.ctimeNs)) throw new ForwardFailure('RESYNC_REQUIRED');
};
try {
validate(await this.safe(record.parts, deadline_at));
const handle = await open(path.join(this.root, ...record.parts), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
this.check(deadline_at);
validate(await handle.stat({bigint: true}));
validate(await this.safe(record.parts, deadline_at));
const buffer = Buffer.alloc(Math.min(payload.length ?? CHUNK, size - offset));
let received = 0;
while (received < buffer.length) {
const {bytesRead} = await handle.read(buffer, received, buffer.length - received, offset + received);
this.check(deadline_at);
if (bytesRead === 0) throw new ForwardFailure('RESYNC_REQUIRED');
received += bytesRead;
}
validate(await handle.stat({bigint: true}));
validate(await this.safe(record.parts, deadline_at));
this.check(deadline_at);
if (record.expires_at <= this.now()) throw new ForwardFailure('INVALID_REQUEST');
return {id: payload.id, name: record.parts.at(-1)!, encoding: 'base64', offset,
next_offset: offset + received, total_bytes: size, eof: offset + received === size,
data: buffer.toString('base64')};
} finally { await handle.close(); }
} catch (error) {
this.check(deadline_at);
throw error instanceof ForwardFailure ? error
: new ForwardFailure(missing(error) ? 'RESYNC_REQUIRED' : 'INTERNAL_ERROR');
} finally { this.reads--; }
}
}
+430
View File
@@ -0,0 +1,430 @@
import {createHash} from 'node:crypto';
import {io, type Socket} from 'socket.io-client';
import {BotStream} from './stream.ts';
import {BotFileService} from './files.ts';
import type {EnsureForward, ForwardAck, ForwardReady, ForwardScope} from '../../types/forward.ts';
import {error_ack, exact, failure_code, ForwardFailure, success_data, validate_command, validate_join,
validate_rpc} from '../../utils/forward_protocol.ts';
type Entry = {
command: EnsureForward;
chain: Promise<unknown>;
pending: number;
socket?: Socket;
detach?: () => void;
ready: boolean;
terminal: boolean;
epoch: number;
lease: number;
retain_until: number;
expiry?: ReturnType<typeof setTimeout>;
stream?: BotStream;
files?: Pick<BotFileService, 'list' | 'read' | 'dispose'>;
unsubscribe?: () => void;
cancel: Set<() => void>;
used_tokens: Map<string, number>;
};
type Options = {
socket_factory?: typeof io;
stream_factory?: (bot_id: number) => BotStream;
file_factory?: (bot_id: number) => Pick<BotFileService, 'list' | 'read' | 'dispose'>;
};
const scope_keys = ['forward_id', 'forward_session_id', 'node_session_id', 'node_id', 'bot_id'] as const;
const fingerprint = (token: string) => createHash('sha256').update(token).digest('hex');
const managers = new WeakMap<Socket, NodeForwardManager>();
export class NodeForwardManager {
private readonly entries = new Map<string, Entry>();
private readonly streams = new Map<number, {stream: BotStream; refs: number}>();
private readonly socket_factory: typeof io;
private readonly stream_factory: (bot_id: number) => BotStream;
private readonly file_factory: (bot_id: number) => Pick<BotFileService, 'list' | 'read' | 'dispose'>;
private stopped = false;
constructor(readonly node_id: number, readonly node_session_id: string, options: Options = {}) {
this.socket_factory = options.socket_factory ?? io;
this.stream_factory = options.stream_factory ?? (bot_id => new BotStream(bot_id));
this.file_factory = options.file_factory ?? (bot_id => new BotFileService(bot_id));
}
async ensure(raw: unknown): Promise<ForwardAck<ForwardReady>> {
let entry: Entry | undefined;
try {
const command = validate_command(raw);
this.check_command(command);
for (const [id, previous] of this.entries) {
if (previous.terminal && previous.pending === 0
&& previous.retain_until + 5000 <= Date.now()) this.entries.delete(id);
}
entry = this.entries.get(command.forward_session_id);
if (entry) {
if (scope_keys.some(key => entry!.command[key] !== command[key])
|| (['url', 'path', 'namespace'] as const).some(key => entry!.command[key] !== command[key])) {
throw new ForwardFailure('SESSION_STALE');
}
if (entry.terminal) throw new ForwardFailure('SESSION_STALE');
if (entry.pending >= 16) throw new ForwardFailure('RATE_LIMITED');
} else {
if (this.entries.size >= 4096 || [...this.entries.values()].filter(item => !item.terminal).length >= 500) {
throw new ForwardFailure('RATE_LIMITED');
}
// Retain closed identities past all known credentials, separately from active capacity.
entry = {command: {...command, token: ''}, chain: Promise.resolve(), pending: 0, ready: false, terminal: false,
epoch: 0, lease: 0, retain_until: command.expires_at, cancel: new Set(), used_tokens: new Map()};
this.entries.set(command.forward_session_id, entry);
this.arm_expiry(entry);
}
const current = entry;
current.retain_until = Math.max(current.retain_until, command.expires_at);
current.pending++;
let running = false;
const run = current.chain.then(() => {
running = true;
return this.establish(current, command);
});
current.chain = run.then(() => { current.pending--; }, () => { current.pending--; });
return await this.until_deadline(run, command.deadline_at, () => {
if (running) this.close_transport(current);
});
} catch (error) {
return error_ack(failure_code(error));
}
}
private check_command(command: EnsureForward) {
if (this.stopped || command.node_session_id !== this.node_session_id) throw new ForwardFailure('SESSION_STALE');
if (command.node_id !== this.node_id) throw new ForwardFailure('FORBIDDEN');
if (command.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT');
if (command.expires_at <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED');
}
private scope(command: EnsureForward): ForwardScope {
const {forward_id, forward_session_id, node_session_id, node_id, bot_id} = command;
return {forward_id, forward_session_id, node_session_id, node_id, bot_id};
}
private async establish(entry: Entry, command: EnsureForward): Promise<ForwardAck<ForwardReady>> {
this.check_command(command);
if (entry.terminal) throw new ForwardFailure('SESSION_STALE');
if (command.expires_at < entry.lease) throw new ForwardFailure('SESSION_STALE');
const digest = fingerprint(command.token);
for (const [token, expiry] of entry.used_tokens) if (expiry <= Date.now()) entry.used_tokens.delete(token);
if (!entry.used_tokens.has(digest) && entry.used_tokens.size >= 256) throw new ForwardFailure('RATE_LIMITED');
let socket = entry.socket;
try {
if (socket?.connected && entry.ready) {
entry.used_tokens.set(digest, command.expires_at);
const joined = validate_join(await this.ack(entry, socket, 'reauth', {token: command.token}, command), command, false);
this.check_command(command);
if (!joined.node_ready) {
entry.ready = false;
await this.ready(entry, socket, command);
}
} else {
if (entry.used_tokens.has(digest)) throw new ForwardFailure('TOKEN_EXPIRED');
this.close_transport(entry);
// No autonomous reconnect: a join window can expire before the lease does.
socket = this.socket_factory(`${command.url.replace(/\/$/, '')}${command.namespace === '/' ? '' : command.namespace}`, {
path: command.path, autoConnect: false, reconnection: false, forceNew: true,
timeout: Math.min(10_000, this.remaining(command)), auth: {token: command.token},
});
entry.socket = socket;
this.install_transport(entry, socket);
await this.connect(entry, socket, command);
entry.used_tokens.set(digest, command.expires_at);
validate_join(await this.ack(entry, socket, 'join_room', {token: command.token}, command), command, true);
this.check_command(command);
if (!entry.stream) {
let shared = this.streams.get(command.bot_id);
if (!shared) {
shared = {stream: this.stream_factory(command.bot_id), refs: 0};
this.streams.set(command.bot_id, shared);
}
shared.refs++;
entry.stream = shared.stream;
}
this.observe_updates(entry, socket);
await this.ready(entry, socket, command);
}
this.check_command(command);
if (entry.socket !== socket || !socket.connected || entry.terminal || !entry.ready) throw new ForwardFailure('SESSION_STALE');
entry.command = {...command, token: ''};
entry.lease = command.expires_at;
entry.used_tokens.set(digest, command.expires_at);
this.arm_expiry(entry);
entry.stream?.resync();
this.check_command(command);
return {status: 'success', data: {...this.scope(command), node_ready: true, lease_expires_at: command.expires_at}};
} catch (error) {
this.close_transport(entry);
if (!entry.lease) this.release_stream(entry);
throw error;
}
}
private remaining(command: EnsureForward) {
const remaining = Math.min(command.deadline_at, command.expires_at) - Date.now();
if (remaining <= 0) throw new ForwardFailure('TIMEOUT');
return Math.min(remaining, 2_147_483_647);
}
private until_deadline<T>(task: Promise<T>, deadline: number, timeout: () => void): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
timeout();
reject(new ForwardFailure('TIMEOUT'));
}, Math.max(0, Math.min(deadline - Date.now(), 2_147_483_647)));
task.then(resolve, reject).finally(() => clearTimeout(timer));
});
}
private connect(entry: Entry, socket: Socket, command: EnsureForward): Promise<void> {
return new Promise((resolve, reject) => {
const done = (error?: ForwardFailure) => {
clearTimeout(timer);
socket.off('connect', connected);
socket.off('connect_error', failed);
entry.cancel.delete(cancel);
if (error) reject(error); else resolve();
};
const connected = () => done();
const failed = () => done(new ForwardFailure('RELAY_UNAVAILABLE'));
const cancel = () => done(new ForwardFailure('CANCELLED'));
const timer = setTimeout(() => done(new ForwardFailure('TIMEOUT')), this.remaining(command));
entry.cancel.add(cancel);
socket.once('connect', connected);
socket.once('connect_error', failed);
socket.connect();
});
}
private async ack(entry: Entry, socket: Socket, event: string, payload: unknown, command: EnsureForward): Promise<unknown> {
if (entry.terminal || entry.socket !== socket || !socket.connected || this.stopped) throw new ForwardFailure('SESSION_STALE');
const epoch = entry.epoch;
const result = await new Promise<unknown>((resolve, reject) => {
const cancel = () => { entry.cancel.delete(cancel); reject(new ForwardFailure('CANCELLED')); };
entry.cancel.add(cancel);
socket.timeout(this.remaining(command)).emit(event, payload, (error: unknown, response: unknown) => {
entry.cancel.delete(cancel);
if (error) reject(new ForwardFailure('TIMEOUT')); else resolve(response);
});
});
if (entry.terminal || entry.epoch !== epoch || entry.socket !== socket || !socket.connected || this.stopped) {
throw new ForwardFailure('SESSION_STALE');
}
return result;
}
private async ready(entry: Entry, socket: Socket, command: EnsureForward) {
const data = success_data(await this.ack(entry, socket, 'forward_node_ready', {
forward_session_id: command.forward_session_id,
}, command));
if (!exact(data, ['node_ready']) || data.node_ready !== true) throw new ForwardFailure('RELAY_UNAVAILABLE');
entry.ready = true;
}
private install_transport(entry: Entry, socket: Socket) {
const seen = new Map<string, number>();
const rpc = async (raw: unknown, callback: unknown) => {
if (typeof callback !== 'function') return;
let response: ForwardAck;
try {
// Relay may dispatch immediately after accepting ready, before its ACK reaches Node.
if (entry.socket !== socket || !socket.connected || entry.terminal || this.stopped || !entry.stream) {
throw new ForwardFailure('SESSION_STALE');
}
const request = validate_rpc(raw);
if (request.forward_session_id !== entry.command.forward_session_id) throw new ForwardFailure('SESSION_STALE');
if (request.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT');
if (entry.lease && entry.lease <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED');
for (const [id, deadline] of seen) if (deadline <= Date.now()) seen.delete(id);
if (seen.has(request.correlation_id)) throw new ForwardFailure('CANCELLED');
if (seen.size >= 512) throw new ForwardFailure('RATE_LIMITED');
seen.set(request.correlation_id, request.deadline_at);
let data: unknown;
switch (request.method) {
case 'bot.snapshot': data = entry.stream.snapshot(); break;
case 'get_context_info': data = entry.stream.get_context_info(request.payload, request.deadline_at); break;
case 'bot.logs': data = entry.stream.logs(request.payload as {limit: number; stream_id?: string; after_seq?: number}); break;
case 'get_log_history':
case 'get_data_file_list':
case 'get_file_data':
case 'execute_function':
case 'workflow.start':
case 'workflow.stop':
case 'workflow.snapshot':
case 'workflow.catalog':
case 'workflow.runtime':
case 'workflow.control':
case 'workflow.command':
case 'tab_complete': {
const epoch = entry.epoch;
const check_transport = () => {
if (entry.socket !== socket || !socket.connected || entry.terminal || this.stopped || entry.epoch !== epoch)
throw new ForwardFailure('SESSION_STALE');
if (request.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT');
if (entry.lease && entry.lease <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED');
};
if (request.method === 'get_data_file_list' || request.method === 'get_file_data') {
// File IDs belong to this transport/session, never the shared BotStream.
const files = entry.files ??= this.file_factory(entry.command.bot_id);
const deadline = Math.min(request.deadline_at, entry.lease || entry.command.expires_at);
data = request.method === 'get_data_file_list' ? await files.list(deadline, entry.lease || entry.command.expires_at)
: await files.read(request.payload as {id: string; offset?: number; length?: number}, deadline);
if (request.method === 'get_data_file_list') {
const listing = data as {expires_at: number};
listing.expires_at = Math.min(listing.expires_at, entry.lease || entry.command.expires_at);
}
} else if (request.method === 'workflow.start') {
data = entry.stream.workflow_start(request.payload, request.deadline_at, check_transport);
} else if (request.method === 'workflow.stop') {
data = await this.until_deadline(entry.stream.workflow_stop(request.payload, request.deadline_at, check_transport),
request.deadline_at, () => {});
} else if (request.method === 'workflow.snapshot') {
data = entry.stream.workflow_snapshot(request.payload, request.deadline_at, check_transport);
} else if (request.method === 'workflow.catalog') {
data = entry.stream.workflow_catalog(request.payload, request.deadline_at, check_transport);
} else if (request.method === 'workflow.runtime') {
data = entry.stream.workflow_runtime(request.payload, request.deadline_at, check_transport);
} else if (request.method === 'workflow.control') {
data = entry.stream.workflow_control(request.payload, request.deadline_at, check_transport);
} else if (request.method === 'workflow.command') {
data = entry.stream.workflow_command(request.payload, request.deadline_at, check_transport);
} else data = request.method === 'get_log_history' ? await entry.stream.get_log_history()
: request.method === 'execute_function' ? await entry.stream.execute_function(
request.payload as {function: string; name: string; args: unknown[]}, request.deadline_at, check_transport)
: await entry.stream.tab_complete(request.payload, request.deadline_at);
if (entry.socket !== socket || !socket.connected || entry.terminal || this.stopped || entry.epoch !== epoch)
throw new ForwardFailure('SESSION_STALE');
if (request.deadline_at <= Date.now()) throw new ForwardFailure('TIMEOUT');
if (entry.lease && entry.lease <= Date.now()) throw new ForwardFailure('TOKEN_EXPIRED');
break;
}
case 'bot.chat': data = entry.stream.chat(request.payload as {message: string}, request.deadline_at); break;
}
response = {status: 'success', data};
} catch (error) {
response = error_ack(failure_code(error));
}
try { callback(response); } catch { /* The request ACK must never escape an event callback. */ }
};
const closed = (raw: unknown) => {
if (entry.socket !== socket || !exact(raw, ['reason', 'code'], ['forward_session_id'])
|| (raw.forward_session_id !== undefined && raw.forward_session_id !== entry.command.forward_session_id)) return;
this.retire(entry);
};
const disconnected = () => {
if (entry.socket === socket) this.close_transport(entry);
};
const status = (raw: unknown) => {
if (entry.socket === socket && exact(raw, ['forward_session_id', 'node_ready', 'reason'])
&& raw.forward_session_id === entry.command.forward_session_id && raw.reason === 'resync_required') {
entry.stream?.resync();
}
};
socket.on('relay_request', rpc);
socket.on('forward_session_closed', closed);
socket.on('relay_status', status);
socket.on('disconnect', disconnected);
entry.detach = () => {
socket.off('relay_request', rpc);
socket.off('forward_session_closed', closed);
socket.off('relay_status', status);
socket.off('disconnect', disconnected);
};
}
private observe_updates(entry: Entry, socket: Socket) {
entry.unsubscribe?.();
let window = Date.now();
let count = 0;
entry.unsubscribe = entry.stream!.subscribe(event => {
if ((event.name !== 'log' && event.name !== 'info_update') || entry.socket !== socket || !socket.connected
|| !entry.ready || entry.terminal || this.stopped) return;
if (Date.now() - window >= 1000) { window = Date.now(); count = 0; }
if (++count > 150 || (socket.io.engine?.writeBuffer.length ?? 0) >= 64) return;
if (event.name === 'log') {
socket.volatile.emit('bot_log', {...event, forward_session_id: entry.command.forward_session_id});
} else {
const {name, payload, ...metadata} = event;
const update = {...metadata,
forward_session_id: entry.command.forward_session_id, data: payload};
// Plugin lifecycle transitions can finish within the same transport
// write. Volatile would drop the final state while that write drains,
// leaving the UI initializing until another plugin change occurs.
// Keep the shared rate/buffer bounds above for these queued packets.
if (payload.name === 'plugin_states' || payload.name === 'workflow_states') socket.emit('info_update', update);
else socket.volatile.emit('info_update', update);
}
});
}
private close_transport(entry: Entry) {
entry.epoch++;
entry.ready = false;
entry.unsubscribe?.();
entry.unsubscribe = undefined;
entry.files?.dispose();
entry.files = undefined;
const socket = entry.socket;
entry.socket = undefined;
entry.detach?.();
entry.detach = undefined;
for (const cancel of [...entry.cancel]) cancel();
entry.cancel.clear();
socket?.disconnect();
if (socket) socket.auth = {};
}
private arm_expiry(entry: Entry) {
clearTimeout(entry.expiry);
const expires_at = Math.max(entry.lease, entry.command.expires_at);
entry.expiry = setTimeout(() => {
if (expires_at <= Date.now()) this.retire(entry);
else this.arm_expiry(entry);
}, Math.min(Math.max(0, expires_at - Date.now()), 2_147_483_647));
entry.expiry.unref();
}
private release_stream(entry: Entry) {
if (!entry.stream) return;
const shared = this.streams.get(entry.command.bot_id);
if (shared && --shared.refs === 0) {
shared.stream.dispose();
this.streams.delete(entry.command.bot_id);
}
entry.stream = undefined;
}
private retire(entry: Entry) {
entry.terminal = true;
clearTimeout(entry.expiry);
this.close_transport(entry);
this.release_stream(entry);
entry.used_tokens.clear();
}
stop() {
if (this.stopped) return;
this.stopped = true;
for (const entry of this.entries.values()) this.retire(entry);
this.entries.clear();
}
}
export function bind_node_session(socket: Socket, node_id: number, node_session_id: string): void {
clear_node_session(socket);
managers.set(socket, new NodeForwardManager(node_id, node_session_id));
}
export function clear_node_session(socket: Socket): void {
managers.get(socket)?.stop();
managers.delete(socket);
}
export function ensure_forward(socket: Socket, command: unknown): Promise<ForwardAck<ForwardReady>> {
if (!socket.connected) return Promise.resolve(error_ack('NODE_OFFLINE'));
return managers.get(socket)?.ensure(command) ?? Promise.resolve(error_ack('UNAUTHORIZED'));
}
+820
View File
@@ -0,0 +1,820 @@
import {randomUUID} from 'node:crypto';
import type {log} from '../../types/utils/logger.ts';
import {Logger, type LogHistoryRecord} from '../../utils/logger.ts';
import {get_project_root_path} from '../../utils/path_utils.ts';
import * as path from 'node:path';
import {local_command_completion} from '../../utils/command_completion.ts';
import {get_bot_context, observe_bot_context} from '../instance/index.ts';
import type {bot_status} from '../../types/bot/instance/bot_runtime.ts';
import type {InfoManager, InfoSnapshot, InfoUpdate} from '../info/init.ts';
import type {FunctionManager} from '../function/init.ts';
import type {RunSnapshot, StartResult, WorkflowManager, WorkflowCommand, WorkflowRuntimeSnapshot} from '../../types/bot/workflow/runtime.ts';
import type {WorkflowCatalog} from '../../types/bot/workflow/metadata.ts';
import {get_workflow_catalog} from '../workflow/catalog.ts';
import {summarize_run, type RunSummary} from '../workflow/runtime/summary.ts';
import {object, validate_workflow_payload} from '../../utils/forward_protocol.ts';
export interface StreamContext {
readonly bot_status: bot_status;
readonly bot_instance: {
chat(message: string): unknown;
players?: Record<string, unknown>;
tabComplete?(text: string, assumeCommand?: boolean, sendBlockInSight?: boolean, timeout?: number): Promise<unknown[]>;
_client?: {
protocolVersion?: number;
state?: string;
_mcBundle?: Array<{data?: {params?: {transactionId?: number}}; metadata?: {name?: string}}>;
write(name: string, payload: unknown): void;
on(name: string, listener: (packet: any) => void): unknown;
off(name: string, listener: (packet: any) => void): unknown;
};
} | null;
readonly logger?: {read_recent_log(count: number): Promise<LogHistoryRecord[]>};
readonly info_manager?: Pick<InfoManager, 'get_all_info'> | null;
readonly function_manager?: Pick<FunctionManager, 'run'> | null;
readonly workflow_manager?: Pick<WorkflowManager, 'start' | 'stop' | 'get_snapshot' | 'get_snapshots'>
& Partial<Pick<WorkflowManager, 'control' | 'get_command' | 'get_runtime_snapshot'>> | null;
log?(segments: log[]): void;
on(event: any, callback: (...args: any[]) => void): unknown;
off(event: any, callback: (...args: any[]) => void): unknown;
}
export interface StreamContextSource {
get(bot_id: number): StreamContext | undefined;
observe(bot_id: number, callback: (context: StreamContext | undefined) => void): () => void;
}
export interface LogSegment {
message: string;
font_color: string;
font_style: Array<'normal' | 'italic' | 'bold' | 'light' | 'underline' | 'url'>;
hoverEvent?: log['hoverEvent'];
clickEvent?: log['clickEvent'];
insertion?: string;
}
export interface BotLogRecord {
id: string;
timestamp: number;
context_generation: string | null;
seq: number;
segments: LogSegment[];
}
export interface BotSnapshot {
bot_id: number;
bot_status: bot_status;
context_exists: boolean;
context_generation: string | null;
stream_id: string;
seq: number;
captured_at: number;
}
export interface BotLogsResult {
stream_id: string;
seq: number;
records: BotLogRecord[];
truncated: boolean;
}
interface StreamEventPayloads {
status_update: {status: bot_status; context_exists: boolean};
log: {records: BotLogRecord[]};
message: {text: string; position: string};
bot_error: {message: string};
info_update: InfoUpdate;
}
export type BotStreamEvent = {[K in keyof StreamEventPayloads]: {
bot_id: number;
context_generation: string | null;
stream_id: string;
seq: number;
emitted_at: number;
name: K;
payload: StreamEventPayloads[K];
}}[keyof StreamEventPayloads];
export type BotStreamErrorCode = 'INVALID_REQUEST' | 'TIMEOUT' | 'NO_CONTEXT'
| 'BOT_NOT_ONLINE' | 'INTERNAL_ERROR' | 'CANCELLED' | 'RESYNC_REQUIRED' | 'RATE_LIMITED';
export class BotStreamError extends Error {
readonly retryable = false;
constructor(readonly code: BotStreamErrorCode, message: string) {
super(message);
this.name = 'BotStreamError';
}
}
export interface BotStreamOptions {
source?: StreamContextSource;
capacity?: number;
now?: () => number;
max_chat_length?: number;
history_reader?: (count: number) => Promise<LogHistoryRecord[]>;
}
const default_source: StreamContextSource = {get: get_bot_context, observe: observe_bot_context};
const generations = new WeakMap<StreamContext, string>();
const completion_pending = new WeakSet<object>();
let completion_transaction = 0;
const statuses = new Set(['offline', 'ready', 'connecting', 'online', 'disconnecting', 'reconnecting']);
const styles = new Set<LogSegment['font_style'][number]>(['normal', 'italic', 'bold', 'light', 'underline', 'url']);
function own(value: unknown, key: string): unknown {
if (!value || typeof value !== 'object') return undefined;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
return descriptor && 'value' in descriptor ? descriptor.value : undefined;
}
function exact(value: unknown, required: string[], optional: string[] = []): value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const keys = Reflect.ownKeys(value);
return required.every(key => Object.hasOwn(value, key)) && keys.every(key => {
if (typeof key !== 'string' || !required.includes(key) && !optional.includes(key)) return false;
const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
return 'value' in descriptor && descriptor.enumerable;
});
}
function text(value: string, bytes: number): string {
if (Buffer.byteLength(value) <= bytes) return value;
let result = '';
let size = 0;
for (const character of value) {
size += Buffer.byteLength(character);
if (size > bytes) break;
result += character;
}
return result;
}
// Project module values to bounded JSON without invoking getters or toJSON methods.
class InfoDataError extends BotStreamError {
constructor(readonly reason: string, readonly field_path: string) {
super('INTERNAL_ERROR', 'Invalid info data');
}
}
function clone_info_data(value: unknown): unknown {
let entries = 0;
let bytes = 0;
const ancestors = new Set<object>();
const max_bytes = 4 * 1024 * 1024;
const visit = (item: unknown, depth: number, field_path: string): unknown => {
const invalid = (reason: string): never => { throw new InfoDataError(reason, field_path); };
if (depth > 64) invalid('max_depth');
if (++entries > 250_000) invalid('max_entries');
let result: unknown;
if (item === null || typeof item === 'boolean' || typeof item === 'string' || typeof item === 'number') {
if (typeof item === 'number' && !Number.isFinite(item)) invalid('non_finite_number');
if (typeof item === 'string' && Buffer.byteLength(item) > max_bytes) invalid('max_bytes');
bytes += Buffer.byteLength(JSON.stringify(item));
result = item;
} else {
if (!Array.isArray(item) && !object(item)) invalid('non_json_value');
const container = item as object;
if (ancestors.has(container)) invalid('cyclic_reference');
ancestors.add(container);
const keys = Reflect.ownKeys(container);
bytes += 2;
if (Array.isArray(item)) {
if (item.length > 65_536) invalid('max_array_length');
if (keys.length !== item.length + 1) invalid('non_dense_array');
const values: unknown[] = [];
for (let index = 0; index < item.length; index++) {
const descriptor = Object.getOwnPropertyDescriptor(item, String(index));
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) invalid('non_data_property');
if (index) bytes++;
values.push(visit(descriptor!.value, depth + 1, `${field_path}[${index}]`));
}
result = values;
} else {
if (keys.length > 65_536) invalid('max_keys');
const values = Object.create(null) as Record<string, unknown>;
for (let index = 0; index < keys.length; index++) {
const key = keys[index];
if (typeof key !== 'string') invalid('non_string_key');
if (Buffer.byteLength(key as string) > max_bytes) invalid('max_bytes');
const descriptor = Object.getOwnPropertyDescriptor(container, key)!;
if (!descriptor.enumerable || !('value' in descriptor)) invalid('non_data_property');
bytes += Buffer.byteLength(JSON.stringify(key)) + 1 + (index ? 1 : 0);
// Paths contain property indexes rather than server-provided keys or item content.
const path_key = ['name', 'value', 'window', 'slots', 'nbt', 'components', 'special', 'offers', 'data', 'pages'].includes(key as string)
? String(key) : `<field:${index}>`;
values[key as string] = visit(descriptor.value, depth + 1, `${field_path}.${path_key}`);
}
result = values;
}
ancestors.delete(container);
}
if (bytes > max_bytes) invalid('max_bytes');
return result;
};
return visit(value, 0, '$');
}
function valid_info_name(value: unknown): value is string {
return typeof value === 'string' && value.length <= 128 && /^[a-zA-Z0-9_-]+$/.test(value);
}
function valid_info_text(value: unknown, max: number): boolean {
return typeof value === 'string' && Buffer.byteLength(value) <= max;
}
function valid_info_i18n(value: unknown): boolean {
if (!exact(value, ['lang', 'name', 'description'], ['fields', 'values'])
|| !valid_info_text(value.lang, 128) || !valid_info_text(value.name, 256)
|| !valid_info_text(value.description, 4096)) return false;
return ['fields', 'values'].every(key => !Object.hasOwn(value, key)
|| object(value[key]) && Object.entries(value[key]).every(([name, metadata]) =>
valid_info_text(name, 128) && valid_info_i18n(metadata)));
}
function project_segments(value: unknown): LogSegment[] {
if (!Array.isArray(value)) return [];
const segments: LogSegment[] = [];
let bytes = 2;
for (let index = 0; index < Math.min(value.length, 256); index++) {
const item = own(value, String(index));
const message = own(item, 'message');
if (typeof message !== 'string') continue;
const color = own(item, 'font_color');
const font_style = own(item, 'font_style');
const safe_styles: LogSegment['font_style'] = [];
if (Array.isArray(font_style)) {
for (let i = 0; i < Math.min(font_style.length, styles.size); i++) {
const style = own(font_style, String(i));
if (typeof style === 'string' && styles.has(style as any) && !safe_styles.includes(style as any)) {
safe_styles.push(style as LogSegment['font_style'][number]);
}
}
}
const segment: LogSegment = {
message: text(message, 16_000),
font_color: typeof color === 'string' && /^#[0-9a-f]{6}$/i.test(color) ? color : '#ffffff',
font_style: safe_styles.length ? safe_styles : ['normal'],
};
for (const key of ['hoverEvent', 'clickEvent'] as const) {
const event = own(item, key);
const action = own(event, 'action');
const value = own(event, 'value');
if (typeof action !== 'string' || typeof value !== 'string') continue;
if (key === 'hoverEvent') {
if (['show_text', 'show_item', 'show_entity'].includes(action)) {
segment.hoverEvent = {action: action as NonNullable<log['hoverEvent']>['action'], value: text(value, 4096)};
const parts = own(event, 'segments');
if (action === 'show_text' && Array.isArray(parts)) {
let remaining = 4096;
segment.hoverEvent.segments = project_segments(parts.slice(0, 64).map(part => ({
message: own(part, 'message'), font_color: own(part, 'font_color'), font_style: own(part, 'font_style'),
}))).map(({message, font_color, font_style}) => {
const clipped = message.slice(0, remaining);
remaining -= clipped.length;
return {message: clipped, font_color, font_style};
}).filter(part => part.message.length > 0);
}
}
} else if (['open_url', 'open_file', 'run_command', 'suggest_command', 'change_page',
'copy_to_clipboard', 'show_dialog', 'custom'].includes(action)) {
segment.clickEvent = {action, value: text(value, 4096)};
}
}
const insertion = own(item, 'insertion');
if (typeof insertion === 'string') segment.insertion = text(insertion, 4096);
const size = Buffer.byteLength(JSON.stringify(segment)) + (segments.length ? 1 : 0);
if (bytes + size > 16_384) break;
segments.push(segment);
bytes += size;
}
return segments;
}
function message_text(value: unknown, depth = 0): string {
if (typeof value === 'string') return text(value, 4096);
if (depth >= 4) return '';
const json = own(value, 'json');
if (json !== undefined) return message_text(json, depth + 1);
const content = own(value, 'text');
let result = typeof content === 'string' ? text(content, 4096) : '';
const extra = own(value, 'extra');
if (Array.isArray(extra)) {
for (let index = 0; index < Math.min(extra.length, 16); index++) {
result = text(result + message_text(own(extra, String(index)), depth + 1), 4096);
}
}
return result;
}
export class BotStream {
readonly stream_id = randomUUID();
private readonly source: StreamContextSource;
private readonly capacity: number;
private readonly now: () => number;
private readonly max_chat_length: number;
private readonly history_reader: (count: number) => Promise<LogHistoryRecord[]>;
private readonly history_stream_id = randomUUID();
private context: StreamContext | undefined;
private generation: string | null = null;
private sequence = 0;
private dropped_through = -1;
private readonly records: BotLogRecord[] = [];
private readonly listeners = new Set<(event: BotStreamEvent) => void>();
private readonly pending_events: BotStreamEvent[] = [];
private publishing = false;
private context_listeners: Array<[string, (...args: any[]) => void]> = [];
private unobserve: (() => void) | undefined;
private disposed = false;
constructor(readonly bot_id: number, options: BotStreamOptions = {}) {
if (!Number.isSafeInteger(bot_id) || bot_id < 1) throw new BotStreamError('INVALID_REQUEST', 'Invalid bot ID');
this.capacity = options.capacity ?? 200;
this.max_chat_length = options.max_chat_length ?? 256;
if (!Number.isSafeInteger(this.capacity) || this.capacity < 1 || this.capacity > 1000
|| !Number.isSafeInteger(this.max_chat_length) || this.max_chat_length < 1) {
throw new BotStreamError('INVALID_REQUEST', 'Invalid stream limits');
}
this.source = options.source ?? default_source;
this.now = options.now ?? Date.now;
this.history_reader = options.history_reader ?? (count => this.context?.logger
? this.context.logger.read_recent_log(count)
: Logger.read_recent_log(path.join(get_project_root_path(), 'data/bot', String(bot_id), 'log'), count));
this.unobserve = this.source.observe(bot_id, context => this.bind(context));
this.bind(this.source.get(bot_id));
}
private bind(context: StreamContext | undefined) {
if (this.disposed || this.context === context) return;
this.detach();
this.context = context;
this.generation = context ? generations.get(context) ?? randomUUID() : null;
if (context) {
generations.set(context, this.generation!);
const listen = (event: string, callback: (...args: any[]) => void) => {
const guarded = (...args: any[]) => {
if (!this.disposed && this.context === context && this.source.get(this.bot_id) === context) {
callback(...args);
}
};
this.context_listeners.push([event, guarded]);
context.on(event, guarded);
};
listen('status_update', () => this.status_event());
listen('log', input => {
const segments = project_segments(input);
if (!segments.length) return;
const record: BotLogRecord = {
id: randomUUID(), timestamp: this.now(), context_generation: this.generation,
seq: ++this.sequence, segments,
};
this.records.push(record);
if (this.records.length > this.capacity) this.dropped_through = this.records.shift()!.seq;
this.publish('log', {records: [record]}, record.seq);
});
listen('message', (input, position) => {
const projected = message_text(input);
if (projected) this.publish('message', {
text: projected,
position: typeof position === 'string' && position.trim() ? text(position, 128) : 'chat',
});
});
listen('error', () => this.publish('bot_error', {message: 'Bot connection error'}));
listen('info_update', input => {
if (context.bot_status !== 'online' || !context.bot_instance || !context.info_manager) return;
try {
const data = clone_info_data(input);
if (!exact(data, ['name', 'value']) || !valid_info_name(data.name)) {
throw new BotStreamError('INTERNAL_ERROR', 'Invalid info update');
}
this.publish('info_update', data as unknown as InfoUpdate);
} catch (error) {
const name = own(input, 'name');
console.error('Invalid bot info update was skipped', {
module: valid_info_name(name) ? name : 'unknown',
reason: error instanceof InfoDataError ? error.reason : 'invalid_update',
path: error instanceof InfoDataError ? error.field_path : '$',
});
}
});
}
this.status_event();
}
private detach() {
for (const [event, listener] of this.context_listeners) this.context?.off(event, listener);
this.context_listeners = [];
}
private state() {
return {
status: this.context && statuses.has(this.context.bot_status) ? this.context.bot_status : 'offline' as bot_status,
context_exists: !!this.context,
};
}
private status_event() {
this.publish('status_update', this.state());
}
private publish<K extends keyof StreamEventPayloads>(name: K, payload: StreamEventPayloads[K], seq = ++this.sequence) {
const event = {
bot_id: this.bot_id, context_generation: this.generation, stream_id: this.stream_id,
seq, emitted_at: this.now(), name, payload,
} as BotStreamEvent;
// Serialize reentrant callbacks so every subscriber sees increasing sequence numbers.
if (this.pending_events.length === 128) this.pending_events.shift();
this.pending_events.push(event);
if (this.publishing) return;
this.publishing = true;
try {
let dispatched = 0;
while (this.pending_events.length && !this.disposed && dispatched++ < 1024) {
const pending = this.pending_events.shift()!;
for (const listener of Array.from(this.listeners)) {
if (this.disposed) break;
try { listener(structuredClone(pending)); } catch { /* Isolate consumer failures from the bot lifecycle. */ }
}
}
} finally {
this.pending_events.length = 0;
this.publishing = false;
}
}
private active() {
if (this.disposed) throw new BotStreamError('CANCELLED', 'Bot stream is disposed');
this.bind(this.source.get(this.bot_id));
}
resync(): void {
this.active();
this.status_event();
}
snapshot(payload: unknown = {}): BotSnapshot {
this.active();
if (!exact(payload, [])) throw new BotStreamError('INVALID_REQUEST', 'Invalid snapshot request');
const state = this.state();
return {
bot_id: this.bot_id, bot_status: state.status, context_exists: state.context_exists,
context_generation: this.generation, stream_id: this.stream_id,
seq: this.sequence, captured_at: this.now(),
};
}
get_context_info(payload: unknown, deadline_at: number): InfoSnapshot[] {
this.active();
if (!exact(payload, [])) throw new BotStreamError('INVALID_REQUEST', 'Invalid info request');
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
throw new BotStreamError('TIMEOUT', 'Info request deadline expired');
const context = this.context;
if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist');
const instance = context.bot_instance;
if (context.bot_status !== 'online' || !instance)
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
const manager = context.info_manager;
if (!manager) throw new BotStreamError('INTERNAL_ERROR', 'Bot info manager is unavailable');
let data: unknown;
try { data = clone_info_data(manager.get_all_info()); }
catch { throw new BotStreamError('INTERNAL_ERROR', 'Could not read bot info'); }
const names = new Set<string>();
if (!Array.isArray(data) || data.length > 256 || !data.every(info => {
if (!exact(info, ['name', 'i18n', 'value']) || !valid_info_name(info.name) || names.has(info.name)
|| !valid_info_i18n(info.i18n)) return false;
names.add(info.name);
return true;
})) throw new BotStreamError('INTERNAL_ERROR', 'Invalid bot info result');
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Info request deadline expired');
if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance
|| context.info_manager !== manager || context.bot_status !== 'online')
throw new BotStreamError('CANCELLED', 'Bot context changed');
return data as InfoSnapshot[];
}
logs(payload: unknown): BotLogsResult {
this.active();
if (!exact(payload, ['limit'], ['stream_id', 'after_seq'])
|| !Number.isSafeInteger(payload.limit) || (payload.limit as number) < 1 || (payload.limit as number) > 200
|| Object.hasOwn(payload, 'stream_id') && (typeof payload.stream_id !== 'string'
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(payload.stream_id))
|| Object.hasOwn(payload, 'after_seq') && (!Number.isSafeInteger(payload.after_seq)
|| (payload.after_seq as number) < 0 || typeof payload.stream_id !== 'string')) {
throw new BotStreamError('INVALID_REQUEST', 'Invalid logs request');
}
const mismatch = payload.stream_id !== undefined && payload.stream_id !== this.stream_id;
const cursor = !mismatch && typeof payload.after_seq === 'number' ? payload.after_seq : undefined;
if (cursor !== undefined && cursor > this.sequence) {
throw new BotStreamError('RESYNC_REQUIRED', 'Log cursor exceeds stream sequence');
}
const available = this.records.filter(record => cursor === undefined || record.seq > cursor);
const candidates = cursor === undefined ? available.slice(-(payload.limit as number)).reverse()
: available.slice(0, payload.limit as number);
const records: BotLogRecord[] = [];
let bytes = 0;
for (const record of candidates) {
const size = Buffer.byteLength(JSON.stringify(record)) + 1;
if (bytes + size > 196_608) break;
records.push(record);
bytes += size;
}
if (cursor === undefined) records.reverse();
return {
stream_id: this.stream_id, seq: this.sequence, records: structuredClone(records),
truncated: mismatch || available.length > records.length
|| (cursor === undefined ? this.dropped_through >= 0 : cursor < this.dropped_through),
};
}
async get_log_history(): Promise<BotLogsResult> {
this.active();
const history = await this.history_reader(20);
this.active();
const records: BotLogRecord[] = [];
let bytes = 0;
for (const entry of history.slice(-20).reverse()) {
const segments = project_segments(entry.log);
if (!segments.length) continue;
const record: BotLogRecord = {id: entry.id, timestamp: entry.timestamp,
context_generation: null, seq: 0, segments};
const size = Buffer.byteLength(JSON.stringify(record)) + 1;
if (bytes + size > 196_608) break;
bytes += size;
records.push(record);
}
// History has its own sequence namespace so it cannot advance live log cursors.
records.reverse();
records.forEach((record, index) => { record.seq = index + 1; });
return {stream_id: this.history_stream_id, seq: records.length, records,
truncated: history.length >= 20 || records.length < history.length};
}
async tab_complete(payload: unknown, deadline_at: number): Promise<{matches: string[]; start: number; length: number}> {
this.active();
if (!exact(payload, ['text']) || typeof payload.text !== 'string' || payload.text.length > 256
|| /[\r\n\u0000]/.test(payload.text)) throw new BotStreamError('INVALID_REQUEST', 'Invalid completion text');
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
throw new BotStreamError('TIMEOUT', 'Completion deadline expired');
const context = this.context;
if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist');
const instance = context.bot_instance;
if (context.bot_status !== 'online' || !instance || typeof instance.tabComplete !== 'function')
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
if (completion_pending.has(instance)) throw new BotStreamError('RATE_LIMITED', 'Completion request is already pending');
completion_pending.add(instance);
// Leave time for Node -> Forward -> Web delivery before the relay deadline.
const timeout = Math.max(1, Math.min(4500, deadline_at - this.now() - 250));
const input = payload.text;
let start = input.search(/\S*$/);
if (start === 0 && input.startsWith('/')) start = 1;
let length = input.length - start;
try {
const client = instance._client;
const modern = (client?.protocolVersion ?? 0) >= 393;
const local = modern ? local_command_completion(instance, input, Object.keys(instance.players ?? {})) : undefined;
const values = await new Promise<unknown[]>((resolve, reject) => {
let received = 0;
let settled = false;
const transactionId = completion_transaction = (completion_transaction + 1) % 2_147_483_647;
const finish = (error?: BotStreamError, values?: unknown[]) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (modern) client!.off('tab_complete', receive);
client?.off('end', ended);
if (error) reject(error); else resolve(values ?? []);
};
const receive = (packet: {transactionId?: number; matches?: unknown[]; start?: number; length?: number}) => {
received++;
if (packet.transactionId !== transactionId) return;
if (!Number.isSafeInteger(packet.start) || !Number.isSafeInteger(packet.length)
|| packet.start! < 0 || packet.length! < 0 || packet.start! + packet.length! > input.length) {
finish(new BotStreamError('INTERNAL_ERROR', 'Invalid completion range'));
return;
}
start = packet.start!;
length = packet.length!;
finish(undefined, packet.matches);
};
const ended = () => finish(new BotStreamError('CANCELLED', 'Bot connection ended'));
const timer = setTimeout(() => {
if (local?.matches.length) { start = local.start; length = local.length; finish(undefined, []); }
else {
try {
context.log?.([{message: `[Tab补全超时] protocol=${client?.protocolVersion ?? 'unknown'} state=${client?.state ?? 'unknown'} transactionId=${transactionId} responses=${received} bundledPackets=${client?._mcBundle?.length ?? 0}`,
font_color: '#d1b478', font_style: ['normal']}]);
} catch { /* Diagnostics cannot prevent request completion. */ }
finish(new BotStreamError('TIMEOUT', 'Completion timed out'));
}
}, local?.matches.length ? Math.min(timeout, 200) : timeout);
client?.on('end', ended);
try {
if (modern) {
client!.on('tab_complete', receive);
client!.write('tab_complete', {transactionId, text: payload.text});
} else {
instance.tabComplete!(payload.text as string, false, false, timeout).then(
values => finish(undefined, values),
() => finish(new BotStreamError('INTERNAL_ERROR', 'Completion failed')));
}
} catch { finish(new BotStreamError('INTERNAL_ERROR', 'Completion failed')); }
});
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Completion deadline expired');
if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance
|| context.bot_status !== 'online') throw new BotStreamError('CANCELLED', 'Bot context changed');
const matches: string[] = [];
if (!Array.isArray(values)) throw new BotStreamError('INTERNAL_ERROR', 'Invalid completion result');
for (const value of values.slice(0, 1000)) {
let match = typeof value === 'string' ? value : own(value, 'match');
if (!modern && start === 1 && input.startsWith('/') && typeof match === 'string' && match.startsWith('/'))
match = match.slice(1);
if (typeof match === 'string' && match.length > 0 && match.length <= 256 && !matches.includes(match))
matches.push(match);
if (matches.length === 100) break;
}
if (local?.matches.length && (!matches.length || start === local.start && length === local.length)) {
if (!matches.length) { start = local.start; length = local.length; }
for (const match of local.matches) {
if (matches.length >= 100) break;
if (!matches.includes(match)) matches.push(match);
}
}
// Legacy protocols only return candidates; replace the last token, preserving the command slash.
return {matches, start, length};
} finally { completion_pending.delete(instance); }
}
async execute_function(payload: {function: string; name: string; args: unknown[]}, deadline_at: number,
check_transport: () => void = () => {}): Promise<boolean> {
this.active();
const context = this.context;
const instance = context?.bot_instance;
const manager = context?.function_manager;
const check = () => {
check_transport();
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Function deadline expired');
if (this.disposed || !context || this.source.get(this.bot_id) !== context || context.bot_instance !== instance)
throw new BotStreamError('CANCELLED', 'Bot context changed');
};
check();
if (!instance || context!.bot_status !== 'online' || !manager) return false;
try {
return await manager.run(payload.function, payload.name, payload.args, check) === true;
} catch (error) {
check();
if (error instanceof BotStreamError) throw error;
return false;
}
}
private workflow_context(deadline_at: number, check_transport: () => void, online = false) {
this.active();
check_transport();
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
throw new BotStreamError('TIMEOUT', 'Workflow request deadline expired');
const context = this.context;
if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist');
const instance = context.bot_instance;
const manager = context.workflow_manager;
if (!manager) throw new BotStreamError('INTERNAL_ERROR', 'Workflow manager is unavailable');
const check = () => {
check_transport();
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Workflow request deadline expired');
if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance
|| context.workflow_manager !== manager) throw new BotStreamError('CANCELLED', 'Bot context changed');
if (online && (context.bot_status !== 'online' || !instance))
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
};
check();
return {manager, check};
}
workflow_start(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): StartResult {
const input = validate_workflow_payload('workflow.start', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport, true);
const trigger = input.trigger === undefined ? undefined : structuredClone(input.trigger);
check();
const result = structuredClone(manager.start(input.workflow_id, trigger));
check();
return result;
}
async workflow_stop(payload: unknown, deadline_at: number,
check_transport: () => void = () => {}): Promise<{stopped: true}> {
const input = validate_workflow_payload('workflow.stop', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
check();
await manager.stop(input.run_id);
check();
return {stopped: true};
}
workflow_snapshot(payload: unknown, deadline_at: number,
check_transport: () => void = () => {}): RunSnapshot | RunSummary[] | (RunSummary & Pick<RunSnapshot, 'trigger'>) | null {
const input = validate_workflow_payload('workflow.snapshot', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
const snapshot = input.run_id === undefined ? null : manager.get_snapshot(input.run_id) ?? null;
const result = structuredClone(input.run_id === undefined ? manager.get_snapshots().map(summarize_run)
: snapshot && input.include_branches === false ? {...summarize_run(snapshot), ...(snapshot.trigger === undefined ? {} : {trigger: snapshot.trigger})} : snapshot);
check();
return result;
}
workflow_catalog(payload: unknown, deadline_at: number,
check_transport: () => void = () => {}): WorkflowCatalog {
this.active();
validate_workflow_payload('workflow.catalog', payload);
const check = () => {
check_transport();
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
throw new BotStreamError('TIMEOUT', 'Workflow request deadline expired');
if (this.disposed) throw new BotStreamError('CANCELLED', 'Bot stream is disposed');
};
check();
const result = structuredClone(get_workflow_catalog());
check();
return result;
}
workflow_runtime(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): WorkflowRuntimeSnapshot {
validate_workflow_payload('workflow.runtime', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
if (!manager.get_runtime_snapshot) throw new BotStreamError('INTERNAL_ERROR', 'Workflow controls unavailable');
const result = structuredClone(manager.get_runtime_snapshot());
check();
return result;
}
workflow_control(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): WorkflowCommand {
const input = validate_workflow_payload('workflow.control', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
if (this.snapshot().context_generation !== input.context_generation) throw new BotStreamError('CANCELLED', 'Bot context changed');
if (!manager.control) throw new BotStreamError('INTERNAL_ERROR', 'Workflow controls unavailable');
const {context_generation, ...request} = structuredClone(input);
check();
try {
const result = structuredClone(manager.control(request));
check();
return result;
} catch (error) {
check();
if (error instanceof BotStreamError) throw error;
const code = (error as {code?: string})?.code;
throw new BotStreamError(code === 'RATE_LIMITED' ? 'RATE_LIMITED' : 'INVALID_REQUEST', 'Workflow control rejected');
}
}
workflow_command(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): WorkflowCommand | null {
const input = validate_workflow_payload('workflow.command', payload);
const {manager, check} = this.workflow_context(deadline_at, check_transport);
if (this.snapshot().context_generation !== input.context_generation) throw new BotStreamError('CANCELLED', 'Bot context changed');
if (!manager.get_command) throw new BotStreamError('INTERNAL_ERROR', 'Workflow controls unavailable');
const result = structuredClone(manager.get_command(input.command_id) ?? null);
check();
return result;
}
chat(payload: unknown, deadline_at: number): {accepted: true} {
this.active();
if (!exact(payload, ['message']) || typeof payload.message !== 'string'
|| !payload.message.trim() || payload.message.length > this.max_chat_length) {
throw new BotStreamError('INVALID_REQUEST', 'Invalid chat request');
}
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now()) {
throw new BotStreamError('TIMEOUT', 'Chat deadline expired');
}
const context = this.context;
if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist');
const instance = context.bot_instance;
if (context.bot_status !== 'online' || !instance || typeof instance.chat !== 'function') {
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
}
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'Chat deadline expired');
if (this.source.get(this.bot_id) !== context || context.bot_instance !== instance || context.bot_status !== 'online') {
throw new BotStreamError('CANCELLED', 'Bot context changed');
}
try { instance.chat(payload.message); } catch {
throw new BotStreamError('INTERNAL_ERROR', 'Bot chat failed');
}
return {accepted: true};
}
subscribe(listener: (event: BotStreamEvent) => void): () => void {
this.active();
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.unobserve?.();
this.unobserve = undefined;
this.detach();
this.context = undefined;
this.generation = null;
this.listeners.clear();
this.records.length = 0;
this.pending_events.length = 0;
}
}
+12
View File
@@ -0,0 +1,12 @@
import {base_define} from "../../../types/bot/plugins/plugin.ts";
export const define: base_define = {
path: "block",
i18n: [
{
lang: "zh-CN",
name: "方块操作",
description: "Bot 方块操作, 提供方块相关功能"
}
]
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {block_arguments, run_block_operation, type CursorArgs} from './shared.ts';
export class init implements BaseFunction<CursorArgs> {
readonly name = 'destroy_cursor_block';
readonly i18n = {lang: 'zh-CN', name: '破坏光标方块', description: '破坏光标指向的方块,默认保留视角,不自动移动或切换工具'};
readonly args = block_arguments('cursor');
async run(bot: Bot, args: CursorArgs): Promise<boolean> {
return run_block_operation(bot, args, 'cursor', 'destroy');
}
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {block_arguments, run_block_operation, type NearestArgs} from './shared.ts';
export class init implements BaseFunction<NearestArgs> {
readonly name = 'destroy_nearest_block';
readonly i18n = {lang: 'zh-CN', name: '破坏最近方块', description: '破坏范围内名称匹配且距离眼睛最近的已加载方块,不自动移动或切换工具'};
readonly args = block_arguments('nearest');
async run(bot: Bot, args: NearestArgs): Promise<boolean> {
return run_block_operation(bot, args, 'nearest', 'destroy');
}
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {block_arguments, run_block_operation, type PositionArgs} from './shared.ts';
export class init implements BaseFunction<PositionArgs> {
readonly name = 'destroy_position_block';
readonly i18n = {lang: 'zh-CN', name: '破坏指定方块', description: '破坏指定坐标及面的方块,操作距离为 4.5,不自动移动或切换工具'};
readonly args = block_arguments('position');
async run(bot: Bot, args: PositionArgs): Promise<boolean> {
return run_block_operation(bot, args, 'position', 'destroy');
}
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {block_arguments, run_block_operation, type GroupArgs} from './shared.ts';
export class init implements BaseFunction<GroupArgs> {
readonly name = 'destroy_position_group_block';
readonly i18n = {lang: 'zh-CN', name: '依次破坏方块', description: '依次破坏坐标列表中的方块,失败停止且不回滚,不自动移动或切换工具'};
readonly args = block_arguments('group');
async run(bot: Bot, args: GroupArgs): Promise<boolean> {
return run_block_operation(bot, args, 'group', 'destroy');
}
}
+12
View File
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {block_arguments, run_block_operation, type CursorArgs} from './shared.ts';
export class init implements BaseFunction<CursorArgs> {
readonly name = 'place_cursor_block';
readonly i18n = {lang: 'zh-CN', name: '向光标方块放置', description: '使用主手物品向光标命中面及交点放置,默认保留视角,不自动移动或装备'};
readonly args = block_arguments('cursor');
async run(bot: Bot, args: CursorArgs): Promise<boolean> {
return run_block_operation(bot, args, 'cursor', 'place');
}
}
+12
View File
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {block_arguments, run_block_operation, type NearestArgs} from './shared.ts';
export class init implements BaseFunction<NearestArgs> {
readonly name = 'place_nearest_block';
readonly i18n = {lang: 'zh-CN', name: '向最近方块放置', description: '使用主手物品向范围内最近的匹配方块指定面放置,不自动移动或装备'};
readonly args = block_arguments('nearest');
async run(bot: Bot, args: NearestArgs): Promise<boolean> {
return run_block_operation(bot, args, 'nearest', 'place');
}
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {block_arguments, run_block_operation, type PositionArgs} from './shared.ts';
export class init implements BaseFunction<PositionArgs> {
readonly name = 'place_position_block';
readonly i18n = {lang: 'zh-CN', name: '向指定方块放置', description: '使用主手物品向指定坐标方块的指定面放置,距离为 4.5,不自动移动或装备'};
readonly args = block_arguments('position');
async run(bot: Bot, args: PositionArgs): Promise<boolean> {
return run_block_operation(bot, args, 'position', 'place');
}
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {block_arguments, run_block_operation, type GroupArgs} from './shared.ts';
export class init implements BaseFunction<GroupArgs> {
readonly name = 'place_position_group_block';
readonly i18n = {lang: 'zh-CN', name: '依次放置方块', description: '使用主手物品按列表放置,每次间隔 100 毫秒,失败停止且不回滚,不自动移动或装备'};
readonly args = block_arguments('group');
async run(bot: Bot, args: GroupArgs): Promise<boolean> {
return run_block_operation(bot, args, 'group', 'place');
}
}
+199
View File
@@ -0,0 +1,199 @@
import type {Bot, RaycastBlock} from '@wp2508/mineflayer';
import type {FunctionArgument} from '../../../types/bot/function/function.ts';
import {on_function_cancel} from '../cancellation.ts';
type Block = NonNullable<ReturnType<Bot['blockAt']>>;
type Vector = Bot['entity']['position'];
export type BlockOperation = 'destroy' | 'place' | 'use';
export type BlockTarget = 'cursor' | 'nearest' | 'position' | 'group';
export type CursorArgs = [reach: number, need_look?: boolean];
export type NearestArgs = [name: string, reach: number, face: number, need_look?: boolean];
export type PositionArgs = [x: number, y: number, z: number, face: number, need_look?: boolean];
export type GroupArgs = [positions: number[], need_look?: boolean];
const digging = new WeakSet<Bot>();
const faces = [[0, -1, 0], [0, 1, 0], [0, 0, -1], [0, 0, 1], [-1, 0, 0], [1, 0, 0]] as const;
export function block_arguments(target: BlockTarget): FunctionArgument[] {
const arg = (name: string, type: FunctionArgument['type'], label: string, description: string): FunctionArgument =>
({name, type, i18n: {lang: 'zh-CN', name: label, description}});
const reach = arg('reach', 'number', '最大距离', '眼睛到方块碰撞箱的最大距离,必须大于 0 且不超过 6');
const face = arg('face', 'number', '操作面', '整数 0 下、1 上、2 北、3 南、4 西、5 东');
const look = {...arg('need_look', 'boolean', '转向操作面', '操作前转向方块操作面的中心,默认不转向'), default: false};
switch (target) {
case 'cursor': return [reach, look];
case 'nearest': return [arg('name', 'string', '方块 ID', '不区分大小写的方块 ID 子串,可带 minecraft: 前缀'), reach, face, look];
case 'position': return [
...['x', 'y', 'z'].map(axis => arg(axis, 'number', `${axis.toUpperCase()} 坐标`, '方块坐标,必须是安全整数;最大操作距离为 4.5')),
face, look,
];
case 'group': return [arg('positions', 'number-array', '坐标与操作面列表', '非空平铺数组,每四项为 x、y、z、face;坐标为安全整数,面为 0 到 5,最大距离 4.5;按顺序操作,失败停止且不回滚'), look];
}
}
function valid_face(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 5;
}
function valid_position(values: unknown[]): boolean {
return values.length === 4 && [values[0], values[1], values[2]].every(value => typeof value === 'number' && Number.isSafeInteger(value))
&& valid_face(values[3]);
}
function valid_block(block: Block | null | undefined): block is Block {
return !!block && !!block.position && typeof block.name === 'string'
&& !['air', 'cave_air', 'void_air'].includes(block.name.toLowerCase().replace(/^minecraft:/, ''))
&& [block.position.x, block.position.y, block.position.z].every(Number.isSafeInteger);
}
function eye(bot: Bot): Vector | undefined {
const self = bot.entity;
const height = (self as Bot['entity'] & {eyeHeight?: number})?.eyeHeight ?? self?.height;
if (!self?.position || ![self.position.x, self.position.y, self.position.z, height].every(Number.isFinite)) return;
return self.position.offset(0, height, 0);
}
function distance(point: Vector, block: Block): number {
const pos = block.position;
return Math.hypot(Math.max(pos.x - point.x, point.x - pos.x - 1, 0),
Math.max(pos.y - point.y, point.y - pos.y - 1, 0), Math.max(pos.z - point.z, point.z - pos.z - 1, 0));
}
export async function run_block_operation(bot: Bot, args: unknown[], target: BlockTarget, operation: BlockOperation): Promise<boolean> {
if (!Array.isArray(args)) return false;
const length = target === 'position' ? 4 : target === 'nearest' ? 3 : 1;
if (args.length !== length && args.length !== length + 1) return false;
if (args.length > length && typeof args[length] !== 'boolean') return false;
const need_look = args[length] === true;
args = args.slice(0, length);
let positions: number[][] = [];
let reach = 4.5;
let query = '';
if (target === 'cursor' || target === 'nearest') {
if (args.length !== (target === 'cursor' ? 1 : 3)) return false;
const value = args[target === 'cursor' ? 0 : 1];
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 6) return false;
reach = value;
if (target === 'nearest') {
if (typeof args[0] !== 'string' || !valid_face(args[2])) return false;
query = args[0].trim().toLowerCase().replace(/^minecraft:/, '');
if (!query) return false;
}
} else if (target === 'position') {
if (!valid_position(args)) return false;
positions = [args.slice() as number[]];
} else {
if (args.length !== 1 || !Array.isArray(args[0]) || !args[0].length || args[0].length % 4 !== 0) return false;
for (let i = 0; i < args[0].length; i += 4) {
const position = args[0].slice(i, i + 4);
if (!valid_position(position)) return false;
positions.push(position);
}
}
const self = bot.entity;
const client = bot._client;
let ended = false;
let owned_dig: Block | undefined;
let cancel!: () => void;
const cancelled = new Promise<false>(resolve => { cancel = () => resolve(false); });
const active = () => !ended && !!self && !!client && bot.entity === self && bot._client === client
&& self.isValid !== false && !(client as typeof client & {ended?: boolean}).ended;
const stop_owned = () => {
if (owned_dig && bot.targetDigBlock === owned_dig) {
try { bot.stopDigging(); } catch { /* The connection may already be closed. */ }
}
};
const on_end = () => { ended = true; stop_owned(); cancel(); };
const unwatch = on_function_cancel(bot, on_end);
const wait = async (promise: Promise<unknown>): Promise<boolean> => {
const result = await Promise.race([promise.then(() => true, () => false), cancelled]);
return result && active();
};
try {
bot.on('end', on_end);
if (!active() || !eye(bot) || operation === 'place' && !bot.heldItem) return false;
let selected: Block | undefined;
let cursor: RaycastBlock | undefined;
if (target === 'cursor') {
cursor = bot.blockAtCursor(reach) ?? undefined;
if (!valid_block(cursor) || !valid_face(cursor.face) || !cursor.intersect
|| ![cursor.intersect.x, cursor.intersect.y, cursor.intersect.z].every(Number.isFinite)) return false;
selected = cursor;
} else if (target === 'nearest') {
// findBlocks sorts block origins around a floored point. Include every
// candidate before applying our eye-to-AABB distance ordering.
const radius = Math.ceil(reach + 2 * Math.sqrt(3));
const matching = (block: Block | null) => !!block && typeof block.name === 'string'
&& block.name.toLowerCase().replace(/^minecraft:/, '').includes(query);
selected = bot.findBlocks({point: eye(bot)!, matching, maxDistance: radius, count: (2 * radius + 1) ** 3})
.map(position => bot.blockAt(position)).filter(valid_block).filter(matching)
.map(block => ({block: block!, distance: distance(eye(bot)!, block!)}))
.filter(entry => entry.distance <= reach).sort((a, b) => a.distance - b.distance)[0]?.block;
if (!selected) return false;
}
const total = target === 'group' ? positions.length : 1;
for (let i = 0; i < total; i++) {
if (!active()) return false;
const position = positions[i];
const block = selected ?? bot.blockAt(self.position.scaled(0).offset(position![0]!, position![1]!, position![2]!));
const point = eye(bot);
if (!valid_block(block) || !point || distance(point, block) > reach) return false;
const loaded = bot.blockAt(block.position);
if (!valid_block(loaded) || loaded.type !== block.type) return false;
const face = cursor?.face ?? (target === 'nearest' ? args[2] as number : position![3]!);
const components = faces[face]!;
const vector = self.position.scaled(0).offset(components[0], components[1], components[2]);
const delta = cursor?.intersect.minus(block.position);
const {type, stateId} = block;
const valid = () => {
const point = eye(bot);
const loaded = bot.blockAt(block.position);
return active() && !!point && distance(point, block) <= reach && valid_block(loaded)
&& loaded.type === type && loaded.stateId === stateId
&& (operation !== 'place' || !!bot.heldItem)
&& (operation !== 'destroy' || bot.canDigBlock(block));
};
if (operation === 'destroy') {
if (digging.has(bot) || bot.targetDigBlock || !bot.canDigBlock(block)) return false;
digging.add(bot);
owned_dig = block;
}
try {
if (need_look && !await wait(bot.lookAt(block.position.offset(0.5, 0.5, 0.5).plus(vector.scaled(0.5))))) return false;
if (!valid()) return false;
if (operation === 'destroy') {
if (bot.targetDigBlock) return false;
try {
const promise = bot.dig(block, 'ignore', vector);
if (!await wait(promise)) { stop_owned(); return false; }
} catch {
stop_owned();
return false;
}
} else if (operation === 'place') {
if (!await wait(bot._placeBlockWithOptions(block, vector, {swingArm: 'right', forceLook: 'ignore', delta}))) return false;
} else if (!await wait(bot.activateBlock(block, vector, delta, 'ignore'))) return false;
} finally {
if (operation === 'destroy') {
owned_dig = undefined;
digging.delete(bot);
}
}
if (!active()) return false;
if (operation === 'place' && target === 'group' && i + 1 < total) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
if (!await wait(new Promise<void>(resolve => { timer = setTimeout(resolve, 100); }))) return false;
} finally { clearTimeout(timer); }
}
}
return active();
} catch {
stop_owned();
return false;
} finally {
unwatch();
bot.removeListener('end', on_end);
}
}
+12
View File
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {block_arguments, run_block_operation, type CursorArgs} from './shared.ts';
export class init implements BaseFunction<CursorArgs> {
readonly name = 'use_cursor_block';
readonly i18n = {lang: 'zh-CN', name: '使用光标方块', description: '右键使用光标命中方块的面与交点,默认保留视角,不自动移动或装备'};
readonly args = block_arguments('cursor');
async run(bot: Bot, args: CursorArgs): Promise<boolean> {
return run_block_operation(bot, args, 'cursor', 'use');
}
}
+12
View File
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {block_arguments, run_block_operation, type NearestArgs} from './shared.ts';
export class init implements BaseFunction<NearestArgs> {
readonly name = 'use_nearest_block';
readonly i18n = {lang: 'zh-CN', name: '使用最近方块', description: '右键使用范围内最近的匹配方块指定面,不自动移动或装备'};
readonly args = block_arguments('nearest');
async run(bot: Bot, args: NearestArgs): Promise<boolean> {
return run_block_operation(bot, args, 'nearest', 'use');
}
}
+12
View File
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {block_arguments, run_block_operation, type PositionArgs} from './shared.ts';
export class init implements BaseFunction<PositionArgs> {
readonly name = 'use_position_block';
readonly i18n = {lang: 'zh-CN', name: '使用指定方块', description: '右键使用指定坐标方块的指定面,距离为 4.5,不自动移动或装备'};
readonly args = block_arguments('position');
async run(bot: Bot, args: PositionArgs): Promise<boolean> {
return run_block_operation(bot, args, 'position', 'use');
}
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {block_arguments, run_block_operation, type GroupArgs} from './shared.ts';
export class init implements BaseFunction<GroupArgs> {
readonly name = 'use_position_group_block';
readonly i18n = {lang: 'zh-CN', name: '依次使用方块', description: '按坐标列表依次右键使用方块,失败停止且不回滚,不自动移动或装备'};
readonly args = block_arguments('group');
async run(bot: Bot, args: GroupArgs): Promise<boolean> {
return run_block_operation(bot, args, 'group', 'use');
}
}
+17
View File
@@ -0,0 +1,17 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {argument, guarded} from './shared.ts';
export class init implements BaseFunction<[message: string]> {
readonly name = 'chat';
readonly i18n = {lang: 'zh-CN', name: '发送聊天', description: '原样发送聊天消息,成功仅表示本地调用完成,不等待服务器确认'};
readonly args = [argument('message', 'string', '消息', '非空白消息,最多 256 个字符,不允许换行或空字符')];
async run(bot: Bot, args: [string]): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 1 || typeof args[0] !== 'string'
|| !args[0].trim() || args[0].length > 256 || /[\r\n\0]/.test(args[0])) return false;
return guarded(bot, async session => {
bot.chat(args[0]);
return session.active();
});
}
}
+12
View File
@@ -0,0 +1,12 @@
import {base_define} from "../../../types/bot/plugins/plugin.ts";
export const define: base_define = {
path: "bot",
i18n: [
{
lang: "zh-CN",
name: "BOT操作",
description: "Bot 自身操作, 提供自身相关功能"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {pulse} from './shared.ts';
export class init implements BaseFunction<[]> {
readonly name = 'jump';
readonly i18n = {lang: 'zh-CN', name: '跳跃一次', description: '在地面且未按住跳跃时,按住跳跃直到下一物理刻,最多等待 1 秒'};
readonly args: [] = [];
async run(bot: Bot, args: []): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 0 || !bot.entity?.onGround) return false;
return pulse(bot, 'jump');
}
}
+21
View File
@@ -0,0 +1,21 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {argument, guarded} from './shared.ts';
export class init implements BaseFunction<[x: number, y: number, z: number]> {
readonly name = 'look_at';
readonly i18n = {lang: 'zh-CN', name: '看向坐标', description: '看向指定有限坐标,允许小数,最多等待 5 秒,目标不能与眼睛重合'};
readonly args = ['x', 'y', 'z'].map(axis => argument(axis, 'number', `${axis} 坐标`, '有限数值,允许小数'));
async run(bot: Bot, args: [number, number, number]): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 3 || !args.every(value => typeof value === 'number' && Number.isFinite(value))) return false;
return guarded(bot, async session => {
const self = bot.entity;
const eye_height = (self as typeof self & {eyeHeight?: number}).eyeHeight;
if (!self.position || ![self.position.x, self.position.y, self.position.z, eye_height].every(Number.isFinite)) return false;
const target = self.position.offset(0, 0, 0);
[target.x, target.y, target.z] = args;
if (target.x === self.position.x && target.y === self.position.y + eye_height! && target.z === self.position.z) return false;
return await session.wait(() => bot.lookAt(target), 5000, 'promise') && session.active();
});
}
}
+103
View File
@@ -0,0 +1,103 @@
import type {Bot, ControlState} from '@wp2508/mineflayer';
import type {FunctionArgument} from '../../../types/bot/function/function.ts';
import {on_function_cancel} from '../cancellation.ts';
export const argument = (name: string, type: FunctionArgument['type'], label: string, description: string): FunctionArgument =>
({name, type, i18n: {lang: 'zh-CN', name: label, description}});
const owners = new WeakMap<Bot, Map<ControlState, symbol>>();
function controls(bot: Bot): Map<ControlState, symbol> {
let keys = owners.get(bot);
if (!keys) owners.set(bot, keys = new Map());
return keys;
}
interface Session {
active(): boolean;
wait(start: () => unknown, duration: number, mode: 'physicsTick' | 'delay' | 'promise'): Promise<boolean>;
}
export async function guarded(bot: Bot, operation: (session: Session) => Promise<boolean>): Promise<boolean> {
const self = bot.entity;
const client = bot._client;
let ended = false;
let cancel: (() => void) | undefined;
const on_end = () => { ended = true; cancel?.(); };
const active = () => !ended && !!self && !!client && self.isValid !== false
&& bot.entity === self && bot._client === client && client.state === 'play';
const unwatch = on_function_cancel(bot, on_end);
try {
if (!active()) return false;
bot.on('end', on_end);
bot.on('error', on_end);
client.on('end', on_end);
client.on('error', on_end);
return await operation({active, wait(start, duration, mode) {
return new Promise<boolean>(resolve => {
let settled = false;
let started = false;
let tick_seen = false;
const finish = (success: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
bot.removeListener('physicsTick', on_tick);
cancel = undefined;
resolve(success && active());
};
const on_tick = () => {
tick_seen = true;
if (started) finish(true);
};
const timer = setTimeout(() => finish(mode === 'delay'), duration);
cancel = () => finish(false);
if (mode === 'physicsTick') bot.on('physicsTick', on_tick);
try {
if (!active()) return finish(false);
const result = start();
started = true;
Promise.resolve(result).then(() => {
if (mode === 'promise') finish(true);
}, () => finish(false));
if (mode === 'physicsTick' && tick_seen) finish(true);
} catch {
finish(false);
}
});
}});
} catch {
return false;
} finally {
unwatch();
cancel?.();
bot.removeListener('end', on_end);
bot.removeListener('error', on_end);
client?.removeListener('end', on_end);
client?.removeListener('error', on_end);
}
}
export async function pulse(bot: Bot, key: ControlState, duration?: number): Promise<boolean> {
return guarded(bot, async session => {
if (bot.getControlState(key)) return false;
const keys = controls(bot);
const owner = Symbol(key);
keys.set(key, owner);
try {
const success = await session.wait(() => bot.setControlState(key, true), duration ?? 1000,
duration === undefined ? 'physicsTick' : 'delay');
return success && session.active();
} finally {
// A persistent command may have taken ownership while this pulse was waiting.
if (keys.get(key) === owner) {
keys.delete(key);
bot.setControlState(key, false);
}
}
});
}
export function set_sneak(bot: Bot, enabled: boolean): void {
controls(bot).delete('sneak');
bot.setControlState('sneak', enabled);
}
+16
View File
@@ -0,0 +1,16 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {argument, guarded, set_sneak} from './shared.ts';
export class init implements BaseFunction<[enabled: boolean]> {
readonly name = 'squat_continue_switch';
readonly i18n = {lang: 'zh-CN', name: '设置持续下蹲', description: '显式设置持续下蹲状态,重复设置相同状态不会切换'};
readonly args = [argument('enabled', 'boolean', '启用下蹲', 'true 持续下蹲,false 取消下蹲')];
async run(bot: Bot, args: [boolean]): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 1 || typeof args[0] !== 'boolean') return false;
return guarded(bot, async session => {
set_sneak(bot, args[0]);
return session.active();
});
}
}
+13
View File
@@ -0,0 +1,13 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {pulse} from './shared.ts';
export class init implements BaseFunction<[]> {
readonly name = 'squat_once';
readonly i18n = {lang: 'zh-CN', name: '下蹲一次', description: '未按住下蹲时,按住下蹲直到下一物理刻,最多等待 1 秒'};
readonly args: [] = [];
async run(bot: Bot, args: []): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 0) return false;
return pulse(bot, 'sneak');
}
}
+19
View File
@@ -0,0 +1,19 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {cancel_functions} from '../cancellation.ts';
export class init implements BaseFunction<[]> {
readonly name = 'stop_current_function';
readonly i18n = {lang: 'zh-CN', name: '停止当前函数', description: '立即取消当前和排队函数,停止挖掘、使用物品并释放所有控制键'};
readonly args: [] = [];
async run(bot: Bot, args: []): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 0) return false;
let success = true;
for (const stop of [() => cancel_functions(bot), () => bot.stopDigging(),
() => bot.deactivateItem(), () => bot.clearControlStates()]) {
try { stop(); } catch { success = false; }
}
return success;
}
}
+16
View File
@@ -0,0 +1,16 @@
import type {Bot, ControlState} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {argument, pulse} from './shared.ts';
export class init implements BaseFunction<[direction: string, duration_ms: number]> {
readonly name = 'use_direction_key';
readonly i18n = {lang: 'zh-CN', name: '按住方向键', description: '按住指定方向键一段时间后释放,拒绝操作已按住的方向键'};
readonly args = [argument('direction', 'string', '方向', '仅允许 forward、back、left、right'),
argument('duration_ms', 'number', '持续毫秒数', '1 到 5000 的安全整数')];
async run(bot: Bot, args: [string, number]): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 2 || typeof args[0] !== 'string'
|| !['forward', 'back', 'left', 'right'].includes(args[0])
|| !Number.isSafeInteger(args[1]) || args[1] < 1 || args[1] > 5000) return false;
return pulse(bot, args[0] as ControlState, args[1]);
}
}
+52
View File
@@ -0,0 +1,52 @@
import {EventEmitter} from 'node:events';
import {AsyncLocalStorage} from 'node:async_hooks';
import type {Bot} from '@wp2508/mineflayer';
type Cancellation = {generation: number; events: EventEmitter};
const scopes = new AsyncLocalStorage<{bot: Bot; cancellation: Cancellation}>();
const states = new WeakMap<Bot, Cancellation & {scopes: Set<Cancellation>}>();
function state(bot: Bot) {
let value = states.get(bot);
if (!value) states.set(bot, value = {generation: 0, events: new EventEmitter(), scopes: new Set()});
return value;
}
function current(bot: Bot): Cancellation {
const scope = scopes.getStore();
return scope?.bot === bot ? scope.cancellation : state(bot);
}
export function cancellation_generation(bot: Bot): number {
return current(bot).generation;
}
export function on_function_cancel(bot: Bot, listener: () => void): () => void {
const {events} = current(bot);
events.on('cancel', listener);
return () => { events.removeListener('cancel', listener); };
}
/** Existing helpers inherit the call's cancellation scope across awaits. */
export function create_function_cancellation(bot: Bot) {
const value = state(bot);
const cancellation: Cancellation = {generation: 0, events: new EventEmitter()};
value.scopes.add(cancellation);
return {
run<T>(operation: () => T): T { return scopes.run({bot, cancellation}, operation); },
cancel() {
cancellation.generation++;
cancellation.events.emit('cancel');
},
dispose() { value.scopes.delete(cancellation); },
};
}
export function cancel_functions(bot: Bot): void {
const value = state(bot);
value.generation++;
value.events.emit('cancel');
for (const cancellation of value.scopes) {
cancellation.generation++;
cancellation.events.emit('cancel');
}
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {entity_arguments, run_entity_operation, type CursorArgs} from './shared.ts';
export class init implements BaseFunction<CursorArgs> {
readonly name = 'attack_cursor_entity';
readonly i18n = {lang: 'zh-CN', name: '攻击光标实体', description: '攻击光标指向且在指定距离内的实体,不自动移动'};
readonly args = entity_arguments(false, false, true);
async run(bot: Bot, args: CursorArgs): Promise<boolean> {
return run_entity_operation(bot, args, false, 'attack');
}
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {entity_arguments, run_entity_operation, type NearestArgs} from './shared.ts';
export class init implements BaseFunction<NearestArgs> {
readonly name = 'attack_nearest_entity';
readonly i18n = {lang: 'zh-CN', name: '攻击最近实体', description: '攻击指定距离内名称匹配的最近实体,可选瞄准,不自动移动'};
readonly args = entity_arguments(true, false, true);
async run(bot: Bot, args: NearestArgs): Promise<boolean> {
return run_entity_operation(bot, args, true, 'attack', this.name);
}
}
+12
View File
@@ -0,0 +1,12 @@
import {base_define} from "../../../types/bot/plugins/plugin.ts";
export const define: base_define = {
path: "entity",
i18n: [
{
lang: "zh-CN",
name: "实体操作",
description: "Bot 实体操作, 提供实体相关功能"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {entity_arguments, run_entity_operation, type CursorItemArgs} from './shared.ts';
export class init implements BaseFunction<CursorItemArgs> {
readonly name = 'feed_cursor_entity';
readonly i18n = {lang: 'zh-CN', name: '喂食光标实体', description: '对光标实体使用指定手的物品,可选瞄准,由服务器判断是否可以喂食'};
readonly args = entity_arguments(false, true);
async run(bot: Bot, args: CursorItemArgs): Promise<boolean> {
return run_entity_operation(bot, args, false, 'item');
}
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {entity_arguments, run_entity_operation, type NearestItemArgs} from './shared.ts';
export class init implements BaseFunction<NearestItemArgs> {
readonly name = 'feed_nearest_entity';
readonly i18n = {lang: 'zh-CN', name: '喂食最近实体', description: '对指定距离内名称匹配的最近实体使用指定手的物品,由服务器判断是否可以喂食'};
readonly args = entity_arguments(true, true);
async run(bot: Bot, args: NearestItemArgs): Promise<boolean> {
return run_entity_operation(bot, args, true, 'item', this.name);
}
}
@@ -0,0 +1,13 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {entity_arguments, run_entity_operation, type CursorArgs} from './shared.ts';
export class init implements BaseFunction<CursorArgs> {
readonly name = 'null_main_hand_right_cursor_entity';
readonly i18n = {lang: 'zh-CN', name: '空主手右键光标实体', description: '主手为空时右键交互光标实体,可选瞄准,主手有物品则失败'};
readonly args = entity_arguments(false, false);
async run(bot: Bot, args: CursorArgs): Promise<boolean> {
return run_entity_operation(bot, args, false, 'empty_hand');
}
}
@@ -0,0 +1,13 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {entity_arguments, run_entity_operation, type NearestArgs} from './shared.ts';
export class init implements BaseFunction<NearestArgs> {
readonly name = 'null_main_hand_right_nearest_entity';
readonly i18n = {lang: 'zh-CN', name: '空主手右键最近实体', description: '主手为空时右键交互指定距离内名称匹配的最近实体,可选瞄准,主手有物品则失败'};
readonly args = entity_arguments(true, false);
async run(bot: Bot, args: NearestArgs): Promise<boolean> {
return run_entity_operation(bot, args, true, 'empty_hand', this.name);
}
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {entity_arguments, run_entity_operation, type CursorArgs} from './shared.ts';
export class init implements BaseFunction<CursorArgs> {
readonly name = 'open_cursor_entity_container';
readonly i18n = {lang: 'zh-CN', name: '打开光标实体容器', description: '激活光标实体并等待新容器窗口,已有窗口时失败,最多等待五秒'};
readonly args = entity_arguments(false, false);
async run(bot: Bot, args: CursorArgs): Promise<boolean> {
return run_entity_operation(bot, args, false, 'open');
}
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {entity_arguments, run_entity_operation, type NearestArgs} from './shared.ts';
export class init implements BaseFunction<NearestArgs> {
readonly name = 'open_nearest_entity_container';
readonly i18n = {lang: 'zh-CN', name: '打开最近实体容器', description: '激活指定距离内名称匹配的最近实体并等待新窗口,已有窗口时失败,最多等待五秒'};
readonly args = entity_arguments(true, false);
async run(bot: Bot, args: NearestArgs): Promise<boolean> {
return run_entity_operation(bot, args, true, 'open', this.name);
}
}
+165
View File
@@ -0,0 +1,165 @@
import type {Bot} from '@wp2508/mineflayer';
import type {FunctionArgument} from '../../../types/bot/function/function.ts';
import {on_function_cancel} from '../cancellation.ts';
type Entity = NonNullable<ReturnType<Bot['entityAtCursor']>>;
export type EntityOperation = 'attack' | 'item' | 'open' | 'empty_hand';
export type CursorArgs = [reach: number, ignore_blocks: boolean, need_look?: boolean];
export type NearestArgs = [name: string, reach: number, lock_target: boolean, need_look?: boolean];
export type CursorItemArgs = [reach: number, ignore_blocks: boolean, off_hand: boolean, need_look?: boolean];
export type NearestItemArgs = [name: string, reach: number, off_hand: boolean, lock_target: boolean, need_look?: boolean];
const target_locks = new WeakMap<Bot, {
self: Bot['entity']; client: Bot['_client']; targets: Map<string, Entity>;
}>();
export function entity_arguments(nearest: boolean, item: boolean, attack = false): FunctionArgument[] {
const arg = (name: string, type: FunctionArgument['type'], label: string, description: string): FunctionArgument =>
({name, type, i18n: {lang: 'zh-CN', name: label, description}});
const reach = arg(nearest && !attack ? 'max_distance' : 'reach', 'number', '最大距离', '眼睛到实体碰撞箱的最大距离,必须为有限正数');
const args = nearest
? [arg('name', 'string', '实体类型 ID 或用户名', '不区分大小写的子串匹配,如 zom 或 minecraft:zom;也支持玩家用户名'), reach]
: [reach, arg('ignore_blocks', 'boolean', '忽略方块遮挡', '是否忽略光标射线上的方块遮挡')];
if (item) args.push(arg('off_hand', 'boolean', '使用副手', 'false 使用主手,true 使用副手,所选手必须持有物品'));
if (nearest) args.push(arg('lock_target', 'boolean', '持续锁定实体', '相同函数和参数持续操作首次选中的实体,直到实体消失;超距时保留锁定但不操作'));
args.push({...arg('need_look', 'boolean', '瞄准实体', '是否先瞄准实体中心,默认 false'), default: false});
return args;
}
function valid_entity(bot: Bot, target: Entity): boolean {
return !!target && target !== bot.entity && target.id !== bot.entity?.id
&& !(bot.username && target.username === bot.username)
&& target.isValid !== false && bot.entities?.[target.id] === target
&& Number.isFinite(target.id) && !!target.position
&& [target.position.x, target.position.y, target.position.z, target.width, target.height].every(Number.isFinite)
&& target.width > 0 && target.height > 0;
}
function distance(bot: Bot, target: Entity): number {
const self = bot.entity;
const eye_height = (self as Entity & {eyeHeight?: number})?.eyeHeight ?? self?.height;
if (!self?.position || ![self.position.x, self.position.y, self.position.z, eye_height].every(Number.isFinite)) return Infinity;
const half = target.width / 2;
const dx = Math.max(Math.abs(self.position.x - target.position.x) - half, 0);
const dy = Math.max(target.position.y - (self.position.y + eye_height), self.position.y + eye_height - (target.position.y + target.height), 0);
const dz = Math.max(Math.abs(self.position.z - target.position.z) - half, 0);
return Math.hypot(dx, dy, dz);
}
function has_item(bot: Bot, off_hand: boolean): boolean {
return off_hand ? !bot.supportFeature('doesntHaveOffHandSlot') && !!bot.inventory?.slots[45] : !!bot.heldItem;
}
function open_container(bot: Bot, target: Entity, active: () => boolean): Promise<boolean> {
if (bot.currentWindow || !active()) return Promise.resolve(false);
return new Promise(resolve => {
let settled = false;
const finish = (success: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
bot.removeListener('windowOpen', on_open);
bot.removeListener('end', on_end);
unwatch();
resolve(success);
};
const on_open = (window: Bot['currentWindow']) => {
finish(!!window && active() && bot.currentWindow === window);
};
const on_end = () => finish(false);
const timer = setTimeout(() => finish(false), 5000);
const unwatch = on_function_cancel(bot, on_end);
bot.on('windowOpen', on_open);
bot.on('end', on_end);
try {
Promise.resolve(bot.interactEntity(target, {hand: 0})).catch(() => finish(false));
} catch {
finish(false);
}
});
}
export async function run_entity_operation(
bot: Bot, args: unknown[], nearest: boolean, operation: EntityOperation, function_name: string = operation,
): Promise<boolean> {
const item = operation === 'item';
const required_count = (item ? 3 : 2) + (nearest ? 1 : 0);
if (!Array.isArray(args) || args.length !== required_count && args.length !== required_count + 1) return false;
if (args.length > required_count && typeof args[required_count] !== 'boolean') return false;
const need_look = args[required_count] === true;
args = args.slice(0, required_count);
const reach = args[nearest ? 1 : 0];
if (typeof reach !== 'number' || !Number.isFinite(reach) || reach <= 0) return false;
if (nearest ? typeof args[0] !== 'string' || !args[0].trim() : typeof args[1] !== 'boolean') return false;
if (item && typeof args[2] !== 'boolean') return false;
if (nearest && typeof args.at(-1) !== 'boolean') return false;
const self = bot.entity;
const client = bot._client;
let ended = false;
let cancel!: () => void;
const cancelled = new Promise<false>(resolve => { cancel = () => resolve(false); });
const on_end = () => { ended = true; cancel(); };
const unwatch = on_function_cancel(bot, on_end);
const active = () => !ended && !!self && bot.entity === self && bot._client === client && self.isValid !== false;
try {
bot.on('end', on_end);
if (operation === 'empty_hand' && bot.heldItem) return false;
if (!active() || operation === 'open' && bot.currentWindow || item && !has_item(bot, args[2] as boolean)) return false;
let target: Entity | undefined;
if (nearest) {
let locks = target_locks.get(bot);
if (!locks || locks.self !== self || locks.client !== client) {
locks = {self, client, targets: new Map()};
target_locks.set(bot, locks);
}
for (const [key, entity] of locks.targets) {
if (!valid_entity(bot, entity)) locks.targets.delete(key);
}
const lock_target = args.at(-1) === true;
const key = JSON.stringify([function_name, [...args, need_look]]);
target = lock_target ? locks.targets.get(key) : undefined;
if (!target) {
const name = (args[0] as string).trim().toLowerCase();
const entity_name = name.replace(/^minecraft:/, '');
target = Object.values(bot.entities).filter(entity => valid_entity(bot, entity)
&& (entity_name.length > 0 && typeof entity.name === 'string'
&& entity.name.toLowerCase().replace(/^minecraft:/, '').includes(entity_name)
|| typeof entity.username === 'string' && entity.username.toLowerCase().includes(name)))
.map(entity => ({entity, distance: distance(bot, entity)}))
.filter(entry => Number.isFinite(entry.distance) && entry.distance <= reach)
.sort((a, b) => a.distance - b.distance)[0]?.entity;
if (lock_target && target) locks.targets.set(key, target);
}
} else {
target = bot.entityAtCursor(reach, true, args[1] as boolean) ?? undefined;
}
const in_reach = () => active() && valid_entity(bot, target!) && distance(bot, target!) <= reach;
if (!target || !in_reach()) return false;
if (need_look) {
if (!await Promise.race([
bot.lookAt(target.position.offset(0, target.height / 2, 0)).then(() => true, () => false), cancelled,
])) return false;
if (!in_reach()) return false;
}
if (operation === 'attack') {
bot.attack(target);
return true;
}
if (operation === 'empty_hand') {
if (bot.heldItem) return false;
bot.interactEntity(target, {hand: 0});
return true;
}
if (item) {
if (!has_item(bot, args[2] as boolean)) return false;
bot.interactEntity(target, {hand: args[2] ? 1 : 0, position: target.position.offset(0, target.height / 2, 0)});
return true;
}
return await open_container(bot, target, active);
} catch {
return false;
} finally {
unwatch();
bot.removeListener('end', on_end);
}
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {entity_arguments, run_entity_operation, type CursorItemArgs} from './shared.ts';
export class init implements BaseFunction<CursorItemArgs> {
readonly name = 'use_item_on_cursor_entity';
readonly i18n = {lang: 'zh-CN', name: '对光标实体使用物品', description: '对光标实体使用指定手的物品,可选瞄准,不自动移动'};
readonly args = entity_arguments(false, true);
async run(bot: Bot, args: CursorItemArgs): Promise<boolean> {
return run_entity_operation(bot, args, false, 'item');
}
}
@@ -0,0 +1,12 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction} from '../../../types/bot/function/function.ts';
import {entity_arguments, run_entity_operation, type NearestItemArgs} from './shared.ts';
export class init implements BaseFunction<NearestItemArgs> {
readonly name = 'use_item_on_nearest_entity';
readonly i18n = {lang: 'zh-CN', name: '对最近实体使用物品', description: '对指定距离内名称匹配的最近实体使用指定手的物品,可选瞄准'};
readonly args = entity_arguments(true, true);
async run(bot: Bot, args: NearestItemArgs): Promise<boolean> {
return run_entity_operation(bot, args, true, 'item', this.name);
}
}
+284
View File
@@ -0,0 +1,284 @@
import {readdir} from 'node:fs/promises';
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction, FunctionArgument, FunctionI18n} from '../../types/bot/function/function.ts';
import type {data_type} from '../../types/bot/instance/bot_runtime.ts';
import type {bot_instance_context} from '../instance/bot_instance_init.ts';
import {create_function_cancellation, on_function_cancel} from './cancellation.ts';
import {measure_sync} from '../info/resource_metrics.ts';
export interface FunctionTemplate {
name: string;
i18n: FunctionI18n;
args: FunctionArgument[];
}
export interface FunctionCategory {
path: string;
name: string;
i18n: FunctionI18n[];
function_list: FunctionTemplate[];
}
export type FunctionList = FunctionCategory[];
export interface FunctionManager {
get_function_list(): FunctionList;
run(path: string, name: string, args: unknown[], before_run?: () => void,
options?: {signal?: AbortSignal}): Promise<boolean>;
/** Cancels queued calls and notifies the running call, which drains before rejecting. */
dispose(): void;
}
const data_types = new Set<data_type>([
'string', 'boolean', 'number', 'datetime', 'string-select', 'number-select',
'string-multi-select', 'number-multi-select', 'string-array', 'number-array',
]);
const valid_name = (value: unknown): value is string => typeof value === 'string' && /^[a-zA-Z0-9_-]+$/.test(value);
const valid_i18n = (value: unknown): value is FunctionI18n => {
if (!value || typeof value !== 'object') return false;
const item = value as FunctionI18n;
return typeof item.lang === 'string' && item.lang.length > 0
&& typeof item.name === 'string' && typeof item.description === 'string';
};
async function load_functions(check_active: () => void = () => {}): Promise<{
list: FunctionList;
functions: Map<string, BaseFunction>;
}> {
const list: FunctionList = [];
const functions = new Map<string, BaseFunction>();
const directories = [''];
const stamp = `${Date.now()}-${process.hrtime.bigint()}`;
const import_module = async (url: URL): Promise<Record<string, unknown>> => {
check_active();
url.searchParams.set('t', stamp);
const module = await import(url.href);
check_active();
return module;
};
for (const path of directories) {
check_active();
const directory = new URL(path ? `./${path}/` : './', import.meta.url);
const entries = await readdir(directory, {withFileTypes: true});
check_active();
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (entry.isDirectory() && valid_name(entry.name)) directories.push(path ? `${path}/${entry.name}` : entry.name);
}
if (!path) continue;
const files = entries.filter(entry => entry.isFile() && entry.name !== 'define.ts'
&& /^[a-zA-Z0-9_-]+\.ts$/.test(entry.name));
const has_define = entries.some(entry => entry.isFile() && entry.name === 'define.ts');
if (!files.length && !has_define) continue;
if (!has_define) throw new Error(`函数分类 ${path} 缺少 define.ts`);
try {
const {define} = await import_module(new URL('define.ts', directory));
const config = define as {path?: unknown; name?: unknown; i18n?: unknown} | undefined;
if (config?.path !== path || !Array.isArray(config.i18n) || !config.i18n.length
|| !config.i18n.every(valid_i18n) || config.name !== undefined && !valid_name(config.name)) {
throw new Error('分类 define 无效,path 必须与目录路径一致');
}
const category: FunctionCategory = {
path, name: typeof config.name === 'string' ? config.name : path.split('/').at(-1)!,
i18n: config.i18n.map(item => ({...item})), function_list: [],
};
for (const file of files) {
const name = file.name.slice(0, -3);
try {
const module = await import_module(new URL(file.name, directory));
if (!Object.hasOwn(module, 'init')) continue;
if (typeof module.init !== 'function') throw new Error('必须导出 init 类');
const instance = new (module.init as new () => BaseFunction)();
if (instance.name !== name || !valid_i18n(instance.i18n)
|| !Array.isArray(instance.args) || typeof instance.run !== 'function') {
throw new Error('函数必须满足 BaseFunction 且 name 与文件名一致');
}
const names = new Set<string>();
let optional = false;
for (const arg of instance.args) {
if (!arg || !valid_name(arg.name) || names.has(arg.name)
|| !data_types.has(arg.type) || !valid_i18n(arg.i18n)) {
throw new Error('函数参数定义无效或名称重复');
}
if (Object.hasOwn(arg, 'default')) {
if (arg.type !== 'boolean' || typeof arg.default !== 'boolean') throw new Error('默认值必须匹配布尔参数');
optional = true;
} else if (optional) throw new Error('带默认值的参数必须位于末尾');
names.add(arg.name);
}
const key = `${path}/${name}`;
if (functions.has(key)) throw new Error('函数重复注册');
functions.set(key, instance);
category.function_list.push({name, i18n: {...instance.i18n},
args: instance.args.map(arg => ({name: arg.name, type: arg.type, i18n: {...arg.i18n},
...(Object.hasOwn(arg, 'default') ? {default: arg.default} : {})}))});
} catch (error) {
throw new Error(`函数 ${path}/${name} 加载失败`, {cause: error});
}
}
list.push(category);
} catch (error) {
throw new Error(`函数分类 ${path} 加载失败`, {cause: error});
}
}
list.sort((a, b) => a.path.localeCompare(b.path));
return {list, functions};
}
/** Builds grouped metadata without a Bot; constructors must have no side effects. */
export async function get_function_list(): Promise<FunctionList> {
return (await load_functions()).list;
}
function valid_argument(value: unknown, type: data_type): boolean {
switch (type) {
case 'number': case 'number-select': return typeof value === 'number' && Number.isFinite(value);
case 'string': case 'string-select': return typeof value === 'string';
case 'boolean': return typeof value === 'boolean';
case 'datetime': return typeof value === 'string'
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) && Number.isFinite(Date.parse(value));
case 'string-array': case 'string-multi-select':
return Array.isArray(value) && Array.from(value).every(item => typeof item === 'string');
case 'number-array': case 'number-multi-select':
return Array.isArray(value) && Array.from(value).every(item => typeof item === 'number' && Number.isFinite(item));
}
}
/** The caller owns disposal; this manager never registers end listeners or follows a replacement Bot. */
export async function create_function_manager(
context: Pick<bot_instance_context<unknown>, 'bot_instance' | 'bot_status'>
& Partial<Pick<bot_instance_context<unknown>, 'workflow_manager'>>,
): Promise<FunctionManager> {
const bot: Bot | null = context.bot_instance;
if (!bot) throw new Error('Bot 实例尚未创建');
const check_instance = () => {
if (context.bot_instance !== bot) throw new Error('Bot 实例已变化');
};
const {list, functions} = await load_functions(check_instance);
let disposed = false;
let tail = Promise.resolve();
const pending = new Set<{reject: (error: unknown) => void; resolve: (value: boolean) => void}>();
let cancel_running: ((reason: unknown) => void) | undefined;
const check_active = () => {
if (disposed) throw new Error('Function manager 已销毁');
check_instance();
};
return {
get_function_list() {
check_active();
return structuredClone(list);
},
async run(path, name, args, before_run, options) {
const signal = options?.signal;
signal?.throwIfAborted();
check_active();
const template = list.find(category => category.path === path)?.function_list.find(item => item.name === name);
const instance = template && functions.get(`${path}/${name}`);
if (!instance || !template) throw new Error('函数不存在');
if (!Array.isArray(args) || args.length > template.args.length
|| !template.args.every((arg, index) => index < args.length
? valid_argument(args[index], arg.type) : Object.hasOwn(arg, 'default'))) {
throw new Error('函数参数数量或类型不正确');
}
const values = structuredClone(template.args.map((arg, index) => index < args.length ? args[index] : arg.default));
if (path === 'workflow' && instance.execution === 'workflow_control') {
const workflow = context.workflow_manager;
const check_control = () => {
signal?.throwIfAborted();
check_active();
if (context.workflow_manager !== workflow) throw new Error('Workflow manager 已变化');
};
before_run?.();
check_control();
if (!workflow) return false;
// Bind only the control capability to this dispatch, never the raw Bot context.
let result = false;
try {
result = await measure_sync(bot, () => instance.run(bot, values, {workflow: {
control(request) {
check_control();
const command = workflow.control(request);
check_control();
return command;
},
}})) === true;
} catch { /* Function failures retain the boolean API. */ }
check_control();
return result;
}
if (context.bot_status !== 'online') throw new Error('Bot 当前不在线');
if (path === 'bot' && name === 'stop_current_function') {
before_run?.();
signal?.throwIfAborted();
check_active();
for (const task of pending) task.resolve(false);
pending.clear();
const result = await measure_sync(bot, () => instance.run(bot, values));
signal?.throwIfAborted();
return result;
}
if (pending.size >= 64) throw new Error('函数等待队列已满');
return new Promise<boolean>((resolve, reject) => {
let cancel_execution: ((reason: unknown) => void) | undefined;
const cleanup = () => signal?.removeEventListener('abort', abort);
const task = {
reject(error: unknown) { cleanup(); reject(error); },
resolve(value: boolean) { cleanup(); resolve(value); },
};
const abort = () => {
if (pending.delete(task)) task.reject(signal!.reason);
else cancel_execution?.(signal!.reason);
};
pending.add(task);
signal?.addEventListener('abort', abort, {once: true});
const execute = async () => {
if (!pending.delete(task)) return;
try {
check_active();
if (context.bot_status !== 'online') throw new Error('Bot 当前不在线');
signal?.throwIfAborted();
before_run?.();
signal?.throwIfAborted();
check_active();
const cancellation = create_function_cancellation(bot);
let interrupted = false;
let reason: unknown;
cancel_running = cancel_execution = error => {
if (interrupted) return;
interrupted = true;
reason = error;
cancellation.cancel();
};
try {
await cancellation.run(async () => {
// Global stop may answer early, but the queue still drains instance.run.
const unwatch = on_function_cancel(bot, () => {
if (!interrupted) task.resolve(false);
});
let result = false;
try { result = await measure_sync(bot, () => instance.run(bot, values)) === true; }
catch { /* Function failures retain the boolean API. */ }
finally { unwatch(); }
if (interrupted) task.reject(reason);
else task.resolve(result);
});
} finally {
cleanup();
cancel_running = cancel_execution = undefined;
cancellation.dispose();
}
} catch (error) { task.reject(error); }
};
tail = tail.then(execute, execute);
});
},
dispose() {
if (disposed) return;
disposed = true;
for (const task of pending) task.reject(new Error('Function manager 已销毁,排队调用已取消'));
pending.clear();
functions.clear();
cancel_running?.(new Error('Function manager 已销毁,当前调用已取消'));
},
};
}
@@ -0,0 +1,24 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
export class init implements BaseFunction<number[]> {
readonly name = 'change_main_hand_slot';
readonly i18n = {
lang: 'zh-CN', name: '切换主手槽位', description: '切换主手选中的快捷栏槽位,编号为 0 到 8',
};
readonly args: FunctionArgument[] = [{
name: 'slot', type: 'number',
i18n: {lang: 'zh-CN', name: '快捷栏槽位', description: '0 到 8 的整数,分别对应快捷栏第 1 到第 9 格'},
}];
async run(bot: Bot, args: number[]): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 1 || !Number.isSafeInteger(args[0])
|| args[0] < 0 || args[0] > 8) return false;
try {
bot.setQuickBarSlot(args[0]);
return bot.quickBarSlot === args[0];
} catch {
return false;
}
}
}
@@ -0,0 +1,22 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
export class init implements BaseFunction<[]> {
readonly name = 'close_current_window';
readonly i18n = {
lang: 'zh-CN', name: '关闭当前窗口', description: '关闭当前打开的容器窗口;没有打开窗口时直接成功',
};
readonly args: FunctionArgument[] = [];
async run(bot: Bot, args: []): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 0) return false;
const window = bot.currentWindow;
if (!window) return true;
try {
await bot.closeWindow(window);
return bot.currentWindow == null;
} catch {
return false;
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import {base_define} from "../../../types/bot/plugins/plugin.ts";
export const define: base_define = {
path: "inventory",
i18n: [
{
lang: "zh-CN",
name: "容器操作",
description: "Bot 容器操作, 提供容器相关功能"
}
]
}
+25
View File
@@ -0,0 +1,25 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
export class init implements BaseFunction<number[]> {
readonly name = 'left_click_slot';
readonly i18n = {
lang: 'zh-CN', name: '左键点击槽位', description: '在当前窗口左键点击一个物品槽位,使用原生拾取、放置或合并行为',
};
readonly args: FunctionArgument[] = [{
name: 'slot', type: 'number',
i18n: {lang: 'zh-CN', name: '物品槽位', description: '当前窗口中的槽位编号'},
}];
async run(bot: Bot, args: number[]): Promise<boolean> {
const window = bot.currentWindow ?? bot.inventory;
if (!window || !Array.isArray(args) || args.length !== 1 || !Number.isSafeInteger(args[0])
|| args[0] < 0 || args[0] >= window.slots.length) return false;
try {
await bot.clickWindow(args[0], 0, 0);
return (bot.currentWindow ?? bot.inventory) === window;
} catch {
return false;
}
}
}
@@ -0,0 +1,25 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
export class init implements BaseFunction<number[]> {
readonly name = 'right_click_slot';
readonly i18n = {
lang: 'zh-CN', name: '右键点击槽位', description: '在当前窗口右键点击一个物品槽位,使用原生半组拾取或单个放置行为',
};
readonly args: FunctionArgument[] = [{
name: 'slot', type: 'number',
i18n: {lang: 'zh-CN', name: '物品槽位', description: '当前窗口中的槽位编号'},
}];
async run(bot: Bot, args: number[]): Promise<boolean> {
const window = bot.currentWindow ?? bot.inventory;
if (!window || !Array.isArray(args) || args.length !== 1 || !Number.isSafeInteger(args[0])
|| args[0] < 0 || args[0] >= window.slots.length) return false;
try {
await bot.clickWindow(args[0], 1, 0);
return (bot.currentWindow ?? bot.inventory) === window;
} catch {
return false;
}
}
}
@@ -0,0 +1,45 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
import {Mutex} from 'async-mutex';
import {cancellation_generation} from '../cancellation.ts';
const locks = new WeakMap<Bot, Mutex>();
export class init implements BaseFunction<number[]> {
readonly name = 'slot_switch_slot';
readonly i18n = {
lang: 'zh-CN', name: '物品交换', description: '依次左键点击源槽位、目标槽位,并将鼠标剩余物品放回源槽位;同类物品按原生规则合并',
};
readonly args: FunctionArgument[] = [
{name: 'slot', type: 'number', i18n: {lang: 'zh-CN', name: '物品槽位', description: '第一个槽位编号'}},
{name: 'new_slot', type: 'number', i18n: {lang: 'zh-CN', name: '目标槽位', description: '第二个槽位编号'}},
];
async run(bot: Bot, args: number[]): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 2 || !args.every(Number.isSafeInteger)) return false;
const window = bot.currentWindow ?? bot.inventory;
if (!window) return false;
const generation = cancellation_generation(bot);
const active = () => cancellation_generation(bot) === generation;
let lock = locks.get(bot);
if (!lock) locks.set(bot, lock = new Mutex());
return lock.runExclusive(async () => {
const [slot, new_slot] = args;
if (!active() || (bot.currentWindow ?? bot.inventory) !== window || window.selectedItem
|| slot === new_slot || args.some(value => value < 0 || value >= window.slots.length)) return false;
try {
await bot.clickWindow(slot, 0, 0);
if (!active() || (bot.currentWindow ?? bot.inventory) !== window) return false;
await bot.clickWindow(new_slot, 0, 0);
if (!active() || (bot.currentWindow ?? bot.inventory) !== window) return false;
// An empty cursor means a final click would pick the source item up again.
if (window.selectedItem) {
await bot.clickWindow(slot, 0, 0);
}
return active() && (bot.currentWindow ?? bot.inventory) === window && !window.selectedItem;
} catch {
return false;
}
});
}
}
+65
View File
@@ -0,0 +1,65 @@
import type {Bot} from "@wp2508/mineflayer";
import type {BaseFunction, FunctionArgument} from "../../../types/bot/function/function.ts";
import {Mutex} from "async-mutex";
import {cancellation_generation} from '../cancellation.ts';
const locks = new WeakMap<Bot, Mutex>();
export class init implements BaseFunction<number[]> {
public name = "slot_to_slot";
public i18n = {
lang: "zh-CN",
name: "物品移动",
description: "将物品从一个槽位移动到另一个槽位, 目标槽位必须为空"
}
public args: FunctionArgument[] = [
{
name: "slot",
type: "number",
i18n: {
lang: "zh-CN",
name: "物品槽位",
description: "物品槽位"
}
},
{
name: "new_slot",
type: "number",
i18n: {
lang: "zh-CN",
name: "新物品槽位",
description: "新物品槽位"
}
}
]
async run(bot: Bot, args: number[]): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 2 || !args.every(Number.isSafeInteger)) {
return false;
}
const [slot, new_slot] = args;
const window = bot.currentWindow ?? bot.inventory;
if (!window) return false;
const generation = cancellation_generation(bot);
const active = () => cancellation_generation(bot) === generation;
let lock = locks.get(bot);
if (!lock) locks.set(bot, lock = new Mutex());
return lock.runExclusive(async () => {
if (!active() || (bot.currentWindow ?? bot.inventory) !== window) return false;
if ([slot, new_slot].some(value => value < 0 || value >= window.slots.length)) {
return false;
}
if (slot === new_slot || !window.slots[slot] || window.slots[new_slot] || window.selectedItem) return false;
try {
await bot.clickWindow(slot, 0, 0);
// Never apply the destination click to a replacement window.
if (!active() || (bot.currentWindow ?? bot.inventory) !== window || window.slots[new_slot]) return false;
await bot.clickWindow(new_slot, 0, 0);
return active();
} catch {
return false;
}
});
}
}
+12
View File
@@ -0,0 +1,12 @@
import {base_define} from "../../../types/bot/plugins/plugin.ts";
export const define: base_define = {
path: "item",
i18n: [
{
lang: "zh-CN",
name: "物品操作",
description: "Bot 物品操作, 提供物品相关功能"
}
]
}
+26
View File
@@ -0,0 +1,26 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
export class init implements BaseFunction<number[]> {
readonly name = 'drop_slot_all_item';
readonly i18n = {
lang: 'zh-CN', name: '丢弃整组物品', description: '丢弃当前窗口指定槽位的整组物品,不主动关闭窗口',
};
readonly args: FunctionArgument[] = [{
name: 'slot', type: 'number',
i18n: {lang: 'zh-CN', name: '物品槽位', description: '当前窗口中的槽位编号'},
}];
async run(bot: Bot, args: number[]): Promise<boolean> {
const window = bot.currentWindow ?? bot.inventory;
if (!window || !Array.isArray(args) || args.length !== 1 || !Number.isSafeInteger(args[0])
|| args[0] < 0 || args[0] >= window.slots.length || !window.slots[args[0]]
|| window.selectedItem) return false;
try {
await bot.clickWindow(args[0], 1, 4);
return (bot.currentWindow ?? bot.inventory) === window && !window.slots[args[0]];
} catch {
return false;
}
}
}
@@ -0,0 +1,44 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
export class init implements BaseFunction<number[]> {
readonly name = 'drop_slot_item_number';
readonly i18n = {
lang: 'zh-CN', name: '丢弃指定数量物品', description: '逐个丢弃指定槽位的物品,数量不超过槽位现有数量',
};
readonly args: FunctionArgument[] = [
{name: 'slot', type: 'number', i18n: {lang: 'zh-CN', name: '物品槽位', description: '当前窗口中的槽位编号'}},
{name: 'count', type: 'number', i18n: {lang: 'zh-CN', name: '丢弃数量', description: '大于零的整数,超过现有数量时丢弃全部'}},
];
async run(bot: Bot, args: number[]): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 2 || !args.every(Number.isSafeInteger)) return false;
const [slot, count] = args;
const window = bot.currentWindow ?? bot.inventory;
if (!window || slot < 0 || slot >= window.slots.length || count <= 0 || window.selectedItem) return false;
const item = window.slots[slot];
if (!item) return false;
const drop_count = Math.min(count, item.count);
const initial_count = item.count;
const type = item.type;
const metadata = item.metadata;
try {
for (let index = 0; index < drop_count; index++) {
const current = window.slots[slot];
if ((bot.currentWindow ?? bot.inventory) !== window || window.selectedItem
|| !current || current.type !== type || current.metadata !== metadata
|| current.count !== initial_count - index) return false;
await bot.clickWindow(slot, 0, 4);
if ((bot.currentWindow ?? bot.inventory) !== window || window.selectedItem) return false;
const remaining = window.slots[slot];
const expected = initial_count - index - 1;
if (expected === 0 ? remaining !== null && remaining !== undefined
: !remaining || remaining.type !== type || remaining.metadata !== metadata
|| remaining.count !== expected) return false;
}
return true;
} catch {
return false;
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction, FunctionArgument} from '../../../types/bot/function/function.ts';
export class init implements BaseFunction<boolean[]> {
readonly name = 'use_item';
readonly i18n = {
lang: 'zh-CN', name: '使用手持物品', description: '发起主手或副手物品的使用,不等待使用完成,也不自动停止持续使用',
};
readonly args: FunctionArgument[] = [{
name: 'off_hand', type: 'boolean',
i18n: {lang: 'zh-CN', name: '使用副手', description: 'false 使用主手,true 使用副手'},
}];
async run(bot: Bot, args: boolean[]): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 1 || typeof args[0] !== 'boolean') return false;
try {
const off_hand = args[0];
if (off_hand) {
if (bot.supportFeature('doesntHaveOffHandSlot') || !bot.inventory?.slots[45]) return false;
} else if (!bot.heldItem) return false;
bot.activateItem(off_hand);
return true;
} catch {
return false;
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import type {base_define} from '../../../types/bot/plugins/plugin.ts';
export const define: base_define = {
path: 'workflow',
i18n: [{lang: 'zh-CN', name: '工作流控制', description: '运行、停止、管理工作流触发器和重新加载工作流'}],
};
+6
View File
@@ -0,0 +1,6 @@
import {WorkflowControlFunction} from './shared.ts';
export class init extends WorkflowControlFunction {
readonly name = 'reload';
readonly i18n = {lang: 'zh-CN', name: '重新加载工作流', description: '提交重新加载指定工作流的请求,接受后立即返回,不等待加载完成'};
}
+6
View File
@@ -0,0 +1,6 @@
import {WorkflowControlFunction} from './shared.ts';
export class init extends WorkflowControlFunction {
readonly name = 'run_once';
readonly i18n = {lang: 'zh-CN', name: '运行工作流一次', description: '提交工作流单次运行请求,接受后立即返回,不等待运行完成'};
}
+26
View File
@@ -0,0 +1,26 @@
import {randomUUID} from 'node:crypto';
import type {Bot} from '@wp2508/mineflayer';
import type {BaseFunction, FunctionExecutionContext, FunctionI18n} from '../../../types/bot/function/function.ts';
import type {WorkflowAction} from '../../../types/bot/workflow/runtime.ts';
export abstract class WorkflowControlFunction implements BaseFunction<[string]> {
abstract readonly name: WorkflowAction;
abstract readonly i18n: FunctionI18n;
readonly execution = 'workflow_control';
readonly args = [{name: 'workflow_id', type: 'string' as const,
i18n: {lang: 'zh-CN', name: '工作流 ID', description: '目标工作流的唯一标识'}}];
async run(_bot: Bot, args: [string], executionContext?: FunctionExecutionContext): Promise<boolean> {
if (!Array.isArray(args) || args.length !== 1 || typeof args[0] !== 'string'
|| args[0].length < 1 || args[0].length > 100 || /[^a-zA-Z0-9_-]/.test(args[0])
|| ['__proto__', 'prototype', 'constructor'].includes(args[0])) return false;
const workflow = executionContext?.workflow;
if (!workflow) return false;
try {
const command = workflow.control({command_id: randomUUID(), action: this.name,
...(this.name === 'stop_run' ? {run_id: args[0]} : {workflow_id: args[0]})});
// Acknowledgement is enough; waiting for completion would deadlock a self-stop.
return command.state === 'accepted' || command.state === 'completed';
} catch { return false; }
}
}
+6
View File
@@ -0,0 +1,6 @@
import {WorkflowControlFunction} from './shared.ts';
export class init extends WorkflowControlFunction {
readonly name = 'stop';
readonly i18n = {lang: 'zh-CN', name: '停止工作流', description: '提交停止指定工作流的请求,接受后立即返回,不等待运行结束'};
}
+8
View File
@@ -0,0 +1,8 @@
import {WorkflowControlFunction} from './shared.ts';
export class init extends WorkflowControlFunction {
readonly name = 'stop_run';
readonly i18n = {lang: 'zh-CN', name: '停止工作流运行实例', description: '提交停止指定运行实例的请求,接受后立即返回,不等待实例结束'};
readonly args = [{name: 'run_id', type: 'string' as const,
i18n: {lang: 'zh-CN', name: '运行实例 ID', description: '目标工作流运行实例的唯一标识'}}];
}
+6
View File
@@ -0,0 +1,6 @@
import {WorkflowControlFunction} from './shared.ts';
export class init extends WorkflowControlFunction {
readonly name = 'trigger_start';
readonly i18n = {lang: 'zh-CN', name: '启动工作流触发器', description: '提交启动指定工作流触发器的请求,接受后立即返回'};
}
+6
View File
@@ -0,0 +1,6 @@
import {WorkflowControlFunction} from './shared.ts';
export class init extends WorkflowControlFunction {
readonly name = 'trigger_stop';
readonly i18n = {lang: 'zh-CN', name: '停止工作流触发器', description: '提交停止指定工作流触发器的请求,接受后立即返回'};
}
+84
View File
@@ -0,0 +1,84 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo, InfoI18n} from '../types.ts';
import type {log} from '../../../types/utils/logger.ts';
import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts';
export interface EntityInfo {
entity_count: number;
player_count: number;
players: log[][];
entities: log[][];
}
export class init implements BaseInfo<EntityInfo> {
readonly name = 'entity';
readonly i18n = {
lang: 'zh-CN', name: '附近实体', description: '当前已加载的附近实体和玩家,不包含机器人自身',
fields: {
entity_count: {lang: 'zh-CN', name: '附近实体数量', description: '已加载的实体数量,包含玩家,不包含机器人自身'},
player_count: {lang: 'zh-CN', name: '附近玩家数量', description: '已加载的其他玩家实体数量'},
players: {lang: 'zh-CN', name: '附近玩家列表', description: '每个玩家名称返回一组 log 片段,优先使用玩家对象的 displayName,其次 username'},
entities: {lang: 'zh-CN', name: '附近实体列表', description: '按显示名称合并去重,每个名称返回一组 log 片段并保留首次出现的样式,包含玩家'},
},
} satisfies InfoI18n;
private info: EntityInfo = {entity_count: 0, player_count: 0, players: [], entities: []};
private disposed = false;
private readonly listeners = new Set<(data: EntityInfo) => void>();
private readonly events = ['entitySpawn', 'entityGone', 'entityUpdate', 'playerJoined', 'playerUpdated', 'playerLeft', 'spawn', 'respawn', 'physicsTick'] as const;
constructor(private readonly instance: Bot) {
this.handle_entities();
for (const event of this.events) instance.on(event, this.handle_entities);
}
private readonly handle_entities = (): void => {
if (this.disposed) return;
const bot = this.instance;
const info: EntityInfo = {entity_count: 0, player_count: 0, players: [], entities: []};
const entity_names = new Set<string>();
const players = new Map(Object.values(bot.players ?? {})
.filter(player => player.entity)
.map(player => [player.entity.id, player]));
for (const entity of Object.values(bot.entities ?? {}).sort((a, b) => a.id - b.id)) {
if (entity.id === bot.entity?.id || entity.isValid === false) continue;
const player = players.get(entity.id) ?? (entity.username ? bot.players?.[entity.username] : undefined);
const is_player = entity.type === 'player' || Boolean(player);
const fallback = player?.username || entity.username || entity.name || '未知';
const display_name = is_player ? player?.displayName ?? entity.displayName : entity.displayName;
const name = MinecraftJsonParser.toLog(display_name ?? fallback);
const segments = name.length ? name : MinecraftJsonParser.toLog(fallback);
info.entity_count++;
const entity_name = segments.map(segment => segment.message).join('');
if (!entity_names.has(entity_name)) {
entity_names.add(entity_name);
info.entities.push(segments);
}
if (is_player) info.players.push(segments);
}
info.player_count = info.players.length;
if (JSON.stringify(this.info) === JSON.stringify(info)) return;
this.info = info;
for (const callback of Array.from(this.listeners)) callback(info);
};
get_info(): EntityInfo {
return this.info;
}
on_update(callback: (data: EntityInfo) => void): () => void {
if (this.disposed) throw new Error('EntityInfo 已销毁');
this.listeners.add(callback);
return () => { this.listeners.delete(callback); };
}
readonly dispose = (): void => {
if (this.disposed) return;
this.disposed = true;
for (const event of this.events) this.instance.off(event, this.handle_entities);
this.listeners.clear();
};
}
+56
View File
@@ -0,0 +1,56 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo} from '../types.ts';
export class init implements BaseInfo<number> {
readonly name = 'food';
readonly i18n = {
lang: 'zh-CN',
name: '饥饿值',
description: '机器人的当前饥饿值',
};
private food: number;
private disposed = false;
private readonly listeners = new Set<(data: number) => void>();
constructor(private readonly instance: Bot) {
this.food = instance.food ?? 0;
instance.on('health', this.handle_food);
}
private readonly handle_food = (): void => {
this.update_food(this.instance.food ?? 0);
};
get_info(): number {
return this.food;
}
on_update(callback: (data: number) => void): () => void {
if (this.disposed) {
throw new Error('FoodInfo 已销毁');
}
this.listeners.add(callback);
return () => {
this.listeners.delete(callback);
};
}
private update_food(food: number): void {
if (this.disposed || Object.is(this.food, food)) return;
this.food = food;
for (const callback of Array.from(this.listeners)) {
callback(food);
}
}
readonly dispose = (): void => {
if (this.disposed) return;
this.disposed = true;
this.instance.off('health', this.handle_food);
this.listeners.clear();
};
}
+56
View File
@@ -0,0 +1,56 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo} from '../types.ts';
export class init implements BaseInfo<number> {
readonly name = 'health';
readonly i18n = {
lang: 'zh-CN',
name: '生命值',
description: '机器人的当前生命值',
};
private health: number;
private disposed = false;
private readonly listeners = new Set<(data: number) => void>();
constructor(private readonly instance: Bot) {
this.health = instance.health ?? 0;
instance.on('health', this.handle_health);
}
private readonly handle_health = (): void => {
this.update_health(this.instance.health ?? 0);
};
get_info(): number {
return this.health;
}
on_update(callback: (data: number) => void): () => void {
if (this.disposed) {
throw new Error('HealthInfo 已销毁');
}
this.listeners.add(callback);
return () => {
this.listeners.delete(callback);
};
}
private update_health(health: number): void {
if (this.disposed || Object.is(this.health, health)) return;
this.health = health;
for (const callback of Array.from(this.listeners)) {
callback(health);
}
}
readonly dispose = (): void => {
if (this.disposed) return;
this.disposed = true;
this.instance.off('health', this.handle_health);
this.listeners.clear();
};
}
+317
View File
@@ -0,0 +1,317 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo, InfoI18n} from '../types.ts';
import type {log} from '../../../types/utils/logger.ts';
import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts';
import {parse_inventory_item, type InventoryItem} from '../../../utils/inventory_item.ts';
export type {InventoryItem} from '../../../utils/inventory_item.ts';
type WireValue = null | boolean | number | string | WireValue[] | {[key: string]: WireValue};
type Item = NonNullable<Bot['inventory']['slots'][number]>;
type Window = Bot['inventory'];
type WindowKind = 'inventory' | 'chest' | 'large_chest' | 'crafting' | 'enchantment'
| 'stonecutter' | 'furnace' | 'merchant' | 'lectern' | 'generic';
export interface InventoryInfo {
window: {
id: number;
kind: WindowKind;
type: string | number;
title: log[];
inventoryStart: number | null;
inventoryEnd: number | null;
hotbarStart: number | null;
hasPlayerInventory: boolean;
craftingResultSlot: number | null;
slots: (InventoryItem | null)[];
properties: Record<string, number>;
} | null;
cursorItem: InventoryItem | null;
handSlot: number | null;
special: Record<string, WireValue>;
}
function to_wire(value: unknown, ancestors = new Set<object>()): WireValue {
if (value == null) return null;
if (typeof value === 'bigint') return value.toString();
if (typeof value === 'string' || typeof value === 'boolean') return value;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (Buffer.isBuffer(value)) return Array.from(value);
if (typeof value === 'object') {
if (ancestors.has(value)) return null;
ancestors.add(value);
try {
if (Array.isArray(value)) return Array.from(value, entry => to_wire(entry, ancestors));
return Object.fromEntries(Object.entries(Object.getOwnPropertyDescriptors(value))
.filter(([, descriptor]) => descriptor.enumerable)
.map(([key, descriptor]) => [key, 'value' in descriptor ? to_wire(descriptor.value, ancestors) : null]));
} finally {
ancestors.delete(value);
}
}
return null;
}
function window_kind(window: Window): WindowKind {
const type = String(window.type).replace(/^minecraft:/, '');
if (window.id === 0 || type === 'inventory') return 'inventory';
if (['furnace', 'blast_furnace', 'smoker'].includes(type)) return 'furnace';
if (['crafting', 'crafting_table', 'crafting3x3'].includes(type)) return 'crafting';
if (['enchantment', 'enchanting_table'].includes(type)) return 'enchantment';
if (type === 'stonecutter' || type === 'lectern') return type;
if (type === 'merchant' || type === 'villager') return 'merchant';
if (['container', 'chest'].includes(type) || /^generic_9x[1-6]$/.test(type)) {
if (window.inventoryStart === 54) return 'large_chest';
if (window.inventoryStart === 27) return 'chest';
}
return 'generic';
}
const field = (name: string, description: string): InfoI18n => ({lang: 'zh-CN', name, description});
const item_fields = {
slot: field('槽位', '当前窗口槽位编号,鼠标物品和交易物品为 null'),
type: field('物品 ID', '当前版本的物品数值 ID,不包含图标'),
name: field('物品标识', '物品英文标识'), displayName: field('物品名称', '优先自定义名称,其次物品名称组件和默认名称,返回 log 片段'),
i18n: {...field('物品翻译', '按方块、物品翻译键依次查询中文名称,缺失时使用英文标识'), fields: {
lang: field('语言', '翻译语言标识'), name: field('中文名称', '物品默认中文名称,不覆盖自定义昵称'),
description: field('描述', '翻译描述,没有时为空字符串'),
}},
count: field('数量', '物品堆叠数量'), metadata: field('元数据', '旧版本物品元数据'),
lore: field('物品描述', '每行是一组 log 片段,保留空行'),
enchants: {...field('附魔', '已解析的附魔列表'), fields: {
id: field('附魔 ID', '版本内数值标识,未知时省略'), name: field('附魔标识', '英文附魔标识,未知时省略'),
i18n: {...field('附魔翻译', '查询中文附魔名称,缺失时回退到附魔标识或 ID'), fields: {
lang: field('语言', '翻译语言标识'), name: field('中文名称', '附魔中文名称'), description: field('描述', '翻译描述,没有时为空字符串'),
}},
level: field('附魔等级', '实际附魔等级'), hidden: field('隐藏附魔提示', '服务器是否要求隐藏此附魔提示'),
}},
durability: {...field('耐久', '耐久信息,没有耐久时为 null'), fields: {
used: field('已用耐久', '已消耗耐久'), max: field('最大耐久', '最大耐久'), remaining: field('剩余耐久', '剩余可用耐久'),
}},
unbreakable: field('不可破坏', '物品是否不会损耗耐久'), glint: field('附魔光效', '强制光效开关,未指定为 null'),
book: {...field('书籍', '已解析的现代或旧版书籍,没有时为 null'), fields: {
title: field('书名', '书籍标题'), author: field('作者', '书籍作者'), pages: field('书页', '每页是一组 log 片段'),
}},
details: {...field('特殊属性', '已解析特殊数据及解除 NBT 包装的未知字段'), fields: {
potion: {...field('药水', '药水类型、颜色和效果'), fields: {
type: field('药水类型', '版本内药水数值 ID 或旧版标识'), color: field('药水颜色', '自定义 RGB 整数颜色,未指定为 null'),
effects: {...field('药水效果', '自定义状态效果列表'), fields: {
id: field('效果 ID', '版本内数值 ID 或英文标识'), name: field('效果名称', '已识别效果名称'),
amplifier: field('效果增幅', '从 0 开始'), duration: field('持续时间', '游戏刻数'),
ambient: field('环境效果', '是否为环境效果'), showParticles: field('显示粒子', '是否显示效果粒子'),
}},
}},
attributes: {...field('属性修饰符', '属性及提示可见性'), fields: {
hidden: field('隐藏属性提示', '是否隐藏属性修饰符提示'), modifiers: {...field('修饰符列表', '已解析属性记录'), fields: {
type: field('属性类型', '版本内数值 ID 或英文标识'), name: field('修饰符名称', '修饰符名称或标识'),
amount: field('数值', '属性修饰数值'), operation: field('运算方式', '加法或乘法运算标识'), slot: field('生效槽位', '属性生效的装备槽'),
hidden: field('隐藏此项', '是否单独隐藏此属性'), displayName: field('显示文本', '覆盖显示文本的 log 片段'),
}},
}},
container: field('容器物品', '嵌套物品,使用相同物品结构'), bundle: field('收纳袋物品', '嵌套物品,使用相同物品结构'),
tooltip: {...field('提示可见性', '隐藏全部或附加提示及旧版标志'), fields: {
hideAll: field('隐藏全部提示', '隐藏全部物品提示'), hideAdditional: field('隐藏附加提示', '隐藏附加提示'),
hiddenComponents: field('隐藏组件', '被隐藏的组件名称,无法识别时保留 ID'), legacyFlags: field('旧版隐藏标志', '原始 HideFlags 数值'),
}},
extra: field('其他标签', '未识别标签解除 NBT 包装后保留'),
}},
truncated: field('数据已裁剪', '物品数据超出解析预算或存在循环引用、访问器等无法表示的数据'),
};
export class init implements BaseInfo<InventoryInfo> {
readonly name = 'inventory';
readonly i18n: InfoI18n = {
...field('背包与窗口', '只读查询当前窗口及物品,支持背包和各类容器,不提供操作'),
fields: {
window: {...field('当前窗口', '未打开容器时返回玩家背包,尚未初始化时为 null'), fields: {
id: field('窗口 ID', '服务器窗口编号'), kind: field('窗口类别', '背包、箱子、合成、附魔、切石、熔炉、交易、讲台或通用窗口'),
type: field('窗口类型', 'Mineflayer 原始窗口类型'), title: field('窗口标题', '标题的 log 片段'),
inventoryStart: field('背包起点', '当前窗口玩家背包起始槽,讲台为 null'),
inventoryEnd: field('背包终点', '玩家背包结束槽,不包含该槽,讲台为 null'),
hotbarStart: field('快捷栏起点', '当前窗口快捷栏起始槽,讲台为 null'),
hasPlayerInventory: field('包含玩家背包', '讲台不包含玩家背包'),
craftingResultSlot: field('结果槽', '窗口结果槽,没有时为 null'),
slots: {...field('物品槽', '按槽号排列,空槽为 null,fields 描述物品'), fields: item_fields},
properties: field('窗口属性', '服务器下发的属性 ID 到数值的映射,未收到属性不填充'),
}},
cursorItem: {...field('鼠标物品', '鼠标携带物品,没有时为 null'), fields: item_fields},
handSlot: field('主手槽', '当前窗口坐标系中的主手槽,讲台或未初始化时为 null'),
special: {...field('特殊窗口状态', '仅包含当前窗口对应状态,未知值为 null'), fields: {
furnace: {...field('熔炉', '熔炉、高炉和烟熏炉状态'), fields: {
burnRemaining: field('剩余燃烧', '剩余燃烧游戏刻'), burnTotal: field('总燃烧时间', '燃料总游戏刻'),
cookProgress: field('烧炼进度', '已烧炼游戏刻'), cookTotal: field('总烧炼时间', '所需游戏刻'),
fuelRatio: field('燃料比例', '剩余燃料比例,范围 0 至 1'), progressRatio: field('烧炼比例', '烧炼进度比例,范围 0 至 1'),
}},
enchantment: {...field('附魔台', '附魔选项与种子'), fields: {
seed: field('附魔种子', '服务器下发的附魔种子'),
options: {...field('附魔选项', '三个候选项,fields 描述每项'), fields: {
index: field('选项索引', '从 0 开始'), requiredLevel: field('所需等级', '选择该项需要达到的等级'),
levelCost: field('等级消耗', '消耗 1、2 或 3 级'), lapisCost: field('青金石消耗', '消耗 1、2 或 3 个青金石'),
enchantmentId: field('提示附魔 ID', '提示的附魔 ID'), enchantmentLevel: field('提示附魔等级', '提示的附魔等级'),
}},
}},
merchant: {...field('村民交易', '由 Mineflayer 专用交易 API 初始化的报价'), fields: {
ready: field('报价就绪', '尚未初始化交易列表时为 false'),
offers: {...field('报价列表', 'fields 描述每项报价'), fields: {
index: field('报价索引', '从 0 开始'),
input1: {...field('第一材料', '交易的第一种材料'), fields: item_fields},
input2: {...field('第二材料', '没有时为 null'), fields: item_fields},
output: {...field('交易产物', '交易结果物品'), fields: item_fields},
realPrice: field('实际价格', '第一材料实际数量'), disabled: field('不可交易', '服务器报告的交易禁用状态'),
uses: field('已交易次数', '当前报价已使用次数'), maxUses: field('最多交易次数', '报价最大使用次数'),
}},
}},
lectern: {...field('讲台', '讲台上的书及页码'), fields: {
page: field('当前页码', '服务器下发的页码,从 0 开始,未知为 null'),
book: {...field('书籍', '支持现代组件与旧版 NBT,未收到书籍时为 null'), fields: {
title: field('书名', '书籍标题'), author: field('作者', '书籍作者'), pages: field('书页', '每页为一组 log 片段'),
}},
}},
}},
},
};
private info: InventoryInfo = {window: null, cursorItem: null, handSlot: null, special: {}};
private disposed = false;
private timer: ReturnType<typeof setTimeout> | undefined;
private attached_inventory: Window | undefined;
private attached_window: Window | null = null;
private readonly properties = new Map<number, Map<number, number>>();
private readonly listeners = new Set<(data: InventoryInfo) => void>();
private readonly events = ['heldItemChanged', 'spawn', 'physicsTick'] as const;
private readonly packets = ['trade_list', 'MC|TrList', 'minecraft:trader_list', 'window_items', 'set_slot', 'set_player_inventory'] as const;
constructor(private readonly instance: Bot) {
this.attach();
this.update();
instance.on('windowOpen', this.handle_open);
instance.on('windowClose', this.handle_close);
for (const event of this.events) instance.on(event, this.refresh);
for (const packet of this.packets) instance._client.on(packet, this.refresh);
instance._client.on('craft_progress_bar', this.handle_property);
}
private attach(): void {
const inventory = this.instance.inventory;
if (inventory !== this.attached_inventory) {
this.attached_inventory?.off('updateSlot', this.refresh);
this.attached_inventory = inventory;
inventory?.on('updateSlot', this.refresh);
}
const window = this.instance.currentWindow as Window | null;
if (window !== this.attached_window) {
this.attached_window?.off('updateSlot', this.refresh);
this.attached_window = window;
if (window) this.properties.delete(window.id);
window?.on('updateSlot', this.refresh);
}
}
private readonly handle_open = (): void => {
this.refresh();
};
private readonly handle_close = (window: Bot['inventory']): void => {
if (window) this.properties.delete(window.id);
this.refresh();
};
private readonly handle_property = (packet: {windowId: number; property: number; value: number}): void => {
const window = this.instance.currentWindow;
if (this.disposed || !window || window.id !== packet.windowId) return;
this.attach();
let values = this.properties.get(window.id);
if (!values) this.properties.set(window.id, values = new Map());
values.set(packet.property, packet.value);
this.refresh();
};
private readonly refresh = (): void => {
if (this.disposed) return;
this.attach();
if (this.timer) return;
this.timer = setTimeout(() => {
this.timer = undefined;
this.update();
}, 50);
this.timer.unref();
};
private update(): void {
if (this.disposed) return;
const bot = this.instance;
const read_item = (item: Item | null | undefined, slot: number | null = null) => parse_inventory_item(item, slot, bot.registry);
const window = bot.currentWindow ?? bot.inventory;
const info: InventoryInfo = {window: null, cursorItem: null, handSlot: null, special: {}};
if (window) {
const kind = window_kind(window);
const hasPlayerInventory = kind !== 'lectern';
const values = this.properties.get(window.id);
const property = (id: number) => values?.get(id) ?? null;
const ratio = (value: number | null, total: number | null) => value === null || total === null || total <= 0
? null : Math.max(0, Math.min(1, value / total));
info.window = {
id: window.id, kind, type: window.type,
title: MinecraftJsonParser.toLog(window.title),
inventoryStart: hasPlayerInventory ? window.inventoryStart : null,
inventoryEnd: hasPlayerInventory ? window.inventoryEnd : null,
hotbarStart: hasPlayerInventory ? window.hotbarStart : null,
hasPlayerInventory,
craftingResultSlot: window.craftingResultSlot >= 0 ? window.craftingResultSlot : null,
slots: Array.from(kind === 'lectern' ? window.slots.slice(0, 1) : window.slots, (item, slot) => read_item(item, slot)),
properties: Object.fromEntries(values ?? []),
};
info.cursorItem = read_item(window.selectedItem);
info.handSlot = hasPlayerInventory && Number.isInteger(bot.quickBarSlot) ? window.hotbarStart + bot.quickBarSlot : null;
if (kind === 'furnace') {
info.special.furnace = {
burnRemaining: property(0), burnTotal: property(1), cookProgress: property(2), cookTotal: property(3),
fuelRatio: ratio(property(0), property(1)), progressRatio: ratio(property(2), property(3)),
};
} else if (kind === 'enchantment') {
info.special.enchantment = {
seed: property(3), options: [0, 1, 2].map(index => ({
index, requiredLevel: property(index), levelCost: index + 1, lapisCost: index + 1,
enchantmentId: property(4 + index), enchantmentLevel: property(7 + index),
})),
};
} else if (kind === 'merchant') {
const trades = (window as Window & {trades?: Awaited<ReturnType<Bot['openVillager']>>['trades']}).trades;
info.special.merchant = to_wire({ready: Array.isArray(trades), offers: (trades ?? []).map((trade, index) => ({
index, input1: read_item(trade.inputItem1), input2: trade.hasItem2 ? read_item(trade.inputItem2) : null,
output: read_item(trade.outputItem), realPrice: trade.realPrice ?? null,
disabled: Boolean(trade.tradeDisabled), uses: trade.nbTradeUses ?? null, maxUses: trade.maximumNbTradeUses ?? null,
}))});
} else if (kind === 'lectern') {
info.special.lectern = {page: property(0), book: to_wire(read_item(window.slots[0])?.book)};
}
}
if (JSON.stringify(this.info) === JSON.stringify(info)) return;
this.info = info;
for (const callback of Array.from(this.listeners)) callback(info);
}
get_info(): InventoryInfo {
return this.info;
}
on_update(callback: (data: InventoryInfo) => void): () => void {
if (this.disposed) throw new Error('InventoryInfo 已销毁');
this.listeners.add(callback);
return () => { this.listeners.delete(callback); };
}
readonly dispose = (): void => {
if (this.disposed) return;
this.disposed = true;
clearTimeout(this.timer);
this.attached_inventory?.off('updateSlot', this.refresh);
this.attached_window?.off('updateSlot', this.refresh);
this.instance.off('windowOpen', this.handle_open);
this.instance.off('windowClose', this.handle_close);
for (const event of this.events) this.instance.off(event, this.refresh);
for (const packet of this.packets) this.instance._client.off(packet, this.refresh);
this.instance._client.off('craft_progress_bar', this.handle_property);
this.properties.clear();
this.listeners.clear();
};
}
+76
View File
@@ -0,0 +1,76 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo, InfoI18n} from '../types.ts';
import type {log} from '../../../types/utils/logger.ts';
import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts';
export interface PlayerListEntry {
displayName: log[];
ping: number;
}
export class init implements BaseInfo<PlayerListEntry[]> {
readonly name = 'player_list';
readonly i18n = {
lang: 'zh-CN', name: '玩家列表', description: '服务器玩家列表,包含机器人自身,fields 描述每个数组元素',
fields: {
displayName: {lang: 'zh-CN', name: '显示名称', description: '优先使用玩家昵称,缺失、为空或解析失败时使用用户名,返回 log 片段'},
ping: {lang: 'zh-CN', name: '玩家延迟', description: '玩家延迟,单位为毫秒,缺失、无效或读取失败时返回 0'},
},
} satisfies InfoI18n;
private players: PlayerListEntry[] = [];
private disposed = false;
private readonly listeners = new Set<(data: PlayerListEntry[]) => void>();
private readonly events = ['playerJoined', 'playerUpdated', 'playerLeft', 'spawn', 'physicsTick'] as const;
constructor(private readonly instance: Bot) {
this.handle_players();
for (const event of this.events) instance.on(event, this.handle_players);
}
private readonly handle_players = (): void => {
if (this.disposed) return;
const players = Object.entries(this.instance.players ?? {})
.sort(([a], [b]) => a.localeCompare(b))
.map(([username, player]): PlayerListEntry => {
let displayName: log[] = [];
try {
displayName = MinecraftJsonParser.toLog(player.displayName);
} catch {
// A malformed nickname must not prevent the player list from updating.
}
if (!displayName.length) displayName = MinecraftJsonParser.toLog(player.username || username);
let ping = 0;
try {
const value = player.ping;
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) ping = value;
} catch {
// Keep the default when latency cannot be read.
}
return {displayName, ping};
});
if (JSON.stringify(this.players) === JSON.stringify(players)) return;
this.players = players;
for (const callback of Array.from(this.listeners)) callback(players);
};
get_info(): PlayerListEntry[] {
return this.players;
}
on_update(callback: (data: PlayerListEntry[]) => void): () => void {
if (this.disposed) throw new Error('PlayerListInfo 已销毁');
this.listeners.add(callback);
return () => { this.listeners.delete(callback); };
}
readonly dispose = (): void => {
if (this.disposed) return;
this.disposed = true;
for (const event of this.events) this.instance.off(event, this.handle_players);
this.listeners.clear();
};
}
+62
View File
@@ -0,0 +1,62 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo, InfoContext, InfoI18n} from '../types.ts';
import type {PluginSnapshot} from '../../plugins/init.ts';
export interface PluginStatesInfo {
available: boolean;
plugins: Omit<PluginSnapshot, 'config'>[];
}
export class init implements BaseInfo<PluginStatesInfo> {
readonly name = 'plugin_states';
readonly i18n: InfoI18n = {
lang: 'zh-CN', name: '插件状态', description: '插件启用情况、生命周期和内部状态,不包含配置',
fields: {
available: {lang: 'zh-CN', name: '状态源可用', description: '是否提供插件管理器状态源'},
plugins: {lang: 'zh-CN', name: '插件列表', description: '包含已禁用和失败记录', fields: {
name: {lang: 'zh-CN', name: '插件名称', description: '稳定的插件标识'},
path: {lang: 'zh-CN', name: '插件路径', description: '插件分类路径'},
enabled: {lang: 'zh-CN', name: '已启用', description: '仅初始化成功且尚未开始清理时为 true'},
lifecycle: {lang: 'zh-CN', name: '生命周期', description: 'initializing / enabled / disabling / disabled / error'},
states: {lang: 'zh-CN', name: '内部状态', description: '插件发布的内部状态;禁用记录保留清理完成时的最终快照'},
}},
},
};
private value: PluginStatesInfo;
private readonly listeners = new Set<(value: PluginStatesInfo) => void>();
private unsubscribe?: () => void;
private disposed = false;
constructor(_bot: Bot, options?: InfoContext) {
const source = options?.plugin_source;
const read = (): PluginStatesInfo => ({available: !!source,
plugins: (source?.get_all_plugins() ?? []).map(({name, path, enabled, lifecycle, states}) =>
structuredClone({name, path, enabled, lifecycle, states})),
});
this.value = read();
this.unsubscribe = source?.on_update(() => {
if (this.disposed) return;
this.value = read();
for (const listener of Array.from(this.listeners)) {
if (!this.listeners.has(listener)) continue;
try { listener(this.get_info()); } catch {}
}
});
}
get_info(): PluginStatesInfo { return structuredClone(this.value); }
on_update(callback: (value: PluginStatesInfo) => void): () => void {
if (this.disposed) throw new Error('Plugin states info has been disposed');
const listener = (value: PluginStatesInfo) => callback(value);
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.unsubscribe?.();
this.listeners.clear();
}
}
+139
View File
@@ -0,0 +1,139 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo, InfoI18n} from '../types.ts';
export interface PositionInfo {
location?: {x: number; y: number; z: number};
yaw?: number;
face_direction?: 'south' | 'west' | 'north' | 'east';
pitch?: number;
velocity?: {x: number; y: number; z: number};
}
export class init implements BaseInfo<PositionInfo> {
readonly name = 'position';
readonly i18n = {
lang: 'zh-CN',
name: '位置',
description: '机器人的当前位置、朝向和速度',
fields: {
location: {
lang: 'zh-CN',
name: '坐标',
description: '机器人在世界中的坐标',
fields: {
x: {lang: 'zh-CN', name: 'X 坐标', description: '东西方向的坐标'},
y: {lang: 'zh-CN', name: 'Y 坐标', description: '垂直方向的坐标'},
z: {lang: 'zh-CN', name: 'Z 坐标', description: '南北方向的坐标'},
},
},
yaw: {
lang: 'zh-CN',
name: '偏航角',
description: '机器人水平方向的朝向角度,单位为弧度',
},
face_direction: {
lang: 'zh-CN',
name: '朝向',
description: '机器人面向的水平方位',
values: {
south: {lang: 'zh-CN', name: '南', description: '朝向南方'},
west: {lang: 'zh-CN', name: '西', description: '朝向西方'},
north: {lang: 'zh-CN', name: '北', description: '朝向北方'},
east: {lang: 'zh-CN', name: '东', description: '朝向东方'},
},
},
pitch: {
lang: 'zh-CN',
name: '俯仰角',
description: '机器人垂直方向的朝向角度,单位为弧度',
},
velocity: {
lang: 'zh-CN',
name: '速度',
description: '机器人在各坐标轴上的速度分量,单位为格每物理刻',
fields: {
x: {lang: 'zh-CN', name: 'X 轴速度', description: '东西方向的速度分量'},
y: {lang: 'zh-CN', name: 'Y 轴速度', description: '垂直方向的速度分量'},
z: {lang: 'zh-CN', name: 'Z 轴速度', description: '南北方向的速度分量'},
},
},
},
} satisfies InfoI18n;
private position: PositionInfo = {};
private disposed = false;
private readonly listeners = new Set<(data: PositionInfo) => void>();
constructor(private readonly instance: Bot) {
this.handle_position();
instance.on('move', this.handle_position);
instance.on('forcedMove', this.handle_position);
instance.on('physicsTick', this.handle_position);
instance.on('spawn', this.handle_position);
}
private readonly handle_position = (): void => {
if (this.disposed) return;
const entity = this.instance.entity;
const position: PositionInfo = {};
const pos = entity?.position;
if (pos) {
position.location = {
x: parseFloat(pos.x.toFixed(2)),
y: parseFloat(pos.y.toFixed(2)),
z: parseFloat(pos.z.toFixed(2)),
};
}
if (typeof entity?.yaw === 'number') {
position.yaw = parseFloat(entity.yaw.toFixed(3));
// Mineflayer yaw: 0 = south, PI / 2 = west.
const directions = ['south', 'west', 'north', 'east'] as const;
const index = ((Math.round(entity.yaw / (Math.PI / 2)) % 4) + 4) % 4;
position.face_direction = directions[index];
}
if (typeof entity?.pitch === 'number') {
position.pitch = parseFloat(entity.pitch.toFixed(3));
}
const vel = entity?.velocity;
if (vel) {
position.velocity = {
x: parseFloat(vel.x.toFixed(3)),
y: parseFloat(vel.y.toFixed(3)),
z: parseFloat(vel.z.toFixed(3)),
};
}
if (JSON.stringify(this.position) === JSON.stringify(position)) return;
this.position = position;
for (const callback of Array.from(this.listeners)) {
callback(position);
}
};
get_info(): PositionInfo {
return this.position;
}
on_update(callback: (data: PositionInfo) => void): () => void {
if (this.disposed) {
throw new Error('PositionInfo 已销毁');
}
this.listeners.add(callback);
return () => {
this.listeners.delete(callback);
};
}
readonly dispose = (): void => {
if (this.disposed) return;
this.disposed = true;
this.instance.off('move', this.handle_position);
this.instance.off('forcedMove', this.handle_position);
this.instance.off('physicsTick', this.handle_position);
this.instance.off('spawn', this.handle_position);
this.listeners.clear();
};
}
+106
View File
@@ -0,0 +1,106 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo, InfoI18n} from '../types.ts';
import type {log} from '../../../types/utils/logger.ts';
import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts';
export interface ScoreboardInfo {
scoreboard_title: log[];
scoreboard: log[][];
header: log[];
footer: log[];
}
export class init implements BaseInfo<ScoreboardInfo> {
readonly name = 'scoreboard';
readonly i18n = {
lang: 'zh-CN', name: '计分板与 TAB', description: '当前侧边栏计分板及 TAB 页眉、页脚,以 log 片段返回',
fields: {
scoreboard_title: {lang: 'zh-CN', name: '计分板标题', description: '当前侧边栏标题的 log 片段,无侧边栏时为空数组'},
scoreboard: {lang: 'zh-CN', name: '计分板内容', description: '按分数降序排列,每行是一组 log 片段,保留空行,不混入隐藏计分板'},
header: {lang: 'zh-CN', name: 'TAB 页眉', description: 'TAB 页眉的 log 片段,保留换行和文本样式'},
footer: {lang: 'zh-CN', name: 'TAB 页脚', description: 'TAB 页脚的 log 片段,保留换行和文本样式'},
},
} satisfies InfoI18n;
private info: ScoreboardInfo = {scoreboard_title: [], scoreboard: [], header: [], footer: []};
private disposed = false;
private timer: ReturnType<typeof setTimeout> | undefined;
private last_update = -Infinity;
private readonly listeners = new Set<(data: ScoreboardInfo) => void>();
private readonly events = ['scoreboardCreated', 'scoreboardDeleted', 'scoreboardTitleChanged', 'scoreboardPosition', 'scoreUpdated', 'scoreRemoved', 'teamCreated', 'teamRemoved', 'teamUpdated', 'teamMemberAdded', 'teamMemberRemoved', 'spawn', 'respawn', 'physicsTick'] as const;
constructor(private readonly instance: Bot) {
this.update();
for (const event of this.events) instance.on(event, this.schedule_update);
instance._client.on('playerlist_header', this.schedule_update);
}
private readonly schedule_update = (): void => {
if (this.disposed || this.timer) return;
// Some Mineflayer events fire before their state mutation finishes.
this.timer = setTimeout(() => {
this.timer = undefined;
this.update();
}, Math.max(0, 2000 - (performance.now() - this.last_update)));
this.timer.unref();
};
private clean_text(component: Parameters<typeof MinecraftJsonParser.toLog>[0]): log[] {
let line_start = true;
return MinecraftJsonParser.toLog(component).flatMap(segment => {
let message = '';
for (const character of String(segment.message)) {
if (character === '\n' || character === '\r') {
message += character;
line_start = true;
} else if (!line_start || !/[\s\u200B-\u200D\u2060]/u.test(character)) {
message += character;
line_start = false;
}
}
return message ? [{...segment, message}] : [];
});
}
private update(): void {
if (this.disposed) return;
this.last_update = performance.now();
const bot = this.instance;
const board = bot.scoreboard?.sidebar;
const info: ScoreboardInfo = {
scoreboard_title: this.clean_text(board?.title),
scoreboard: board ? board.items.map(item => {
try {
return this.clean_text(item.displayName);
} catch {
return this.clean_text(item.name);
}
}) : [],
header: this.clean_text(bot.tablist?.header),
footer: this.clean_text(bot.tablist?.footer),
};
if (JSON.stringify(this.info) === JSON.stringify(info)) return;
this.info = info;
for (const callback of Array.from(this.listeners)) callback(info);
}
get_info(): ScoreboardInfo {
return this.info;
}
on_update(callback: (data: ScoreboardInfo) => void): () => void {
if (this.disposed) throw new Error('ScoreboardInfo 已销毁');
this.listeners.add(callback);
return () => { this.listeners.delete(callback); };
}
readonly dispose = (): void => {
if (this.disposed) return;
this.disposed = true;
clearTimeout(this.timer);
this.timer = undefined;
for (const event of this.events) this.instance.off(event, this.schedule_update);
this.instance._client.off('playerlist_header', this.schedule_update);
this.listeners.clear();
};
}
+156
View File
@@ -0,0 +1,156 @@
import type {Bot} from '@wp2508/mineflayer';
import minecraftData from '@wp2508/minecraft-data';
import type {BaseInfo, InfoI18n} from '../types.ts';
import type {log} from '../../../types/utils/logger.ts';
import {MinecraftJsonParser} from '../../../utils/message_json_parser.ts';
export interface SelfInfo {
displayName?: log[];
level?: number;
experience?: {points: number; progress: number};
effects?: {id: number; name?: string; display_name: string; amplifier: number; duration: number}[];
armor?: number;
game_mode?: Bot['game']['gameMode'];
difficulty?: Bot['game']['difficulty'];
}
interface Attribute {
value: number;
modifiers: {operation: number; amount: number}[];
}
export class init implements BaseInfo<SelfInfo> {
readonly name = 'self';
readonly i18n = {
lang: 'zh-CN', name: '自身信息', description: '机器人的等级、经验、药水效果、护甲、游戏模式和难度',
fields: {
displayName: {lang: 'zh-CN', name: '显示名称', description: '优先使用玩家昵称,缺失或为空时使用用户名,返回 log 片段'},
level: {lang: 'zh-CN', name: '等级', description: '机器人当前的经验等级'},
experience: {
lang: 'zh-CN', name: '经验值', description: '服务器同步的经验值和升级进度',
fields: {
points: {lang: 'zh-CN', name: '总经验值', description: '服务器同步的累计经验点数'},
progress: {lang: 'zh-CN', name: '等级进度', description: '当前等级的经验条进度,范围为 0 至 1'},
},
},
effects: {
lang: 'zh-CN', name: '药水效果', description: '机器人当前具有的状态效果,fields 描述每个数组元素',
fields: {
id: {lang: 'zh-CN', name: '效果 ID', description: '当前版本的状态效果数值标识'},
name: {lang: 'zh-CN', name: '效果名称', description: '状态效果的英文标识,未知效果省略'},
display_name: {lang: 'zh-CN', name: '效果中文昵称', description: '按机器人版本查询的中文名称,没有中文名称时返回未知'},
amplifier: {lang: 'zh-CN', name: '效果增幅', description: '效果增幅等级,从 0 开始,0 表示一级'},
duration: {lang: 'zh-CN', name: '持续时间', description: '服务器最近下发的持续游戏刻数,-1 表示无限,不在本地倒计时'},
},
},
armor: {lang: 'zh-CN', name: '护甲值', description: '应用属性修饰符后的护甲点数,2 点对应一个护甲图标'},
game_mode: {
lang: 'zh-CN', name: '游戏模式', description: '机器人当前的游戏模式',
values: {
survival: {lang: 'zh-CN', name: '生存', description: '生存模式'},
creative: {lang: 'zh-CN', name: '创造', description: '创造模式'},
adventure: {lang: 'zh-CN', name: '冒险', description: '冒险模式'},
spectator: {lang: 'zh-CN', name: '旁观', description: '旁观模式'},
},
},
difficulty: {
lang: 'zh-CN', name: '游戏难度', description: '服务器当前的游戏难度',
values: {
peaceful: {lang: 'zh-CN', name: '和平', description: '和平难度'},
easy: {lang: 'zh-CN', name: '简单', description: '简单难度'},
normal: {lang: 'zh-CN', name: '普通', description: '普通难度'},
hard: {lang: 'zh-CN', name: '困难', description: '困难难度'},
},
},
},
} satisfies InfoI18n;
private self: SelfInfo = {};
private disposed = false;
private readonly listeners = new Set<(data: SelfInfo) => void>();
private readonly events = ['experience', 'game', 'spawn', 'respawn', 'playerJoined', 'playerUpdated', 'playerLeft', 'physicsTick'] as const;
private readonly entity_events = ['entityEffect', 'entityEffectEnd', 'entityAttributes'] as const;
constructor(private readonly instance: Bot) {
this.handle_self();
for (const event of this.events) instance.on(event, this.handle_self);
for (const event of this.entity_events) instance.on(event, this.handle_entity);
}
private readonly handle_entity = (entity: Bot['entity']): void => {
if (entity === this.instance.entity) this.handle_self();
};
private readonly handle_self = (): void => {
if (this.disposed) return;
const bot = this.instance;
const self: SelfInfo = {};
const player = bot.players?.[bot.username] ?? bot.player;
const username = player?.username || bot.username;
const displayName = MinecraftJsonParser.toLog(player?.displayName ?? username);
if (displayName.length) self.displayName = displayName;
else if (username) self.displayName = MinecraftJsonParser.toLog(username);
if (typeof bot.experience?.level === 'number') self.level = bot.experience.level;
if (typeof bot.experience?.points === 'number' && typeof bot.experience.progress === 'number') {
self.experience = {points: bot.experience.points, progress: bot.experience.progress};
}
const entity = bot.entity;
if (entity) {
const data = bot.version ? minecraftData(bot.version) : null;
self.effects = Object.values(entity.effects ?? {}).map(effect => {
const name = bot.registry?.effects?.[effect.id]?.name;
const display_name = data?.effects[effect.id]?.displayName;
return {
id: effect.id,
...(name ? {name} : {}),
display_name: display_name && /\p{Script=Han}/u.test(display_name) ? display_name : '未知',
amplifier: effect.amplifier,
duration: effect.duration,
};
}).sort((a, b) => a.id - b.id);
// Mineflayer populates attributes at runtime, but its Entity type omits them.
const attributes = (entity as typeof entity & {attributes?: Record<string, Attribute>}).attributes;
const armor = attributes?.['minecraft:armor'] ?? attributes?.armor
?? attributes?.['minecraft:generic.armor'] ?? attributes?.['generic.armor'];
if (armor) {
let base = armor.value;
for (const modifier of armor.modifiers ?? []) {
if (modifier.operation === 0) base += modifier.amount;
}
let value = base;
for (const modifier of armor.modifiers ?? []) {
if (modifier.operation === 1) value += base * modifier.amount;
}
for (const modifier of armor.modifiers ?? []) {
if (modifier.operation === 2) value *= 1 + modifier.amount;
}
self.armor = Math.min(30, Math.max(0, value));
}
}
if (bot.game?.gameMode != null) self.game_mode = bot.game.gameMode;
if (bot.game?.difficulty != null) self.difficulty = bot.game.difficulty;
if (JSON.stringify(this.self) === JSON.stringify(self)) return;
this.self = self;
for (const callback of Array.from(this.listeners)) callback(self);
};
get_info(): SelfInfo {
return this.self;
}
on_update(callback: (data: SelfInfo) => void): () => void {
if (this.disposed) throw new Error('SelfInfo 已销毁');
this.listeners.add(callback);
return () => { this.listeners.delete(callback); };
}
readonly dispose = (): void => {
if (this.disposed) return;
this.disposed = true;
for (const event of this.events) this.instance.off(event, this.handle_self);
for (const event of this.entity_events) this.instance.off(event, this.handle_entity);
this.listeners.clear();
};
}
+101
View File
@@ -0,0 +1,101 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo, InfoI18n} from '../types.ts';
export interface ServerInfo {
host?: string;
brand?: string;
version?: string;
protocol?: number;
compression_threshold?: number;
online_seconds: number;
online_time: string;
ping: number;
}
export class init implements BaseInfo<ServerInfo> {
readonly name = 'server';
readonly i18n = {
lang: 'zh-CN', name: '服务器信息', description: '当前连接的服务器、协议、在线时长和延迟',
fields: {
host: {lang: 'zh-CN', name: '主机', description: '连接配置中的服务器主机和端口'},
brand: {lang: 'zh-CN', name: 'Brand', description: '服务器报告的品牌字符串'},
version: {lang: 'zh-CN', name: '版本', description: '当前连接使用的 Minecraft 版本'},
protocol: {lang: 'zh-CN', name: '协议', description: '当前连接使用的协议版本号'},
compression_threshold: {lang: 'zh-CN', name: '压缩阈值', description: '触发数据包压缩的字节数,负数表示未启用压缩'},
online_seconds: {lang: 'zh-CN', name: '本次在线秒数', description: '从本次首次登录成功起累计的秒数'},
online_time: {lang: 'zh-CN', name: '本次在线', description: '本次在线时长,格式为小时和分钟,例如 3h 05m'},
ping: {lang: 'zh-CN', name: '服务器延迟', description: '服务器下发的当前机器人玩家延迟,单位毫秒,缺失或读取失败时为 0'},
},
} satisfies InfoI18n;
private info: ServerInfo = {online_seconds: 0, online_time: '0h 00m', ping: 0};
private login_at: number | undefined;
private disposed = false;
private readonly listeners = new Set<(data: ServerInfo) => void>();
private readonly events = ['game', 'playerJoined', 'playerUpdated'] as const;
private readonly timer: ReturnType<typeof setInterval>;
constructor(private readonly instance: Bot) {
this.handle_server();
instance.on('login', this.handle_login);
for (const event of this.events) instance.on(event, this.handle_server);
this.timer = setInterval(this.handle_server, 1000);
this.timer.unref();
}
private readonly handle_login = (): void => {
// Backend switches can emit login again without starting a new connection.
this.login_at ??= performance.now();
this.handle_server();
};
private readonly handle_server = (): void => {
if (this.disposed) return;
const bot = this.instance;
const client = bot._client;
const online_seconds = this.login_at === undefined ? 0
: Math.max(0, Math.floor((performance.now() - this.login_at) / 1000));
const info: ServerInfo = {
online_seconds,
online_time: `${Math.floor(online_seconds / 3600)}h ${String(Math.floor(online_seconds / 60) % 60).padStart(2, '0')}m`,
ping: 0,
};
const host = (bot as Bot & {server_address?: string}).server_address;
if (host) info.host = host;
if (typeof bot.game?.serverBrand === 'string') info.brand = bot.game.serverBrand;
if (bot.version) info.version = bot.version;
if (typeof client?.protocolVersion === 'number') info.protocol = client.protocolVersion;
// The protocol library exposes a number at runtime despite its string declaration.
const threshold = client?.compressionThreshold as unknown;
if (typeof threshold === 'number') info.compression_threshold = threshold;
try {
const ping = (bot.players?.[bot.username] ?? bot.player)?.ping;
if (typeof ping === 'number' && Number.isFinite(ping) && ping >= 0) info.ping = ping;
} catch {
// Keep zero when player latency is unavailable.
}
if (JSON.stringify(this.info) === JSON.stringify(info)) return;
this.info = info;
for (const callback of Array.from(this.listeners)) callback(info);
};
get_info(): ServerInfo {
return this.info;
}
on_update(callback: (data: ServerInfo) => void): () => void {
if (this.disposed) throw new Error('ServerInfo 已销毁');
this.listeners.add(callback);
return () => { this.listeners.delete(callback); };
}
readonly dispose = (): void => {
if (this.disposed) return;
this.disposed = true;
clearInterval(this.timer);
this.instance.off('login', this.handle_login);
for (const event of this.events) this.instance.off(event, this.handle_server);
this.listeners.clear();
};
}
+84
View File
@@ -0,0 +1,84 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo, InfoContext} from '../types.ts';
import type {WorkflowRuntimeSnapshot} from '../../../types/bot/workflow/runtime.ts';
import {summarize_run, type RunSummary} from '../../workflow/runtime/summary.ts';
interface WorkflowStates extends WorkflowRuntimeSnapshot {available: boolean; runs: RunSummary[]}
const push_interval_ms = 250;
export class init implements BaseInfo<WorkflowStates> {
readonly name = 'workflow_states';
readonly i18n = {lang: 'zh-CN', name: '工作流状态', description: '工作流运行状态、当前节点与结束原因'};
private readonly listeners = new Set<(value: WorkflowStates) => void>();
private unsubscribe?: () => void;
private value: WorkflowStates;
private disposed = false;
private dirty = false;
private timer?: ReturnType<typeof setTimeout>;
private last_sent: string;
private readonly read: () => WorkflowStates;
constructor(_bot: Bot, options?: InfoContext) {
const source = options?.workflow_source;
this.read = (): WorkflowStates => {
const runtime = source?.get_runtime_snapshot?.() ?? {source_config_revision: 0, workflows: [], commands: []};
// Older command results remain queryable by ID; do not resend all 100 on each live update.
const recent = new Set(runtime.commands.filter(command => command.state !== 'accepted').slice(-20).map(command => command.command_id));
return {available: !!source, ...runtime,
commands: runtime.commands.filter(command => command.state === 'accepted' || recent.has(command.command_id)),
runs: (source?.get_snapshots() ?? []).map(summarize_run)};
};
this.value = this.read();
this.last_sent = JSON.stringify(this.value);
this.unsubscribe = source?.on_update(event => {
if (this.disposed || event.type !== 'run_updated' && event.type !== 'runtime_updated') return;
this.dirty = true;
this.schedule();
});
}
private schedule() {
if (this.disposed || this.timer !== undefined || !this.listeners.size) return;
// A fixed window bounds traffic without starving updates during continuous activity.
this.timer = setTimeout(() => {
this.timer = undefined;
if (this.disposed || !this.dirty || !this.listeners.size) return;
this.value = this.read();
this.dirty = false;
const serialized = JSON.stringify(this.value);
if (serialized === this.last_sent) return;
this.last_sent = serialized;
const snapshot = this.value;
for (const listener of [...this.listeners]) {
if (this.disposed || !this.listeners.has(listener)) continue;
try {listener(structuredClone(snapshot));} catch { /* Observers own their failures. */ }
}
}, push_interval_ms);
this.timer.unref?.();
}
get_info() {
if (!this.disposed && this.dirty) this.value = this.read();
return structuredClone(this.value);
}
on_update(callback: (value: WorkflowStates) => void) {
if (this.disposed) throw new Error('Workflow info has been disposed');
if (!this.listeners.size) this.last_sent = JSON.stringify(this.get_info());
this.listeners.add(callback);
if (this.dirty) this.schedule();
return () => {
this.listeners.delete(callback);
if (!this.listeners.size && this.timer !== undefined) {
clearTimeout(this.timer);
this.timer = undefined;
}
};
}
dispose() {
if (this.disposed) return;
if (this.dirty) this.value = this.read();
this.disposed = true;
if (this.timer !== undefined) clearTimeout(this.timer);
this.timer = undefined;
this.unsubscribe?.();
this.listeners.clear();
}
}
+126
View File
@@ -0,0 +1,126 @@
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo, InfoI18n} from '../types.ts';
export interface WorldInfo {
time?: {time: number; time_of_day: number; day: number; is_day: boolean};
biome?: {id: number; name: string};
dimension?: string;
weather?: 'clear' | 'rain' | 'thunder';
light?: {block: number; sky: number};
}
export class init implements BaseInfo<WorldInfo> {
readonly name = 'world';
readonly i18n = {
lang: 'zh-CN',
name: '世界信息',
description: '机器人所在世界的时间、群系、维度、天气和光照',
fields: {
time: {
lang: 'zh-CN', name: '世界时间', description: '服务器同步的世界时间',
fields: {
time: {lang: 'zh-CN', name: '总时间', description: '世界累计昼夜时间,单位为游戏刻'},
time_of_day: {lang: 'zh-CN', name: '日内时间', description: '一天内的时间,范围为 0 至 23999 游戏刻'},
day: {lang: 'zh-CN', name: '天数', description: '世界时间对应的天数,从 0 开始'},
is_day: {lang: 'zh-CN', name: '是否白天', description: '日内时间是否处于 0 至 12999 游戏刻'},
},
},
biome: {
lang: 'zh-CN', name: '当前群系', description: '机器人当前位置的生物群系',
fields: {
id: {lang: 'zh-CN', name: '群系 ID', description: '当前版本的群系数值标识'},
name: {lang: 'zh-CN', name: '群系名称', description: '群系的英文标识'},
},
},
dimension: {
lang: 'zh-CN', name: '当前维度', description: '机器人所在的世界维度',
values: {
overworld: {lang: 'zh-CN', name: '主世界', description: '主世界维度'},
the_nether: {lang: 'zh-CN', name: '地狱', description: '下界维度'},
the_end: {lang: 'zh-CN', name: '末地', description: '末地维度'},
},
},
weather: {
lang: 'zh-CN', name: '当前天气', description: '世界天气状态,局部降水表现由群系决定',
values: {
clear: {lang: 'zh-CN', name: '晴天', description: '当前没有降水'},
rain: {lang: 'zh-CN', name: '降水', description: '当前正在降水,寒冷群系表现为降雪'},
thunder: {lang: 'zh-CN', name: '雷暴', description: '当前正在发生雷暴'},
},
},
light: {
lang: 'zh-CN', name: '当前位置光照', description: '当前位置的原始光照等级,不包含昼夜和天气衰减',
fields: {
block: {lang: 'zh-CN', name: '方块光照', description: '方块光源产生的光照等级,范围为 0 至 15'},
sky: {lang: 'zh-CN', name: '天空光照', description: '天空光照等级,范围为 0 至 15'},
},
},
},
} satisfies InfoI18n;
private world: WorldInfo = {};
private disposed = false;
private readonly listeners = new Set<(data: WorldInfo) => void>();
private readonly events = ['time', 'rain', 'game', 'spawn', 'respawn', 'move', 'forcedMove', 'physicsTick', 'chunkColumnLoad', 'chunkColumnUnload', 'blockUpdate'] as const;
constructor(private readonly instance: Bot) {
this.handle_world();
for (const event of this.events) instance.on(event, this.handle_world);
}
private readonly handle_world = (): void => {
if (this.disposed) return;
const bot = this.instance;
const world: WorldInfo = {};
if (typeof bot.time?.time === 'number') {
world.time = {
time: bot.time.time,
time_of_day: bot.time.timeOfDay,
day: bot.time.day,
is_day: bot.time.isDay,
};
}
if (typeof bot.game?.dimension === 'string') {
world.dimension = bot.game.dimension.replace(/^minecraft:/, '');
}
if (typeof bot.isRaining === 'boolean') {
// Nether and End have no weather, even if the server retains rain state.
const no_weather = world.dimension === 'the_nether' || world.dimension === 'the_end';
world.weather = no_weather || !bot.isRaining ? 'clear'
: bot.thunderState > 0 ? 'thunder' : 'rain';
}
const pos = bot.entity?.position;
const block = pos && bot.blockAt(pos, false);
if (block) {
if (block.biome) {
world.biome = {
id: block.biome.id,
name: block.biome.name,
};
}
world.light = {block: block.light, sky: block.skyLight};
}
if (JSON.stringify(this.world) === JSON.stringify(world)) return;
this.world = world;
for (const callback of Array.from(this.listeners)) callback(world);
};
get_info(): WorldInfo {
return this.world;
}
on_update(callback: (data: WorldInfo) => void): () => void {
if (this.disposed) throw new Error('WorldInfo 已销毁');
this.listeners.add(callback);
return () => { this.listeners.delete(callback); };
}
readonly dispose = (): void => {
if (this.disposed) return;
this.disposed = true;
for (const event of this.events) this.instance.off(event, this.handle_world);
this.listeners.clear();
};
}
+143
View File
@@ -0,0 +1,143 @@
import {readdir} from 'node:fs/promises';
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo, InfoConstructor, InfoContext, InfoI18n} from './types.ts';
export interface InfoSnapshot {
readonly name: string;
readonly i18n: InfoI18n;
readonly value: unknown;
}
export interface InfoUpdate {
readonly name: string;
readonly value: unknown;
}
export interface InfoManager {
get_info(name: string): InfoSnapshot | undefined;
get_all_info(): InfoSnapshot[];
/** Subscribes to future changes; returns an idempotent unsubscribe function. */
on_update(callback: (event: InfoUpdate) => void): () => void;
/** Safe to call repeatedly, including before the bot emits end. */
dispose(): void;
}
/** Scans bot/ and instance/ recursively and creates fresh modules for this bot. */
export async function create_info_manager(bot: Bot, options?: InfoContext): Promise<InfoManager> {
const infos = new Map<string, BaseInfo<unknown>>();
const subscriptions = new Set<() => void>();
const listeners = new Set<(event: InfoUpdate) => void>();
let disposed = false;
const assert_active = () => {
if (disposed) throw new Error('Info manager has been disposed');
};
const cleanup = (callback: () => void) => {
try { callback(); }
catch (error) { console.error('Info manager cleanup failed', error); }
};
const snapshot = (info: BaseInfo<unknown>): InfoSnapshot => ({
name: info.name,
i18n: {...info.i18n},
value: structuredClone(info.get_info()),
});
const manager: InfoManager = {
get_info(name) {
const info = infos.get(name);
return info ? snapshot(info) : undefined;
},
get_all_info() {
return Array.from(infos.values(), snapshot);
},
on_update(callback) {
assert_active();
// Each subscription owns its cancellation, even for the same callback.
const listener = (event: InfoUpdate) => callback(event);
listeners.add(listener);
return () => { listeners.delete(listener); };
},
dispose() {
if (disposed) return;
disposed = true;
bot.off('end', manager.dispose);
listeners.clear();
for (const unsubscribe of subscriptions) cleanup(unsubscribe);
subscriptions.clear();
for (const info of infos.values()) cleanup(() => info.dispose());
infos.clear();
},
};
// Install before the first await so an end during loading also cancels creation.
bot.once('end', manager.dispose);
try {
const directories = [new URL('./bot/', import.meta.url), new URL('./instance/', import.meta.url)];
const modules: URL[] = [];
for (const directory of directories) {
const entries = await readdir(directory, {withFileTypes: true});
assert_active();
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (entry.isDirectory() && /^[a-zA-Z0-9_-]+$/.test(entry.name)) {
directories.push(new URL(`${entry.name}/`, directory));
} else if (entry.isFile() && /^[a-zA-Z0-9_-]+\.ts$/.test(entry.name)) {
modules.push(new URL(entry.name, directory));
}
}
}
modules.sort((a, b) => a.href.localeCompare(b.href));
for (const url of modules) {
let info: BaseInfo<unknown> | undefined;
let unsubscribe: (() => void) | undefined;
try {
assert_active();
url.searchParams.set('t', `${Date.now()}-${process.hrtime.bigint()}`);
const module = await import(url.href) as {init?: unknown};
assert_active();
if (typeof module.init !== 'function') {
throw new Error('Info module must export an init constructor');
}
const candidate = new (module.init as InfoConstructor<unknown>)(bot, options);
if (!candidate || typeof candidate.dispose !== 'function') {
throw new Error('Info module must implement dispose()');
}
// Keep ownership locally until validation and subscription both succeed.
info = candidate;
assert_active();
if (typeof info.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(info.name)
|| !info.i18n || typeof info.i18n.lang !== 'string'
|| typeof info.i18n.name !== 'string' || typeof info.i18n.description !== 'string'
|| typeof info.get_info !== 'function' || typeof info.on_update !== 'function') {
throw new Error('Info module does not implement BaseInfo');
}
const name = info.name;
if (infos.has(name)) throw new Error(`Duplicate info name: ${name}`);
const cancel = info.on_update(value => {
if (disposed) return;
for (const listener of Array.from(listeners)) {
if (disposed) break;
if (!listeners.has(listener)) continue;
try { listener({name, value: structuredClone(value)}); }
catch (error) { console.error(`Info update listener failed: ${name}`, error); }
}
});
if (typeof cancel !== 'function') {
throw new Error('Info on_update() must return an unsubscribe function');
}
unsubscribe = cancel;
assert_active();
infos.set(name, info);
subscriptions.add(unsubscribe);
} catch (error) {
if (unsubscribe) cleanup(unsubscribe);
if (info) cleanup(() => info!.dispose());
throw new Error(`Info module ${url.pathname} initialization failed`, {cause: error});
}
}
assert_active();
return manager;
} catch (error) {
manager.dispose();
throw error;
}
}
+53
View File
@@ -0,0 +1,53 @@
import {performance} from 'node:perf_hooks';
import type {Bot} from '@wp2508/mineflayer';
import {instrument_bot, metadata, ResourceInfo} from '../resource_metrics.ts';
export interface CpuInfo {
scope: 'bot_context';
estimated: true;
method: 'instrumented_sync_time';
usage_percent: number | null;
sampled_time_ms: number;
interval_ms: number;
coverage: string;
}
const coverage = '计量 Bot.emit 和初始 _client.emit 监听器的同步执行(包括数据包监听器和 physicsTick),'
+ '以及函数 run 的同步调用阶段;嵌套调用去重。不包含 emit 前的协议解析、异步等待及后续执行、'
+ '无关定时器、上述入口之外的插件执行和 BotContext 通知。'
+ '同步经过时间包含垃圾回收和操作系统调度暂停,因此该指标不是进程 CPU 使用率。';
export class init extends ResourceInfo<CpuInfo> {
readonly name = 'used_cpu';
readonly i18n = metadata('上下文 CPU 占用估算', coverage, {
scope: ['归属范围', '指标归属于机器人上下文,固定为 bot_context'],
estimated: ['是否为估算', '固定为 true,表示该指标始终是估算值'],
method: ['计量方法', '通过插桩计量同步经过时间,不使用操作系统 CPU 计数器'],
usage_percent: ['占用比例', '采样同步耗时除以实际经过时间再乘以 100,单位为百分比;首次采样前为 null'],
sampled_time_ms: ['采样同步耗时', '本次采样间隔内去重后的同步执行耗时,单位为毫秒'],
interval_ms: ['采样间隔', '通过单调时钟测得的实际经过时间,单位为毫秒'],
coverage: ['计量覆盖范围', '同步执行计量包含的入口及未覆盖的执行阶段'],
});
private readonly meter: ReturnType<typeof instrument_bot>;
constructor(bot: Bot) {
super({scope: 'bot_context', estimated: true, method: 'instrumented_sync_time',
usage_percent: null, sampled_time_ms: 0, interval_ms: 0, coverage});
this.meter = instrument_bot(bot);
let time = performance.now();
let total = this.meter.total();
this.start(1000, () => {
const now = performance.now();
const next = this.meter.total();
const elapsed = now - time;
const sampled = Math.max(0, next - total);
time = now;
total = next;
this.publish({...this.info, usage_percent: elapsed > 0 ? sampled / elapsed * 100 : null,
sampled_time_ms: sampled, interval_ms: elapsed});
});
}
override dispose(): void {
super.dispose();
this.meter.dispose();
}
}
+188
View File
@@ -0,0 +1,188 @@
import {setImmediate as yield_batch} from 'node:timers/promises';
import {performance} from 'node:perf_hooks';
import type {Bot} from '@wp2508/mineflayer';
import type {InfoI18n} from '../types.ts';
import {metadata, ResourceInfo} from '../resource_metrics.ts';
export interface MemoryInfo {
estimated: true;
total_bytes: number;
chunks_bytes: number;
entities_bytes: number;
inventory_bytes: number;
other_bytes: number;
partial: boolean;
categories_complete: Record<Category, boolean>;
}
type Category = 'chunks' | 'entities' | 'inventory' | 'other';
const excluded = new Set(['registry', 'world', '_client', 'client', 'bot', 'global', 'globalThis',
'_events', '_eventsCount', '_maxListeners', '__proto__', 'constructor', 'prototype']);
const own = (object: unknown, key: PropertyKey): unknown => object && typeof object === 'object'
? Object.getOwnPropertyDescriptor(object, key)?.value : undefined;
const typed_buffer = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype), 'buffer')!.get!;
const data_buffer = Object.getOwnPropertyDescriptor(DataView.prototype, 'buffer')!.get!;
const array_length = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')!.get!;
const shared_length = Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, 'byteLength')!.get!;
/** Bounded live-data estimate. Objects/slots use approximate JS overhead; backing stores use real byteLength. */
export async function estimate_memory(bot: Bot, cancelled = () => false,
limits = {nodes: 50000, properties: 200000, milliseconds: 200}): Promise<MemoryInfo> {
const info: MemoryInfo = {estimated: true, total_bytes: 0, chunks_bytes: 0, entities_bytes: 0,
inventory_bytes: 0, other_bytes: 0, partial: true,
categories_complete: {chunks: false, entities: false, inventory: false, other: false}};
const start = performance.now();
const seen = new WeakSet<object>();
const blocked = new WeakSet<object>();
for (const key of ['registry', 'world', '_client']) {
const value = own(bot, key);
if (value && typeof value === 'object') blocked.add(value);
}
blocked.add(bot);
blocked.add(globalThis);
type Task = {value: unknown} | {object: object; keys: PropertyKey[]; index: number; columns?: boolean}
| {load_columns: true};
const categories: Category[] = ['chunks', 'entities', 'inventory', 'other'];
const roots = {entities: ['entity', 'entities', 'players'], inventory: ['inventory', 'currentWindow'],
other: ['game', 'time', 'settings', 'scoreboards', 'teams', 'tablist']};
// Fixed category order and DFS order make first-visit ownership deterministic, including backing stores.
const states = categories.map((category, index) => ({category,
stack: (category === 'chunks' ? [{load_columns: true}]
: [{object: bot, keys: roots[category], index: 0}]) as Task[],
nodes: 0, properties: 0, elapsed: 0, incomplete: false,
node_limit: Math.floor(limits.nodes / 4) + (index < limits.nodes % 4 ? 1 : 0),
property_limit: Math.floor(limits.properties / 4) + (index < limits.properties % 4 ? 1 : 0),
}));
let steps = 0;
let processed = 0;
while (states.some(state => state.stack.length)) {
if (cancelled() || performance.now() - start >= limits.milliseconds) break;
const state = states[steps++ % states.length];
if (!state.stack.length) continue;
if (state.elapsed >= limits.milliseconds / 4) {
state.incomplete = true;
state.stack.length = 0;
continue;
}
const turn = performance.now();
const {stack, category} = state;
const add = (bytes: number) => { info[`${category}_bytes`] += bytes; };
const cap = () => { state.incomplete = true; stack.length = 0; };
const task = stack.pop()!;
try {
if ('load_columns' in task) {
// WorldSync exposes getColumns on its prototype; resolve descriptors without invoking getters.
const world = own(bot, 'world');
const world_descriptor = Object.getOwnPropertyDescriptor(bot, 'world');
if (world_descriptor && !('value' in world_descriptor)) state.incomplete = true;
let target = world;
let descriptor: PropertyDescriptor | undefined;
for (let depth = 0; target && depth < 16; depth++, target = Object.getPrototypeOf(target)) {
descriptor = Object.getOwnPropertyDescriptor(target, 'getColumns');
if (descriptor) break;
}
if (world && typeof descriptor?.value !== 'function') state.incomplete = true;
else if (world) {
const columns = Reflect.apply(descriptor!.value, world, []);
const length = own(columns, 'length');
if (!Array.isArray(columns) || typeof length !== 'number') state.incomplete = true;
else {
if (length > 4096) state.incomplete = true;
// Extraction is incremental and charged to the chunks property budget.
stack.push({object: columns,
keys: Array.from({length: Math.min(length, 4096)}, (_, i) => String(i)),
index: 0, columns: true});
}
}
} else if ('object' in task) {
if (task.index < task.keys.length) {
if (state.properties >= state.property_limit) cap();
else {
state.properties++;
const key = task.keys[task.index++];
if (task.index < task.keys.length) stack.push(task);
if (typeof key !== 'string' || !excluded.has(key)) {
const descriptor = Object.getOwnPropertyDescriptor(task.object, key);
if (descriptor && !('value' in descriptor)) state.incomplete = true;
else if (descriptor) {
if (task.columns) {
const column = own(descriptor.value, 'column');
if (column && typeof column === 'object') stack.push({value: column});
else state.incomplete = true;
} else {
if (task.object !== bot) add(8 + (typeof key === 'string' ? key.length * 2 : 0));
if (typeof descriptor.value !== 'function') stack.push({value: descriptor.value});
}
}
}
}
}
} else {
const {value} = task;
if (typeof value === 'string') add(value.length * 2);
else if (typeof value === 'number' || typeof value === 'bigint') add(8);
else if (typeof value === 'boolean') add(4);
else if (value && typeof value === 'object' && !seen.has(value) && !blocked.has(value)) {
if (state.nodes >= state.node_limit) { cap(); continue; }
seen.add(value);
state.nodes++;
add(32);
if (ArrayBuffer.isView(value)) {
// Includes a pooled Buffer's full backing allocation once, even for overlapping views.
stack.push({value: Reflect.apply(value instanceof DataView ? data_buffer : typed_buffer, value, [])});
} else if (value instanceof ArrayBuffer || value instanceof SharedArrayBuffer) {
add(Reflect.apply(value instanceof ArrayBuffer ? array_length : shared_length, value, []));
} else {
// Never read property values directly: accessors and prototypes are deliberately omitted.
const keys = Reflect.ownKeys(value);
const remaining = Math.max(0, state.property_limit - state.properties);
if (keys.length > remaining) { keys.length = remaining; state.incomplete = true; }
if (keys.length) stack.push({object: value, keys, index: 0});
if (value instanceof Map || value instanceof Set) state.incomplete = true;
}
}
}
} catch { state.incomplete = true; }
finally { state.elapsed += performance.now() - turn; }
if (++processed % 256 === 0) await yield_batch();
}
for (const state of states) info.categories_complete[state.category] = !state.incomplete && !state.stack.length;
info.partial = Object.values(info.categories_complete).some(complete => !complete);
info.total_bytes = info.chunks_bytes + info.entities_bytes + info.inventory_bytes + info.other_bytes;
return info;
}
export class init extends ResourceInfo<MemoryInfo> {
readonly name = 'used_memory';
readonly i18n: InfoI18n = {...metadata('自有数据内存估算', '仅通过自身属性描述符分批估算当前数据,不代表堆内存或进程常驻内存。'
+ '扫描根为已加载的区块列对象(最多 4096 个)、entity/entities/players 和 inventory/currentWindow。'
+ '其他分类仅扫描本实例的 game、time、settings、scoreboards、teams、tablist 自有数据根。'
+ '排除注册表、世界对象遍历、客户端、全局对象、监听器、函数和原型;不调用属性 getter。'
+ '按区块、实体、物品栏、其他的固定顺序逐步轮转,共享对象及底层缓冲区归首次访问分类且只计一次,池化缓冲区按完整底层分配大小计入。'
+ '对象和字符串开销为近似值,不统计 Map/Set 内部数据。上限为 50000 个节点、200000 个属性和 200 毫秒,'
+ '节点、属性和同步处理时间各分类至多使用四分之一,总时限包含让出事件循环的时间。'
+ '每轮只处理一个值或属性,每 256 步让出事件循环;getColumns 和 ownKeys 枚举本身仍同步执行,单次调用可能超时。'
+ '完成状态仅针对上述有限范围;未完成分类的零值不表示没有内存占用。', {}), fields: {
...metadata('', '', {
estimated: ['是否为估算', '固定为 true,表示该指标始终是估算值'],
total_bytes: ['估算总内存', '各分类已扫描部分内存估算值之和,单位为字节;partial 为 true 时不是完整总量'],
chunks_bytes: ['区块内存', '已加载区块列自身数据的内存估算,单位为字节'],
entities_bytes: ['实体内存', '实体和玩家自身数据的内存估算,单位为字节'],
inventory_bytes: ['物品栏内存', '物品栏和当前窗口自身数据的内存估算,单位为字节'],
other_bytes: ['其他内存', '仅 game、time、settings、scoreboards、teams、tablist 自有数据的内存估算,单位为字节'],
partial: ['结果是否不完整', '首次采样尚未完成,或因扫描上限、取消、不支持或无法访问而遗漏了数据'],
}).fields,
categories_complete: metadata('分类完成状态', '各分类是否完成约定范围的扫描;初始均为 false,false 时字节数仅为已扫描部分,零值不能说明实际无占用。', {
chunks: ['区块扫描完成', '已扫描约定的全部区块列且未触及上限或遗漏数据时为 true'],
entities: ['实体扫描完成', '已扫描 entity、entities、players 且未触及上限或遗漏数据时为 true'],
inventory: ['物品栏扫描完成', '已扫描 inventory、currentWindow 且未触及上限或遗漏数据时为 true'],
other: ['其他扫描完成', '已扫描 game、time、settings、scoreboards、teams、tablist 且未触及上限或遗漏数据时为 true'],
}),
}};
constructor(bot: Bot) {
super({estimated: true, total_bytes: 0, chunks_bytes: 0, entities_bytes: 0,
inventory_bytes: 0, other_bytes: 0, partial: true,
categories_complete: {chunks: false, entities: false, inventory: false, other: false}});
this.start(5000, async () => { this.publish(await estimate_memory(bot, () => this.disposed)); }, true);
}
}
+62
View File
@@ -0,0 +1,62 @@
import {performance} from 'node:perf_hooks';
import type {Bot} from '@wp2508/mineflayer';
import {metadata, ResourceInfo} from '../resource_metrics.ts';
export interface NetworkInfo {
available: boolean;
received_bytes: number;
sent_bytes: number;
received_bytes_per_second: number | null;
sent_bytes_per_second: number | null;
interval_ms: number;
}
/** Totals include the first observed counters of every new socket. Unobserved old-socket tails are unknowable. */
export class NetworkCounter {
private socket: object | undefined;
private read = 0;
private written = 0;
private time: number | undefined;
private received = 0;
private sent = 0;
sample(socket: {bytesRead?: number; bytesWritten?: number} | undefined, now: number): NetworkInfo {
const read = socket?.bytesRead;
const written = socket?.bytesWritten;
const available = typeof read === 'number' && Number.isFinite(read) && read >= 0
&& typeof written === 'number' && Number.isFinite(written) && written >= 0;
const elapsed = this.time === undefined ? 0 : now - this.time;
const continuous = available && socket === this.socket && this.time !== undefined
&& read >= this.read && written >= this.written && elapsed > 0;
const dr = available ? (socket === this.socket && read >= this.read ? read - this.read : read) : 0;
const dw = available ? (socket === this.socket && written >= this.written ? written - this.written : written) : 0;
this.received += dr;
this.sent += dw;
if (available) {
this.socket = socket;
this.read = read;
this.written = written;
}
this.time = available ? now : undefined;
return {available, received_bytes: this.received, sent_bytes: this.sent,
received_bytes_per_second: continuous ? dr * 1000 / elapsed : null,
sent_bytes_per_second: continuous ? dw * 1000 / elapsed : null, interval_ms: continuous ? elapsed : 0};
}
}
export class init extends ResourceInfo<NetworkInfo> {
readonly name = 'used_network';
readonly i18n = metadata('机器人网络流量', '读取传输层套接字的字节计数,包含协议开销;'
+ '更换套接字后保留累计观察量,不包含旧套接字被替换前未观察到的尾部流量。', {
available: ['计数是否可用', '套接字是否同时提供有效的接收和发送字节计数'],
received_bytes: ['累计接收量', '累计观察到的接收流量,单位为字节'],
sent_bytes: ['累计发送量', '累计观察到的发送流量,单位为字节'],
received_bytes_per_second: ['接收速率', '接收流量速率,单位为字节每秒;缺少连续采样基线时为 null'],
sent_bytes_per_second: ['发送速率', '发送流量速率,单位为字节每秒;缺少连续采样基线时为 null'],
interval_ms: ['速率采样间隔', '通过单调时钟测得的实际速率采样间隔,单位为毫秒;缺少连续采样基线时为 0'],
});
constructor(bot: Bot) {
const counter = new NetworkCounter();
super(counter.sample(bot._client?.socket, performance.now()));
this.start(1000, () => { this.publish(counter.sample(bot._client?.socket, performance.now())); });
}
}
+75
View File
@@ -0,0 +1,75 @@
import {lstat, opendir} from 'node:fs/promises';
import path from 'node:path';
import {performance} from 'node:perf_hooks';
import type {Bot} from '@wp2508/mineflayer';
import {get_project_root_path} from '../../../utils/path_utils.ts';
import type {InfoContext} from '../types.ts';
import {metadata, ResourceInfo} from '../resource_metrics.ts';
export interface StorageInfo {
available: boolean;
total_bytes: number | null;
file_count: number;
partial: boolean;
}
/** lstat and streamed directory enumeration never intentionally follow symlinks. Live mutation can race a scan. */
export async function scan_storage(directory: string, cancelled = () => false,
limits = {entries: 20000, depth: 32, milliseconds: 2000}): Promise<StorageInfo> {
const info: StorageInfo = {available: false, total_bytes: null, file_count: 0, partial: false};
const start = performance.now();
let entries = 0;
const pending: [string, number][] = [[directory, 0]];
while (pending.length) {
if (cancelled() || entries >= limits.entries || performance.now() - start >= limits.milliseconds) {
info.partial = true; break;
}
const [current, depth] = pending.pop()!;
entries++;
try {
const stat = await lstat(current);
if (current === directory) {
if (!stat.isDirectory() || stat.isSymbolicLink()) { info.partial = true; break; }
info.available = true;
info.total_bytes = 0;
}
if (stat.isSymbolicLink()) continue;
if (stat.isFile()) {
info.total_bytes! += stat.size;
info.file_count++;
} else if (stat.isDirectory()) {
if (depth >= limits.depth) { info.partial = true; continue; }
const dir = await opendir(current);
for await (const entry of dir) {
if (cancelled() || entries + pending.length >= limits.entries
|| performance.now() - start >= limits.milliseconds) { info.partial = true; break; }
if (!entry.isSymbolicLink()) pending.push([path.join(current, entry.name), depth + 1]);
}
}
} catch (error) {
if (current === directory && (error as NodeJS.ErrnoException).code === 'ENOENT') {
info.available = true;
info.total_bytes = 0;
} else info.partial = true;
}
}
return info;
}
export class init extends ResourceInfo<StorageInfo> {
readonly name = 'used_storage';
readonly i18n = metadata('机器人数据存储', '统计 data/bot/<bot_id> 下文件的逻辑大小,跳过符号链接。'
+ '每 30 秒尽力扫描一次,不重叠执行;上限为 20000 个条目、32 层深度和 2 秒。'
+ '扫描期间的并发文件变动可能影响结果;该指标不代表实际分配的磁盘空间。', {
available: ['存储统计是否可用', '目录可访问或不存在时为 true,不存在按 0 统计;缺少有效机器人编号或根目录不可访问时为 false'],
total_bytes: ['文件总大小', '已观察到的普通文件逻辑大小之和,单位为字节;不可用时为 null'],
file_count: ['文件数量', '已观察到的普通文件数量'],
partial: ['结果是否不完整', '因扫描上限、取消或错误而未完成扫描;保留已读取文件的统计小计'],
});
constructor(_bot: Bot, options?: InfoContext) {
super({available: false, total_bytes: null, file_count: 0, partial: true});
if (!Number.isSafeInteger(options?.bot_id) || options!.bot_id! <= 0) return;
const directory = path.join(get_project_root_path(), 'data', 'bot', String(options!.bot_id));
this.start(30000, async () => { this.publish(await scan_storage(directory, () => this.disposed)); }, true);
}
}
+104
View File
@@ -0,0 +1,104 @@
import {performance} from 'node:perf_hooks';
import type {Bot} from '@wp2508/mineflayer';
import type {BaseInfo, InfoI18n} from './types.ts';
type Meter = {depth: number; total: number};
const meters = new WeakMap<object, Meter>();
/** Only the synchronous invocation is timed, even when it returns a Promise. */
export function measure_sync<T>(bot: object, invoke: () => T): T {
const meter = meters.get(bot);
if (!meter) return invoke();
const outer = meter.depth++ === 0;
const start = outer ? performance.now() : 0;
try { return invoke(); }
finally {
meter.depth--;
if (outer) meter.total += performance.now() - start;
}
}
export function instrument_bot(bot: Bot): {total: () => number; dispose: () => void} {
if (meters.has(bot)) throw new Error('Bot resource meter already installed');
const meter: Meter = {depth: 0, total: 0};
meters.set(bot, meter);
const restore: (() => void)[] = [];
try {
for (const target of new Set([bot, bot._client])) {
if (!target || typeof target.emit !== 'function') continue;
const descriptor = Object.getOwnPropertyDescriptor(target, 'emit');
const original = target.emit;
const wrapper = function(this: typeof target, ...args: Parameters<typeof original>) {
return measure_sync(bot, () => Reflect.apply(original, this, args));
};
Object.defineProperty(target, 'emit', {configurable: true, writable: true,
enumerable: descriptor?.enumerable ?? false, value: wrapper});
restore.push(() => {
if (Object.getOwnPropertyDescriptor(target, 'emit')?.value !== wrapper) return;
if (descriptor) Object.defineProperty(target, 'emit', descriptor);
else delete (target as unknown as {emit?: unknown}).emit;
});
}
} catch (error) {
for (const undo of restore.reverse()) undo();
meters.delete(bot);
throw error;
}
let disposed = false;
return {total: () => meter.total, dispose: () => {
if (disposed) return;
disposed = true;
meters.delete(bot);
for (const undo of restore.reverse()) undo();
}};
}
export function metadata(name: string, description: string, fields: Record<string, readonly [string, string]>): InfoI18n {
return {lang: 'zh-CN', name, description, fields: Object.fromEntries(Object.entries(fields).map(
([key, [name, description]]) => [key, {lang: 'zh-CN', name, description}],
))};
}
/** No module owns bot end listeners; InfoManager owns disposal. */
export abstract class ResourceInfo<T> implements BaseInfo<T> {
abstract readonly name: string;
abstract readonly i18n: InfoI18n;
protected disposed = false;
private timer?: ReturnType<typeof setInterval>;
private running = false;
private readonly listeners = new Set<(value: T) => void>();
constructor(protected info: T) {}
get_info(): T { return this.info; }
on_update(callback: (value: T) => void): () => void {
if (this.disposed) throw new Error('Resource info disposed');
this.listeners.add(callback);
return () => { this.listeners.delete(callback); };
}
protected publish(info: T): void {
if (this.disposed) return;
this.info = info;
for (const listener of Array.from(this.listeners)) {
if (this.disposed) break;
try { listener(info); }
catch (error) { console.error('Resource info listener failed', error); }
}
}
protected start(interval: number, sample: () => void | Promise<void>, immediate = false): void {
const run = async () => {
if (this.running || this.disposed) return;
this.running = true;
try { await sample(); }
catch (error) { if (!this.disposed) console.error('Resource sampling failed', error); }
finally { this.running = false; }
};
this.timer = setInterval(() => { void run(); }, interval);
this.timer.unref?.();
if (immediate) void run();
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
clearInterval(this.timer);
this.listeners.clear();
}
}
+49
View File
@@ -0,0 +1,49 @@
import type {Bot} from '@wp2508/mineflayer';
import type {PluginSnapshot} from '../plugins/init.ts';
import type {RunSnapshot, WorkflowRunEvent} from '../../types/bot/workflow/runtime.ts';
export interface InfoI18n {
readonly lang: string;
readonly name: string;
readonly description: string;
readonly fields?: Readonly<Record<string, InfoI18n>>;
readonly values?: Readonly<Record<string, InfoI18n>>;
}
/** 一个信息对象绑定一个 Bot 实例;由信息管理器统一监听 end 并销毁,单独使用时由调用方负责销毁。 */
export interface BaseInfo<T> {
/** 稳定的模块标识,不使用翻译后的显示名称。 */
readonly name: string;
readonly i18n: InfoI18n;
/** 同步读取当前值;销毁后仍可读取最后保存的值。 */
get_info(): T;
/**
* 订阅后续变化,不立即回调;初始值通过 get_info() 获取。
* 返回的取消函数可重复调用,销毁后禁止新增订阅并抛出错误。
* 回调同步执行,订阅者应自行处理异常。
*/
on_update(callback: (data: T) => void): () => void;
/** 移除 Bot 事件监听和全部订阅;可重复调用,销毁后不再发布更新。 */
dispose(): void;
}
/** 构造签名单独约束类本身,不属于 BaseInfo 的实例成员。 */
export interface InfoConstructor<T> {
new (instance: Bot, options?: InfoContext): BaseInfo<T>;
}
export interface InfoContext {
readonly bot_id?: number;
readonly plugin_source?: {
get_all_plugins(): PluginSnapshot[];
on_update(callback: (event: PluginSnapshot) => void): () => void;
};
readonly workflow_source?: {
get_snapshots(): RunSnapshot[];
get_runtime_snapshot?(): import('../../types/bot/workflow/runtime.ts').WorkflowRuntimeSnapshot;
on_update(callback: (event: WorkflowRunEvent) => void): () => void;
};
}
+626
View File
@@ -0,0 +1,626 @@
import { createBot } from "@wp2508/mineflayer";
import type { Bot, BotEvents } from "@wp2508/mineflayer";
import {bot_instance_config, bot_status} from "../../types/bot/instance/bot_runtime.ts";
import {
validate_host,
validate_port,
validate_proxy,
validate_username,
validate_version
} from "../../utils/validate.ts";
import {type bot_operation_code, type validate_code, validate_code_map} from "../../utils/validate_code_map.ts";
import { EventEmitter } from "node:events";
import { Mutex } from "async-mutex";
import {Logger} from "../../utils/logger.ts";
import {get_project_root_path} from "../../utils/path_utils.ts";
import * as path from 'path';
import {log} from "../../types/utils/logger.ts";
import {MinecraftJsonParser} from "../../utils/message_json_parser.ts";
import {remember_command_tree} from '../../utils/command_completion.ts';
import {create_info_manager, type InfoManager, type InfoUpdate} from '../info/init.ts';
import {create_function_manager, type FunctionManager} from '../function/init.ts';
import {PluginManager} from '../plugins/init.ts';
import type {WorkflowManager, WorkflowRunEvent} from '../../types/bot/workflow/runtime.ts';
// 深度清理的最大递归深度与最大节点数,避免遍历无关的深层对象图
const CLEANUP_MAX_DEPTH = 8;
const CLEANUP_MAX_VISITED = 2000;
// 共享 / 全局对象,禁止递归进入,避免误清理缓存或全局状态
const CLEANUP_SKIP_KEYS = new Set(['registry', 'supportFeature', 'constructor']);
// 需要显式清理的定时器与待发队列字段名
const CLEANUP_TIMER_KEYS = ['closeTimer', '_keepAliveTimer', 'keepAliveTimer', 'doPhysicsTimer'];
const CLEANUP_QUEUE_KEYS = ['_writeQueue', '_priorityWriteQueue', '_mcBundle'];
// 全局对象,绝不能被遍历清理
const CLEANUP_PROTECTED: unknown[] = [globalThis, process, console];
// 'end' 事件未按时派发时的兜底清理延时(毫秒)
const CLEANUP_FALLBACK_DELAY = 1000;
// 事件表:mineflayer 的全部事件 + 实例自有的 log 事件
export interface bot_instance_event extends BotEvents {
log: (log: log[]) => void;
status_update: (status: bot_status) => void;
instance_created: (instance: Bot) => void;
info_update: (event: InfoUpdate) => void;
connection_lost: () => void;
connection_requested: () => void;
reconnect_attempt: () => void;
workflow_update: (event: WorkflowRunEvent) => void;
}
export type bot_event_name = keyof bot_instance_event;
// 单个事件的回调参数元组,例如 chat -> [username, message, translate, jsonMsg, matches]
export type bot_event_args<E extends bot_event_name> = Parameters<bot_instance_event[E]>;
export type bot_event_handler<E extends bot_event_name> = (...args: bot_event_args<E>) => void;
// onAny / onceAny 在事件名之后接收对应事件的全部参数;
// 事件名受联合类型约束,参数保持宽松以便统一处理任意事件
export type bot_event_any_handler = (event: bot_event_name, ...args: any[]) => void;
export type bot_instance_context<T> = {
readonly bot_instance: Bot | null,
readonly info_manager: InfoManager | null,
readonly function_manager: FunctionManager | null,
workflow_manager: WorkflowManager | null,
bot_config: bot_instance_config,
readonly bot_status: bot_status,
readonly plugin_manager: PluginManager,
event_emitter: EventEmitter,
logger: Logger | null,
action_lock: boolean,
instance_lock: Mutex,
validate_config: (config: bot_instance_config) => validate_code,
log: (log: log[]) => void,
on: <E extends bot_event_name>(event: E, callback: bot_event_handler<E>) => void,
onAny: (callback: bot_event_any_handler) => void,
once: <E extends bot_event_name>(event: E, callback: bot_event_handler<E>) => void,
onceAny: (callback: bot_event_any_handler) => void,
off: <E extends bot_event_name>(event: E, callback: bot_event_handler<E>) => void,
emit: <E extends bot_event_name>(event: E, ...args: bot_event_args<E>) => void,
connect: () => Promise<bot_operation_code>,
create_instance: () => Promise<bot_operation_code>,
disconnect: () => Promise<bot_operation_code>,
schedule_reconnect: (delay: number) => Promise<void>,
cancel_reconnect: () => void,
inject: (callback: (context: bot_instance_context<T>) => void) => void,
clear_this_context: () => Promise<void>
}
export class BotContext implements bot_instance_context<any> {
get is_disposed(): boolean {return this.disposal_requested;}
public workflow_manager: WorkflowManager | null = null;
private bot_instance_origin: Bot | null = null;
public get bot_instance(): Bot | null {
return this.bot_instance_origin;
}
private info_manager_origin: InfoManager | null = null;
public get info_manager(): InfoManager | null {
return this.info_manager_origin;
}
private info_unsubscribe: (() => void) | undefined;
private function_manager_origin: FunctionManager | null = null;
public get function_manager(): FunctionManager | null {
return this.function_manager_origin;
}
private cleanup_task: Promise<void> | undefined;
private dispose_task: Promise<void> | undefined;
private disposal_requested = false;
private operation_sequence = 0;
private reconnect_timer: ReturnType<typeof setTimeout> | undefined;
public readonly bot_config: bot_instance_config = {
username: '',
host: '',
port: 0,
version: '',
user_id: 0,
bot_id: 0,
proxy: null
};
private bot_status_origin: bot_status = 'offline';
public get bot_status(): bot_status {
return this.bot_status_origin;
}
private set_status(value: bot_status) {
if (this.bot_status_origin === value) {
return;
}
const transitions: Record<bot_status, readonly bot_status[]> = {
offline: ['ready', 'reconnecting'],
ready: ['connecting', 'disconnecting'],
connecting: ['online', 'disconnecting'],
online: ['disconnecting'],
disconnecting: ['offline'],
reconnecting: ['ready', 'offline'],
};
if (!transitions[this.bot_status_origin].includes(value)) {
throw new Error(`无效的机器人状态转换: ${this.bot_status_origin} -> ${value}`);
}
this.bot_status_origin = value;
this.notify('status_update', value);
}
private notify<E extends bot_event_name>(event: E, ...args: bot_event_args<E>) {
try {
this.emit(event, ...args);
} catch (error) {
this.logger?.log([this.logger.error(`生命周期事件 ${String(event)} 处理失败: ${String(error)}`)]);
}
}
public readonly plugin_manager: PluginManager;
public event_emitter = new EventEmitter()
// 记录每个原始回调对应的包装函数,off 时才能找回并移除同一个监听器
private readonly event_wrappers = new Map<
string,
Map<(...args: any[]) => void, ((...args: any[]) => void)[]>
>();
public logger: Logger | null = null;
public action_lock: boolean = false;
// 串行化 create_instance / connect / disconnect,防止并发操作实例
public readonly instance_lock : Mutex = new Mutex();
constructor(config: bot_instance_config) {
this.bot_config = config;
const log_path = path.join(get_project_root_path(), 'data/bot/', this.bot_config.bot_id.toString(), '/log');
this.logger = new Logger(log_path);
this.plugin_manager = new PluginManager(this);
}
public log(log: log[]) {
this.logger.log(log);
this.emit('log', log);
}
public create_instance(): Promise<bot_operation_code> {
if (this.bot_status === 'reconnecting' || this.reconnect_timer !== undefined) this.cancel_reconnect();
const sequence = this.operation_sequence;
return this.instance_lock.runExclusive(async () => {
await this.cleanup_task;
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled;
if (this.bot_status === 'online') return validate_code_map.bot_context.instance_connected;
if (this.bot_status === 'connecting') return validate_code_map.bot_context.instance_connecting;
if (this.bot_instance) return validate_code_map.bot_context.ok;
const result = await this.create_bot_instance(sequence);
if (result !== validate_code_map.bot_context.ok) await this.cleanup_task;
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled;
return result;
});
}
private async create_bot_instance(sequence: number): Promise<bot_operation_code> {
const result = this.validate_config(this.bot_config);
if (result !== validate_code_map.username.ok) return result;
let instance: Bot | undefined;
let pending_manager: InfoManager | undefined;
let pending_functions: FunctionManager | undefined;
let initialized = false;
const failure = (): bot_operation_code | undefined => {
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled;
if (this.bot_instance !== instance) return validate_code_map.bot_context.error;
};
try {
const {port, plugins, workflow, config_revision, ...options} = this.bot_config;
instance = createBot({...options, ...(port != null ? {port} : {}), autoConnect: false, hideErrors: true});
(instance as Bot & {server_address: string}).server_address = `${options.host.includes(':') ? `[${options.host}]` : options.host}:${port ?? 25565}`;
this.bot_instance_origin = instance;
instance._client.on('declare_commands', packet => remember_command_tree(instance, packet));
const terminate = <E extends 'end' | 'kicked' | 'error'>(event: E, ...args: bot_event_args<E>) => {
if (this.bot_instance !== instance || this.disposal_requested) return;
const reason = args[0];
if (event === 'error') {
this.log([this.logger.error(`连接错误: ${String(reason)}`)]);
} else if (event === 'kicked') {
let kickReason: Parameters<typeof MinecraftJsonParser.toLog>[0] = args[0] as Parameters<typeof MinecraftJsonParser.toLog>[0];
if (typeof kickReason === 'string') {
try { kickReason = JSON.parse(kickReason); }
catch { /* Plain-text disconnect reasons are valid too. */ }
}
const reasonLog = MinecraftJsonParser.toLog(kickReason);
if (!reasonLog.length) {
let fallback = '未知原因';
try { fallback = JSON.stringify(kickReason) ?? fallback; }
catch { /* Keep a readable fallback for malformed data. */ }
reasonLog.push(this.logger.error(fallback));
}
this.log([
this.logger.custom('[被踢出] ', '#cf8e8e', ['bold']),
...reasonLog,
]);
} else {
this.log([this.logger.warning(`连接已断开: ${String(reason ?? '未知原因')}`)]);
}
const sequence = this.operation_sequence;
// Initialization failures are handled by the caller, including retry attempts.
const report_connection_lost = initialized;
const cleanup = this.clear_bot_instance(event === 'end');
this.notify(event, ...args);
void cleanup.then(() => {
if (report_connection_lost && !this.disposal_requested && sequence === this.operation_sequence) {
this.notify('connection_lost');
}
}).catch(error => {
this.logger?.log([this.logger.error(`清理实例失败: ${String(error)}`)]);
});
};
instance.on('end', reason => terminate('end', reason));
instance.on('kicked', (reason, logged_in) => terminate('kicked', reason, logged_in));
instance.on('error', (error: Error) => terminate('error', error));
instance.on('initialLogin', () => {
if (this.bot_instance !== instance || this.bot_status !== 'connecting' || this.disposal_requested) return;
this.set_status('online');
});
instance.on("message", (jsonMsg, position) => {
const channel = {
chat: {label: '聊天', color: '#8fb0c9'},
system: {label: '系统', color: '#d1b478'},
game_info: {label: '提示', color: '#8fb98f'},
}[position] ?? {label: String(position), color: '#8fb0c9'};
this.log([
this.logger.custom(`[${channel.label}] `, channel.color, ['bold']),
...MinecraftJsonParser.parse(jsonMsg).log,
]);
})
instance.onAny((event: string, ...args: any[]) => {
if (this.bot_instance === instance && !this.disposal_requested) this.emit(event, ...args);
});
this.set_status('ready');
const before_loading = failure();
if (before_loading !== undefined) return before_loading;
const manager = pending_manager = await create_info_manager(instance, {
bot_id: this.bot_config.bot_id, plugin_source: this.plugin_manager,
workflow_source: this.workflow_manager,
});
const after_loading = failure();
if (after_loading !== undefined) return after_loading;
this.info_manager_origin = manager;
pending_manager = undefined;
this.info_unsubscribe = manager.on_update(event => {
if (this.bot_instance === instance && this.info_manager === manager
&& !this.disposal_requested) {
this.notify('info_update', event);
}
});
pending_functions = await create_function_manager(this);
const after_functions = failure();
if (after_functions !== undefined) return after_functions;
this.function_manager_origin = pending_functions;
pending_functions = undefined;
this.notify('instance_created', instance);
const after_notification = failure();
if (after_notification !== undefined) return after_notification;
this.log([this.logger.custom('[NODE] 创建机器人实例成功', '#8fb98f', ['bold'])]);
const after_log = failure();
if (after_log !== undefined) return after_log;
initialized = true;
return validate_code_map.bot_context.ok;
} catch (error) {
this.logger?.log([this.logger.error(`创建实例失败: ${String(error)}`)]);
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled;
return validate_code_map.bot_context.error;
} finally {
if (!initialized) {
pending_functions?.dispose();
try { pending_manager?.dispose(); }
catch (error) { this.logger?.log([this.logger.error(`清理信息管理器失败: ${String(error)}`)]); }
if (instance && this.bot_instance === instance) await this.clear_bot_instance();
else await this.cleanup_task;
}
}
}
public inject(callback: (context: bot_instance_context<any>) => void) {
callback(this as bot_instance_context<any>);
}
public connect(): Promise<bot_operation_code> {
if (this.bot_status === 'reconnecting' || this.reconnect_timer !== undefined) this.cancel_reconnect();
const sequence = this.operation_sequence;
if (!this.disposal_requested) this.notify('connection_requested');
return this.connect_instance(sequence);
}
private connect_instance(sequence: number): Promise<bot_operation_code> {
return this.instance_lock.runExclusive(async () => {
await this.cleanup_task;
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled;
if (this.bot_status === 'online') return validate_code_map.bot_context.instance_connected;
if (this.bot_status === 'connecting') return validate_code_map.bot_context.instance_connecting;
if (this.bot_status === 'reconnecting') this.set_status('offline');
if (!this.bot_instance) {
const result = await this.create_bot_instance(sequence);
if (result !== validate_code_map.bot_context.ok) {
await this.cleanup_task;
return result;
}
}
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
if (sequence !== this.operation_sequence) return validate_code_map.bot_context.cancelled;
const instance = this.bot_instance;
if (!instance) return validate_code_map.bot_context.error;
this.set_status('connecting');
if (this.disposal_requested || sequence !== this.operation_sequence) {
await this.clear_bot_instance();
return validate_code_map.bot_context.cancelled;
}
try {
instance.connect();
if (this.bot_instance !== instance) {
await this.cleanup_task;
return validate_code_map.bot_context.error;
}
return validate_code_map.bot_context.ok;
} catch (error) {
this.logger?.log([this.logger.error(`连接实例失败: ${String(error)}`)]);
await this.clear_bot_instance();
return validate_code_map.bot_context.error;
}
});
}
public disconnect(): Promise<bot_operation_code> {
this.cancel_reconnect();
return this.instance_lock.runExclusive(async () => {
if (this.disposal_requested) return validate_code_map.bot_context.disposed;
await this.clear_bot_instance();
return validate_code_map.bot_context.ok;
});
}
public cancel_reconnect(): void {
this.operation_sequence++;
clearTimeout(this.reconnect_timer);
this.reconnect_timer = undefined;
if (this.bot_status === 'reconnecting') this.set_status('offline');
}
public async schedule_reconnect(delay: number): Promise<void> {
if (!Number.isInteger(delay) || delay < 0 || delay > 2_147_483_647) {
throw new Error('重连延时必须是有效的毫秒整数');
}
const sequence = this.operation_sequence;
await this.instance_lock.runExclusive(() => {
if (this.disposal_requested || sequence !== this.operation_sequence || this.bot_status !== 'offline') return;
this.reconnect_timer = setTimeout(() => {
this.reconnect_timer = undefined;
if (this.disposal_requested || sequence !== this.operation_sequence) return;
this.notify('reconnect_attempt');
void this.connect_instance(sequence).then(result => {
if (result !== validate_code_map.bot_context.ok && !this.disposal_requested
&& sequence === this.operation_sequence && this.bot_status === 'offline') {
this.notify('connection_lost');
}
}).catch(error => {
this.logger?.log([this.logger.error(`自动重连失败: ${String(error)}`)]);
});
}, delay);
this.set_status('reconnecting');
});
}
public on<E extends bot_event_name>(event: E, callback: bot_event_handler<E>) {
const wrapper: bot_event_handler<E> = (...args) => callback(...args);
this.remember_wrapper(event, callback, wrapper);
this.event_emitter.on(event, wrapper);
}
public onAny(callback: bot_event_any_handler) {
const wrapper = (event: string, ...args: any[]) => callback(event as bot_event_name, ...args);
this.remember_wrapper('any', callback, wrapper);
this.event_emitter.on('any', wrapper);
}
public once<E extends bot_event_name>(event: E, callback: bot_event_handler<E>) {
const wrapper: bot_event_handler<E> = (...args) => callback(...args);
this.remember_wrapper(event, callback, wrapper);
this.event_emitter.once(event, wrapper);
}
public onceAny(callback: bot_event_any_handler) {
const wrapper = (event: string, ...args: any[]) => callback(event as bot_event_name, ...args);
this.remember_wrapper('any', callback, wrapper);
this.event_emitter.once('any', wrapper);
}
public off<E extends bot_event_name>(event: E, callback: bot_event_handler<E>) {
const callbacks = this.event_wrappers.get(event);
const wrappers = callbacks?.get(callback);
if (!callbacks || !wrappers) return;
const wrapper = wrappers.pop();
if (!wrapper) return;
this.event_emitter.off(event, wrapper);
if (wrappers.length === 0) {
callbacks.delete(callback);
}
}
// 记录原始回调 -> 包装函数栈,off 时才能找回并移除同一个监听器;
// 同一回调可多次注册,off 从栈顶移除最近一次
private remember_wrapper(
event: string,
callback: (...args: any[]) => void,
wrapper: (...args: any[]) => void,
) {
let callbacks = this.event_wrappers.get(event);
if (!callbacks) {
callbacks = new Map();
this.event_wrappers.set(event, callbacks);
}
let wrappers = callbacks.get(callback);
if (!wrappers) {
wrappers = [];
callbacks.set(callback, wrappers);
}
wrappers.push(wrapper);
}
public emit<E extends bot_event_name>(event: E, ...args: bot_event_args<E>): void;
public emit(event: string, ...args: any[]): void;
public emit(event: string, ...args: any[]) {
// 'error' 无监听器时 EventEmitter 会抛出;转发前先兜底,避免 bot 的错误
// 变成未捕获异常,同时保证 'any' / onAny 仍能观察到该事件
if (event !== 'error' || this.event_emitter.listenerCount('error') > 0) {
this.event_emitter.emit(event, ...args);
}
this.event_emitter.emit('any', event, ...args);
}
private clear_bot_instance(ended = false): Promise<void> {
if (this.cleanup_task) return this.cleanup_task;
const instance = this.bot_instance;
if (!instance) return Promise.resolve();
this.bot_instance_origin = null;
const manager = this.info_manager_origin;
const functions = this.function_manager_origin;
this.function_manager_origin = null;
const unsubscribe = this.info_unsubscribe;
this.info_manager_origin = null;
this.info_unsubscribe = undefined;
let cleaned = false;
let finish!: () => void;
const task = new Promise<void>(resolve => { finish = resolve; });
this.cleanup_task = task.then(() => {
this.cleanup_task = undefined;
if (!this.disposal_requested) this.set_status('offline');
});
const cleanup_task = this.cleanup_task;
const run_cleanup = () => {
if (cleaned) return;
cleaned = true;
clearTimeout(fallback_timer);
try {
this.deep_cleanup(instance, new Set(), 0);
this.log([this.logger.custom('[NODE] 机器人实例已关闭', '#cf8e8e', ['bold'])]);
} catch (error) {
this.logger?.log([this.logger.error(`清理实例资源失败: ${String(error)}`)]);
} finally {
finish();
}
};
// 等 end 的所有监听器执行完毕后再摘除监听器,保留 mineflayer 的内部资源清理。
const on_end = () => { queueMicrotask(run_cleanup); };
const fallback_timer = setTimeout(run_cleanup, CLEANUP_FALLBACK_DELAY);
for (const cleanup of [unsubscribe, manager ? () => manager.dispose() : undefined, functions ? () => functions.dispose() : undefined]) {
try { cleanup?.(); }
catch (error) { this.logger?.log([this.logger.error(`清理信息管理器失败: ${String(error)}`)]); }
}
this.set_status('disconnecting');
if (ended) {
on_end();
} else {
instance.once('end', on_end);
try {
instance.end(validate_code_map.bot_context.instance_end_by_user.toString());
} catch {
on_end();
}
}
return cleanup_task;
}
// 深度遍历对象图,释放监听器 / 定时器 / 流句柄等残留状态;
// seen 去重,depth 限制递归深度,visited 限制遍历规模,避免开销失控
private deep_cleanup(target: unknown, seen: Set<object>, depth: number, visited = { count: 0 }) {
if (target === null || typeof target !== 'object') return;
if (depth > CLEANUP_MAX_DEPTH || visited.count > CLEANUP_MAX_VISITED) return;
const object = target as Record<string, any>;
if (CLEANUP_PROTECTED.includes(object)) return;
if (seen.has(object)) return;
seen.add(object);
visited.count++;
this.release_own_state(object);
for (const key of Object.keys(object)) {
if (CLEANUP_SKIP_KEYS.has(key)) continue;
let value: unknown;
try {
value = object[key];
} catch {
continue;
}
if (value === null || typeof value !== 'object') continue;
this.deep_cleanup(value, seen, depth + 1, visited);
}
}
// 释放单个对象自身持有的监听器 / 定时器 / 流句柄 / 待发队列
private release_own_state(object: Record<string, any>) {
for (const key of CLEANUP_TIMER_KEYS) {
const timer = object[key];
if (timer === null || timer === undefined) continue;
clearInterval(timer as NodeJS.Timeout);
clearTimeout(timer as NodeJS.Timeout);
try {
object[key] = null;
} catch {
// 只读 / 冻结对象无法赋值,忽略
}
}
for (const key of CLEANUP_QUEUE_KEYS) {
if (Array.isArray(object[key])) {
(object[key] as unknown[]).length = 0;
}
}
// 仅销毁流 / socket 类句柄,避免误调用无关对象的 destroy
if (typeof object.destroy === 'function' && object.destroyed !== true
&& (typeof object.pipe === 'function' || typeof object.write === 'function')) {
try {
object.destroy();
} catch {
// 忽略已关闭句柄在销毁时的异常
}
}
if (typeof object.removeAllListeners === 'function') {
object.removeAllListeners();
}
}
public validate_config(config: bot_instance_config): validate_code {
const username_validate = validate_username(config.username);
if (username_validate !== validate_code_map.username.ok) return username_validate;
const host_validate = validate_host(config.host);
if (host_validate !== validate_code_map.host.ok) return host_validate;
if (config.port != null) {
const port_validate = validate_port(config.port);
if (port_validate !== validate_code_map.port.ok) return port_validate;
}
const version_validate = validate_version(config.version);
if (version_validate !== validate_code_map.version.ok) return version_validate;
if (config.proxy != null) {
const proxy_validate = validate_proxy(config.proxy);
if (proxy_validate !== validate_code_map.proxy.ok) return proxy_validate;
}
return validate_code_map.username.ok;
}
public clear_this_context(): Promise<void> {
if (this.dispose_task) return this.dispose_task;
this.disposal_requested = true;
this.cancel_reconnect();
const plugins_disposed = this.plugin_manager.dispose().catch(error => {
this.logger?.log([this.logger.error(`插件卸载失败: ${String(error)}`)]);
});
const workflow_disposed = this.workflow_manager?.dispose().catch(error => {
this.logger?.log([this.logger.error(`Workflow cleanup failed: ${String(error)}`)]);
});
this.dispose_task = Promise.all([plugins_disposed, workflow_disposed]).then(() => this.instance_lock.runExclusive(async () => {
await this.clear_bot_instance();
this.action_lock = false;
if (this.bot_status === 'offline') {
this.notify('status_update', 'offline');
} else {
this.set_status('offline');
}
this.event_emitter.removeAllListeners();
this.event_wrappers.clear();
this.workflow_manager = null;
await this.logger?.close();
}));
return this.dispose_task;
}
}
+181
View File
@@ -0,0 +1,181 @@
import {bot_instance_context, BotContext} from "./bot_instance_init";
import {bot_config, bot_status} from "../../types/bot/instance/bot_runtime";
import {validate_code_map} from "../../utils/validate_code_map";
import {init_workflow} from "../workflow/init";
import {update_one_bot_status} from "../../service/server/update/bot_status.ts";
import type {Socket} from "socket.io-client";
const bot_context_map: Map<number, bot_instance_context<any>> = new Map();
const bot_action_lock_map = new Map<number, boolean>();
type ContextObserver = (context: bot_instance_context<any> | undefined) => void;
const bot_context_observers = new Map<number, Set<ContextObserver>>();
export function get_bot_context(bot_id: number): bot_instance_context<any> | undefined {
return bot_context_map.get(bot_id);
}
function notify_context_observer(callback: ContextObserver, context: bot_instance_context<any> | undefined) {
try {
callback(context);
} catch {
console.error('Bot context observer failed');
}
}
function notify_bot_context(bot_id: number) {
for (const callback of Array.from(bot_context_observers.get(bot_id) ?? [])) {
notify_context_observer(callback, bot_context_map.get(bot_id));
}
}
export function observe_bot_context(bot_id: number, callback: ContextObserver): () => void {
let observers = bot_context_observers.get(bot_id);
if (!observers) {
observers = new Set();
bot_context_observers.set(bot_id, observers);
}
observers.add(callback);
notify_context_observer(callback, bot_context_map.get(bot_id));
return () => {
observers.delete(callback);
if (observers.size === 0 && bot_context_observers.get(bot_id) === observers) {
bot_context_observers.delete(bot_id);
}
};
}
function remove_bot_context(bot_id: number, context: bot_instance_context<any>) {
if (bot_context_map.get(bot_id) === context) {
bot_context_map.delete(bot_id);
notify_bot_context(bot_id);
}
}
export async function start_bot_context(main_server_socket: Socket, bot_config: bot_config, node_id: number) {
if (!Number.isSafeInteger(node_id) || node_id <= 0
|| !bot_config || typeof bot_config !== "object" || Array.isArray(bot_config)
|| !bot_config.instance_config || typeof bot_config.instance_config !== "object"
|| Array.isArray(bot_config.instance_config)
|| !Number.isSafeInteger(bot_config.instance_config.bot_id) || bot_config.instance_config.bot_id <= 0
|| !Number.isSafeInteger(bot_config.instance_config.user_id) || bot_config.instance_config.user_id <= 0
|| !Array.isArray(bot_config.plugins) || !Array.isArray(bot_config.workflow)
|| !bot_config.plugins.every(item => item && typeof item === "object" && !Array.isArray(item))
|| !bot_config.workflow.every(item => item && typeof item === "object" && !Array.isArray(item))) {
return validate_code_map.web.invalid_parameter;
}
const bot_id = bot_config.instance_config.bot_id;
if (bot_action_lock_map.get(bot_id) ?? false) {
return validate_code_map.bot_context.processing;
}
bot_action_lock_map.set(bot_id, true);
let new_context: BotContext | undefined;
let context_started = false;
try {
const temp_context = bot_context_map.get(bot_id);
if (temp_context !== undefined) {
if (temp_context.bot_status === "online" || temp_context.bot_status === "connecting"
|| temp_context.bot_status === "reconnecting" || temp_context.bot_status === "ready") {
const result = await temp_context.connect();
return result === validate_code_map.bot_context.ok ? validate_code_map.bot_context.context_created : result;
} else if (temp_context.bot_status === "disconnecting") {
return validate_code_map.bot_context.processing;
} else {
await temp_context.clear_this_context();
remove_bot_context(bot_id, temp_context);
}
}
const context = new_context = new BotContext(bot_config.instance_config);
context.bot_config.plugins = structuredClone(bot_config.plugins);
context.bot_config.workflow = structuredClone(bot_config.workflow);
context.on("status_update", (status) => {
void update_one_bot_status(main_server_socket, node_id, bot_id, bot_config.instance_config.user_id, status)
.catch(error => console.error(`Bot ${bot_id} status update failed`, error));
});
const origin_clean = context.clear_this_context.bind(context);
context.clear_this_context = async () => {
try {
await origin_clean();
} finally {
remove_bot_context(bot_id, context);
}
};
const validation_result = context.validate_config(bot_config.instance_config);
if (validation_result !== validate_code_map.bot_context.ok) {
console.error(`Bot ${bot_id} configuration validation failed, code: ${validation_result}`);
return validation_result;
}
await context.plugin_manager.init(bot_config.plugins);
await init_workflow(context, bot_config.workflow);
bot_context_map.set(bot_id, context);
notify_bot_context(bot_id);
const result = await context.connect();
if (result !== validate_code_map.bot_context.ok
&& result !== validate_code_map.bot_context.instance_connected
&& result !== validate_code_map.bot_context.instance_connecting) {
console.error(`Bot ${bot_id} startup failed, code: ${result}`);
return result;
}
context_started = true;
return result === validate_code_map.bot_context.ok ? validate_code_map.bot_context.context_created : result;
} catch (error) {
console.error(`Bot ${bot_id} startup failed: ${error instanceof Error ? error.message : String(error)}`);
return validate_code_map.bot_context.error;
} finally {
if (new_context && !context_started) {
try {
await new_context.clear_this_context();
} catch (error) {
console.error(`Bot ${bot_id} cleanup failed`, error);
}
}
bot_action_lock_map.delete(bot_id);
}
}
export async function get_bot_context_status() : Promise<{
bot_id: number;
status: bot_status;
}[]> {
return Array.from(bot_context_map).map(([bot_id, context]) => {
return {
bot_id,
status: context.bot_status,
}
});
}
export async function get_bot_context_status_by_ids(bot_id: number[]) : Promise<{
bot_id: number;
status: bot_status;
}[]> {
return bot_id.map((id) => {
return {
bot_id: id,
status: bot_context_map.get(id)?.bot_status ?? "offline",
}
});
}
export async function stop_bot_context(bot_id: number) {
if (bot_action_lock_map.get(bot_id) ?? false) {
return validate_code_map.bot_context.processing;
}
bot_action_lock_map.set(bot_id, true);
try {
if (!bot_context_map.has(bot_id)) {
return validate_code_map.bot_context.instance_end_by_user;
}
const context = bot_context_map.get(bot_id);
if (context !== undefined) {
await context.clear_this_context();
}
if (context) remove_bot_context(bot_id, context);
return validate_code_map.bot_context.instance_end_by_user;
} catch (error) {
return validate_code_map.bot_context.error;
} finally {
bot_action_lock_map.delete(bot_id);
}
}
+14
View File
@@ -0,0 +1,14 @@
export type PluginErrorReason = 'INVALID_CONFIG' | 'ALREADY_INSTALLED' | 'NOT_INSTALLED'
| 'MANAGER_DISPOSED' | 'LOAD_FAILED' | 'INIT_FAILED' | 'DISPOSE_FAILED';
export class PluginError extends Error {
constructor(readonly reason: PluginErrorReason) {
super({
INVALID_CONFIG: 'Invalid plugin config', ALREADY_INSTALLED: 'Duplicate plugin',
NOT_INSTALLED: 'Plugin not installed', MANAGER_DISPOSED: 'Plugin manager disposed',
LOAD_FAILED: 'Plugin load failed', INIT_FAILED: 'Plugin initialization failed',
DISPOSE_FAILED: 'Plugin disposal failed',
}[reason]);
this.name = 'PluginError';
}
}
+346
View File
@@ -0,0 +1,346 @@
import {readdir} from 'node:fs/promises';
import {isDeepStrictEqual} from 'node:util';
import type {BasePlugin, PluginContext, base_define, bot_config_plugin, plugin} from '../../types/bot/plugins/plugin.ts';
import type {data_type} from '../../types/bot/instance/bot_runtime.ts';
import {PluginError} from './error.ts';
import {plain_plugin_object, valid_plugin_config, valid_plugin_name, valid_plugin_path} from '../../utils/plugin_validation.ts';
export {PluginError} from './error.ts';
const valid_name = valid_plugin_name;
const data_types = new Set<data_type>(['string', 'boolean', 'number', 'datetime', 'string-select', 'number-select',
'string-multi-select', 'number-multi-select', 'string-array', 'number-array']);
const valid_i18n = (value: unknown): boolean => Array.isArray(value) && value.length > 0 && value.every(item =>
item && typeof item.lang === 'string' && item.lang.length > 0 && typeof item.name === 'string' && typeof item.description === 'string');
function valid_value(value: unknown, type: data_type): boolean {
switch (type) {
case 'string': case 'string-select': return typeof value === 'string';
case 'number': case 'number-select': return typeof value === 'number' && Number.isFinite(value);
case 'boolean': return typeof value === 'boolean';
case 'datetime': return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) && Number.isFinite(Date.parse(value));
case 'string-array': case 'string-multi-select': return Array.isArray(value) && Array.from(value).every(item => typeof item === 'string');
case 'number-array': case 'number-multi-select': return Array.isArray(value) && Array.from(value).every(item => typeof item === 'number' && Number.isFinite(item));
}
}
function validate_item(item: bot_config_plugin<unknown>) {
if (!plain_plugin_object(item) || !valid_name(item.name) || !valid_plugin_path(item.path)
|| item.config !== undefined && !valid_plugin_config(item.config)) {
throw new PluginError('INVALID_CONFIG');
}
}
async function import_module(url: URL): Promise<Record<string, unknown>> {
url.searchParams.set('t', `${Date.now()}-${process.hrtime.bigint()}`);
return import(url.href);
}
function instantiate(module: Record<string, unknown>, name: string): BasePlugin {
if (typeof module.init !== 'function') throw new Error(`Plugin ${name} must export an init class`);
const instance = new (module.init as new () => BasePlugin)();
const template = instance.config_template;
if (instance.name !== name || template?.name !== name || !valid_i18n(template.i18n)
|| typeof template.version !== 'string' || !template.version.length || !Array.isArray(template.config)
|| typeof instance.init !== 'function' || typeof instance.get_status !== 'function'
|| typeof instance.on_states_update !== 'function' || typeof instance.dispose !== 'function') {
throw new Error(`Invalid plugin metadata: ${name}`);
}
const names = new Set<string>();
for (const field of template.config) {
if (!field || !valid_name(field.name) || names.has(field.name) || !valid_i18n(field.i18n)
|| typeof field.required !== 'boolean' || !data_types.has(field.type) || !Object.hasOwn(field, 'default')
|| field.default != null && !valid_value(field.default, field.type)) throw new Error(`Invalid plugin config metadata: ${name}`);
names.add(field.name);
}
return instance;
}
function resolve_config(template: plugin, input: Record<string, unknown>): Record<string, unknown> {
const names = new Set(template.config.map(field => field.name));
for (const key of Object.keys(input)) if (!names.has(key)) throw new PluginError('INVALID_CONFIG');
const config: Record<string, unknown> = {};
for (const field of template.config) {
const value = Object.hasOwn(input, field.name) ? input[field.name] : field.default;
if (value == null ? field.required : !valid_value(value, field.type)) throw new PluginError('INVALID_CONFIG');
Object.defineProperty(config, field.name, {value: structuredClone(value), enumerable: true, writable: true, configurable: true});
}
return config;
}
export interface PluginSnapshot {
name: string;
path: string;
config: Record<string, unknown>;
enabled: boolean;
lifecycle: 'initializing' | 'enabled' | 'disabling' | 'disabled' | 'error';
/** Last observed internal state; disabled records retain the final disposal snapshot. */
states: unknown;
error?: {code: string; message: string};
}
type Entry = {instance: BasePlugin; path: string; config: Record<string, unknown>; unsubscribe?: () => void; quarantined?: boolean};
export class PluginManager {
private readonly entries = new Map<string, Entry>();
private readonly records = new Map<string, PluginSnapshot>();
private readonly listeners = new Set<(event: PluginSnapshot) => void>();
private tail: Promise<void> = Promise.resolve();
private disposed = false;
private dispose_task?: Promise<void>;
constructor(private readonly context: PluginContext) {}
on_update(callback: (event: PluginSnapshot) => void): () => void {
this.check_active();
const listener = (event: PluginSnapshot) => callback(event);
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
}
private publish(record: PluginSnapshot) {
this.records.set(record.name, structuredClone(record));
for (const listener of Array.from(this.listeners)) {
if (!this.listeners.has(listener)) continue;
try { listener(structuredClone(record)); } catch {}
}
}
private capture(entry: Entry): unknown {
try { return structuredClone(entry.instance.get_status()); }
catch { return null; }
}
private transition(entry: Entry, lifecycle: PluginSnapshot['lifecycle']) {
this.publish({name: entry.instance.name, path: entry.path, config: entry.config,
enabled: lifecycle === 'enabled', lifecycle, states: this.capture(entry),
...(entry.quarantined ? {error: {code: 'DISPOSE_FAILED', message: 'Plugin disposal failed'}} : {})});
}
private check_active() {
if (this.disposed) throw new PluginError('MANAGER_DISPOSED');
}
private enqueue(operation: () => Promise<void>): Promise<void> {
if (this.disposed) return Promise.reject(new PluginError('MANAGER_DISPOSED'));
const task = this.tail.then(async () => { this.check_active(); await operation(); });
this.tail = task.catch(() => {});
return task;
}
private async prepare(item: bot_config_plugin<unknown>): Promise<Entry> {
validate_item(item);
if (this.entries.get(item.name)?.quarantined) throw new PluginError('DISPOSE_FAILED');
let instance: BasePlugin;
try {
const module = await import_module(new URL(`./${item.path}/${item.name}.ts`, import.meta.url));
this.check_active();
instance = instantiate(module, item.name);
} catch (error) {
if (error instanceof PluginError) throw error;
throw new PluginError('LOAD_FAILED');
}
return {instance, path: item.path, config: resolve_config(instance.config_template, (item.config ?? {}) as Record<string, unknown>)};
}
private async install(entry: Entry) {
try {
this.check_active();
this.transition(entry, 'initializing');
entry.unsubscribe = entry.instance.on_states_update(() => {
const record = this.records.get(entry.instance.name);
if (record) this.transition(entry, record.lifecycle);
});
this.check_active();
await entry.instance.init(this.context, structuredClone(entry.config));
this.check_active();
this.entries.set(entry.instance.name, entry);
this.transition(entry, 'enabled');
} catch (error) {
this.entries.delete(entry.instance.name);
this.transition(entry, 'disabling');
let cleanup_failed = false;
try { await entry.instance.dispose(); }
catch { cleanup_failed = true; }
finally {
try { entry.unsubscribe?.(); } catch { cleanup_failed = true; }
if (cleanup_failed) {
entry.quarantined = true;
this.entries.set(entry.instance.name, entry);
}
this.transition(entry, 'error');
}
if (cleanup_failed) throw new PluginError('DISPOSE_FAILED');
throw error instanceof PluginError ? error : new PluginError('INIT_FAILED');
}
}
apply_desired(plugins: bot_config_plugin<unknown>[]): Promise<void> {
return this.enqueue(async () => {
if (!Array.isArray(plugins)) throw new PluginError('INVALID_CONFIG');
const desired = new Map<string, bot_config_plugin<unknown>>();
for (const item of plugins) {
validate_item(item);
if (desired.has(item.name)) throw new PluginError('ALREADY_INSTALLED');
const current = this.entries.get(item.name);
if (current && current.path !== item.path) throw new PluginError('INVALID_CONFIG');
desired.set(item.name, structuredClone(item));
}
for (const entry of this.entries.values()) {
if (entry.quarantined) throw new PluginError('DISPOSE_FAILED');
}
const candidates = new Map<string, Entry>();
// Constructors own no resources. Prepare every replacement before disposing live plugins.
for (const item of desired.values()) {
const current = this.entries.get(item.name);
if (current && isDeepStrictEqual(current.config,
resolve_config(current.instance.config_template, (item.config ?? {}) as Record<string, unknown>))) continue;
try { candidates.set(item.name, await this.prepare(item)); }
catch (error) {
if (!current && error instanceof PluginError && error.reason === 'LOAD_FAILED') {
this.publish({name: item.name, path: item.path, config: (item.config ?? {}) as Record<string, unknown>,
enabled: false, lifecycle: 'error', states: null});
}
throw error;
}
}
this.check_active();
for (const name of Array.from(this.entries.keys())) {
if (!desired.has(name) || candidates.has(name)) await this.remove(name);
this.check_active();
}
for (const candidate of candidates.values()) await this.install(candidate);
});
}
init(plugins: bot_config_plugin<unknown>[]): Promise<void> {
return this.enqueue(async () => {
const names = new Set(this.entries.keys());
for (const item of plugins) {
validate_item(item);
if (names.has(item.name)) throw new PluginError('ALREADY_INSTALLED');
names.add(item.name);
}
const installed: string[] = [];
try {
for (const item of plugins) {
let candidate: Entry;
try { candidate = await this.prepare(item); }
catch (error) {
if (error instanceof PluginError && error.reason === 'LOAD_FAILED') {
this.publish({name: item.name, path: item.path, config: (item.config ?? {}) as Record<string, unknown>,
enabled: false, lifecycle: 'error', states: null});
}
throw error;
}
await this.install(candidate);
installed.push(item.name);
}
} catch (error) {
const errors = [error];
for (const name of installed.reverse()) {
try { await this.remove(name); } catch (cleanup) { errors.push(cleanup); }
}
if (errors.length > 1) throw new PluginError('DISPOSE_FAILED');
throw error;
}
});
}
get_plugin(name: string): PluginSnapshot | undefined {
const record = this.records.get(name);
return record && structuredClone(record);
}
get_all_plugins(): PluginSnapshot[] {
return Array.from(this.records.values(), record => structuredClone(record));
}
inject(item: bot_config_plugin<unknown>): Promise<void> { return this.init([item]); }
re_inject(item: bot_config_plugin<unknown>): Promise<void> {
return this.enqueue(async () => {
const candidate = await this.prepare(item);
await this.remove(item.name);
await this.install(candidate);
});
}
update_config(name: string, config: Record<string, unknown>): Promise<void> {
return this.enqueue(async () => {
if (!valid_name(name) || !valid_plugin_config(config)) throw new PluginError('INVALID_CONFIG');
const previous = this.entries.get(name);
if (!previous || !this.records.get(name)?.enabled) throw new PluginError('NOT_INSTALLED');
const candidate = await this.prepare({name, path: previous.path, config});
this.check_active();
await this.remove(name);
await this.install(candidate);
});
}
private async remove(name: string) {
const entry = this.entries.get(name);
if (!entry) return;
if (entry.quarantined) throw new PluginError('DISPOSE_FAILED');
this.transition(entry, 'disabling');
let failed = false;
try { await entry.instance.dispose(); }
catch { failed = true; }
finally {
try { entry.unsubscribe?.(); } catch { failed = true; }
if (failed) entry.quarantined = true;
else this.entries.delete(name);
this.transition(entry, failed ? 'error' : 'disabled');
}
if (failed) throw new PluginError('DISPOSE_FAILED');
}
uninject(name: string): Promise<void> { return this.enqueue(() => this.remove(name)); }
dispose(): Promise<void> {
if (this.dispose_task) return this.dispose_task;
this.disposed = true;
this.dispose_task = this.tail.then(async () => {
const errors: unknown[] = [];
for (const name of Array.from(this.entries.keys()).reverse()) {
try { await this.remove(name); } catch (error) { errors.push(error); }
}
this.listeners.clear();
if (errors.length) throw new PluginError('DISPOSE_FAILED');
});
return this.dispose_task;
}
}
export async function create_plugin_manager(context: PluginContext, plugins: bot_config_plugin<unknown>[]): Promise<PluginManager> {
const manager = new PluginManager(context);
try { await manager.init(plugins); return manager; }
catch (error) {
try { await manager.dispose(); } catch { throw new PluginError('DISPOSE_FAILED'); }
throw error;
}
}
export async function get_plugin_list(): Promise<{path: string; name: string; path_config: base_define; plugin_config_template: plugin}[]> {
const result: Awaited<ReturnType<typeof get_plugin_list>> = [];
const directories = [''];
const names = new Set<string>();
for (const path of directories) {
const directory = new URL(path ? `./${path}/` : './', import.meta.url);
const entries = (await readdir(directory, {withFileTypes: true})).sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) if (entry.isDirectory() && valid_name(entry.name)) directories.push(path ? `${path}/${entry.name}` : entry.name);
if (!path || !entries.some(entry => entry.isFile() && entry.name === 'define.ts')) continue;
const {define} = await import_module(new URL('define.ts', directory)) as {define: base_define};
if (define?.path !== path || !valid_i18n(define.i18n)) throw new Error(`Invalid plugin category: ${path}`);
for (const entry of entries) {
if (!entry.isFile() || entry.name === 'define.ts' || !/^[a-zA-Z0-9_-]+\.ts$/.test(entry.name)) continue;
const module = await import_module(new URL(entry.name, directory));
if (!Object.hasOwn(module, 'init')) continue;
const name = entry.name.slice(0, -3);
const instance = instantiate(module, name);
if (names.has(name)) throw new Error(`Duplicate plugin: ${name}`);
names.add(name);
result.push({path, name, path_config: structuredClone(define), plugin_config_template: structuredClone(instance.config_template)});
}
}
return result.sort((a, b) => a.path.localeCompare(b.path) || a.name.localeCompare(b.name));
}
+80
View File
@@ -0,0 +1,80 @@
import type {bot_status} from "../../../types/bot/instance/bot_runtime";
import {BasePlugin, type PluginContext, type plugin} from "../../../types/bot/plugins/plugin";
const config_template = {
name: 'auto_reconnect' as const,
i18n: [{lang: 'zh-CN', name: '自动重连', description: 'Bot 连接断开后自动重连'}],
version: '1.0.0',
config: [
{
name: 'reconnect_delay' as const,
i18n: [{lang: 'zh-CN', name: '重连延时', description: '重连时的延时时间(单位:毫秒)'}],
required: false,
default: 1000,
type: 'number',
},
{
name: 'reconnect_max_attempts' as const,
i18n: [{lang: 'zh-CN', name: '最大重连次数', description: '最大重连次数,默认 10 次'}],
required: false,
default: 10,
type: 'number',
},
],
} satisfies plugin;
type auto_reconnect_status = {attempts: number};
type auto_reconnect_config = {
[K in typeof config_template['config'][number]['name']]: number;
};
export class init extends BasePlugin<auto_reconnect_config, auto_reconnect_status> {
readonly name = 'auto_reconnect';
readonly config_template = config_template;
private cleanup?: () => void;
constructor() {
super();
this.set_states({attempts: 0});
}
async init(context: PluginContext, config: auto_reconnect_config): Promise<void> {
const delay = Number.isInteger(config.reconnect_delay) && config.reconnect_delay >= 0
&& config.reconnect_delay <= 2_147_483_647 ? config.reconnect_delay : 1000;
const max_attempts = Number.isInteger(config.reconnect_max_attempts) && config.reconnect_max_attempts >= 0
&& config.reconnect_max_attempts < 100 ? config.reconnect_max_attempts : 10;
let uninjected = false;
const lost = () => {
if (uninjected || this.get_status()!.attempts >= max_attempts) return;
void context.schedule_reconnect(delay).catch(error => {
if (context.logger) context.log([context.logger.error(`安排自动重连失败: ${String(error)}`)]);
});
};
const attempt = () => { this.set_states({attempts: this.get_status()!.attempts + 1}); };
const reset = () => {
if (this.get_status()!.attempts !== 0) this.set_states({attempts: 0});
};
const updated = (value: bot_status) => {
if (value === 'online') reset();
};
this.cleanup = () => {
if (uninjected) return;
uninjected = true;
context.cancel_reconnect();
context.off('connection_lost', lost);
context.off('reconnect_attempt', attempt);
context.off('connection_requested', reset);
context.off('status_update', updated);
};
context.on('connection_lost', lost);
context.on('reconnect_attempt', attempt);
context.on('connection_requested', reset);
context.on('status_update', updated);
}
async dispose(): Promise<void> {
this.cleanup?.();
this.cleanup = undefined;
}
}
+12
View File
@@ -0,0 +1,12 @@
import {base_define} from "../../../types/bot/plugins/plugin.ts";
export const define: base_define = {
path: "runtime",
i18n: [
{
lang: "zh-CN",
name: "运行时插件",
description: "Bot 运行时的插件,提供一些基础功能"
}
]
}
+58
View File
@@ -0,0 +1,58 @@
import type {WorkflowCatalog} from '../../types/bot/workflow/metadata.ts';
import {node_registry, query_registry, start_registry} from './registry.ts';
import {event_registry} from './node_action/event_node/registry.ts';
import {describe, edge_input_output, result_output} from './metadata.ts';
import {node_fields, policy_fields} from './common_template.ts';
import {assert_json} from './runtime/value.ts';
import {validate_fields, validate_i18n, validate_output} from './runtime/template.ts';
export function validate_workflow_catalog(catalog: WorkflowCatalog): void {
assert_json(catalog);
if (catalog.catalog_version !== 1) throw new Error('Invalid workflow catalog version');
for (const group of [catalog.nodes, catalog.start_nodes, catalog.events, catalog.queries]) {
const names = new Set<string>();
for (const item of group) {
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(item.name) || names.has(item.name) || !/^\d+\.\d+\.\d+$/.test(item.version)) {
throw new Error('Invalid or duplicate workflow catalog entry');
}
names.add(item.name); validate_i18n(item.i18n); validate_fields(item.config); validate_output(item.output);
}
}
for (const node of catalog.nodes) {
const ports = new Set<string>();
for (const port of node.ports) {
if (!/^[a-zA-Z0-9_-]+$/.test(port.name) || ports.has(port.name)) throw new Error('Invalid workflow port');
ports.add(port.name); validate_i18n(port.i18n);
}
if (!ports.has('failure')) throw new Error('Workflow node needs failure port');
}
validate_fields(catalog.common.node_fields); validate_fields(catalog.common.policy_fields);
validate_output(catalog.common.result); validate_output(catalog.common.edge_input);
for (const reference of catalog.common.references) validate_i18n(reference.i18n);
}
/** Pure catalog inspection: no Bot, actor, event subscriptions or user code execution. */
export function get_workflow_catalog(options: {allow_code?: boolean} = {}): WorkflowCatalog {
const catalog: WorkflowCatalog = structuredClone({
catalog_version: 1,
nodes: Object.values(node_registry).map(module => module.config_template),
start_nodes: Object.values(start_registry).map(module => module.config_template),
events: Object.values(event_registry).map(module => module.config_template),
queries: Object.values(query_registry).map(module => module.config_template),
common: {
node_fields, policy_fields, result: result_output, edge_input: edge_input_output,
references: [
{root: 'trigger', i18n: describe('触发参数', '本次运行的事件、手动输入或定时触发数据。')},
{root: 'vars', i18n: describe('运行变量', '当前分支变量,合并后包含无冲突的分支更新。')},
{root: 'outputs', i18n: describe('上游节点结果', '沿已执行连线传入的节点结果;条件分支和 any 合并不保证所有祖先都存在。')},
{root: 'last_result', i18n: describe('最近结果', '当前路径最近一个节点的执行结果和节点 ID。')},
{root: 'inputs', i18n: describe('当前入线', '以连线 ID 为键的直接上游结果;all 合并包含全部匹配入线。')},
],
},
});
const code = catalog.nodes.find(node => node.name === 'code');
if (code) code.availability = options.allow_code
? {available: true} : {available: false, reason: 'CODE_EXECUTION_DISABLED'};
validate_workflow_catalog(catalog);
return catalog;
}
+22
View File
@@ -0,0 +1,22 @@
import type {WorkflowField} from '../../types/bot/workflow/metadata.ts';
import {describe} from './metadata.ts';
export const node_fields: WorkflowField[] = [{
name: 'timeout_ms', type: 'number', required: false, default: 30_000, integer: true, min: 1, max: 2_147_483_647,
i18n: describe('节点超时', '节点最长执行时间,单位毫秒;超时会请求取消并等待操作清理。'),
}];
export const policy_fields: WorkflowField[] = [
{name: 'max_concurrent_runs', type: 'number', required: false, default: 1, integer: true, min: 1, max: 256,
i18n: describe('并行实例上限', '同一工作流同时运行的最大实例数,还受 bot 总并行上限限制。')},
{name: 'on_busy', type: 'select', required: false, default: 'skip',
i18n: describe('繁忙时策略', '达到 bot 总并行上限或本工作流实例上限时如何处理新触发。'), options: [
{value: 'skip', i18n: describe('忽略', '直接忽略本次触发。')},
{value: 'queue', i18n: describe('排队', '在等待队列未满时保存触发,空出名额后执行。')},
]},
{name: 'max_queue', type: 'number', required: false, default: 10, integer: true, min: 0, max: 1000,
i18n: describe('等待队列上限', '本工作流最多保存的等待触发数量,0 表示不保存。')},
{name: 'max_steps', type: 'number', required: false, default: 1000, integer: true, min: 1, max: 100000,
i18n: describe('最大执行步数', '一次运行内所有分支累计执行的节点数上限。')},
{name: 'timeout_ms', type: 'number', required: false, default: 60_000, integer: true, min: 1, max: 2_147_483_647,
i18n: describe('运行超时', '从实际启动开始计算的整次运行时限,单位毫秒。')},
];
+18
View File
@@ -0,0 +1,18 @@
import type {bot_instance_context} from '../instance/bot_instance_init.ts';
import type {workflow_config} from '../../types/bot/workflow/workflow.ts';
import {WorkflowManager} from './runtime/states_manager.ts';
export async function init_workflow(context: bot_instance_context<any>, workflow: workflow_config[]): Promise<void> {
if (context.workflow_manager) throw new Error('Workflow manager is already initialized');
const manager = new WorkflowManager(context, workflow, {source_config_revision: context.bot_config.config_revision ?? 0});
context.workflow_manager = manager;
try { manager.initialize(); }
catch (error) {
if (context.workflow_manager === manager) context.workflow_manager = null;
await manager.dispose();
throw error;
}
}
export {create_workflow_manager, WorkflowManager} from './runtime/states_manager.ts';
export {get_workflow_catalog} from './catalog.ts';
+35
View File
@@ -0,0 +1,35 @@
import type {WorkflowI18n, WorkflowOutputTemplate, WorkflowPortTemplate} from '../../types/bot/workflow/metadata.ts';
export const describe = (name: string, description: string): WorkflowI18n[] => [{lang: 'zh-CN', name, description}];
export const output = (type: WorkflowOutputTemplate['type'], name: string, description: string): WorkflowOutputTemplate =>
({type, i18n: describe(name, description)});
export const port = (name: string, label: string, description: string): WorkflowPortTemplate =>
({name, i18n: describe(label, description)});
export const failure_port = port('failure', '失败', '参数解析、执行或节点超时失败时执行此分支。');
export const success_port = port('success', '成功', '节点执行成功后继续执行。');
export const result_output: WorkflowOutputTemplate = {
...output('object', '执行结果', '节点执行状态、输出和可选错误。'),
properties: {
status: output('string', '执行状态', 'success 或 failure。'),
output: output('json', '节点输出', '具体结构由对应节点描述定义。'),
execution: {...output('number', '执行序号', 'outputs 中保存的本次运行节点执行序号。'), optional: true},
node_id: {...output('string', '节点 ID', 'last_result 中提供的最近执行节点 ID。'), optional: true},
error: {...output('object', '错误', '失败时可能提供的错误详情。'), optional: true, properties: {
code: output('string', '错误代码', '用于异常分流的稳定错误代码。'),
message: output('string', '错误说明', '本次失败的可读说明。'),
node_id: output('string', '错误节点', '产生错误的节点实例 ID。'),
}},
},
};
export const edge_input_output: WorkflowOutputTemplate = {
...output('object', '入线数据', '一条连线传递的来源与执行结果。'),
properties: {
source: output('string', '来源节点', '上游节点实例 ID。'),
port: output('string', '来源出口', '上游节点选择的出口名称。'),
result: result_output,
},
};
export const event_args_output: WorkflowOutputTemplate = {
...output('array', '原始参数', '按事件参数顺序复制的安全 JSON 数据,不可序列化的参数为 null。'),
items: {...output('json', '参数值', '事件原始参数的 JSON 副本。'), nullable: true},
};
+132
View File
@@ -0,0 +1,132 @@
import {Worker} from 'node:worker_threads';
import type {BranchState, NodeHandler} from '../../../types/bot/workflow/runtime.ts';
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
import type {Json} from '../../../types/bot/workflow/workflow.ts';
import {describe, failure_port, output, success_port} from '../metadata.ts';
import {assert_json} from '../runtime/value.ts';
export const config_template: WorkflowNodeTemplate = {
name: 'code', version: '1.0.0',
i18n: describe('执行代码', '执行 JavaScript 异步函数体,通过 state 读取状态副本,并返回 JSON 值;是否可用由宿主配置决定。'),
config: [
{name: 'source', type: 'code', language: 'javascript', required: true, max_length: 100_000,
i18n: describe('JavaScript 代码', '最多 100000 个字符,可使用 await;通过 return 返回有效 JSON 值,修改 state 副本不会写回工作流状态。')},
],
output: output('json', '代码返回值', '代码通过 return 返回并经校验的 JSON 值。'),
ports: [success_port, failure_port],
availability: {available: false, reason: 'CODE_EXECUTION_DISABLED'},
};
const worker_source = `
const {parentPort, workerData} = require('node:worker_threads');
function serialize_output(output) {
const ancestors = new Set();
let entries = 0;
let bytes = 0;
function visit(value, depth) {
if (depth > 64 || ++entries > 100000) throw new Error('Code output exceeds JSON limits');
if (value === null || typeof value === 'boolean') bytes += 5;
else if (typeof value === 'number' && Number.isFinite(value)) bytes += String(value).length;
else if (typeof value === 'string') {
if (value.length > 1048576) throw new Error('Code output exceeds JSON limits');
bytes += Buffer.byteLength(JSON.stringify(value));
} else if (typeof value === 'object') {
const array = Array.isArray(value);
if (!array && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
throw new Error('Code output must contain plain JSON objects');
if (ancestors.has(value)) throw new Error('Code output contains a cycle');
ancestors.add(value);
const keys = Reflect.ownKeys(value);
if (keys.length > 100000 || (array && value.length > 100000)) throw new Error('Code output exceeds JSON limits');
let count = 0;
bytes += 2;
for (const key of keys) {
if (array && key === 'length') continue;
if (typeof key !== 'string' || ['__proto__', 'prototype', 'constructor'].includes(key))
throw new Error('Code output contains an unsafe key');
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor.enumerable || !('value' in descriptor)) throw new Error('Code output must contain data properties');
if (array) {
const index = Number(key);
if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= value.length)
throw new Error('Code output contains an invalid array');
count++;
} else {
if (key.length > 1048576) throw new Error('Code output exceeds JSON limits');
bytes += Buffer.byteLength(JSON.stringify(key)) + 1;
}
bytes++;
visit(descriptor.value, depth + 1);
}
if (array && count !== value.length) throw new Error('Code output contains an array hole');
ancestors.delete(value);
} else throw new Error('Code output is not JSON');
if (bytes > 1048576) throw new Error('Code output exceeds JSON limits');
}
try { visit(output, 0); }
catch (error) { error.code = 'INVALID_JSON'; throw error; }
return JSON.stringify(output);
}
(async () => {
try {
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const execute = new AsyncFunction('state', '"use strict";\\n' + workerData.source);
const output = await execute(JSON.parse(workerData.state));
parentPort.postMessage({status: 'success', output: serialize_output(output)});
} catch (error) {
parentPort.postMessage({status: 'failure',
code: typeof error?.code === 'string' ? error.code.slice(0, 256) : 'CODE_EXECUTION_FAILED',
message: (typeof error?.message === 'string' ? error.message : String(error)).slice(0, 4096)});
}
})();
`;
export const execute: NodeHandler = async (node, state, signal, deps) => {
if (node.type !== 'code') throw new Error('Expected a code node');
signal.throwIfAborted();
assert_json(state);
const serialized = JSON.stringify(state);
let output: Json;
if (deps.execute_code) {
output = await deps.execute_code(node.config.source, JSON.parse(serialized) as BranchState, signal);
} else {
const worker = new Worker(worker_source, {eval: true, execArgv: [],
workerData: {source: node.config.source, state: serialized},
stdout: true, stderr: true,
resourceLimits: {maxOldGenerationSizeMb: 32, maxYoungGenerationSizeMb: 8, stackSizeMb: 4}});
// Capture and discard worker output instead of forwarding it to the bot process.
worker.stdout.resume();
worker.stderr.resume();
let on_abort: () => void;
try {
output = await new Promise<Json>((resolve, reject) => {
on_abort = () => reject(signal.reason);
worker.once('message', message => {
if (message?.status === 'success') {
try {
if (typeof message.output !== 'string' || Buffer.byteLength(message.output) > 1_048_576) {
throw Object.assign(new Error('Invalid code worker output'), {code: 'INVALID_JSON'});
}
resolve(JSON.parse(message.output));
} catch (error) { reject(error); }
}
else reject(Object.assign(new Error(typeof message?.message === 'string'
? message.message : 'Code execution failed'),
{code: typeof message?.code === 'string' ? message.code : 'CODE_EXECUTION_FAILED'}));
});
worker.once('error', reject);
worker.once('exit', code => reject(Object.assign(
new Error(`Code worker exited before returning a result (${code})`), {code: 'CODE_WORKER_EXITED'})));
signal.addEventListener('abort', on_abort, {once: true});
if (signal.aborted) on_abort();
});
} finally {
signal.removeEventListener('abort', on_abort!);
await worker.terminate();
worker.removeAllListeners();
}
}
signal.throwIfAborted();
assert_json(output);
return {port: 'success', result: {status: 'success', output}};
};
+23
View File
@@ -0,0 +1,23 @@
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
import {describe, failure_port, output, port} from '../metadata.ts';
import {evaluate_condition} from '../runtime/value.ts';
export const config_template: WorkflowNodeTemplate = {
name: 'condition', version: '1.0.0',
i18n: describe('条件判断', '计算条件表达式,根据布尔结果选择为真或为假分支。'),
config: [
{name: 'condition', type: 'condition', required: true,
i18n: describe('判断条件', '支持多个条件全部满足(and/且)、任一满足(or/或)及嵌套分组、取反;每项可使用 eq、contains、gt、lt 比较字面量或状态引用。')},
],
output: {...output('boolean', '判断结果', '条件表达式的布尔结果,true 和 false 均为成功执行;计算失败时输出 null。'), nullable: true},
ports: [port('true', '为真', '条件结果为 true 时执行。'),
port('false', '为假', '条件结果为 false 时执行。'), failure_port],
};
export const execute: NodeHandler = async (node, state, signal) => {
if (node.type !== 'condition') throw new Error('Expected a condition node');
signal.throwIfAborted();
const output = evaluate_condition(node.config.condition, state);
return {port: output ? 'true' : 'false', result: {status: 'success', output}};
};
+32
View File
@@ -0,0 +1,32 @@
import type {NodeHandler, NodeResult} from '../../../types/bot/workflow/runtime.ts';
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
import {describe, failure_port, output, port} from '../metadata.ts';
export const config_template: WorkflowNodeTemplate = {
name: 'error', version: '1.0.0',
i18n: describe('异常分流', '检查上一节点的执行状态和错误代码,保留原执行结果并选择分支。'),
config: [
{name: 'codes', type: 'array', required: false,
i18n: describe('错误代码', '省略此字段时匹配全部失败结果;提供列表时仅匹配其中的错误代码,显式空列表不匹配任何错误。'),
item: {name: 'code', type: 'string', required: true,
i18n: describe('错误代码', '与上一节点 error.code 精确匹配的字符串。')}},
],
output: output('json', '上一节点输出', '透传上一节点的 output,缺失时为 null;失败分支保留原失败状态及可选错误。'),
ports: [port('normal', '正常', '上一节点不存在或状态不是 failure 时执行,节点状态为 success。'),
port('error', '匹配异常', '上一节点失败且匹配错误代码规则时执行,保留 failure 状态。'),
port('unmatched', '未匹配异常', '上一节点失败但不匹配错误代码规则时执行,保留 failure 状态。'), failure_port],
};
export const execute: NodeHandler = async (node, state, signal) => {
if (node.type !== 'error') throw new Error('Expected an error node');
signal.throwIfAborted();
const previous = state.last_result;
if (!previous || previous.status !== 'failure') {
return {port: 'normal', result: {status: 'success', output: previous?.output ?? null}};
}
const result: NodeResult = {status: previous.status, output: previous.output};
if (previous.error) result.error = {...previous.error};
const matches = node.config.codes === undefined
|| (previous.error !== undefined && node.config.codes.includes(previous.error.code));
return {port: matches ? 'error' : 'unmatched', result};
};
+35
View File
@@ -0,0 +1,35 @@
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
import {describe, failure_port, output, success_port} from '../metadata.ts';
import {resolve_value} from '../runtime/value.ts';
export const config_template: WorkflowNodeTemplate = {
name: 'function', version: '1.0.0',
i18n: describe('调用函数', '解析参数值后调用机器人函数,返回 true 时成功,否则进入失败分支。'),
provider: 'function',
config: [
{name: 'path', type: 'string', required: true,
i18n: describe('函数路径', '函数所属目录,以 / 分隔;每段为 1 至 100 个字母、数字、下划线或连字符,禁止保留名称。')},
{name: 'name', type: 'string', required: true, max_length: 100,
i18n: describe('函数名称', '函数标识,由 1 至 100 个字母、数字、下划线或连字符组成,禁止保留名称。')},
{name: 'args', type: 'array', required: true, default: [], max: 100,
i18n: describe('函数参数', '按调用顺序提供参数,最多 100 项,每项可使用字面量或状态引用。'),
item: {name: 'argument', type: 'value_source', required: true,
i18n: describe('参数值', '解析字面量或状态引用,将得到的 JSON 值作为对应位置的函数参数。')}},
],
output: {...output('boolean', '函数返回值', '函数返回 true 时输出 true;返回其他值时输出 false,错误代码为 FUNCTION_RETURNED_FALSE;异常或超时输出 null。'), nullable: true},
ports: [success_port, failure_port],
};
export const execute: NodeHandler = async (node, state, signal, deps) => {
if (node.type !== 'function') throw new Error('Expected a function node');
signal.throwIfAborted();
const output = await deps.run_function(node.config.path, node.config.name,
node.config.args.map(source => resolve_value(source, state)), signal);
signal.throwIfAborted();
if (output !== true) {
return {port: 'failure', result: {status: 'failure', output: false,
error: {code: 'FUNCTION_RETURNED_FALSE', message: 'Function returned false', node_id: node.id}}};
}
return {port: 'success', result: {status: 'success', output}};
};
+37
View File
@@ -0,0 +1,37 @@
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
import {describe, failure_port, output, port} from '../metadata.ts';
import {assert_json} from '../runtime/value.ts';
export const config_template: WorkflowNodeTemplate = {
name: 'goto', version: '1.0.0',
i18n: describe('重试跳转', '按当前节点的重试计数限制跳转次数,重试目标由 retry 出口连线指定。'),
config: [
{name: 'max_retries', type: 'number', required: true, default: 2, integer: true, min: 0, max: 10_000,
i18n: describe('最大重试次数', '允许经过 retry 出口的次数;0 表示直接进入次数耗尽分支,默认允许重试 2 次。')},
{name: 'delay_ms', type: 'number', required: true, default: 0, integer: true, min: 0, max: 2_147_483_647,
i18n: describe('重试间隔', '每次重试跳转前等待的毫秒数,0 表示不增加等待时长。')},
],
output: output('json', '上一节点输出', '重试或次数耗尽时均透传上一节点的 output,缺失时为 null。'),
ports: [port('retry', '重试', '未达到最大重试次数时增加计数、等待指定间隔,再以 success 状态跳转;必须连接重试目标。'),
port('exhausted', '次数耗尽', '已达到最大重试次数时以 failure 状态执行,错误代码为 RETRY_EXHAUSTED。'), failure_port],
};
export const execute: NodeHandler = async (node, state, signal, deps) => {
if (node.type !== 'goto') throw new Error('Expected a goto node');
signal.throwIfAborted();
const count = Object.hasOwn(state.retry_counts, node.id) ? state.retry_counts[node.id] : 0;
if (!Number.isSafeInteger(count) || count < 0 || !Number.isSafeInteger(node.config.max_retries)
|| node.config.max_retries < 0 || !Number.isFinite(node.config.delay_ms) || node.config.delay_ms < 0) {
throw Object.assign(new Error('Invalid retry configuration or count'), {code: 'INVALID_RETRY'});
}
if (count >= node.config.max_retries) {
return {port: 'exhausted', result: {status: 'failure', output: state.last_result?.output ?? null,
error: {code: 'RETRY_EXHAUSTED', message: 'Retry limit exhausted', node_id: node.id}}};
}
const retry_counts = {[node.id]: count + 1};
assert_json(retry_counts);
await deps.sleep('milliseconds', node.config.delay_ms, signal);
signal.throwIfAborted();
return {port: 'retry', result: {status: 'success', output: state.last_result?.output ?? null}, retry_counts};
};
+31
View File
@@ -0,0 +1,31 @@
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
import {describe, failure_port, output, success_port} from '../metadata.ts';
import {resolve_value} from '../runtime/value.ts';
export const config_template: WorkflowNodeTemplate = {
name: 'log', version: '1.0.0',
i18n: describe('记录日志', '解析消息值并按指定级别记录日志,同时输出解析后的原始值。'),
config: [
{name: 'level', type: 'select', required: true, default: 'info',
i18n: describe('日志级别', '选择日志的记录级别;错误级别本身不会使节点执行失败。'),
options: [
{value: 'info', i18n: describe('信息', '记录一般运行信息。')},
{value: 'warning', i18n: describe('警告', '记录需要关注的情况。')},
{value: 'error', i18n: describe('错误', '记录错误信息,仍按正常日志节点继续执行。')},
]},
{name: 'message', type: 'value_source', required: true,
i18n: describe('日志消息', '使用字面量或状态引用;字符串直接记录,其他 JSON 值序列化后记录。')},
],
output: output('json', '消息原值', '解析 message 得到的原始 JSON 值,非字符串值不会因日志序列化而改变输出类型。'),
ports: [success_port, failure_port],
};
export const execute: NodeHandler = async (node, state, signal, deps) => {
if (node.type !== 'log') throw new Error('Expected a log node');
signal.throwIfAborted();
const output = resolve_value(node.config.message, state);
deps.log(node.config.level, typeof output === 'string' ? output : JSON.stringify(output));
signal.throwIfAborted();
return {port: 'success', result: {status: 'success', output}};
};
+25
View File
@@ -0,0 +1,25 @@
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
import {describe, edge_input_output, failure_port, output, success_port} from '../metadata.ts';
export const config_template: WorkflowNodeTemplate = {
name: 'merge', version: '1.0.0',
i18n: describe('分支合流', '按全部入线汇合或每次到达独立继续的方式处理分支,输出本次入线数据。'),
config: [
{name: 'mode', type: 'select', required: true, default: 'all',
i18n: describe('合流方式', '选择是否等待每一条入线;全部入线模式至少需要两条入线且不能作为入口节点。'),
options: [
{value: 'all', i18n: describe('全部入线', '等待同一轮次的每一条入线均到达后合并状态并执行一次;互斥分支可能导致入线无法到齐。')},
{value: 'any', i18n: describe('每次到达', '每条入线每次到达都独立执行,不等待其他入线,也不会只执行首次到达。')},
]},
],
output: {...output('object', '入线数据', '以连线 ID 为键的 inputs 对象;all 包含本轮全部入线,any 包含本次到达的入线。'),
nullable: true, additional_properties: edge_input_output},
ports: [success_port, failure_port],
};
export const execute: NodeHandler = async (node, state, signal) => {
if (node.type !== 'merge') throw new Error('Expected a merge node');
signal.throwIfAborted();
return {port: 'success', result: {status: 'success', output: state.inputs}};
};
+40
View File
@@ -0,0 +1,40 @@
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
import {describe, failure_port, output, success_port} from '../metadata.ts';
import {assert_json, resolve_value} from '../runtime/value.ts';
export const config_template: WorkflowNodeTemplate = {
name: 'query', version: '1.0.0',
i18n: describe('查询数据', '解析命名参数后调用查询提供方,返回 JSON 数据,并可将结果保存到工作流变量。'),
provider: 'query',
config: [
{name: 'source', type: 'string', required: true, provider: 'query', max_length: 100,
i18n: describe('查询来源', '选择查询提供方,标识由 1 至 100 个字母、数字、下划线或连字符组成,禁止保留名称。')},
{name: 'args', type: 'map', required: true, default: {},
i18n: describe('查询参数', '按名称提供参数;键为 1 至 100 个字母、数字、下划线或连字符,禁止保留名称。'),
item: {name: 'argument', type: 'value_source', required: true,
i18n: describe('参数值', '解析字面量或状态引用,将得到的 JSON 值传给对应名称的查询参数。')}},
{name: 'save_as', type: 'string', required: false, max_length: 100,
i18n: describe('保存变量', '可选,将查询输出保存到 vars 中的此名称;名称为 1 至 100 个字母、数字、下划线或连字符,禁止保留名称。')},
],
output: output('json', '查询结果', '查询提供方返回的原始 JSON 值,与可选保存到 vars 的值相同。'),
ports: [success_port, failure_port],
};
export const execute: NodeHandler = async (node, state, signal, deps) => {
if (node.type !== 'query') throw new Error('Expected a query node');
signal.throwIfAborted();
const args = Object.fromEntries(Object.entries(node.config.args)
.map(([key, source]) => [key, resolve_value(source, state)]));
assert_json(args);
const output = await deps.query(node.config.source, args, signal);
signal.throwIfAborted();
assert_json(output);
const result = {status: 'success' as const, output};
if (node.config.save_as !== undefined) {
const vars = {[node.config.save_as]: output};
assert_json(vars);
return {port: 'success', result, vars};
}
return {port: 'success', result};
};
+28
View File
@@ -0,0 +1,28 @@
import type {NodeHandler} from '../../../types/bot/workflow/runtime.ts';
import type {WorkflowNodeTemplate} from '../../../types/bot/workflow/metadata.ts';
import {describe, failure_port, output, success_port} from '../metadata.ts';
export const config_template: WorkflowNodeTemplate = {
name: 'sleep', version: '1.0.0',
i18n: describe('等待', '按毫秒或游戏刻等待指定时长,再继续执行。'),
config: [
{name: 'mode', type: 'select', required: true, default: 'milliseconds',
i18n: describe('计时方式', '选择等待时长的单位。'),
options: [
{value: 'milliseconds', i18n: describe('毫秒', '按毫秒等待,1000 毫秒为 1 秒。')},
{value: 'ticks', i18n: describe('游戏刻', '按游戏刻等待,实际时长取决于游戏刻推进速度。')},
]},
{name: 'value', type: 'number', required: true, default: 1000, integer: true, min: 0, max: 2_147_483_647,
i18n: describe('等待时长', '使用所选单位的非负整数时长,0 表示不增加等待时长。')},
],
output: output('null', '等待结果', '等待完成后输出 null。'),
ports: [success_port, failure_port],
};
export const execute: NodeHandler = async (node, _state, signal, deps) => {
if (node.type !== 'sleep') throw new Error('Expected a sleep node');
signal.throwIfAborted();
await deps.sleep(node.config.mode, node.config.value, signal);
signal.throwIfAborted();
return {port: 'success', result: {status: 'success', output: null}};
};
@@ -0,0 +1,27 @@
import type {Json} from '../../../../types/bot/workflow/workflow.ts';
import type {WorkflowEventTemplate} from '../../../../types/bot/workflow/metadata.ts';
import {describe, output, event_args_output} from '../../metadata.ts';
import {normalize_custom_event} from './custom.ts';
export const config_template: WorkflowEventTemplate = {
name: 'chat', version: '1.0.0', readiness: 'spawned',
i18n: describe('聊天事件', '接收玩家公开聊天,提供用户名、消息内容及安全的原始参数。'),
config: [],
output: {
...output('object', '聊天数据', '从聊天事件前两个字符串参数提取的数据。'),
properties: {
args: event_args_output,
event: output('string', '事件名称', '固定为 chat。'),
username: output('string', '用户名', '发送公开聊天消息的玩家名称。'),
message: output('string', '消息内容', '玩家发送的聊天文本。'),
},
},
};
export function normalize(args: unknown[]): Record<string, Json> | undefined {
if (args.length > 32 || typeof args[0] !== 'string' || typeof args[1] !== 'string') return;
const original = normalize_custom_event('chat', args);
if (!original) return;
const trigger = {...original, username: args[0], message: args[1]};
if (Buffer.byteLength(JSON.stringify(trigger)) <= 65_536) return trigger;
}

Some files were not shown because too many files have changed in this diff Show More