mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 19:00:22 +00:00
refactor: remove dock shim and move session routing into plugins
This commit is contained in:
@@ -1,194 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withEnv } from "../test-utils/env.js";
|
||||
import { getChannelDock } from "./dock.js";
|
||||
|
||||
function emptyConfig(): OpenClawConfig {
|
||||
return {} as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("channels dock", () => {
|
||||
it("telegram and googlechat threading contexts map thread ids consistently", () => {
|
||||
const hasRepliedRef = { value: false };
|
||||
const telegramDock = getChannelDock("telegram");
|
||||
const googleChatDock = getChannelDock("googlechat");
|
||||
|
||||
const telegramContext = telegramDock?.threading?.buildToolContext?.({
|
||||
cfg: emptyConfig(),
|
||||
context: {
|
||||
To: " room-1 ",
|
||||
MessageThreadId: 42,
|
||||
ReplyToId: "fallback",
|
||||
CurrentMessageId: "9001",
|
||||
},
|
||||
hasRepliedRef,
|
||||
});
|
||||
const googleChatContext = googleChatDock?.threading?.buildToolContext?.({
|
||||
cfg: emptyConfig(),
|
||||
context: { To: " space-1 ", ReplyToId: "thread-abc" },
|
||||
hasRepliedRef,
|
||||
});
|
||||
|
||||
expect(telegramContext).toEqual({
|
||||
currentChannelId: "room-1",
|
||||
currentThreadTs: "42",
|
||||
currentMessageId: "9001",
|
||||
hasRepliedRef,
|
||||
});
|
||||
expect(googleChatContext).toEqual({
|
||||
currentChannelId: "space-1",
|
||||
currentThreadTs: "thread-abc",
|
||||
hasRepliedRef,
|
||||
});
|
||||
});
|
||||
|
||||
it("telegram threading does not treat ReplyToId as thread id in DMs", () => {
|
||||
const hasRepliedRef = { value: false };
|
||||
const telegramDock = getChannelDock("telegram");
|
||||
const context = telegramDock?.threading?.buildToolContext?.({
|
||||
cfg: emptyConfig(),
|
||||
context: { To: " dm-1 ", ReplyToId: "12345", CurrentMessageId: "12345" },
|
||||
hasRepliedRef,
|
||||
});
|
||||
|
||||
expect(context).toEqual({
|
||||
currentChannelId: "dm-1",
|
||||
currentThreadTs: undefined,
|
||||
currentMessageId: "12345",
|
||||
hasRepliedRef,
|
||||
});
|
||||
});
|
||||
|
||||
it("irc resolveDefaultTo matches account id case-insensitively", () => {
|
||||
const ircDock = getChannelDock("irc");
|
||||
const cfg = {
|
||||
channels: {
|
||||
irc: {
|
||||
defaultTo: "#root",
|
||||
accounts: {
|
||||
Work: { defaultTo: "#work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const accountDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "work" });
|
||||
const rootDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "missing" });
|
||||
|
||||
expect(accountDefault).toBe("#work");
|
||||
expect(rootDefault).toBe("#root");
|
||||
});
|
||||
|
||||
it("signal allowFrom formatter normalizes values and preserves wildcard", () => {
|
||||
const signalDock = getChannelDock("signal");
|
||||
|
||||
const formatted = signalDock?.config?.formatAllowFrom?.({
|
||||
cfg: emptyConfig(),
|
||||
allowFrom: [" signal:+14155550100 ", " * "],
|
||||
});
|
||||
|
||||
expect(formatted).toEqual(["+14155550100", "*"]);
|
||||
});
|
||||
|
||||
it("telegram allowFrom formatter trims, strips prefix, and lowercases", () => {
|
||||
const telegramDock = getChannelDock("telegram");
|
||||
|
||||
const formatted = telegramDock?.config?.formatAllowFrom?.({
|
||||
cfg: emptyConfig(),
|
||||
allowFrom: [" TG:User ", "telegram:Foo", " Plain "],
|
||||
});
|
||||
|
||||
expect(formatted).toEqual(["user", "foo", "plain"]);
|
||||
});
|
||||
|
||||
it("telegram dock config readers preserve omitted-account fallback semantics", () => {
|
||||
withEnv({ TELEGRAM_BOT_TOKEN: "tok-env" }, () => {
|
||||
const telegramDock = getChannelDock("telegram");
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["top-owner"],
|
||||
defaultTo: "@top-target",
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "tok-work",
|
||||
allowFrom: ["work-owner"],
|
||||
defaultTo: "@work-target",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(telegramDock?.config?.resolveAllowFrom?.({ cfg })).toEqual(["top-owner"]);
|
||||
expect(telegramDock?.config?.resolveDefaultTo?.({ cfg })).toBe("@top-target");
|
||||
});
|
||||
});
|
||||
|
||||
it("slack dock config readers stay read-only when tokens are unresolved SecretRefs", () => {
|
||||
const slackDock = getChannelDock("slack");
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "SLACK_BOT_TOKEN",
|
||||
},
|
||||
appToken: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "SLACK_APP_TOKEN",
|
||||
},
|
||||
defaultTo: "channel:C111",
|
||||
dm: { allowFrom: ["U123"] },
|
||||
channels: {
|
||||
C111: { requireMention: false },
|
||||
},
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(slackDock?.config?.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual(["U123"]);
|
||||
expect(slackDock?.config?.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe(
|
||||
"channel:C111",
|
||||
);
|
||||
expect(
|
||||
slackDock?.threading?.resolveReplyToMode?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
chatType: "channel",
|
||||
}),
|
||||
).toBe("all");
|
||||
expect(
|
||||
slackDock?.groups?.resolveRequireMention?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
groupId: "C111",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("dock config readers coerce numeric allowFrom/defaultTo entries through shared helpers", () => {
|
||||
const telegramDock = getChannelDock("telegram");
|
||||
const signalDock = getChannelDock("signal");
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: [12345],
|
||||
defaultTo: 67890,
|
||||
},
|
||||
signal: {
|
||||
allowFrom: [14155550100],
|
||||
defaultTo: 42,
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(telegramDock?.config?.resolveAllowFrom?.({ cfg })).toEqual(["12345"]);
|
||||
expect(telegramDock?.config?.resolveDefaultTo?.({ cfg })).toBe("67890");
|
||||
expect(signalDock?.config?.resolveAllowFrom?.({ cfg })).toEqual(["14155550100"]);
|
||||
expect(signalDock?.config?.resolveDefaultTo?.({ cfg })).toBe("42");
|
||||
});
|
||||
});
|
||||
@@ -1,636 +0,0 @@
|
||||
import { inspectDiscordAccount } from "../../extensions/discord/src/account-inspect.js";
|
||||
import { resolveSignalAccount } from "../../extensions/signal/src/accounts.js";
|
||||
import { inspectSlackAccount } from "../../extensions/slack/src/account-inspect.js";
|
||||
import { resolveSlackReplyToMode } from "../../extensions/slack/src/accounts.js";
|
||||
import { buildSlackThreadingToolContext } from "../../extensions/slack/src/threading-tool-context.js";
|
||||
import { inspectTelegramAccount } from "../../extensions/telegram/src/account-inspect.js";
|
||||
import {
|
||||
resolveChannelGroupRequireMention,
|
||||
resolveChannelGroupToolsPolicy,
|
||||
} from "../config/group-policy.js";
|
||||
import {
|
||||
formatAllowFromLowercase,
|
||||
formatNormalizedAllowFromEntries,
|
||||
} from "../plugin-sdk/allow-from.js";
|
||||
import {
|
||||
mapAllowFromEntries,
|
||||
resolveOptionalConfigString,
|
||||
formatTrimmedAllowFromEntries,
|
||||
formatWhatsAppConfigAllowFromEntries,
|
||||
resolveIMessageConfigAllowFrom,
|
||||
resolveIMessageConfigDefaultTo,
|
||||
resolveWhatsAppConfigAllowFrom,
|
||||
resolveWhatsAppConfigDefaultTo,
|
||||
} from "../plugin-sdk/channel-config-helpers.js";
|
||||
import { requireActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
resolveGoogleChatGroupRequireMention,
|
||||
resolveGoogleChatGroupToolPolicy,
|
||||
resolveIMessageGroupRequireMention,
|
||||
resolveIMessageGroupToolPolicy,
|
||||
resolveLineGroupRequireMention,
|
||||
resolveLineGroupToolPolicy,
|
||||
resolveSlackGroupRequireMention,
|
||||
resolveSlackGroupToolPolicy,
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveTelegramGroupToolPolicy,
|
||||
resolveWhatsAppGroupRequireMention,
|
||||
resolveWhatsAppGroupToolPolicy,
|
||||
} from "./plugins/group-mentions.js";
|
||||
import { normalizeSignalMessagingTarget } from "./plugins/normalize/signal.js";
|
||||
import type {
|
||||
ChannelCapabilities,
|
||||
ChannelCommandAdapter,
|
||||
ChannelConfigAdapter,
|
||||
ChannelElevatedAdapter,
|
||||
ChannelGroupAdapter,
|
||||
ChannelId,
|
||||
ChannelAgentPromptAdapter,
|
||||
ChannelMentionAdapter,
|
||||
ChannelPlugin,
|
||||
ChannelThreadingContext,
|
||||
ChannelThreadingAdapter,
|
||||
ChannelThreadingToolContext,
|
||||
} from "./plugins/types.js";
|
||||
import {
|
||||
resolveWhatsAppGroupIntroHint,
|
||||
resolveWhatsAppMentionStripRegexes,
|
||||
} from "./plugins/whatsapp-shared.js";
|
||||
import { CHAT_CHANNEL_ORDER, type ChatChannelId, getChatChannelMeta } from "./registry.js";
|
||||
|
||||
export type ChannelDock = {
|
||||
id: ChannelId;
|
||||
capabilities: ChannelCapabilities;
|
||||
commands?: ChannelCommandAdapter;
|
||||
outbound?: {
|
||||
textChunkLimit?: number;
|
||||
};
|
||||
streaming?: ChannelDockStreaming;
|
||||
elevated?: ChannelElevatedAdapter;
|
||||
config?: Pick<
|
||||
ChannelConfigAdapter<unknown>,
|
||||
"resolveAllowFrom" | "formatAllowFrom" | "resolveDefaultTo"
|
||||
>;
|
||||
groups?: ChannelGroupAdapter;
|
||||
mentions?: ChannelMentionAdapter;
|
||||
threading?: ChannelThreadingAdapter;
|
||||
agentPrompt?: ChannelAgentPromptAdapter;
|
||||
};
|
||||
|
||||
type ChannelDockStreaming = {
|
||||
blockStreamingCoalesceDefaults?: {
|
||||
minChars?: number;
|
||||
idleMs?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000 = { textChunkLimit: 4000 };
|
||||
|
||||
const DEFAULT_BLOCK_STREAMING_COALESCE = {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
};
|
||||
|
||||
function formatAllowFromWithReplacements(
|
||||
allowFrom: Array<string | number>,
|
||||
replacements: RegExp[],
|
||||
): string[] {
|
||||
return formatNormalizedAllowFromEntries({
|
||||
allowFrom,
|
||||
normalizeEntry: (entry) => {
|
||||
let normalized = entry;
|
||||
for (const replacement of replacements) {
|
||||
normalized = normalized.replace(replacement, "");
|
||||
}
|
||||
return normalized.toLowerCase();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const formatDiscordAllowFrom = (allowFrom: Array<string | number>) =>
|
||||
allowFrom
|
||||
.map((entry) =>
|
||||
String(entry)
|
||||
.trim()
|
||||
.replace(/^<@!?/, "")
|
||||
.replace(/>$/, "")
|
||||
.replace(/^discord:/i, "")
|
||||
.replace(/^user:/i, "")
|
||||
.replace(/^pk:/i, "")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
function resolveDirectOrGroupChannelId(context: ChannelThreadingContext): string | undefined {
|
||||
const isDirect = context.ChatType?.toLowerCase() === "direct";
|
||||
return (isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined;
|
||||
}
|
||||
|
||||
function buildSignalThreadToolContext(params: {
|
||||
context: ChannelThreadingContext;
|
||||
hasRepliedRef: ChannelThreadingToolContext["hasRepliedRef"];
|
||||
}): ChannelThreadingToolContext {
|
||||
const currentChannelIdRaw = resolveDirectOrGroupChannelId(params.context);
|
||||
const currentChannelId = currentChannelIdRaw
|
||||
? (normalizeSignalMessagingTarget(currentChannelIdRaw) ?? currentChannelIdRaw.trim())
|
||||
: undefined;
|
||||
return {
|
||||
currentChannelId,
|
||||
currentThreadTs: params.context.ReplyToId,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
};
|
||||
}
|
||||
|
||||
function buildIMessageThreadToolContext(params: {
|
||||
context: ChannelThreadingContext;
|
||||
hasRepliedRef: ChannelThreadingToolContext["hasRepliedRef"];
|
||||
}): ChannelThreadingToolContext {
|
||||
return {
|
||||
currentChannelId: resolveDirectOrGroupChannelId(params.context),
|
||||
currentThreadTs: params.context.ReplyToId,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
};
|
||||
}
|
||||
|
||||
function buildThreadToolContextFromMessageThreadOrReply(params: {
|
||||
context: ChannelThreadingContext;
|
||||
hasRepliedRef: ChannelThreadingToolContext["hasRepliedRef"];
|
||||
}): ChannelThreadingToolContext {
|
||||
const threadId = params.context.MessageThreadId ?? params.context.ReplyToId;
|
||||
return {
|
||||
currentChannelId: params.context.To?.trim() || undefined,
|
||||
currentThreadTs: threadId != null ? String(threadId) : undefined,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCaseInsensitiveAccount<T>(
|
||||
accounts: Record<string, T> | undefined,
|
||||
accountId?: string | null,
|
||||
): T | undefined {
|
||||
if (!accounts) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
return (
|
||||
accounts[normalized] ??
|
||||
accounts[
|
||||
Object.keys(accounts).find((key) => key.toLowerCase() === normalized.toLowerCase()) ?? ""
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDefaultToCaseInsensitiveAccount(params: {
|
||||
channel?:
|
||||
| {
|
||||
accounts?: Record<string, { defaultTo?: string }>;
|
||||
defaultTo?: string;
|
||||
}
|
||||
| undefined;
|
||||
accountId?: string | null;
|
||||
}): string | undefined {
|
||||
const account = resolveCaseInsensitiveAccount(params.channel?.accounts, params.accountId);
|
||||
return (account?.defaultTo ?? params.channel?.defaultTo)?.trim() || undefined;
|
||||
}
|
||||
|
||||
function resolveChannelDefaultTo(
|
||||
channel:
|
||||
| {
|
||||
accounts?: Record<string, { defaultTo?: string }>;
|
||||
defaultTo?: string;
|
||||
}
|
||||
| undefined,
|
||||
accountId?: string | null,
|
||||
): string | undefined {
|
||||
return resolveDefaultToCaseInsensitiveAccount({ channel, accountId });
|
||||
}
|
||||
|
||||
type CaseInsensitiveDefaultToChannel = {
|
||||
accounts?: Record<string, { defaultTo?: string }>;
|
||||
defaultTo?: string;
|
||||
};
|
||||
|
||||
type CaseInsensitiveDefaultToChannels = Partial<
|
||||
Record<"irc" | "googlechat", CaseInsensitiveDefaultToChannel>
|
||||
>;
|
||||
|
||||
function resolveNamedChannelDefaultTo(params: {
|
||||
channels?: CaseInsensitiveDefaultToChannels;
|
||||
channelId: keyof CaseInsensitiveDefaultToChannels;
|
||||
accountId?: string | null;
|
||||
}): string | undefined {
|
||||
return resolveChannelDefaultTo(params.channels?.[params.channelId], params.accountId);
|
||||
}
|
||||
// Channel docks: lightweight channel metadata/behavior for shared code paths.
|
||||
//
|
||||
// Rules:
|
||||
// - keep this module *light* (no monitors, probes, puppeteer/web login, etc)
|
||||
// - OK: config readers, allowFrom formatting, mention stripping patterns, threading defaults
|
||||
// - shared code should import from here (and from `src/channels/registry.ts`), not from the plugins registry
|
||||
//
|
||||
// Adding a channel:
|
||||
// - add a new entry to `DOCKS`
|
||||
// - keep it cheap; push heavy logic into `src/channels/plugins/<id>.ts` or channel modules
|
||||
const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
telegram: {
|
||||
id: "telegram",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "channel", "thread"],
|
||||
nativeCommands: true,
|
||||
blockStreaming: true,
|
||||
},
|
||||
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
mapAllowFromEntries(inspectTelegramAccount({ cfg, accountId }).config.allowFrom),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
formatAllowFromLowercase({
|
||||
allowFrom,
|
||||
stripPrefixRe: /^(telegram|tg):/i,
|
||||
}),
|
||||
resolveDefaultTo: ({ cfg, accountId }) =>
|
||||
resolveOptionalConfigString(inspectTelegramAccount({ cfg, accountId }).config.defaultTo),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveTelegramGroupRequireMention,
|
||||
resolveToolPolicy: resolveTelegramGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off",
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
// Telegram auto-threading should only use actual thread/topic IDs.
|
||||
// ReplyToId is a message ID and causes invalid message_thread_id in DMs.
|
||||
const threadId = context.MessageThreadId;
|
||||
const rawCurrentMessageId = context.CurrentMessageId;
|
||||
const currentMessageId =
|
||||
typeof rawCurrentMessageId === "number"
|
||||
? rawCurrentMessageId
|
||||
: rawCurrentMessageId?.trim() || undefined;
|
||||
return {
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: threadId != null ? String(threadId) : undefined,
|
||||
currentMessageId,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
id: "whatsapp",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
polls: true,
|
||||
reactions: true,
|
||||
media: true,
|
||||
},
|
||||
commands: {
|
||||
enforceOwnerForCommands: true,
|
||||
skipWhenConfigEmpty: true,
|
||||
},
|
||||
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }),
|
||||
formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom),
|
||||
resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveWhatsAppGroupRequireMention,
|
||||
resolveToolPolicy: resolveWhatsAppGroupToolPolicy,
|
||||
resolveGroupIntroHint: resolveWhatsAppGroupIntroHint,
|
||||
},
|
||||
mentions: {
|
||||
stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx),
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
const channelId = context.From?.trim() || context.To?.trim() || undefined;
|
||||
return {
|
||||
currentChannelId: channelId,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
id: "discord",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel", "thread"],
|
||||
polls: true,
|
||||
reactions: true,
|
||||
media: true,
|
||||
nativeCommands: true,
|
||||
threads: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 2000 },
|
||||
streaming: DEFAULT_BLOCK_STREAMING_COALESCE,
|
||||
elevated: {
|
||||
allowFromFallback: ({ cfg }) =>
|
||||
cfg.channels?.discord?.allowFrom ?? cfg.channels?.discord?.dm?.allowFrom,
|
||||
},
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||
const account = inspectDiscordAccount({ cfg, accountId });
|
||||
return mapAllowFromEntries(account.config.allowFrom ?? account.config.dm?.allowFrom);
|
||||
},
|
||||
formatAllowFrom: ({ allowFrom }) => formatDiscordAllowFrom(allowFrom),
|
||||
resolveDefaultTo: ({ cfg, accountId }) =>
|
||||
resolveOptionalConfigString(inspectDiscordAccount({ cfg, accountId }).config.defaultTo),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveDiscordGroupRequireMention,
|
||||
resolveToolPolicy: resolveDiscordGroupToolPolicy,
|
||||
},
|
||||
mentions: {
|
||||
stripRegexes: () => [/<@!?\d+>/g],
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off",
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
},
|
||||
irc: {
|
||||
id: "irc",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
blockStreaming: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 350 },
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 300, idleMs: 1000 },
|
||||
},
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||
const channel = cfg.channels?.irc;
|
||||
const account = resolveCaseInsensitiveAccount(channel?.accounts, accountId);
|
||||
return mapAllowFromEntries(account?.allowFrom ?? channel?.allowFrom);
|
||||
},
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
formatAllowFromWithReplacements(allowFrom, [/^irc:/i, /^user:/i]),
|
||||
resolveDefaultTo: ({ cfg, accountId }) =>
|
||||
resolveNamedChannelDefaultTo({
|
||||
channels: cfg.channels as CaseInsensitiveDefaultToChannels | undefined,
|
||||
channelId: "irc",
|
||||
accountId,
|
||||
}),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||
if (!groupId) {
|
||||
return true;
|
||||
}
|
||||
return resolveChannelGroupRequireMention({
|
||||
cfg,
|
||||
channel: "irc",
|
||||
groupId,
|
||||
accountId,
|
||||
groupIdCaseInsensitive: true,
|
||||
});
|
||||
},
|
||||
resolveToolPolicy: ({ cfg, accountId, groupId, senderId, senderName, senderUsername }) => {
|
||||
if (!groupId) {
|
||||
return undefined;
|
||||
}
|
||||
// IRC supports per-channel tool policies. Prefer the shared resolver so
|
||||
// toolsBySender is honored consistently across surfaces.
|
||||
return resolveChannelGroupToolsPolicy({
|
||||
cfg,
|
||||
channel: "irc",
|
||||
groupId,
|
||||
accountId,
|
||||
groupIdCaseInsensitive: true,
|
||||
senderId,
|
||||
senderName,
|
||||
senderUsername,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
googlechat: {
|
||||
id: "googlechat",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
reactions: true,
|
||||
media: true,
|
||||
threads: true,
|
||||
blockStreaming: true,
|
||||
},
|
||||
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||
const channel = cfg.channels?.googlechat as
|
||||
| {
|
||||
accounts?: Record<string, { dm?: { allowFrom?: Array<string | number> } }>;
|
||||
dm?: { allowFrom?: Array<string | number> };
|
||||
}
|
||||
| undefined;
|
||||
const account = resolveCaseInsensitiveAccount(channel?.accounts, accountId);
|
||||
return mapAllowFromEntries(account?.dm?.allowFrom ?? channel?.dm?.allowFrom);
|
||||
},
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
formatAllowFromWithReplacements(allowFrom, [
|
||||
/^(googlechat|google-chat|gchat):/i,
|
||||
/^user:/i,
|
||||
/^users\//i,
|
||||
]),
|
||||
resolveDefaultTo: ({ cfg, accountId }) =>
|
||||
resolveNamedChannelDefaultTo({
|
||||
channels: cfg.channels as CaseInsensitiveDefaultToChannels | undefined,
|
||||
channelId: "googlechat",
|
||||
accountId,
|
||||
}),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveGoogleChatGroupRequireMention,
|
||||
resolveToolPolicy: resolveGoogleChatGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.googlechat?.replyToMode ?? "off",
|
||||
buildToolContext: ({ context, hasRepliedRef }) =>
|
||||
buildThreadToolContextFromMessageThreadOrReply({ context, hasRepliedRef }),
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
id: "slack",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel", "thread"],
|
||||
reactions: true,
|
||||
media: true,
|
||||
nativeCommands: true,
|
||||
threads: true,
|
||||
},
|
||||
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
||||
streaming: DEFAULT_BLOCK_STREAMING_COALESCE,
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||
const account = inspectSlackAccount({ cfg, accountId });
|
||||
return mapAllowFromEntries(account.config.allowFrom ?? account.dm?.allowFrom);
|
||||
},
|
||||
formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }),
|
||||
resolveDefaultTo: ({ cfg, accountId }) =>
|
||||
resolveOptionalConfigString(inspectSlackAccount({ cfg, accountId }).config.defaultTo),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
resolveToolPolicy: resolveSlackGroupToolPolicy,
|
||||
},
|
||||
mentions: {
|
||||
stripRegexes: () => [/<@[^>]+>/g],
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
resolveSlackReplyToMode(inspectSlackAccount({ cfg, accountId }), chatType),
|
||||
allowExplicitReplyTagsWhenOff: false,
|
||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||
},
|
||||
},
|
||||
signal: {
|
||||
id: "signal",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: true,
|
||||
media: true,
|
||||
},
|
||||
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
||||
streaming: DEFAULT_BLOCK_STREAMING_COALESCE,
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
mapAllowFromEntries(resolveSignalAccount({ cfg, accountId }).config.allowFrom),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
formatNormalizedAllowFromEntries({
|
||||
allowFrom,
|
||||
normalizeEntry: (entry) =>
|
||||
entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")),
|
||||
}),
|
||||
resolveDefaultTo: ({ cfg, accountId }) =>
|
||||
resolveOptionalConfigString(resolveSignalAccount({ cfg, accountId }).config.defaultTo),
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) =>
|
||||
buildSignalThreadToolContext({ context, hasRepliedRef }),
|
||||
},
|
||||
},
|
||||
imessage: {
|
||||
id: "imessage",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: true,
|
||||
media: true,
|
||||
},
|
||||
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }),
|
||||
formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom),
|
||||
resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveIMessageGroupRequireMention,
|
||||
resolveToolPolicy: resolveIMessageGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) =>
|
||||
buildIMessageThreadToolContext({ context, hasRepliedRef }),
|
||||
},
|
||||
},
|
||||
line: {
|
||||
id: "line",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 5000 },
|
||||
groups: {
|
||||
resolveRequireMention: resolveLineGroupRequireMention,
|
||||
resolveToolPolicy: resolveLineGroupToolPolicy,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {
|
||||
return {
|
||||
id: plugin.id,
|
||||
capabilities: plugin.capabilities,
|
||||
commands: plugin.commands,
|
||||
outbound: plugin.outbound?.textChunkLimit
|
||||
? { textChunkLimit: plugin.outbound.textChunkLimit }
|
||||
: undefined,
|
||||
streaming: plugin.streaming
|
||||
? { blockStreamingCoalesceDefaults: plugin.streaming.blockStreamingCoalesceDefaults }
|
||||
: undefined,
|
||||
elevated: plugin.elevated,
|
||||
config: plugin.config
|
||||
? {
|
||||
resolveAllowFrom: plugin.config.resolveAllowFrom,
|
||||
formatAllowFrom: plugin.config.formatAllowFrom,
|
||||
resolveDefaultTo: plugin.config.resolveDefaultTo,
|
||||
}
|
||||
: undefined,
|
||||
groups: plugin.groups,
|
||||
mentions: plugin.mentions,
|
||||
threading: plugin.threading,
|
||||
agentPrompt: plugin.agentPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
function listPluginDockEntries(): Array<{ id: ChannelId; dock: ChannelDock; order?: number }> {
|
||||
const registry = requireActivePluginRegistry();
|
||||
const entries: Array<{ id: ChannelId; dock: ChannelDock; order?: number }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of registry.channels) {
|
||||
const plugin = entry.plugin;
|
||||
const id = String(plugin.id).trim();
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
if (CHAT_CHANNEL_ORDER.includes(plugin.id as ChatChannelId)) {
|
||||
continue;
|
||||
}
|
||||
const dock = entry.dock ?? buildDockFromPlugin(plugin);
|
||||
entries.push({ id: plugin.id, dock, order: plugin.meta.order });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function listChannelDocks(): ChannelDock[] {
|
||||
const baseEntries = CHAT_CHANNEL_ORDER.map((id) => ({
|
||||
id,
|
||||
dock: DOCKS[id],
|
||||
order: getChatChannelMeta(id).order,
|
||||
}));
|
||||
const pluginEntries = listPluginDockEntries();
|
||||
const combined = [...baseEntries, ...pluginEntries];
|
||||
combined.sort((a, b) => {
|
||||
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId);
|
||||
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId);
|
||||
const orderA = a.order ?? (indexA === -1 ? 999 : indexA);
|
||||
const orderB = b.order ?? (indexB === -1 ? 999 : indexB);
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
return String(a.id).localeCompare(String(b.id));
|
||||
});
|
||||
return combined.map((entry) => entry.dock);
|
||||
}
|
||||
|
||||
export function getChannelDock(id: ChannelId): ChannelDock | undefined {
|
||||
const core = DOCKS[id as ChatChannelId];
|
||||
if (core) {
|
||||
return core;
|
||||
}
|
||||
const registry = requireActivePluginRegistry();
|
||||
const pluginEntry = registry.channels.find((entry) => entry.plugin.id === id);
|
||||
if (!pluginEntry) {
|
||||
return undefined;
|
||||
}
|
||||
return pluginEntry.dock ?? buildDockFromPlugin(pluginEntry.plugin);
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelAccountState,
|
||||
ChannelMessageCapability,
|
||||
ChannelSetupInput,
|
||||
} from "../types.core.js";
|
||||
import type { ChannelPlugin } from "../types.js";
|
||||
import type { ChannelMessageActionName } from "../types.js";
|
||||
import type {
|
||||
ChannelMessageActionName,
|
||||
ChannelMessageCapability,
|
||||
ChannelPlugin,
|
||||
} from "../types.js";
|
||||
|
||||
function sortStrings(values: readonly string[]) {
|
||||
return [...values].toSorted((left, right) => left.localeCompare(right));
|
||||
|
||||
@@ -8,8 +8,8 @@ import type { ChannelId, ChannelPlugin } from "./types.js";
|
||||
// Channel plugins registry (runtime).
|
||||
//
|
||||
// This module is intentionally "heavy" (plugins may import channel monitors, web login, etc).
|
||||
// Shared code paths (reply flow, command auth, sandbox explain) should depend on `src/channels/dock.ts`
|
||||
// instead, and only call `getChannelPlugin()` at execution boundaries.
|
||||
// Shared code paths should prefer narrower adapters and helpers instead of reaching into
|
||||
// channel-specific runtime modules directly.
|
||||
//
|
||||
function dedupeChannels(channels: ChannelPlugin[]): ChannelPlugin[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveOutboundSessionRoute } from "./outbound-session.js";
|
||||
|
||||
describe("resolveOutboundSessionRoute", () => {
|
||||
beforeEach(() => {
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
});
|
||||
|
||||
const baseConfig = {} as OpenClawConfig;
|
||||
|
||||
it("resolves provider-specific session routes", async () => {
|
||||
|
||||
@@ -1,25 +1,3 @@
|
||||
import {
|
||||
parseDiscordTarget,
|
||||
type DiscordTargetKind,
|
||||
} from "../../../extensions/discord/src/targets.js";
|
||||
import {
|
||||
parseIMessageTarget,
|
||||
normalizeIMessageHandle,
|
||||
} from "../../../extensions/imessage/src/targets.js";
|
||||
import {
|
||||
looksLikeUuid,
|
||||
resolveSignalPeerId,
|
||||
resolveSignalRecipient,
|
||||
resolveSignalSender,
|
||||
} from "../../../extensions/signal/src/identity.js";
|
||||
import { resolveSlackAccount } from "../../../extensions/slack/src/accounts.js";
|
||||
import { createSlackWebClient } from "../../../extensions/slack/src/client.js";
|
||||
import { normalizeAllowListLower } from "../../../extensions/slack/src/monitor/allow-list.js";
|
||||
import { parseSlackTarget } from "../../../extensions/slack/src/targets.js";
|
||||
import { buildTelegramGroupPeerId } from "../../../extensions/telegram/src/bot/helpers.js";
|
||||
import { resolveTelegramTargetChatType } from "../../../extensions/telegram/src/inline-buttons.js";
|
||||
import { parseTelegramThreadId } from "../../../extensions/telegram/src/outbound-params.js";
|
||||
import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js";
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import type { ChatType } from "../../channels/chat-type.js";
|
||||
import { getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
@@ -52,9 +30,6 @@ export type ResolveOutboundSessionRouteParams = {
|
||||
threadId?: string | number | null;
|
||||
};
|
||||
|
||||
// Cache Slack channel type lookups to avoid repeated API calls.
|
||||
const SLACK_CHANNEL_TYPE_CACHE = new Map<string, "channel" | "group" | "dm" | "unknown">();
|
||||
|
||||
function normalizeThreadId(value?: string | number | null): string | undefined {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
@@ -124,238 +99,6 @@ function buildBaseSessionKey(params: {
|
||||
});
|
||||
}
|
||||
|
||||
// Best-effort mpim detection: allowlist/config, then Slack API (if token available).
|
||||
async function resolveSlackChannelType(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
channelId: string;
|
||||
}): Promise<"channel" | "group" | "dm" | "unknown"> {
|
||||
const channelId = params.channelId.trim();
|
||||
if (!channelId) {
|
||||
return "unknown";
|
||||
}
|
||||
const cached = SLACK_CHANNEL_TYPE_CACHE.get(`${params.accountId ?? "default"}:${channelId}`);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const groupChannels = normalizeAllowListLower(account.dm?.groupChannels);
|
||||
const channelIdLower = channelId.toLowerCase();
|
||||
if (
|
||||
groupChannels.includes(channelIdLower) ||
|
||||
groupChannels.includes(`slack:${channelIdLower}`) ||
|
||||
groupChannels.includes(`channel:${channelIdLower}`) ||
|
||||
groupChannels.includes(`group:${channelIdLower}`) ||
|
||||
groupChannels.includes(`mpim:${channelIdLower}`)
|
||||
) {
|
||||
SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "group");
|
||||
return "group";
|
||||
}
|
||||
|
||||
const channelKeys = Object.keys(account.channels ?? {});
|
||||
if (
|
||||
channelKeys.some((key) => {
|
||||
const normalized = key.trim().toLowerCase();
|
||||
return (
|
||||
normalized === channelIdLower ||
|
||||
normalized === `channel:${channelIdLower}` ||
|
||||
normalized.replace(/^#/, "") === channelIdLower
|
||||
);
|
||||
})
|
||||
) {
|
||||
SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "channel");
|
||||
return "channel";
|
||||
}
|
||||
|
||||
const token = account.botToken?.trim() || account.userToken || "";
|
||||
if (!token) {
|
||||
SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "unknown");
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createSlackWebClient(token);
|
||||
const info = await client.conversations.info({ channel: channelId });
|
||||
const channel = info.channel as { is_im?: boolean; is_mpim?: boolean } | undefined;
|
||||
const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel";
|
||||
SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, type);
|
||||
return type;
|
||||
} catch {
|
||||
SLACK_CHANNEL_TYPE_CACHE.set(`${account.accountId}:${channelId}`, "unknown");
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSlackSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): Promise<OutboundSessionRoute | null> {
|
||||
const parsed = parseSlackTarget(params.target, { defaultKind: "channel" });
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const isDm = parsed.kind === "user";
|
||||
let peerKind: ChatType = isDm ? "direct" : "channel";
|
||||
if (!isDm && /^G/i.test(parsed.id)) {
|
||||
// Slack mpim/group DMs share the G-prefix; detect to align session keys with inbound.
|
||||
const channelType = await resolveSlackChannelType({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
channelId: parsed.id,
|
||||
});
|
||||
if (channelType === "group") {
|
||||
peerKind = "group";
|
||||
}
|
||||
if (channelType === "dm") {
|
||||
peerKind = "direct";
|
||||
}
|
||||
}
|
||||
const peer: RoutePeer = {
|
||||
kind: peerKind,
|
||||
id: parsed.id,
|
||||
};
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "slack",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
const threadId = normalizeThreadId(params.threadId ?? params.replyToId);
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey,
|
||||
threadId,
|
||||
});
|
||||
return {
|
||||
sessionKey: threadKeys.sessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: peerKind === "direct" ? "direct" : "channel",
|
||||
from:
|
||||
peerKind === "direct"
|
||||
? `slack:${parsed.id}`
|
||||
: peerKind === "group"
|
||||
? `slack:group:${parsed.id}`
|
||||
: `slack:channel:${parsed.id}`,
|
||||
to: peerKind === "direct" ? `user:${parsed.id}` : `channel:${parsed.id}`,
|
||||
threadId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDiscordSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const parsed = parseDiscordTarget(params.target, {
|
||||
defaultKind: resolveDiscordOutboundTargetKindHint(params),
|
||||
});
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const isDm = parsed.kind === "user";
|
||||
const peer: RoutePeer = {
|
||||
kind: isDm ? "direct" : "channel",
|
||||
id: parsed.id,
|
||||
};
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
const explicitThreadId = normalizeThreadId(params.threadId);
|
||||
const threadCandidate = explicitThreadId ?? normalizeThreadId(params.replyToId);
|
||||
// Discord threads use their own channel id; avoid adding a :thread suffix.
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey,
|
||||
threadId: threadCandidate,
|
||||
useSuffix: false,
|
||||
});
|
||||
return {
|
||||
sessionKey: threadKeys.sessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: isDm ? "direct" : "channel",
|
||||
from: isDm ? `discord:${parsed.id}` : `discord:channel:${parsed.id}`,
|
||||
to: isDm ? `user:${parsed.id}` : `channel:${parsed.id}`,
|
||||
threadId: explicitThreadId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDiscordOutboundTargetKindHint(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): DiscordTargetKind | undefined {
|
||||
const resolvedKind = params.resolvedTarget?.kind;
|
||||
if (resolvedKind === "user") {
|
||||
return "user";
|
||||
}
|
||||
if (resolvedKind === "group" || resolvedKind === "channel") {
|
||||
return "channel";
|
||||
}
|
||||
|
||||
const target = params.target.trim();
|
||||
if (/^channel:/i.test(target)) {
|
||||
return "channel";
|
||||
}
|
||||
if (/^(user:|discord:|@|<@!?)/i.test(target)) {
|
||||
return "user";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveTelegramSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const parsed = parseTelegramTarget(params.target);
|
||||
const chatId = parsed.chatId.trim();
|
||||
if (!chatId) {
|
||||
return null;
|
||||
}
|
||||
const parsedThreadId = parsed.messageThreadId;
|
||||
const fallbackThreadId = normalizeThreadId(params.threadId);
|
||||
const resolvedThreadId = parsedThreadId ?? parseTelegramThreadId(fallbackThreadId);
|
||||
// Telegram topics are encoded in the peer id (chatId:topic:<id>).
|
||||
const chatType = resolveTelegramTargetChatType(params.target);
|
||||
// If the target is a username and we lack a resolvedTarget, default to DM to avoid group keys.
|
||||
const isGroup =
|
||||
chatType === "group" ||
|
||||
(chatType === "unknown" &&
|
||||
params.resolvedTarget?.kind &&
|
||||
params.resolvedTarget.kind !== "user");
|
||||
// For groups: include thread ID in peerId. For DMs: use simple chatId (thread handled via suffix).
|
||||
const peerId =
|
||||
isGroup && resolvedThreadId ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : chatId;
|
||||
const peer: RoutePeer = {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
};
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "telegram",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
// Use thread suffix for DM topics to match inbound session key format
|
||||
const threadKeys =
|
||||
resolvedThreadId && !isGroup
|
||||
? { sessionKey: `${baseSessionKey}:thread:${resolvedThreadId}` }
|
||||
: null;
|
||||
return {
|
||||
sessionKey: threadKeys?.sessionKey ?? baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup
|
||||
? `telegram:group:${peerId}`
|
||||
: resolvedThreadId
|
||||
? `telegram:${chatId}:topic:${resolvedThreadId}`
|
||||
: `telegram:${chatId}`,
|
||||
to: `telegram:${chatId}`,
|
||||
threadId: resolvedThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWhatsAppSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
@@ -385,131 +128,6 @@ function resolveWhatsAppSession(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSignalSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const stripped = stripProviderPrefix(params.target, "signal");
|
||||
const lowered = stripped.toLowerCase();
|
||||
if (lowered.startsWith("group:")) {
|
||||
const groupId = stripped.slice("group:".length).trim();
|
||||
if (!groupId) {
|
||||
return null;
|
||||
}
|
||||
const peer: RoutePeer = { kind: "group", id: groupId };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "signal",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: "group",
|
||||
from: `group:${groupId}`,
|
||||
to: `group:${groupId}`,
|
||||
};
|
||||
}
|
||||
|
||||
let recipient = stripped.trim();
|
||||
if (lowered.startsWith("username:")) {
|
||||
recipient = stripped.slice("username:".length).trim();
|
||||
} else if (lowered.startsWith("u:")) {
|
||||
recipient = stripped.slice("u:".length).trim();
|
||||
}
|
||||
if (!recipient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uuidCandidate = recipient.toLowerCase().startsWith("uuid:")
|
||||
? recipient.slice("uuid:".length)
|
||||
: recipient;
|
||||
const sender = resolveSignalSender({
|
||||
sourceUuid: looksLikeUuid(uuidCandidate) ? uuidCandidate : null,
|
||||
sourceNumber: looksLikeUuid(uuidCandidate) ? null : recipient,
|
||||
});
|
||||
const peerId = sender ? resolveSignalPeerId(sender) : recipient;
|
||||
const displayRecipient = sender ? resolveSignalRecipient(sender) : recipient;
|
||||
const peer: RoutePeer = { kind: "direct", id: peerId };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "signal",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: "direct",
|
||||
from: `signal:${displayRecipient}`,
|
||||
to: `signal:${displayRecipient}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveIMessageSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
const parsed = parseIMessageTarget(params.target);
|
||||
if (parsed.kind === "handle") {
|
||||
const handle = normalizeIMessageHandle(parsed.to);
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
const peer: RoutePeer = { kind: "direct", id: handle };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: "direct",
|
||||
from: `imessage:${handle}`,
|
||||
to: `imessage:${handle}`,
|
||||
};
|
||||
}
|
||||
|
||||
const peerId =
|
||||
parsed.kind === "chat_id"
|
||||
? String(parsed.chatId)
|
||||
: parsed.kind === "chat_guid"
|
||||
? parsed.chatGuid
|
||||
: parsed.chatIdentifier;
|
||||
if (!peerId) {
|
||||
return null;
|
||||
}
|
||||
const peer: RoutePeer = { kind: "group", id: peerId };
|
||||
const baseSessionKey = buildBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
const toPrefix =
|
||||
parsed.kind === "chat_id"
|
||||
? "chat_id"
|
||||
: parsed.kind === "chat_guid"
|
||||
? "chat_guid"
|
||||
: "chat_identifier";
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: "group",
|
||||
from: `imessage:group:${peerId}`,
|
||||
to: `${toPrefix}:${peerId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatrixSession(
|
||||
params: ResolveOutboundSessionRouteParams,
|
||||
): OutboundSessionRoute | null {
|
||||
@@ -944,12 +562,7 @@ type OutboundSessionResolver = (
|
||||
) => OutboundSessionRoute | null | Promise<OutboundSessionRoute | null>;
|
||||
|
||||
const OUTBOUND_SESSION_RESOLVERS: Partial<Record<ChannelId, OutboundSessionResolver>> = {
|
||||
slack: resolveSlackSession,
|
||||
discord: resolveDiscordSession,
|
||||
telegram: resolveTelegramSession,
|
||||
whatsapp: resolveWhatsAppSession,
|
||||
signal: resolveSignalSession,
|
||||
imessage: resolveIMessageSession,
|
||||
matrix: resolveMatrixSession,
|
||||
msteams: resolveMSTeamsSession,
|
||||
mattermost: resolveMattermostSession,
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { discordPlugin } from "../../../extensions/discord/src/channel.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
@@ -888,6 +889,10 @@ const discordConfig = {
|
||||
} as OpenClawConfig;
|
||||
|
||||
describe("outbound policy", () => {
|
||||
beforeEach(() => {
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
});
|
||||
|
||||
it("allows cross-provider sends when enabled", () => {
|
||||
const cfg = {
|
||||
...slackConfig,
|
||||
@@ -930,6 +935,10 @@ describe("outbound policy", () => {
|
||||
});
|
||||
|
||||
describe("resolveOutboundSessionRoute", () => {
|
||||
beforeEach(() => {
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
});
|
||||
|
||||
const baseConfig = {} as OpenClawConfig;
|
||||
|
||||
it("resolves provider-specific session routes", async () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ export {
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
} from "../agents/tools/common.js";
|
||||
export type { ChannelDock } from "../channels/dock.js";
|
||||
export { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
|
||||
export {
|
||||
deleteAccountFromConfigSection,
|
||||
|
||||
@@ -248,7 +248,6 @@ export {
|
||||
buildNestedDmConfigSchema,
|
||||
buildCatchallMultiAccountChannelSchema,
|
||||
} from "../channels/plugins/config-schema.js";
|
||||
export type { ChannelDock } from "../channels/dock.js";
|
||||
export { getChatChannelMeta } from "../channels/registry.js";
|
||||
export {
|
||||
compileAllowlist,
|
||||
@@ -330,6 +329,7 @@ export {
|
||||
normalizeAgentId,
|
||||
resolveThreadSessionKeys,
|
||||
} from "../routing/session-key.js";
|
||||
export { buildAgentSessionKey, type RoutePeer } from "../routing/resolve-route.js";
|
||||
export {
|
||||
formatAllowFromLowercase,
|
||||
formatNormalizedAllowFromEntries,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
export { jsonResult, readStringParam } from "../agents/tools/common.js";
|
||||
export type { ReplyPayload } from "../auto-reply/types.js";
|
||||
export type { ChannelDock } from "../channels/dock.js";
|
||||
export {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
export type { ReplyPayload } from "../auto-reply/types.js";
|
||||
export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js";
|
||||
export type { ChannelDock } from "../channels/dock.js";
|
||||
export { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
|
||||
export {
|
||||
deleteAccountFromConfigSection,
|
||||
|
||||
@@ -2,7 +2,6 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createJiti } from "jiti";
|
||||
import type { ChannelDock } from "../channels/dock.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { isChannelConfigured } from "../config/plugin-auto-enable.js";
|
||||
@@ -407,7 +406,6 @@ function resolvePluginModuleExport(moduleExport: unknown): {
|
||||
|
||||
function resolveSetupChannelRegistration(moduleExport: unknown): {
|
||||
plugin?: ChannelPlugin;
|
||||
dock?: ChannelDock;
|
||||
} {
|
||||
const resolved =
|
||||
moduleExport &&
|
||||
@@ -420,14 +418,12 @@ function resolveSetupChannelRegistration(moduleExport: unknown): {
|
||||
}
|
||||
const setup = resolved as {
|
||||
plugin?: unknown;
|
||||
dock?: unknown;
|
||||
};
|
||||
if (!setup.plugin || typeof setup.plugin !== "object") {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
plugin: setup.plugin as ChannelPlugin,
|
||||
...(setup.dock && typeof setup.dock === "object" ? { dock: setup.dock as ChannelDock } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1167,10 +1163,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
hookPolicy: entry?.hooks,
|
||||
registrationMode,
|
||||
});
|
||||
api.registerChannel({
|
||||
plugin: setupRegistration.plugin,
|
||||
...(setupRegistration.dock ? { dock: setupRegistration.dock } : {}),
|
||||
});
|
||||
api.registerChannel(setupRegistration.plugin);
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "node:path";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { ChannelDock } from "../channels/dock.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { registerContextEngineForOwner } from "../context-engine/registry.js";
|
||||
import type {
|
||||
@@ -82,7 +81,6 @@ export type PluginChannelRegistration = {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
plugin: ChannelPlugin;
|
||||
dock?: ChannelDock;
|
||||
source: string;
|
||||
rootDir?: string;
|
||||
};
|
||||
@@ -516,7 +514,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
pluginId: record.id,
|
||||
pluginName: record.name,
|
||||
plugin,
|
||||
dock: normalized.dock,
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ import type { ProviderCapabilities } from "../agents/provider-capabilities.js";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { ThinkLevel } from "../auto-reply/thinking.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { ChannelDock } from "../channels/dock.js";
|
||||
import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { createVpsAwareOAuthHandlers } from "../commands/oauth-flow.js";
|
||||
import type { OnboardOptions } from "../commands/onboard-types.js";
|
||||
@@ -1155,7 +1154,6 @@ export type OpenClawPluginService = {
|
||||
|
||||
export type OpenClawPluginChannelRegistration = {
|
||||
plugin: ChannelPlugin;
|
||||
dock?: ChannelDock;
|
||||
};
|
||||
|
||||
export type OpenClawPluginDefinition = {
|
||||
|
||||
Reference in New Issue
Block a user