fix(inbound): route fabric DM channels as peer.kind=direct / ChatType=direct (#6)

This commit was merged in pull request #6.
This commit is contained in:
h z
2026-05-25 14:03:20 +00:00
4 changed files with 78 additions and 8 deletions

View File

@@ -11,6 +11,15 @@
import { createChatChannelPlugin, createChannelPluginBase, buildChannelOutboundSessionRoute, } from 'openclaw/plugin-sdk/core'; import { createChatChannelPlugin, createChannelPluginBase, buildChannelOutboundSessionRoute, } from 'openclaw/plugin-sdk/core';
import { FabricClient } from './fabric-client.js'; import { FabricClient } from './fabric-client.js';
import { listFabricAccountIds, resolveFabricAccount, resolveDefaultFabricAccountId, } from './accounts.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:<channelId> ---- // ---- target grammar: fabric:<channelId> ----
export function stripFabricTargetPrefix(raw) { export function stripFabricTargetPrefix(raw) {
let s = (raw ?? '').trim(); let s = (raw ?? '').trim();
@@ -38,13 +47,18 @@ export function resolveFabricOutboundSessionRoute(params) {
const id = stripFabricTargetPrefix(params.target); const id = stripFabricTargetPrefix(params.target);
if (!id) if (!id)
return null; 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({ return buildChannelOutboundSessionRoute({
cfg: params.cfg, cfg: params.cfg,
agentId: params.agentId, agentId: params.agentId,
channel: 'fabric', channel: 'fabric',
accountId: params.accountId, accountId: params.accountId,
peer: { kind: 'group', id }, peer: { kind: peerKind, id },
chatType: 'group', chatType,
from: `fabric:channel:${id}`, from: `fabric:channel:${id}`,
to: `fabric:${id}`, to: `fabric:${id}`,
}); });

View File

@@ -4,6 +4,7 @@ import { join } from 'node:path';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch'; import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch';
import { resolveCoalesce } from './accounts.js'; import { resolveCoalesce } from './accounts.js';
import { fabricPeerRoutingForXType } from './channel.js';
import { recordChannelType } from './channel-meta.js'; import { recordChannelType } from './channel-meta.js';
import { enqueueDelivery, flushFabricForChannel } from './coalesce.js'; import { enqueueDelivery, flushFabricForChannel } from './coalesce.js';
export class FabricInbound { export class FabricInbound {
@@ -433,11 +434,19 @@ export class FabricInbound {
const core = this.core; const core = this.core;
const cfg = this.cfg; const cfg = this.cfg;
try { try {
// Route by xType. DM channels need peer.kind='direct' so openclaw
// treats them as 1:1 (sessionKey 'agent:<id>:fabric:direct:<chan>'
// 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({ const route = core.channel.routing.resolveAgentRoute({
cfg: this.cfg, cfg: this.cfg,
channel: 'fabric', channel: 'fabric',
accountId: agentId, accountId: agentId,
peer: { kind: 'group', id: channelId }, peer: { kind: peerKind, id: channelId },
}); });
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId, agentId: route.agentId,
@@ -451,7 +460,7 @@ export class FabricInbound {
To: `fabric:${channelId}`, To: `fabric:${channelId}`,
SessionKey: route.sessionKey, SessionKey: route.sessionKey,
AccountId: route.accountId ?? agentId, AccountId: route.accountId ?? agentId,
ChatType: 'group', ChatType: chatType,
ConversationLabel: `fabric:${guild.nodeId}`, ConversationLabel: `fabric:${guild.nodeId}`,
SenderId: m.authorUserId ?? 'fabric', SenderId: m.authorUserId ?? 'fabric',
Provider: 'fabric', Provider: 'fabric',

View File

@@ -21,6 +21,39 @@ import {
resolveDefaultFabricAccountId, resolveDefaultFabricAccountId,
type ResolvedFabricAccount, type ResolvedFabricAccount,
} from './accounts.js'; } 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 }; type AnyCfg = { channels?: { fabric?: unknown }; [k: string]: unknown };
@@ -45,13 +78,18 @@ export function looksLikeFabricTargetId(raw: string): boolean {
export function resolveFabricOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) { export function resolveFabricOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
const id = stripFabricTargetPrefix(params.target); const id = stripFabricTargetPrefix(params.target);
if (!id) return null; 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({ return buildChannelOutboundSessionRoute({
cfg: params.cfg, cfg: params.cfg,
agentId: params.agentId, agentId: params.agentId,
channel: 'fabric', channel: 'fabric',
accountId: params.accountId, accountId: params.accountId,
peer: { kind: 'group', id }, peer: { kind: peerKind, id },
chatType: 'group', chatType,
from: `fabric:channel:${id}`, from: `fabric:channel:${id}`,
to: `fabric:${id}`, to: `fabric:${id}`,
}); });

View File

@@ -6,6 +6,7 @@ import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-
import type { FabricClient, FabricSession } from './fabric-client.js'; import type { FabricClient, FabricSession } from './fabric-client.js';
import type { IdentityRegistry } from './identity.js'; import type { IdentityRegistry } from './identity.js';
import { resolveCoalesce } from './accounts.js'; import { resolveCoalesce } from './accounts.js';
import { fabricPeerRoutingForXType } from './channel.js';
import { recordChannelType } from './channel-meta.js'; import { recordChannelType } from './channel-meta.js';
import { enqueueDelivery, flushFabricForChannel } from './coalesce.js'; import { enqueueDelivery, flushFabricForChannel } from './coalesce.js';
@@ -504,11 +505,19 @@ export class FabricInbound {
const core = this.core as Core & Record<string, unknown>; const core = this.core as Core & Record<string, unknown>;
const cfg = this.cfg as { session?: { store?: unknown } }; const cfg = this.cfg as { session?: { store?: unknown } };
try { try {
// Route by xType. DM channels need peer.kind='direct' so openclaw
// treats them as 1:1 (sessionKey 'agent:<id>:fabric:direct:<chan>'
// 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({ const route = core.channel.routing.resolveAgentRoute({
cfg: this.cfg, cfg: this.cfg,
channel: 'fabric', channel: 'fabric',
accountId: agentId, accountId: agentId,
peer: { kind: 'group', id: channelId }, peer: { kind: peerKind, id: channelId },
}); });
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId, agentId: route.agentId,
@@ -523,7 +532,7 @@ export class FabricInbound {
To: `fabric:${channelId}`, To: `fabric:${channelId}`,
SessionKey: route.sessionKey, SessionKey: route.sessionKey,
AccountId: route.accountId ?? agentId, AccountId: route.accountId ?? agentId,
ChatType: 'group', ChatType: chatType,
ConversationLabel: `fabric:${guild.nodeId}`, ConversationLabel: `fabric:${guild.nodeId}`,
SenderId: m.authorUserId ?? 'fabric', SenderId: m.authorUserId ?? 'fabric',
Provider: 'fabric', Provider: 'fabric',