消息流
用户在群里发一条 /ping,到 Bot 的回复落回平台,中间经过的每个环节都在一条单向管道上:入站从平台 Endpoint 流向命令/AI,出站从插件代码流向平台 Endpoint。ImRuntime 同时实现唯一入站边界 EndpointEventGateway 与出站服务 OutboundMessageService;两边都从 operation 持有的 generation 快照取数,天然热重载安全。
入站:Adapter → 中间件 → 命令 → AI 兜底
各环节的真实代码位置:
Client 与 Endpoint 分工。Client 是真实平台 SDK 或干净的协议 Client,负责平台 API 与原始事件;Endpoint 负责账号、Transport、热重载生命周期和框架边界。所有 Endpoint 继承
Endpoint<TClient>,并通过唯一的emit(name, payload)入站。每个事件统一包装为:tsinterface EndpointEvent<TPayload, TClient> { readonly name: string; readonly payload: TPayload; readonly endpoint: { id: CapabilityId; adapter: string }; readonly client: TClient; }原生事件先以
platform.receive无损上送(payload 为{ name, event }),已知事件再可选投影为message.receive、notice.receive、request.receive、system.receive。因此消息、入群申请、成员变动、上线/离线和平台扩展事件都能在插件中访问同一个 Client。候选 generation 在start()期间产生的首批事件由 Endpoint 基座暂存,提交时按序回放;退役代的迟到事件被丢弃。消息归一化。对于
message.receive,Endpoint 把平台消息投影为以下 payload:tsinterface IncomingMessage { readonly conversation: ConversationRef; // 结构化会话(endpoint/kind/id/parent/threadId) readonly message?: MessageRef; // 平台消息身份(原生消息 id) readonly content: string; readonly segments?: readonly Segment[]; readonly sender?: { id: string; name?: string; roles?: readonly string[] }; readonly replyTo?: { id: string }; // 显式平台引用,不从 metadata 猜测 readonly metadata?: Readonly<Record<string, unknown>>; }租约与 Message。
ImRuntime.endpointEvents.receive在事件所属 generation 上取得租约(在途事件不被重载打断,见 generation 与生命周期)。消息事件构造Message,并注入按需读取当前平台 SDK 实例的$clientgetter、$reply(content)与$replyFrom(owner, content);dispatch 结束后作用域关闭,之后再读取$client或调用$reply都会失败。入站中间件。
MiddlewareIndex按phase(before-dispatch先、after-dispatch后)与order排序,逐个包住终端动作:tsdefineMiddleware({ phase: 'before-dispatch', // 默认 target: 'inbound', // 默认;'outbound' 拦截出站 order: 0, async handle(context, next) { // context.input 是 Message(inbound)或 OutboundEnvelope(outbound) await next(); // 不调用 next() 即拦截 }, });命令分发。
MessageDispatcher先解析命令前缀(默认按消息所属适配器实例的配置:endpoints[i].commandPrefix覆盖顶层commandPrefix,默认''无前缀,见 配置即数据),前缀不匹配直接 miss;命中前缀则剥离后交给CommandIndex.dispatch。命令有返回值时,分发器用命令 owner 身份$replyFrom(owner, value)自动回复。AI 兜底。命令 miss(或无前缀文本)时,
ImRuntime从当前消息所持 snapshot 的 root resources 解析 generation-ownedIngressRoute。装了@zhin.js/agent的 composition root 会在 generation setup 提供该内部 route;未安装则消息安静丢弃。它不是OutboundMessageService上可变的插件 setter。事件广播。dispatch 完成后向
onMessage订阅者发出RuntimeMessageEvent(含方向、conversation、sender、≤200 字的contentPreview、时间戳),Console 的实时消息流就是消费它。
出站:$reply → 渲染 → 中间件 → Endpoint
- SendContent 形态(
packages/im/core/src/plugin-runtime/im/contracts.ts):字符串;canonicalSegment(一等公民,见下文「多模态」);component(name, props)组件调用(经ComponentIndex递归渲染,深度上限 32);raw(payload)原样透传;以及它们的数组嵌套。 - Envelope 携带
conversation(结构化会话寻址ConversationRef,@zhin.js/im-contract)、requester(发起方插件,用于组件权限与审计)、generation,并提供replace(payload)给出站中间件改写内容。 - 出站中间件与入站共用一套定义,
target: 'outbound'即拦截出站。 - 最终一公里在
AdapterIndex.send:endpoint 必须声明outbound能力、且处于started && !stopped,否则抛错;通过后调用endpoint.send()落到平台。
普通消息发送都应走这条统一管道($reply / $replyFrom / OutboundMessageService.send),避免绕过渲染、中间件与事件广播。入群审批、角色管理、平台查询等非消息业务则应从当前事件/命令/工具 operation 解析 Client,直接调用平台 SDK;不要把 Client 缓存到 operation 之外。
多模态:双向 Segment 一贯制
全框架只有一种媒体表达——@zhin.js/im-contract 的 canonical Segment + MediaRef:
interface MediaRef {
kind: 'url' | 'path' | 'base64' | 'file'; // file = 平台不透明引用(Telegram file_id 等)
value: string;
mime_type?: string;
file_name?: string;
size?: number;
}
// image / audio / video / file 段的 data 一律为 { media: MediaRef, alt?/duration?/name? }入站:适配器把平台载荷归一为 Segment[] 随 emit('message.receive', { segments }) 上送。不透明平台 id 必须经当前 generation 的 EndpointContentPort 物化;引用解析期间快照租约一直持有。所有 URL、path 与 base64 随后进入同一条流水线:HTTPS/SSRF 与重定向检查 → 字节上限 → 文件魔数识别 → 声明 MIME/实际类型一致性检查 → UserMessage.media。框架不信任扩展名或 Adapter 声明的 MIME,也不把二进制/base64 写入会话事实源。每项媒体恰好产生 accepted | derived | unsupported | rejected | failed 终态;失败以明确的不可信 user-context 文本呈现,绝不伪装成“模型已看到图片”。Provider 必须显式声明 text/image/audio/video/file 输入能力,缺省仅 text;不支持的类型不会猜测放行。
会话事实、引用与通知
ConversationEventStore 是 IM 上下文的唯一事实源。入站/出站消息、撤回 tombstone、回应、成员加入/退出、禁言/解禁和角色变化按会话幂等追加;不再维护 im_transcripts 或文本型 chat_history 双轨。合并转发条目使用中性 actor,不映射成模型 user/assistant/system role。
会话中尚未被 Agent session 消费的入站消息也从该 Store 按游标读取,作为不可信 user-context 投影;当前触发 Turn 的消息会被排除,避免重复。不存在进程级 passive buffer,失败 Turn 不推进游标,HMR 与多 Root 也不会共享旁路状态。
当前 Turn 把 replyTo、forward 与媒体注册为 scoped TurnReference。Agent 只暴露 inspect_conversation_reference(reference, depth?):先查本地事实源,再通过持租约的 Endpoint 回源;跨会话、跨 Endpoint、过期 Turn 均 fail-closed。尚未消费的重要 notice 会作为明确标注的“不可信会话数据”附在下一次用户 Turn,永远不进入 system/developer prompt;只有 Turn 成功提交才推进 session cursor,失败会保留。高频 reaction/poke 会聚合,登录、二维码、断线等 process 事件只进入诊断日志。
出站:AI 回复 → OutputElement[] → canonical Segment[](publishOutboundElements)→ $reply(Segment 是一等 SendContent)→ normalizeOutboundPayload(html→image/文本、keyboard、媒体协商)→ endpoint。媒体协商按 adapter definition 的 segments.outboundMedia 声明驱动('url' | 'path' | 'base64' | 'upload'):仅 url-or-text 端点会在中央把非 URL 媒体降级为文本;其余由 adapter 按平台最优路径自物化(URL 直发 / base64 直发 / 平台上传 / 读盘),无 data.media 的段会被 warn 丢弃——legacy data.url/file/base64 形状已不存在。
用户交互:确认、选择与输入
用户交互与 LLM prompt assembly 是两个不同概念。命令创作面使用 context.interaction (UserInteraction),通过 ask() 的 type 表达 text / number / confirm / select / multiselect / list,通过 sequence() 声明连续交互。
每个请求都可以声明 title、description 与 tip。UserInteraction 隐藏呈现和回复解析: 确认与小规模单选先转换为平台无关的 UserInteractionView,再统一渲染为 markdown + canonical keyboard。声明 segments.interactive: native 的 Adapter(如 QQ)编码为原生按钮;其他 Adapter 在 Core 中降级为编号列表,点击按钮和手动回复最终进入同一文本解析入口。多选和过多选项直接使用 列表,避免平台按钮数量限制。
AI 工具 ask_user 也复用这一模块,因此工具审批、命令向导和 AI 追问不会各自维护一套 平台按钮逻辑。
Endpoint 1:N 展开
一个适配器插件实例配置里声明 endpoints: [{name, ...}] 时,AdapterIndex 把它展开成 N 条独立 endpoint 记录(配置合并规则见 配置即数据):
- 每条记录的能力 id 形如
<slot id>~<name>,拥有自己的生命周期(start/open/close/stop)与在线状态; - 消息上的
$adapter携带展开后的标识(如icqq~8596238),回复沿原路返回对应账号; - Console 侧按
(adapter, endpointId)寻址,AdapterIndex.resolve依次匹配本地名、能力 id、owner 路径段和 Endpoint 的运行时名(如 ICQQ 的 uin),多匹配时优先精确的 endpoint 名。
因此"两个 QQ 号各收各的消息、各发各的回复"不需要任何特殊代码——配两个 endpoint entry 即可。