Outbound: route sessions through channel plugins

This commit is contained in:
Gustavo Madeira Santana 2026-03-18 04:03:03 +00:00
parent 826c592deb
commit 4079de21ce
No known key found for this signature in database
4 changed files with 175 additions and 456 deletions

View File

@ -1,4 +1,4 @@
import { requireBundledChannelPlugin } from "../channels/plugins/bundled.js";
import { bundledChannelPlugins } from "../channels/plugins/bundled.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { getChannelSetupWizardAdapter } from "./channel-setup/registry.js";
@ -20,15 +20,11 @@ type PatchedSetupAdapterFields = {
};
export function setDefaultChannelPluginRegistryForTests(): void {
const channels = [
{ pluginId: "discord", plugin: requireBundledChannelPlugin("discord"), source: "test" },
{ pluginId: "feishu", plugin: requireBundledChannelPlugin("feishu"), source: "test" },
{ pluginId: "slack", plugin: requireBundledChannelPlugin("slack"), source: "test" },
{ pluginId: "telegram", plugin: requireBundledChannelPlugin("telegram"), source: "test" },
{ pluginId: "whatsapp", plugin: requireBundledChannelPlugin("whatsapp"), source: "test" },
{ pluginId: "signal", plugin: requireBundledChannelPlugin("signal"), source: "test" },
{ pluginId: "imessage", plugin: requireBundledChannelPlugin("imessage"), source: "test" },
] as unknown as Parameters<typeof createTestRegistry>[0];
const channels = bundledChannelPlugins.map((plugin) => ({
pluginId: plugin.id,
plugin,
source: "test" as const,
})) as unknown as Parameters<typeof createTestRegistry>[0];
setActivePluginRegistry(createTestRegistry(channels));
}

View File

@ -44,6 +44,42 @@ describe("resolveOutboundSessionRoute", () => {
chatType?: "direct" | "group";
};
}> = [
{
name: "WhatsApp group jid",
cfg: baseConfig,
channel: "whatsapp",
target: "120363040000000000@g.us",
expected: {
sessionKey: "agent:main:whatsapp:group:120363040000000000@g.us",
from: "120363040000000000@g.us",
to: "120363040000000000@g.us",
chatType: "group",
},
},
{
name: "Matrix room target",
cfg: baseConfig,
channel: "matrix",
target: "room:!ops:matrix.example",
expected: {
sessionKey: "agent:main:matrix:channel:!ops:matrix.example",
from: "matrix:channel:!ops:matrix.example",
to: "room:!ops:matrix.example",
chatType: "channel",
},
},
{
name: "MSTeams conversation target",
cfg: baseConfig,
channel: "msteams",
target: "conversation:19:meeting_abc@thread.tacv2",
expected: {
sessionKey: "agent:main:msteams:channel:19:meeting_abc@thread.tacv2",
from: "msteams:channel:19:meeting_abc@thread.tacv2",
to: "conversation:19:meeting_abc@thread.tacv2",
chatType: "channel",
},
},
{
name: "Slack thread",
cfg: baseConfig,
@ -115,6 +151,18 @@ describe("resolveOutboundSessionRoute", () => {
sessionKey: "agent:main:direct:alice",
},
},
{
name: "Nextcloud Talk room target",
cfg: baseConfig,
channel: "nextcloud-talk",
target: "room:opsroom42",
expected: {
sessionKey: "agent:main:nextcloud-talk:group:opsroom42",
from: "nextcloud-talk:room:opsroom42",
to: "nextcloud-talk:opsroom42",
chatType: "group",
},
},
{
name: "BlueBubbles chat_* prefix stripping",
cfg: baseConfig,
@ -125,6 +173,18 @@ describe("resolveOutboundSessionRoute", () => {
from: "group:ABC123",
},
},
{
name: "Zalo direct target",
cfg: perChannelPeerCfg,
channel: "zalo",
target: "zl:123456",
expected: {
sessionKey: "agent:main:zalo:direct:123456",
from: "zalo:123456",
to: "zalo:123456",
chatType: "direct",
},
},
{
name: "Zalo Personal DM target",
cfg: perChannelPeerCfg,
@ -135,6 +195,30 @@ describe("resolveOutboundSessionRoute", () => {
chatType: "direct",
},
},
{
name: "Nostr prefixed target",
cfg: perChannelPeerCfg,
channel: "nostr",
target: "nostr:npub1example",
expected: {
sessionKey: "agent:main:nostr:direct:npub1example",
from: "nostr:npub1example",
to: "nostr:npub1example",
chatType: "direct",
},
},
{
name: "Tlon group target",
cfg: baseConfig,
channel: "tlon",
target: "group:~zod/main",
expected: {
sessionKey: "agent:main:tlon:group:chat/~zod/main",
from: "tlon:group:chat/~zod/main",
to: "tlon:chat/~zod/main",
chatType: "group",
},
},
{
name: "Slack mpim allowlist -> group key",
cfg: slackMpimCfg,

View File

@ -5,11 +5,8 @@ import type { ChannelId } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { recordSessionMetaFromInbound, resolveStorePath } from "../../config/sessions.js";
import type { RoutePeer } from "../../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../routing/session-key.js";
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
import { buildOutboundBaseSessionKey } from "./base-session-key.js";
import type { ResolvedMessagingTarget } from "./target-resolver.js";
import { normalizeOutboundThreadId } from "./thread-id.js";
export type OutboundSessionRoute = {
sessionKey: string;
@ -80,426 +77,6 @@ function buildBaseSessionKey(params: {
return buildOutboundBaseSessionKey(params);
}
function resolveWhatsAppSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
const normalized = normalizeWhatsAppTarget(params.target);
if (!normalized) {
return null;
}
const isGroup = isWhatsAppGroupJid(normalized);
const peer: RoutePeer = {
kind: isGroup ? "group" : "direct",
id: normalized,
};
const baseSessionKey = buildBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
channel: "whatsapp",
accountId: params.accountId,
peer,
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer,
chatType: isGroup ? "group" : "direct",
from: normalized,
to: normalized,
};
}
function resolveMatrixSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
const stripped = stripProviderPrefix(params.target, "matrix");
const isUser =
params.resolvedTarget?.kind === "user" || stripped.startsWith("@") || /^user:/i.test(stripped);
const rawId = stripKindPrefix(stripped);
if (!rawId) {
return null;
}
const peer: RoutePeer = { kind: isUser ? "direct" : "channel", id: rawId };
const baseSessionKey = buildBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
channel: "matrix",
accountId: params.accountId,
peer,
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer,
chatType: isUser ? "direct" : "channel",
from: isUser ? `matrix:${rawId}` : `matrix:channel:${rawId}`,
to: `room:${rawId}`,
};
}
function buildSimpleBaseSession(params: {
route: ResolveOutboundSessionRouteParams;
channel: string;
peer: RoutePeer;
}) {
const baseSessionKey = buildBaseSessionKey({
cfg: params.route.cfg,
agentId: params.route.agentId,
channel: params.channel,
accountId: params.route.accountId,
peer: params.peer,
});
return { baseSessionKey, peer: params.peer };
}
function resolveMSTeamsSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
let trimmed = params.target.trim();
if (!trimmed) {
return null;
}
trimmed = trimmed.replace(/^(msteams|teams):/i, "").trim();
const lower = trimmed.toLowerCase();
const isUser = lower.startsWith("user:");
const rawId = stripKindPrefix(trimmed);
if (!rawId) {
return null;
}
const conversationId = rawId.split(";")[0] ?? rawId;
const isChannel = !isUser && /@thread\.tacv2/i.test(conversationId);
const peer: RoutePeer = {
kind: isUser ? "direct" : isChannel ? "channel" : "group",
id: conversationId,
};
const baseSessionKey = buildBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
channel: "msteams",
accountId: params.accountId,
peer,
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer,
chatType: isUser ? "direct" : isChannel ? "channel" : "group",
from: isUser
? `msteams:${conversationId}`
: isChannel
? `msteams:channel:${conversationId}`
: `msteams:group:${conversationId}`,
to: isUser ? `user:${conversationId}` : `conversation:${conversationId}`,
};
}
function resolveMattermostSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
let trimmed = params.target.trim();
if (!trimmed) {
return null;
}
trimmed = trimmed.replace(/^mattermost:/i, "").trim();
const lower = trimmed.toLowerCase();
const resolvedKind = params.resolvedTarget?.kind;
const isUser =
resolvedKind === "user" ||
(resolvedKind !== "channel" &&
resolvedKind !== "group" &&
(lower.startsWith("user:") || trimmed.startsWith("@")));
if (trimmed.startsWith("@")) {
trimmed = trimmed.slice(1).trim();
}
const rawId = stripKindPrefix(trimmed);
if (!rawId) {
return null;
}
const { baseSessionKey, peer } = buildSimpleBaseSession({
route: params,
channel: "mattermost",
peer: { kind: isUser ? "direct" : "channel", id: rawId },
});
const threadId = normalizeOutboundThreadId(params.replyToId ?? params.threadId);
const threadKeys = resolveThreadSessionKeys({
baseSessionKey,
threadId,
});
return {
sessionKey: threadKeys.sessionKey,
baseSessionKey,
peer,
chatType: isUser ? "direct" : "channel",
from: isUser ? `mattermost:${rawId}` : `mattermost:channel:${rawId}`,
to: isUser ? `user:${rawId}` : `channel:${rawId}`,
threadId,
};
}
function resolveBlueBubblesSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
const stripped = stripProviderPrefix(params.target, "bluebubbles");
const lower = stripped.toLowerCase();
const isGroup =
lower.startsWith("chat_id:") ||
lower.startsWith("chat_guid:") ||
lower.startsWith("chat_identifier:") ||
lower.startsWith("group:");
const rawPeerId = isGroup
? stripKindPrefix(stripped)
: stripped.replace(/^(imessage|sms|auto):/i, "");
// BlueBubbles inbound group ids omit chat_* prefixes; strip them to align sessions.
const peerId = isGroup
? rawPeerId.replace(/^(chat_id|chat_guid|chat_identifier):/i, "")
: rawPeerId;
if (!peerId) {
return null;
}
const peer: RoutePeer = {
kind: isGroup ? "group" : "direct",
id: peerId,
};
const baseSessionKey = buildBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
channel: "bluebubbles",
accountId: params.accountId,
peer,
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer,
chatType: isGroup ? "group" : "direct",
from: isGroup ? `group:${peerId}` : `bluebubbles:${peerId}`,
to: `bluebubbles:${stripped}`,
};
}
function resolveNextcloudTalkSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
let trimmed = params.target.trim();
if (!trimmed) {
return null;
}
trimmed = trimmed.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").trim();
trimmed = trimmed.replace(/^room:/i, "").trim();
if (!trimmed) {
return null;
}
const peer: RoutePeer = { kind: "group", id: trimmed };
const baseSessionKey = buildBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
channel: "nextcloud-talk",
accountId: params.accountId,
peer,
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer,
chatType: "group",
from: `nextcloud-talk:room:${trimmed}`,
to: `nextcloud-talk:${trimmed}`,
};
}
function resolveZaloSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
return resolveZaloLikeSession(params, "zalo", /^(zl):/i);
}
function resolveZaloLikeSession(
params: ResolveOutboundSessionRouteParams,
channel: "zalo" | "zalouser",
aliasPrefix: RegExp,
): OutboundSessionRoute | null {
const trimmed = stripProviderPrefix(params.target, channel).replace(aliasPrefix, "").trim();
if (!trimmed) {
return null;
}
const isGroup = trimmed.toLowerCase().startsWith("group:");
const peerId = stripKindPrefix(trimmed);
const peer: RoutePeer = { kind: isGroup ? "group" : "direct", id: peerId };
const baseSessionKey = buildBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
channel,
accountId: params.accountId,
peer,
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer,
chatType: isGroup ? "group" : "direct",
from: isGroup ? `${channel}:group:${peerId}` : `${channel}:${peerId}`,
to: `${channel}:${peerId}`,
};
}
function resolveZalouserSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
// Keep DM vs group aligned with inbound sessions for Zalo Personal.
return resolveZaloLikeSession(params, "zalouser", /^(zlu):/i);
}
function resolveNostrSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
const trimmed = stripProviderPrefix(params.target, "nostr").trim();
if (!trimmed) {
return null;
}
const peer: RoutePeer = { kind: "direct", id: trimmed };
const baseSessionKey = buildBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
channel: "nostr",
accountId: params.accountId,
peer,
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer,
chatType: "direct",
from: `nostr:${trimmed}`,
to: `nostr:${trimmed}`,
};
}
function normalizeTlonShip(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return trimmed;
}
return trimmed.startsWith("~") ? trimmed : `~${trimmed}`;
}
function resolveTlonSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
let trimmed = stripProviderPrefix(params.target, "tlon");
trimmed = trimmed.trim();
if (!trimmed) {
return null;
}
const lower = trimmed.toLowerCase();
let isGroup =
lower.startsWith("group:") || lower.startsWith("room:") || lower.startsWith("chat/");
let peerId = trimmed;
if (lower.startsWith("group:") || lower.startsWith("room:")) {
peerId = trimmed.replace(/^(group|room):/i, "").trim();
if (!peerId.startsWith("chat/")) {
const parts = peerId.split("/").filter(Boolean);
if (parts.length === 2) {
peerId = `chat/${normalizeTlonShip(parts[0])}/${parts[1]}`;
}
}
isGroup = true;
} else if (lower.startsWith("dm:")) {
peerId = normalizeTlonShip(trimmed.slice("dm:".length));
isGroup = false;
} else if (lower.startsWith("chat/")) {
peerId = trimmed;
isGroup = true;
} else if (trimmed.includes("/")) {
const parts = trimmed.split("/").filter(Boolean);
if (parts.length === 2) {
peerId = `chat/${normalizeTlonShip(parts[0])}/${parts[1]}`;
isGroup = true;
}
} else {
peerId = normalizeTlonShip(trimmed);
}
const peer: RoutePeer = { kind: isGroup ? "group" : "direct", id: peerId };
const baseSessionKey = buildBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
channel: "tlon",
accountId: params.accountId,
peer,
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer,
chatType: isGroup ? "group" : "direct",
from: isGroup ? `tlon:group:${peerId}` : `tlon:${peerId}`,
to: `tlon:${peerId}`,
};
}
/**
* Feishu ID formats:
* - oc_xxx: chat_id (can be group or DM, use chat_mode to distinguish or explicit dm:/group: prefix)
* - ou_xxx: user open_id (DM)
* - on_xxx: user union_id (DM)
* - cli_xxx: app_id (not a valid send target)
*/
function resolveFeishuSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
let trimmed = stripProviderPrefix(params.target, "feishu");
trimmed = stripProviderPrefix(trimmed, "lark").trim();
if (!trimmed) {
return null;
}
const lower = trimmed.toLowerCase();
let isGroup = false;
let typeExplicit = false;
if (lower.startsWith("group:") || lower.startsWith("chat:")) {
trimmed = trimmed.replace(/^(group|chat):/i, "").trim();
isGroup = true;
typeExplicit = true;
} else if (lower.startsWith("user:") || lower.startsWith("dm:")) {
trimmed = trimmed.replace(/^(user|dm):/i, "").trim();
isGroup = false;
typeExplicit = true;
}
const idLower = trimmed.toLowerCase();
// Only infer type from ID prefix if not explicitly specified
// Note: oc_ is a chat_id and can be either group or DM (must check chat_mode from API)
// Only ou_/on_ can be reliably identified as user IDs (always DM)
if (!typeExplicit) {
if (idLower.startsWith("ou_") || idLower.startsWith("on_")) {
isGroup = false;
}
// oc_ requires explicit prefix: dm:oc_xxx or group:oc_xxx
}
const peer: RoutePeer = {
kind: isGroup ? "group" : "direct",
id: trimmed,
};
const baseSessionKey = buildBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
channel: "feishu",
accountId: params.accountId,
peer,
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer,
chatType: isGroup ? "group" : "direct",
from: isGroup ? `feishu:group:${trimmed}` : `feishu:${trimmed}`,
to: trimmed,
};
}
function resolveFallbackSession(
params: ResolveOutboundSessionRouteParams,
): OutboundSessionRoute | null {
@ -538,24 +115,6 @@ function resolveFallbackSession(
};
}
type OutboundSessionResolver = (
params: ResolveOutboundSessionRouteParams,
) => OutboundSessionRoute | null | Promise<OutboundSessionRoute | null>;
const OUTBOUND_SESSION_RESOLVERS: Partial<Record<ChannelId, OutboundSessionResolver>> = {
whatsapp: resolveWhatsAppSession,
matrix: resolveMatrixSession,
msteams: resolveMSTeamsSession,
mattermost: resolveMattermostSession,
bluebubbles: resolveBlueBubblesSession,
"nextcloud-talk": resolveNextcloudTalkSession,
zalo: resolveZaloSession,
zalouser: resolveZalouserSession,
nostr: resolveNostrSession,
tlon: resolveTlonSession,
feishu: resolveFeishuSession,
};
export async function resolveOutboundSessionRoute(
params: ResolveOutboundSessionRouteParams,
): Promise<OutboundSessionRoute | null> {
@ -578,11 +137,7 @@ export async function resolveOutboundSessionRoute(
if (pluginRoute) {
return pluginRoute;
}
const resolver = OUTBOUND_SESSION_RESOLVERS[params.channel];
if (!resolver) {
return resolveFallbackSession(nextParams);
}
return await resolver(nextParams);
return resolveFallbackSession(nextParams);
}
export async function ensureOutboundSessionEntry(params: {

View File

@ -975,6 +975,42 @@ describe("resolveOutboundSessionRoute", () => {
chatType?: "direct" | "group";
};
}> = [
{
name: "WhatsApp group jid",
cfg: baseConfig,
channel: "whatsapp",
target: "120363040000000000@g.us",
expected: {
sessionKey: "agent:main:whatsapp:group:120363040000000000@g.us",
from: "120363040000000000@g.us",
to: "120363040000000000@g.us",
chatType: "group",
},
},
{
name: "Matrix room target",
cfg: baseConfig,
channel: "matrix",
target: "room:!ops:matrix.example",
expected: {
sessionKey: "agent:main:matrix:channel:!ops:matrix.example",
from: "matrix:channel:!ops:matrix.example",
to: "room:!ops:matrix.example",
chatType: "channel",
},
},
{
name: "MSTeams conversation target",
cfg: baseConfig,
channel: "msteams",
target: "conversation:19:meeting_abc@thread.tacv2",
expected: {
sessionKey: "agent:main:msteams:channel:19:meeting_abc@thread.tacv2",
from: "msteams:channel:19:meeting_abc@thread.tacv2",
to: "conversation:19:meeting_abc@thread.tacv2",
chatType: "channel",
},
},
{
name: "Slack thread",
cfg: baseConfig,
@ -1046,6 +1082,18 @@ describe("resolveOutboundSessionRoute", () => {
sessionKey: "agent:main:direct:alice",
},
},
{
name: "Nextcloud Talk room target",
cfg: baseConfig,
channel: "nextcloud-talk",
target: "room:opsroom42",
expected: {
sessionKey: "agent:main:nextcloud-talk:group:opsroom42",
from: "nextcloud-talk:room:opsroom42",
to: "nextcloud-talk:opsroom42",
chatType: "group",
},
},
{
name: "BlueBubbles chat_* prefix stripping",
cfg: baseConfig,
@ -1056,6 +1104,18 @@ describe("resolveOutboundSessionRoute", () => {
from: "group:ABC123",
},
},
{
name: "Zalo direct target",
cfg: perChannelPeerCfg,
channel: "zalo",
target: "zl:123456",
expected: {
sessionKey: "agent:main:zalo:direct:123456",
from: "zalo:123456",
to: "zalo:123456",
chatType: "direct",
},
},
{
name: "Zalo Personal DM target",
cfg: perChannelPeerCfg,
@ -1066,6 +1126,30 @@ describe("resolveOutboundSessionRoute", () => {
chatType: "direct",
},
},
{
name: "Nostr prefixed target",
cfg: perChannelPeerCfg,
channel: "nostr",
target: "nostr:npub1example",
expected: {
sessionKey: "agent:main:nostr:direct:npub1example",
from: "nostr:npub1example",
to: "nostr:npub1example",
chatType: "direct",
},
},
{
name: "Tlon group target",
cfg: baseConfig,
channel: "tlon",
target: "group:~zod/main",
expected: {
sessionKey: "agent:main:tlon:group:chat/~zod/main",
from: "tlon:group:chat/~zod/main",
to: "tlon:chat/~zod/main",
chatType: "group",
},
},
{
name: "Slack mpim allowlist -> group key",
cfg: slackMpimCfg,