platform 服务(平台适配器)
1. 定位
platform 是 Aalis 的平台抽象服务:每个聊天/终端平台(OneBot、CLI、WebUI、Telegram、Discord……)实现一个 PlatformAdapter 并以服务名 platform 注册。它统一了「查询连接状态 / 按 sessionId 发消息 / 调平台原生 API / 自报机器人身份」这几件事,使核心与其它插件无须知道具体平台细节。
- 服务注册名(DI key):
'platform'——platform.use打到本插件选中的那一家,platform.list()列出全部(多 adapter 并存是常态)。 - 契约包:
@aalis/api-platform(packages/api-platform/src/index.ts)。 - 该契约包既是 runtime 服务契约也带聚合/路由 helper 纯函数:
PlatformAdapter接口是要provide.use(platform, …)注册的真实服务;而getPlatform*/resolvePlatformBySession/sendPlatformMessage/callPlatformAction等是消费方应优先使用的纯函数 helper(传caps.platform即可,没有 entry、无自递归隐患,取代了历史上的PlatformRouterfacade,见src/index.ts)。
关键认知:
platform是天然的多实例服务。同时跑 CLI + OneBot 时,'platform'名下有两个 entry。消费方几乎从不用platform.use打「选中的那一家」,而是用 helper 按platform名或按sessionId路由到正确的 adapter。
2. 契约
核心接口 PlatformAdapter(src/index.ts)
export interface PlatformAdapter {
adapterName: string; // 显示名,如 'OneBot' / 'CLI'
platform: string; // 平台标识,如 'onebot'/'cli'/'webui'
sessionTypes?: readonly SessionTypeDecl[]; // 平台类型:类型名与一对一还是多方;不声明的平台整个算一个一对一类型 private
getConnections(): PlatformConnection[]; // 必须:当前所有连接快照
sendMessage( // 必须:向某 sessionId 发纯文本
sessionId: string,
content: string,
options?: { skipSplit?: boolean },
): Promise<void>;
canHandle?(sessionId: string): boolean | Promise<boolean>; // 路由用,见下
getSelfIdentity?(sessionId?: string): PlatformSelfIdentity | undefined; // 自报机器人身份
callAction?(sessionId: string, action: string, params: Record<string, unknown>): Promise<unknown>; // 平台原生 API
}重要类型
PlatformConnection(src/index.ts)——单条连接状态:id / platform / selfId? / selfNickname? / status: 'online'|'offline'|'connecting' / detail?: Record<string, unknown>。
PlatformSelfIdentity(src/index.ts)——机器人自身身份,用于 prompt 注入与归档:platform / selfId? / nickname?。
helper 返回的条目是 core 的 ProviderEntry<PlatformAdapter>:use(这个 adapter 的业务句柄)、id(插件实例 id,即 contextId),另有 label? / priority / selected。
SessionTypeDecl(src/index.ts)——平台类型:name(类型名,非空、不含 :、不是 *,与窗口键 平台:类型:目标 的第二段同一套写法)/ label?(给人看的显示名,可以是文字对象;界面按读者语言显示,写给模型的会话类型与召回片段的位置取原文;不给时写类型名)/ multiParty(多方为 true,一对一为 false)。类型名由平台自定,消息在 IncomingMessage.sessionType 上带它。
聚合 / 路由 helper(纯函数,第一参是 Pick<ServiceSelection<PlatformAdapter>, 'list'>)
第一参传 caps.platform 即可:helper 只调它的 list(),每次调用重新枚举。只在部分 adapter 里找时,给一个只列它们的 { list }。要条目本身直接用 caps.platform.list()。
| helper | 作用 |
|---|---|
getPlatformAdapters(source) | 枚举所有 adapter(list() 各条的 use) |
getPlatformNames(source) | 枚举所有平台名(adapter.platform 去重) |
aggregatePlatformConnections(source) | 聚合所有连接 |
aggregatePlatformDetails(source) | 聚合展示详情(每项 { adapterName, platform, contextId, connections },contextId 取条目的 id) |
getPlatformSelfIdentity(source, platform, sessionId?) | 按平台名取自身身份;同平台装了多个实例时,取第一个认得这个会话的实例给出的身份 |
sessionTypesOf(adapter) | 平台的类型:声明的条目;没声明(没写、空列表,或平台没有适配器)时为 [{ name: 'private', multiParty: false }] |
resolveSessionType(types, name) | 按平台的类型解析消息带的类型名:不带类型名时,声明里有多方类型的按其中第一个、多方,只有一对一类型的按第一个,没声明的按 private、一对一;声明里有的按声明;声明里没有的按多方并标出 undeclared。解析到声明的类型时带上它的 label |
sessionTypeLabel(source, platform, type) | 平台类型的显示名:这个平台各适配器声明里这个类型的 label;平台不在、类型没声明或没给显示名时为 undefined,调用方写类型名 |
resolvePlatformBySession(source, sessionId, logger?) | 按 sessionId 找接管它的 adapter 条目(ProviderEntry<PlatformAdapter>,use 是那个 adapter,id 是插件实例 id;优先 canHandle,否则 startsWith(platform+':') 兜底),按 list() 的顺序取第一个认领者;canHandle 抛错按不认领,经 logger 告警 |
claimantsOf(source, sessionId, logger?) | 认领 sessionId 的全部 adapter 条目,按 list() 的顺序;判定与 resolvePlatformBySession 相同,逐个问、不在第一个认领者处停。用来看出一个 id 是不是同时归几个平台 |
sendPlatformMessage(source, sessionId, content, options?) | 按 sessionId 路由发文本(无 adapter 接管则抛错,见下) |
callPlatformAction(source, sessionId, action, params) | 按 sessionId 路由调原生 action(无 adapter 接管、或 adapter 不支持 callAction 则抛错) |
没有 adapter 接管时,两个发送 helper 的报错写明 sessionId;sessionId 带平台前缀时,另列出这个平台各实例(条目 id)的已知账号与连接状态,如 <实例甲>: 111(online);<实例乙>: 222(online)、333(offline),分得清是账号写错、账号不属于任何实例,还是连接没上来。
类型随描述符提供:defineService<PlatformAdapter>('platform')(src/index.ts),消费方把描述符写进 uses 即得类型,platform.use 推断为 PlatformAdapter,platform.list() 的条目为 ProviderEntry<PlatformAdapter>。
3. 谁提供 / 谁消费
参考实现(provider)
- OneBot 适配器
packages/plugin-adapter-onebot/src/index.ts—— 协议类平台的完整范例:实现了可选方法canHandle、getSelfIdentity、callAction,声明group(多方)、private(一对一)、channel(多方)三个类型(显示名群聊、私聊、频道),入站消息带类型名与机器人账号(selfId),在插件apply里注册。它可以装多个实例(reusable),每个实例可带多个账号:canHandle只认本实例连接里的账号(含配置里写明、连接还没上来的),会话 IDonebot:<selfId>:…因此按账号路由到所属实例。还附带若干非标准扩展方法(getSelfMutes/getSentMessages/handleFriendRequest等),通过交叉类型暴露给特定消费者(plugin-tool-onebot),见 §6。 - CLI 适配器
packages/plugin-cli/src/index.ts—— 最小实现的范例:只实现adapterName/platform/getConnections/sendMessage+ 显式canHandle(因为它的 sessionId 是配置直给的cli-default,不带cli:前缀,必须自报接管);不声明类型,整个平台算一个一对一类型private。
其它带
provides: [..., 'platform']的插件:plugin-webui-server(src/index.ts)。注意它和 CLI 都同时 provide 别的服务名(cli/webui-server),一个插件提供多服务是允许的。
典型消费点
- plugin-agent:
getPlatformSelfIdentity(this.caps.platform, incoming.platform, incoming.sessionId)给归档的 assistant 消息打userId/nickname元数据(src/index.ts);uses中声明platform: optional(platform)。 - plugin-persona:在
agent:turn:before中间件里取getPlatformSelfIdentity,把机器人自身身份装进PersonaIdentity注入人设 prompt;会话类型按根会话记下的聊天窗口经sessionTypeLabel取显示名(src/index.ts,uses中声明platform: optional(platform))。 - plugin-memory-vector:召回片段的位置没有群名时写说话所在类型的显示名,按向量记下的平台与类型名经
sessionTypeLabel取(src/index.ts,uses中声明platform: optional(platform))。 - plugin-user-relation:用
sendPlatformMessage(platform, sessionId, text)主动外发(src/commands.ts);用getPlatformNames(platform)做「真实平台白名单」过滤伪造 person(src/extractor.ts、src/service.ts)。 - plugin-webui-server:
aggregatePlatformDetails(platform)喂平台面板(src/index.ts)。 - plugin-session-settings:
getPlatformAdapters(platform)列可设的平台档与各平台声明的平台类型档,类型的显示名经sessionTypeLabel取。 - plugin-gateway:按入站消息的平台取适配器的类型声明,算出聊天范围(
data.chat),回复策略三个插件按它判生效范围。 - plugin-session-manager:房间建档时按入站消息的平台取适配器的类型声明(
sessionTypesOf),经 api-gateway 的chatScopeOf记下聊天窗口;建档与补记之前用claimantsOf问遍认领这个会话 id 的适配器,不止一个平台认领时不建档、不补记,否则第一个认领者记进SessionInfo.originInstance,供会话列表按实例分组;建档时没有认领者的,之后用resolvePlatformBySession补记(src/index.ts)。 - plugin-tool-onebot:平台动作经
callPlatformAction(platform, 'onebot:<selfId>:internal:0', action, params)发出,按账号路由到所属实例;撤回自己发的消息、处理好友申请与群请求按会话(或账号)用resolvePlatformBySession找到所属实例,自身禁言列表汇总所有 onebot 实例(src/index.ts)。
4. 写一个 provider
最小必须 vs 可选
必须实现:adapterName、platform、getConnections()、sendMessage()。 强烈建议:当本平台的 sessionId 不形如 <platform>:<...> 时(如 CLI 自定义 id),必须实现 canHandle(),否则 resolvePlatformBySession 的前缀兜底会漏掉你,sendPlatformMessage / callPlatformAction 都找不到你的 adapter。插件能装多个实例(reusable)时同样必须实现 canHandle(),只认自己实例的会话:前缀兜底会让第一个实例接下这个平台的全部会话。 按能力实现:getSelfIdentity(要让 agent/persona 认知自身身份就实现)、callAction(暴露平台原生 API)。 有多方会话就必须声明:sessionTypes(类型名与一对一还是多方),并在入站消息上带类型名。不声明的平台整个算一个一对一类型 private:它的群会按一对一记下,群里任何人都会被当成那个唯一的对端。声明了多方类型、消息却没带类型名的,按声明里第一个多方类型记下(失败关闭,一对一的聊天也当多方);只声明了一对一类型的,按其中第一个记下。给类型写 label,设置页的平台类型档、模型看到的会话类型与召回片段都用它,不写时写类型名。消息带了声明里没有的类型名时按多方记下,session-manager 每个平台与类型名告警一次。
会话 ID 约定
会话表里还没有记录的会话收到第一条原生入站(不带 source)时,session-manager 按这条消息建档,记下出生平台(消息的平台)与聊天窗口(平台类型名、多方标记、机器人账号、目标),之后不变;这几项是会话字段(originPlatform、originChat),读方按字段判,不从 ID 里拆。
选层(平台档、平台类型档、场所)与会话列表都读这些字段,不看 ID:房间不论从哪个入口驱动(包括 owner 从 WebUI 往群里插话),都按记下的出生平台选平台档、按聊天窗口的类型名选平台类型档。ID 的写法归适配器自己,只用于路由:没实现 canHandle() 的适配器靠 <platform>:<...> 前缀被认领,见上文。子会话的 ID 由 session-manager 给(参考实现与根会话一样是 session-<8位>),不带父会话的 ID:子会话没有窗口,适配器不会按 ID 把它认成哪个窗口。
入站消息:出站接口之外还需 emit
PlatformAdapter 只覆盖出站与查询。入站不在接口里——adapter 收到平台消息后须 events.use.emit('inbound:message', msg)(msg 为 IncomingMessage,packages/schema-message/src/index.ts),由 plugin-gateway(src/index.ts 监听)接管路由到 agent。OneBot 见 src/index.ts 一带的 events.use.emit('inbound:message', …);CLI 见 src/index.ts。出站文本回显则可订阅 events.use.on('outbound:message', …)(CLI src/index.ts)。详见 message-llm-pipeline。
注册(provide)
import { platform } from '@aalis/api-platform';
import type { PlatformAdapter, PlatformConnection } from '@aalis/api-platform';
import { definePlugin, events, provide } from '@aalis/core';
export default definePlugin({
name: '@your-scope/plugin-adapter-foo',
subsystem: 'platform',
provides: [platform],
uses: { provide, events },
apply({ provide, events }) {
void events;
let online = false;
const adapter: PlatformAdapter = {
adapterName: 'Foo',
platform: 'foo',
sessionTypes: [
{ name: 'private', multiParty: false },
{ name: 'group', multiParty: true },
],
getConnections(): PlatformConnection[] {
return [{ id: 'foo:bot', platform: 'foo', status: online ? 'online' : 'offline' }];
},
canHandle(sid) {
return sid.startsWith('foo:');
},
getSelfIdentity() {
return { platform: 'foo', selfId: 'bot-123', nickname: 'FooBot' };
},
async sendMessage(_sessionId, _content) {},
};
provide.use(platform, adapter);
},
});注册选项(provide.use(descriptor, instance, { priority?, label?, entryId? }),packages/core/src/composition/core-services.ts):
- priority:普通数字,越大越优先。platform 通常用默认
0。 - entryId:单插件多连接想拆成多 entry 时用
'${lifecycle.use.id}/${sub}'(per-entry provide,见 service-model);单 adapter 内自管多连接(如 OneBot 的states[])则不需要。 - label:展示用,会进
list()/services.use.inspect返回条目的label。 - 双源同步:
provides: [platform]与package.json的aalis.service.provides必须一致;激活后 core 会校验「声明了provides却没真provide」直接打成 error,dev-mode 还会反向 warn「provide 了但没声明」。见 manifest-metadata。
5. 标准消费方式
- 优先用 helper,而非裸
platform.use:要发消息用sendPlatformMessage(platform, sessionId, text);要按名取身份用getPlatformSelfIdentity(platform, name, sessionId);要列平台用getPlatformNames(platform);要调原生 API 用callPlatformAction(platform, sessionId, action, params)。这些 helper 内部都走source.list()聚合,天然处理多 adapter。 - helper 每次重新枚举:helper 每次调用都读
source.list();条目的use钉在那一条登记上,一次发送从认领到发出都落在同一个适配器实例上,它中途撤回则访问成员抛错,同一 id 重新登记的新实例由下一次调用重新枚举取到。见 选择层与业务层。 - platform 是可选依赖:上面所有消费者都在
uses里以optional(platform)声明它。没有任何 adapter 时,getPlatformNames返回[]、getPlatformSelfIdentity返回undefined、sendPlatformMessage/callPlatformAction抛错(无 adapter 接管 / 不支持 callAction)。消费方必须容忍这些:persona/agent 在identity?.selfId处用可选链优雅降级(plugin-agent/src/index.ts)。 - 错误边界:
resolvePlatformBySession与claimantsOf对每个 adapter 的canHandle抛错只告警(传了logger时)、按不认领处理,不中断(src/index.ts)——你的canHandle抛错不会拖垮全局路由,但也意味着会被静默跳过,实现应保证稳健。
6. 能力 / 风险 → 影响
- 跨会话身份隔离:
getSelfIdentity(sessionId?)必须按sessionId定位到正确的连接再返回身份(OneBot 用parseSessionId→findStateBySelfId)。多账号/多连接平台若忽略sessionId永远返回同一身份,会把 A 会话的机器人身份泄漏进 B 会话的 LLM prompt(persona 把它装进AsyncLocalStorage正是为防跨会话泄漏,plugin-persona/src/index.ts)。 callAction是平台原生权能的逃逸口:它能调任意平台 Action(封禁、踢人等)。本服务不在 adapter 层做 authority 校验——风险/等级控制由调用工具侧(如plugin-tool-onebot的工具定义 + 其risk标注)经 authority 把关。provider 实现callAction时应假设调用方已鉴权,但要对sessionId解析失败/连接不可用做硬校验(OneBot 在不可用时 throw)。authority 模型见 security-model 与docs/plugins/plugin-authority.md。platform不是沙盒:它只是出口抽象;sendMessage/callAction直达真实平台,没有任何隔离层。SSRF 安全出口请走safeFetch(util-network-guard),不要在 adapter 里裸fetch外部 URL。
7. 边界与注意事项
- 路由依赖
canHandle/ 前缀约定:resolvePlatformBySession对未实现canHandle的 adapter 用sessionId.startsWith(platform + ':')兜底(src/index.ts)。若你的 sessionId 既不带前缀又不实现canHandle,它将永远无法被路由——sendPlatformMessage抛「没有 platform adapter 能处理」。CLI 即是典型反例(plugin-cli/src/index.ts)。前缀兜底只认平台名、不认实例,同一插件装多个实例时按list()的顺序(本插件偏好 > 全局偏好 > priority > 注册顺序)落到第一个,所以能多实例的适配器要自己实现canHandle(OneBot 即是)。 getPlatformNames用adapter.platform字段去重,不是用服务名:同一platform字符串值的多个 adapter 会被并成一个名。确保platform字段在你的生态里唯一且稳定(它会进 authority 作用域、user-relation 白名单、归档 metadata)。- 非标准扩展方法不在契约里:OneBot 的
getSelfMutes/getSentMessages/handleFriendRequest等是PlatformAdapter & {…}交叉类型上附加的扩展(adapter-onebot/src/index.ts),只对知情消费者(plugin-tool-onebot,按会话路由到所属实例后用typeof探测方法)可见。第三方 adapter 不要依赖这些;要扩展平台专属能力,沿用「callAction(sessionId, action, params)走平台原生 API」这条标准通道,或自己另起一个独立服务名而非污染platform契约。 - 多 entry 时
platform.use选中的那一家基本无意义:因为 platform 是按名/按 sessionId 路由的多实例服务。误用platform.use当「主平台」会在多 adapter 时打到不确定的那一个(按本插件偏好 > 全局偏好 > priority > 注册顺序)。一律用 helper。
8. 交叉链接
- 概念:service-model(DI 按名、多 entry、per-entry provide)、lazy-service-access(业务句柄是活的、provider bounce)、manifest-metadata(
provides/uses双源 +subsystem)、message-llm-pipeline(inbound:message/outbound:message与 agent 路由)、security-model、storage-uri-grammar。 - 核心:
docs/core/service.md、docs/plugins/plugin-authority.md、docs/core/context.md、docs/core/events.md。 - 相关契约包:
@aalis/schema-message(IncomingMessage/OutgoingMessage)、plugin-gateway(入站路由)、plugin-flow-control(限速闸门后端)。