diff --git a/dist/fabric/src/channel.js b/dist/fabric/src/channel.js index cb06c38..bca9610 100644 --- a/dist/fabric/src/channel.js +++ b/dist/fabric/src/channel.js @@ -11,6 +11,15 @@ import { createChatChannelPlugin, createChannelPluginBase, buildChannelOutboundSessionRoute, } from 'openclaw/plugin-sdk/core'; import { FabricClient } from './fabric-client.js'; import { listFabricAccountIds, resolveFabricAccount, resolveDefaultFabricAccountId, } from './accounts.js'; +import { getChannelType } from './channel-meta.js'; +export function fabricPeerRoutingForXType(xType) { + if (xType === 'dm') + return { peerKind: 'direct', chatType: 'direct' }; + return { peerKind: 'group', chatType: 'group' }; +} +export function fabricPeerRoutingForChannel(channelId) { + return fabricPeerRoutingForXType(getChannelType(channelId)); +} // ---- target grammar: fabric: ---- export function stripFabricTargetPrefix(raw) { let s = (raw ?? '').trim(); @@ -38,13 +47,18 @@ export function resolveFabricOutboundSessionRoute(params) { const id = stripFabricTargetPrefix(params.target); if (!id) return null; + // Consult the channel-meta cache populated by inbound — DM channels + // need peer.kind='direct' so the outbound session key matches the + // inbound one. Cache miss falls back to 'group' (the pre-fix default, + // no regression on cold cache). + const { peerKind, chatType } = fabricPeerRoutingForChannel(id); return buildChannelOutboundSessionRoute({ cfg: params.cfg, agentId: params.agentId, channel: 'fabric', accountId: params.accountId, - peer: { kind: 'group', id }, - chatType: 'group', + peer: { kind: peerKind, id }, + chatType, from: `fabric:channel:${id}`, to: `fabric:${id}`, }); diff --git a/dist/fabric/src/inbound.js b/dist/fabric/src/inbound.js index 7a4033b..06ebb35 100644 --- a/dist/fabric/src/inbound.js +++ b/dist/fabric/src/inbound.js @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { io } from 'socket.io-client'; import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch'; import { resolveCoalesce } from './accounts.js'; +import { fabricPeerRoutingForXType } from './channel.js'; import { recordChannelType } from './channel-meta.js'; import { enqueueDelivery, flushFabricForChannel } from './coalesce.js'; export class FabricInbound { @@ -433,11 +434,19 @@ export class FabricInbound { const core = this.core; const cfg = this.cfg; try { + // Route by xType. DM channels need peer.kind='direct' so openclaw + // treats them as 1:1 (sessionKey 'agent::fabric:direct:' + // and ctx.ChatType='direct') rather than as a multi-party group. + // Without this, the agent's user-prompt metadata says + // 'is_group_chat: true' on a DM and downstream prompt logic + // (commands-handlers `isDirectMessage` checks ChatType==='direct') + // misclassifies the turn. + const { peerKind, chatType } = fabricPeerRoutingForXType(m.xType); const route = core.channel.routing.resolveAgentRoute({ cfg: this.cfg, channel: 'fabric', accountId: agentId, - peer: { kind: 'group', id: channelId }, + peer: { kind: peerKind, id: channelId }, }); const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId, @@ -451,7 +460,7 @@ export class FabricInbound { To: `fabric:${channelId}`, SessionKey: route.sessionKey, AccountId: route.accountId ?? agentId, - ChatType: 'group', + ChatType: chatType, ConversationLabel: `fabric:${guild.nodeId}`, SenderId: m.authorUserId ?? 'fabric', Provider: 'fabric', diff --git a/src/channel.ts b/src/channel.ts index 38603d1..8fb58c0 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -21,6 +21,39 @@ import { resolveDefaultFabricAccountId, type ResolvedFabricAccount, } from './accounts.js'; +import { getChannelType } from './channel-meta.js'; + +/** + * Map a Fabric channel xType to an openclaw routing peer.kind / ChatType. + * + * Fabric distinguishes channels by xType ('dm' | 'triage' | 'group' | + * 'broadcast' | 'announce' | ...). Openclaw's session router only knows + * 'direct' | 'group' | 'channel'. We collapse: + * - 'dm' → 'direct' (1:1 conversation; agent always speaks) + * - rest → 'group' (multi-party; turn-engine gates speech) + * + * Sessions are keyed by peer.kind, so inbound and outbound MUST agree — + * otherwise the agent's outbound message lands in a different session + * than the inbound that triggered it and conversation state splits. + * + * Outbound has no live xType (the agent target is just a channelId), so + * it consults the channel-meta cache populated by inbound. Cache miss + * (channel never observed) falls back to 'group' — same as the pre-fix + * behavior, no regression on cold cache. The proactive-DM-first-message + * edge case (agent DMs a channel before any inbound) still lands as + * 'group' on that one outbound; the next inbound + outbound pair will + * agree on 'direct'. + */ +export type FabricPeerRouting = { peerKind: 'direct' | 'group'; chatType: 'direct' | 'group' }; + +export function fabricPeerRoutingForXType(xType: string | null | undefined): FabricPeerRouting { + if (xType === 'dm') return { peerKind: 'direct', chatType: 'direct' }; + return { peerKind: 'group', chatType: 'group' }; +} + +export function fabricPeerRoutingForChannel(channelId: string): FabricPeerRouting { + return fabricPeerRoutingForXType(getChannelType(channelId)); +} type AnyCfg = { channels?: { fabric?: unknown }; [k: string]: unknown }; @@ -45,13 +78,18 @@ export function looksLikeFabricTargetId(raw: string): boolean { export function resolveFabricOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) { const id = stripFabricTargetPrefix(params.target); if (!id) return null; + // Consult the channel-meta cache populated by inbound — DM channels + // need peer.kind='direct' so the outbound session key matches the + // inbound one. Cache miss falls back to 'group' (the pre-fix default, + // no regression on cold cache). + const { peerKind, chatType } = fabricPeerRoutingForChannel(id); return buildChannelOutboundSessionRoute({ cfg: params.cfg, agentId: params.agentId, channel: 'fabric', accountId: params.accountId, - peer: { kind: 'group', id }, - chatType: 'group', + peer: { kind: peerKind, id }, + chatType, from: `fabric:channel:${id}`, to: `fabric:${id}`, }); diff --git a/src/inbound.ts b/src/inbound.ts index a34ee59..4b15a3e 100644 --- a/src/inbound.ts +++ b/src/inbound.ts @@ -6,6 +6,7 @@ import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply- import type { FabricClient, FabricSession } from './fabric-client.js'; import type { IdentityRegistry } from './identity.js'; import { resolveCoalesce } from './accounts.js'; +import { fabricPeerRoutingForXType } from './channel.js'; import { recordChannelType } from './channel-meta.js'; import { enqueueDelivery, flushFabricForChannel } from './coalesce.js'; @@ -504,11 +505,19 @@ export class FabricInbound { const core = this.core as Core & Record; const cfg = this.cfg as { session?: { store?: unknown } }; try { + // Route by xType. DM channels need peer.kind='direct' so openclaw + // treats them as 1:1 (sessionKey 'agent::fabric:direct:' + // and ctx.ChatType='direct') rather than as a multi-party group. + // Without this, the agent's user-prompt metadata says + // 'is_group_chat: true' on a DM and downstream prompt logic + // (commands-handlers `isDirectMessage` checks ChatType==='direct') + // misclassifies the turn. + const { peerKind, chatType } = fabricPeerRoutingForXType(m.xType); const route = core.channel.routing.resolveAgentRoute({ cfg: this.cfg, channel: 'fabric', accountId: agentId, - peer: { kind: 'group', id: channelId }, + peer: { kind: peerKind, id: channelId }, }); const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId, @@ -523,7 +532,7 @@ export class FabricInbound { To: `fabric:${channelId}`, SessionKey: route.sessionKey, AccountId: route.accountId ?? agentId, - ChatType: 'group', + ChatType: chatType, ConversationLabel: `fabric:${guild.nodeId}`, SenderId: m.authorUserId ?? 'fabric', Provider: 'fabric',