refactor(acp): generalize message-channel binds

This commit is contained in:
Peter Steinberger
2026-03-28 02:52:31 +00:00
parent 491969efb0
commit 68416fdf83
22 changed files with 644 additions and 394 deletions

View File

@@ -127,7 +127,7 @@
"exportName": "ChannelConfiguredBindingProvider",
"kind": "type",
"source": {
"line": 578,
"line": 590,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -1201,6 +1201,15 @@
"path": "src/channels/plugins/types.core.ts"
}
},
{
"declaration": "export type ChannelCommandConversationContext = ChannelCommandConversationContext;",
"exportName": "ChannelCommandConversationContext",
"kind": "type",
"source": {
"line": 578,
"path": "src/channels/plugins/types.adapters.ts"
}
},
{
"declaration": "export type ChannelGroupContext = ChannelGroupContext;",
"exportName": "ChannelGroupContext",
@@ -1783,6 +1792,15 @@
"path": "src/channels/plugins/types.adapters.ts"
}
},
{
"declaration": "export type ChannelCommandConversationContext = ChannelCommandConversationContext;",
"exportName": "ChannelCommandConversationContext",
"kind": "type",
"source": {
"line": 578,
"path": "src/channels/plugins/types.adapters.ts"
}
},
{
"declaration": "export type ChannelConfigAdapter = ChannelConfigAdapter<ResolvedAccount>;",
"exportName": "ChannelConfigAdapter",
@@ -1815,7 +1833,7 @@
"exportName": "ChannelConfiguredBindingProvider",
"kind": "type",
"source": {
"line": 578,
"line": 590,
"path": "src/channels/plugins/types.adapters.ts"
}
},
@@ -2184,7 +2202,7 @@
"exportName": "ChannelSecurityAdapter",
"kind": "type",
"source": {
"line": 591,
"line": 606,
"path": "src/channels/plugins/types.adapters.ts"
}
},

View File

@@ -12,7 +12,7 @@
{"declaration":"export type ChannelConfigUiHint = ChannelConfigUiHint;","entrypoint":"index","exportName":"ChannelConfigUiHint","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":37,"sourcePath":"src/channels/plugins/types.plugin.ts"}
{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"index","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":569,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"index","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":574,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":578,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"index","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":590,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelGatewayContext = ChannelGatewayContext<ResolvedAccount>;","entrypoint":"index","exportName":"ChannelGatewayContext","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":243,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelId = ChannelId;","entrypoint":"index","exportName":"ChannelId","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"index","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":516,"sourcePath":"src/channels/plugins/types.core.ts"}
@@ -131,6 +131,7 @@
{"declaration":"export type BaseTokenResolution = BaseTokenResolution;","entrypoint":"channel-contract","exportName":"BaseTokenResolution","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":565,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelAccountSnapshot = ChannelAccountSnapshot;","entrypoint":"channel-contract","exportName":"ChannelAccountSnapshot","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":144,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelAgentTool = ChannelAgentTool;","entrypoint":"channel-contract","exportName":"ChannelAgentTool","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":18,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-contract","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":578,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelGroupContext = ChannelGroupContext;","entrypoint":"channel-contract","exportName":"ChannelGroupContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":216,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelMessageActionAdapter = ChannelMessageActionAdapter;","entrypoint":"channel-contract","exportName":"ChannelMessageActionAdapter","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":516,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"channel-contract","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/channel-contract","kind":"type","recordType":"export","sourceLine":482,"sourcePath":"src/channels/plugins/types.core.ts"}
@@ -195,10 +196,11 @@
{"declaration":"export type ChannelCapabilitiesDisplayLine = ChannelCapabilitiesDisplayLine;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayLine","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":46,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelCapabilitiesDisplayTone = ChannelCapabilitiesDisplayTone;","entrypoint":"channel-runtime","exportName":"ChannelCapabilitiesDisplayTone","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":44,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelCommandAdapter = ChannelCommandAdapter;","entrypoint":"channel-runtime","exportName":"ChannelCommandAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":449,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelCommandConversationContext = ChannelCommandConversationContext;","entrypoint":"channel-runtime","exportName":"ChannelCommandConversationContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":578,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfigAdapter = ChannelConfigAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelConfigAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":95,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingConversationRef = ChannelConfiguredBindingConversationRef;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingConversationRef","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":569,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingMatch;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingMatch","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":574,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":578,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelConfiguredBindingProvider = ChannelConfiguredBindingProvider;","entrypoint":"channel-runtime","exportName":"ChannelConfiguredBindingProvider","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":590,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelDirectoryAdapter = ChannelDirectoryAdapter;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":411,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelDirectoryEntry = ChannelDirectoryEntry;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntry","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":469,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelDirectoryEntryKind = ChannelDirectoryEntryKind;","entrypoint":"channel-runtime","exportName":"ChannelDirectoryEntryKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":467,"sourcePath":"src/channels/plugins/types.core.ts"}
@@ -239,7 +241,7 @@
{"declaration":"export type ChannelResolveKind = ChannelResolveKind;","entrypoint":"channel-runtime","exportName":"ChannelResolveKind","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":422,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelResolverAdapter = ChannelResolverAdapter;","entrypoint":"channel-runtime","exportName":"ChannelResolverAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":432,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelResolveResult = ChannelResolveResult;","entrypoint":"channel-runtime","exportName":"ChannelResolveResult","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":424,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelSecurityAdapter = ChannelSecurityAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelSecurityAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":591,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelSecurityAdapter = ChannelSecurityAdapter<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelSecurityAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":606,"sourcePath":"src/channels/plugins/types.adapters.ts"}
{"declaration":"export type ChannelSecurityContext = ChannelSecurityContext<ResolvedAccount>;","entrypoint":"channel-runtime","exportName":"ChannelSecurityContext","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":254,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelSecurityDmPolicy = ChannelSecurityDmPolicy;","entrypoint":"channel-runtime","exportName":"ChannelSecurityDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":245,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type ChannelSetupAdapter = ChannelSetupAdapter;","entrypoint":"channel-runtime","exportName":"ChannelSetupAdapter","importSpecifier":"openclaw/plugin-sdk/channel-runtime","kind":"type","recordType":"export","sourceLine":60,"sourcePath":"src/channels/plugins/types.adapters.ts"}

View File

@@ -32,6 +32,7 @@ import { createBlueBubblesConversationBindingManager } from "./conversation-bind
import {
matchBlueBubblesAcpConversation,
normalizeBlueBubblesAcpConversationId,
resolveBlueBubblesConversationIdFromTarget,
} from "./conversation-id.js";
import {
resolveBlueBubblesGroupRequireMention,
@@ -108,6 +109,13 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
bindingConversationId: compiledBinding.conversationId,
conversationId,
}),
resolveCommandConversation: ({ originatingTo, commandTo, fallbackTo }) => {
const conversationId =
resolveBlueBubblesConversationIdFromTarget(originatingTo ?? "") ??
resolveBlueBubblesConversationIdFromTarget(commandTo ?? "") ??
resolveBlueBubblesConversationIdFromTarget(fallbackTo ?? "");
return conversationId ? { conversationId } : null;
},
},
messaging: {
normalizeTarget: normalizeBlueBubblesMessagingTarget,

View File

@@ -334,6 +334,44 @@ describe("discordPlugin outbound", () => {
});
});
describe("discordPlugin bindings", () => {
it("preserves user-prefixed current conversation ids for DM binds", () => {
const result = discordPlugin.bindings?.resolveCommandConversation?.({
accountId: "default",
originatingTo: "user:123456789012345678",
});
expect(result).toEqual({
conversationId: "user:123456789012345678",
});
});
it("preserves channel-prefixed current conversation ids for channel binds", () => {
const result = discordPlugin.bindings?.resolveCommandConversation?.({
accountId: "default",
originatingTo: "channel:987654321098765432",
});
expect(result).toEqual({
conversationId: "channel:987654321098765432",
});
});
it("preserves channel-prefixed parent ids for thread binds", () => {
const result = discordPlugin.bindings?.resolveCommandConversation?.({
accountId: "default",
originatingTo: "channel:thread-42",
threadId: "thread-42",
threadParentId: "parent-9",
});
expect(result).toEqual({
conversationId: "thread-42",
parentConversationId: "channel:parent-9",
});
});
});
describe("discordPlugin security", () => {
it("normalizes dm allowlist entries with trimmed prefixes and mentions", () => {
const resolveDmPolicy = discordPlugin.security?.resolveDmPolicy;

View File

@@ -367,6 +367,66 @@ function matchDiscordAcpConversation(params: {
return null;
}
function resolveDiscordConversationIdFromTargets(
targets: Array<string | undefined>,
): string | undefined {
for (const raw of targets) {
const trimmed = raw?.trim();
if (!trimmed) {
continue;
}
try {
const target = parseDiscordTarget(trimmed, { defaultKind: "channel" });
if (target?.normalized) {
return target.normalized;
}
} catch {
const mentionMatch = trimmed.match(/^<#(\d+)>$/);
if (mentionMatch?.[1]) {
return `channel:${mentionMatch[1]}`;
}
if (/^\d{6,}$/.test(trimmed)) {
return normalizeDiscordMessagingTarget(trimmed);
}
}
}
return undefined;
}
function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined {
const sessionKey = typeof raw === "string" ? raw.trim().toLowerCase() : "";
if (!sessionKey) {
return undefined;
}
const match = sessionKey.match(/(?:^|:)channel:([^:]+)$/);
return match?.[1] ? `channel:${match[1]}` : undefined;
}
function resolveDiscordCommandConversation(params: {
threadId?: string;
threadParentId?: string;
parentSessionKey?: string;
originatingTo?: string;
commandTo?: string;
fallbackTo?: string;
}) {
const targets = [params.originatingTo, params.commandTo, params.fallbackTo];
if (params.threadId) {
const parentConversationId =
normalizeDiscordMessagingTarget(params.threadParentId?.trim() ?? "") ||
parseDiscordParentChannelFromSessionKey(params.parentSessionKey) ||
resolveDiscordConversationIdFromTargets(targets);
return {
conversationId: params.threadId,
...(parentConversationId && parentConversationId !== params.threadId
? { parentConversationId }
: {}),
};
}
const conversationId = resolveDiscordConversationIdFromTargets(targets);
return conversationId ? { conversationId } : null;
}
function parseDiscordExplicitTarget(raw: string) {
try {
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
@@ -553,6 +613,22 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
conversationId,
parentConversationId,
}),
resolveCommandConversation: ({
threadId,
threadParentId,
parentSessionKey,
originatingTo,
commandTo,
fallbackTo,
}) =>
resolveDiscordCommandConversation({
threadId,
threadParentId,
parentSessionKey,
originatingTo,
commandTo,
fallbackTo,
}),
},
status: createComputedAccountStatusAdapter<ResolvedDiscordAccount, DiscordProbe, unknown>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, {

View File

@@ -14,6 +14,7 @@ import {
createAllowlistProviderGroupPolicyWarningCollector,
projectConfigAccountIdWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import {
createChannelDirectoryAdapter,
@@ -44,7 +45,12 @@ import {
import { FEISHU_CARD_INTERACTION_VERSION } from "./card-interaction.js";
import { createFeishuClient } from "./client.js";
import { FeishuConfigSchema } from "./config-schema.js";
import { parseFeishuConversationId } from "./conversation-id.js";
import {
buildFeishuConversationId,
parseFeishuConversationId,
parseFeishuDirectConversationId,
parseFeishuTargetId,
} from "./conversation-id.js";
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { getFeishuRuntime } from "./runtime.js";
@@ -309,6 +315,99 @@ function matchFeishuAcpConversation(params: {
};
}
function resolveFeishuSenderScopedCommandConversation(params: {
accountId: string;
parentConversationId?: string;
threadId?: string;
senderId?: string;
sessionKey?: string;
parentSessionKey?: string;
}): string | undefined {
const parentConversationId = params.parentConversationId?.trim();
const threadId = params.threadId?.trim();
const senderId = params.senderId?.trim();
if (!parentConversationId || !threadId || !senderId) {
return undefined;
}
const expectedScopePrefix = `feishu:group:${parentConversationId.toLowerCase()}:topic:${threadId.toLowerCase()}:sender:`;
const isSenderScopedSession = [params.sessionKey, params.parentSessionKey].some((candidate) => {
const normalized = typeof candidate === "string" ? candidate.trim().toLowerCase() : "";
if (!normalized) {
return false;
}
const scopedRest = normalized.replace(/^agent:[^:]+:/, "");
return scopedRest.startsWith(expectedScopePrefix);
});
const senderScopedConversationId = buildFeishuConversationId({
chatId: parentConversationId,
scope: "group_topic_sender",
topicId: threadId,
senderOpenId: senderId,
});
if (isSenderScopedSession) {
return senderScopedConversationId;
}
if (!params.sessionKey?.trim()) {
return undefined;
}
const boundConversation = getSessionBindingService()
.listBySession(params.sessionKey)
.find((binding) => {
if (
binding.conversation.channel !== "feishu" ||
binding.conversation.accountId !== params.accountId
) {
return false;
}
return binding.conversation.conversationId === senderScopedConversationId;
});
return boundConversation?.conversation.conversationId;
}
function resolveFeishuCommandConversation(params: {
accountId: string;
threadId?: string;
senderId?: string;
sessionKey?: string;
parentSessionKey?: string;
originatingTo?: string;
commandTo?: string;
fallbackTo?: string;
}) {
if (params.threadId) {
const parentConversationId =
parseFeishuTargetId(params.originatingTo) ??
parseFeishuTargetId(params.commandTo) ??
parseFeishuTargetId(params.fallbackTo);
if (!parentConversationId) {
return null;
}
const senderScopedConversationId = resolveFeishuSenderScopedCommandConversation({
accountId: params.accountId,
parentConversationId,
threadId: params.threadId,
senderId: params.senderId,
sessionKey: params.sessionKey,
parentSessionKey: params.parentSessionKey,
});
return {
conversationId:
senderScopedConversationId ??
buildFeishuConversationId({
chatId: parentConversationId,
scope: "group_topic",
topicId: params.threadId,
}),
parentConversationId,
};
}
const conversationId =
parseFeishuDirectConversationId(params.originatingTo) ??
parseFeishuDirectConversationId(params.commandTo) ??
parseFeishuDirectConversationId(params.fallbackTo);
return conversationId ? { conversationId } : null;
}
function jsonActionResult(details: Record<string, unknown>) {
return {
content: [{ type: "text" as const, text: JSON.stringify(details) }],
@@ -942,6 +1041,26 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
conversationId,
parentConversationId,
}),
resolveCommandConversation: ({
accountId,
threadId,
senderId,
sessionKey,
parentSessionKey,
originatingTo,
commandTo,
fallbackTo,
}) =>
resolveFeishuCommandConversation({
accountId,
threadId,
senderId,
sessionKey,
parentSessionKey,
originatingTo,
commandTo,
fallbackTo,
}),
},
setup: feishuSetupAdapter,
setupWizard: feishuSetupWizard,

View File

@@ -21,6 +21,7 @@ import { createIMessageConversationBindingManager } from "./conversation-binding
import {
matchIMessageAcpConversation,
normalizeIMessageAcpConversationId,
resolveIMessageConversationIdFromTarget,
} from "./conversation-id.js";
import {
resolveIMessageGroupRequireMention,
@@ -141,6 +142,13 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
bindingConversationId: compiledBinding.conversationId,
conversationId,
}),
resolveCommandConversation: ({ originatingTo, commandTo, fallbackTo }) => {
const conversationId =
resolveIMessageConversationIdFromTarget(originatingTo ?? "") ??
resolveIMessageConversationIdFromTarget(commandTo ?? "") ??
resolveIMessageConversationIdFromTarget(fallbackTo ?? "");
return conversationId ? { conversationId } : null;
},
},
messaging: {
normalizeTarget: normalizeIMessageMessagingTarget,

View File

@@ -238,6 +238,31 @@ function matchMatrixAcpConversation(params: {
return null;
}
function resolveMatrixCommandConversation(params: {
threadId?: string;
originatingTo?: string;
commandTo?: string;
fallbackTo?: string;
}) {
const parentConversationId = [params.originatingTo, params.commandTo, params.fallbackTo]
.map((candidate) => {
const trimmed = candidate?.trim();
if (!trimmed) {
return undefined;
}
const target = resolveMatrixTargetIdentity(trimmed);
return target?.kind === "room" ? target.id : undefined;
})
.find((candidate): candidate is string => Boolean(candidate));
if (params.threadId) {
return {
conversationId: params.threadId,
...(parentConversationId ? { parentConversationId } : {}),
};
}
return parentConversationId ? { conversationId: parentConversationId } : null;
}
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
createChatChannelPlugin<ResolvedMatrixAccount, MatrixProbe>({
base: {
@@ -322,6 +347,13 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
conversationId,
parentConversationId,
}),
resolveCommandConversation: ({ threadId, originatingTo, commandTo, fallbackTo }) =>
resolveMatrixCommandConversation({
threadId,
originatingTo,
commandTo,
fallbackTo,
}),
},
status: createComputedAccountStatusAdapter<ResolvedMatrixAccount, MatrixProbe>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),

View File

@@ -223,6 +223,36 @@ function matchTelegramAcpConversation(params: {
};
}
function resolveTelegramCommandConversation(params: {
threadId?: string;
originatingTo?: string;
commandTo?: string;
fallbackTo?: string;
}) {
const chatId = [params.originatingTo, params.commandTo, params.fallbackTo]
.map((candidate) => {
const trimmed = candidate?.trim();
return trimmed ? parseTelegramTarget(trimmed).chatId.trim() : "";
})
.find((candidate) => candidate.length > 0);
if (!chatId) {
return null;
}
if (params.threadId) {
return {
conversationId: `${chatId}:topic:${params.threadId}`,
parentConversationId: chatId,
};
}
if (chatId.startsWith("-")) {
return null;
}
return {
conversationId: chatId,
parentConversationId: chatId,
};
}
function parseTelegramExplicitTarget(raw: string) {
const target = parseTelegramTarget(raw);
return {
@@ -372,6 +402,13 @@ export const telegramPlugin = createChatChannelPlugin({
conversationId,
parentConversationId,
}),
resolveCommandConversation: ({ threadId, originatingTo, commandTo, fallbackTo }) =>
resolveTelegramCommandConversation({
threadId,
originatingTo,
commandTo,
fallbackTo,
}),
},
groups: {
resolveRequireMention: resolveTelegramGroupRequireMention,

View File

@@ -116,7 +116,7 @@ type FakeBinding = {
targetSessionKey: string;
targetKind: "subagent" | "session";
conversation: {
channel: "discord" | "matrix" | "telegram" | "feishu" | "bluebubbles" | "imessage";
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
@@ -241,7 +241,7 @@ function createSessionBindingCapabilities() {
type AcpBindInput = {
targetSessionKey: string;
conversation: {
channel?: "discord" | "matrix" | "telegram" | "feishu" | "bluebubbles" | "imessage";
channel?: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
@@ -255,47 +255,16 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
input.placement === "child" ? "thread-created" : input.conversation.conversationId;
const boundBy = typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1";
const channel = input.conversation.channel ?? "discord";
const conversation =
channel === "discord"
? {
channel: "discord" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
parentConversationId: "parent-1",
}
: channel === "matrix"
? {
channel: "matrix" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
parentConversationId:
input.placement === "child"
? input.conversation.conversationId
: input.conversation.parentConversationId,
}
: channel === "feishu"
? {
channel: "feishu" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
}
: channel === "bluebubbles"
? {
channel: "bluebubbles" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
}
: channel === "imessage"
? {
channel: "imessage" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
}
: {
channel: "telegram" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
};
const nextParentConversationId =
input.placement === "child"
? input.conversation.conversationId
: input.conversation.parentConversationId;
const conversation = {
channel,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
...(nextParentConversationId ? { parentConversationId: nextParentConversationId } : {}),
};
return createSessionBinding({
targetSessionKey: input.targetSessionKey,
conversation,
@@ -337,28 +306,33 @@ function createThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg)
return params;
}
function createTelegramTopicParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:-1003841603622",
AccountId: "default",
MessageThreadId: "498",
});
params.command.senderId = "user-1";
return params;
}
type ConversationCommandFixture = {
accountId?: string;
channel: string;
originatingTo: string;
senderId?: string;
sessionKey?: string;
threadId?: string;
threadParentId?: string;
};
function createTelegramDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
function createConversationParams(
commandBody: string,
fixture: ConversationCommandFixture,
cfg: OpenClawConfig = baseCfg,
) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:123456789",
AccountId: "default",
Provider: fixture.channel,
Surface: fixture.channel,
OriginatingChannel: fixture.channel,
OriginatingTo: fixture.originatingTo,
AccountId: fixture.accountId ?? "default",
...(fixture.senderId ? { SenderId: fixture.senderId } : {}),
...(fixture.sessionKey ? { SessionKey: fixture.sessionKey } : {}),
...(fixture.threadId ? { MessageThreadId: fixture.threadId } : {}),
...(fixture.threadParentId ? { ThreadParentId: fixture.threadParentId } : {}),
});
params.command.senderId = "user-1";
params.command.senderId = fixture.senderId ?? "user-1";
return params;
}
@@ -371,86 +345,106 @@ async function runThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = ba
}
async function runTelegramAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createTelegramTopicParams(commandBody, cfg), true);
return handleAcpCommand(
createConversationParams(
commandBody,
{
channel: "telegram",
originatingTo: "telegram:-1003841603622",
threadId: "498",
},
cfg,
),
true,
);
}
async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createTelegramDmParams(commandBody, cfg), true);
}
function createMatrixRoomParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example.org",
AccountId: "default",
});
params.command.senderId = "user-1";
return params;
return handleAcpCommand(
createConversationParams(
commandBody,
{
channel: "telegram",
originatingTo: "telegram:123456789",
},
cfg,
),
true,
);
}
function createMatrixThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = createMatrixRoomParams(commandBody, cfg);
const params = createConversationParams(
commandBody,
{
channel: "matrix",
originatingTo: "room:!room:example.org",
},
cfg,
);
params.ctx.MessageThreadId = "$thread-root";
return params;
}
async function runMatrixAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createMatrixRoomParams(commandBody, cfg), true);
return handleAcpCommand(
createConversationParams(
commandBody,
{
channel: "matrix",
originatingTo: "room:!room:example.org",
},
cfg,
),
true,
);
}
async function runMatrixThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createMatrixThreadParams(commandBody, cfg), true);
}
function createFeishuDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "feishu",
Surface: "feishu",
OriginatingChannel: "feishu",
OriginatingTo: "user:ou_sender_1",
AccountId: "default",
SenderId: "ou_sender_1",
});
params.command.senderId = "user-1";
return params;
}
async function runFeishuDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createFeishuDmParams(commandBody, cfg), true);
}
function createBlueBubblesDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "bluebubbles",
Surface: "bluebubbles",
OriginatingChannel: "bluebubbles",
OriginatingTo: "bluebubbles:+15555550123",
AccountId: "default",
});
params.command.senderId = "user-1";
return params;
return handleAcpCommand(
createConversationParams(
commandBody,
{
channel: "feishu",
originatingTo: "user:ou_sender_1",
senderId: "ou_sender_1",
},
cfg,
),
true,
);
}
async function runBlueBubblesDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createBlueBubblesDmParams(commandBody, cfg), true);
}
function createIMessageDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "imessage",
Surface: "imessage",
OriginatingChannel: "imessage",
OriginatingTo: "imessage:+15555550123",
AccountId: "default",
});
params.command.senderId = "user-1";
return params;
return handleAcpCommand(
createConversationParams(
commandBody,
{
channel: "bluebubbles",
originatingTo: "bluebubbles:+15555550123",
},
cfg,
),
true,
);
}
async function runIMessageDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createIMessageDmParams(commandBody, cfg), true);
return handleAcpCommand(
createConversationParams(
commandBody,
{
channel: "imessage",
originatingTo: "imessage:+15555550123",
},
cfg,
),
true,
);
}
async function runInternalAcpCommand(params: {
@@ -801,7 +795,7 @@ describe("/acp command", () => {
const result = await runDiscordAcpCommand("/acp spawn codex --bind here", cfg);
expect(result?.reply?.text).toContain("Bound this channel to");
expect(result?.reply?.text).toContain("Bound this conversation to");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
@@ -943,7 +937,7 @@ describe("/acp command", () => {
const result = await runFeishuDmAcpCommand("/acp spawn codex --thread here");
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
expect(result?.reply?.text).toContain("Bound this thread to");
expect(result?.reply?.text).toContain("Bound this conversation to");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { setDefaultChannelPluginRegistryForTests } from "../../../commands/channel-test-helpers.js";
import type { OpenClawConfig } from "../../../config/config.js";
import {
__testing as sessionBindingTesting,
@@ -8,7 +9,6 @@ import {
} from "../../../infra/outbound/session-binding-service.js";
import { buildCommandTestParams } from "../commands-spawn.test-harness.js";
import {
isAcpCommandDiscordChannel,
resolveAcpCommandBindingContext,
resolveAcpCommandConversationId,
resolveAcpCommandParentConversationId,
@@ -51,6 +51,7 @@ function registerFeishuBindingAdapterForTest(accountId: string) {
describe("commands-acp context", () => {
beforeEach(() => {
setDefaultChannelPluginRegistryForTests();
sessionBindingTesting.resetSessionBindingAdaptersForTests();
});
@@ -69,9 +70,8 @@ describe("commands-acp context", () => {
accountId: "work",
threadId: "thread-42",
conversationId: "thread-42",
parentConversationId: "parent-1",
parentConversationId: "channel:parent-1",
});
expect(isAcpCommandDiscordChannel(params)).toBe(true);
});
it("resolves discord thread parent from ParentSessionKey when targets point at the thread", () => {
@@ -90,7 +90,7 @@ describe("commands-acp context", () => {
accountId: "work",
threadId: "thread-42",
conversationId: "thread-42",
parentConversationId: "parent-9",
parentConversationId: "channel:parent-9",
});
});
@@ -110,7 +110,7 @@ describe("commands-acp context", () => {
accountId: "work",
threadId: "thread-42",
conversationId: "thread-42",
parentConversationId: "parent-11",
parentConversationId: "channel:parent-11",
});
});
@@ -129,7 +129,6 @@ describe("commands-acp context", () => {
conversationId: "123456789",
});
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
expect(isAcpCommandDiscordChannel(params)).toBe(false);
});
it("builds canonical telegram topic conversation ids from originating chat + thread", () => {

View File

@@ -1,78 +1,7 @@
import {
buildTelegramTopicConversationId,
normalizeConversationText,
parseTelegramChatIdFromTarget,
} from "../../../acp/conversation-id.js";
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
import { normalizeConversationText } from "../../../acp/conversation-id.js";
import { resolveChannelConfiguredBindingProviderByChannel } from "../../../channels/plugins/binding-provider.js";
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import { resolveBlueBubblesConversationIdFromTarget } from "../../../plugin-sdk/bluebubbles.js";
import {
buildFeishuConversationId,
parseFeishuDirectConversationId,
parseFeishuTargetId,
} from "../../../plugin-sdk/feishu.js";
import { resolveIMessageConversationIdFromTarget } from "../../../plugin-sdk/imessage-core.js";
import { parseAgentSessionKey } from "../../../routing/session-key.js";
import type { HandleCommandsParams } from "../commands-types.js";
import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js";
import {
resolveMatrixConversationId,
resolveMatrixParentConversationId,
} from "../matrix-context.js";
import { resolveTelegramConversationId } from "../telegram-context.js";
function resolveFeishuSenderScopedConversationId(params: {
accountId: string;
parentConversationId?: string;
threadId?: string;
senderId?: string;
sessionKey?: string;
parentSessionKey?: string;
}): string | undefined {
const parentConversationId = normalizeConversationText(params.parentConversationId);
const threadId = normalizeConversationText(params.threadId);
const senderId = normalizeConversationText(params.senderId);
const expectedScopePrefix = `feishu:group:${parentConversationId?.toLowerCase()}:topic:${threadId?.toLowerCase()}:sender:`;
const isSenderScopedSession = [params.sessionKey, params.parentSessionKey].some((candidate) => {
const scopedRest = parseAgentSessionKey(candidate)?.rest?.trim().toLowerCase() ?? "";
return Boolean(scopedRest && expectedScopePrefix && scopedRest.startsWith(expectedScopePrefix));
});
if (!parentConversationId || !threadId || !senderId) {
return undefined;
}
if (!isSenderScopedSession && params.sessionKey?.trim()) {
const boundConversation = getSessionBindingService()
.listBySession(params.sessionKey)
.find((binding) => {
if (
binding.conversation.channel !== "feishu" ||
binding.conversation.accountId !== params.accountId
) {
return false;
}
return (
binding.conversation.conversationId ===
buildFeishuConversationId({
chatId: parentConversationId,
scope: "group_topic_sender",
topicId: threadId,
senderOpenId: senderId,
})
);
});
if (boundConversation) {
return boundConversation.conversation.conversationId;
}
return undefined;
}
return buildFeishuConversationId({
chatId: parentConversationId,
scope: "group_topic_sender",
topicId: threadId,
senderOpenId: senderId,
});
}
export function resolveAcpCommandChannel(params: HandleCommandsParams): string {
const raw =
@@ -96,159 +25,56 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string
return threadId || undefined;
}
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
function resolveAcpCommandConversationRef(params: HandleCommandsParams): {
conversationId: string;
parentConversationId?: string;
} | null {
const channel = resolveAcpCommandChannel(params);
if (channel === "matrix") {
return resolveMatrixConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
}
if (channel === "telegram") {
const telegramConversationId = resolveTelegramConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
if (telegramConversationId) {
return telegramConversationId;
}
const threadId = resolveAcpCommandThreadId(params);
const parentConversationId = resolveAcpCommandParentConversationId(params);
if (threadId && parentConversationId) {
return (
buildTelegramTopicConversationId({
chatId: parentConversationId,
topicId: threadId,
}) ?? threadId
);
}
}
if (channel === "feishu") {
const threadId = resolveAcpCommandThreadId(params);
const parentConversationId = resolveAcpCommandParentConversationId(params);
if (threadId && parentConversationId) {
const senderScopedConversationId = resolveFeishuSenderScopedConversationId({
accountId: resolveAcpCommandAccountId(params),
parentConversationId,
threadId,
senderId: params.command.senderId ?? params.ctx.SenderId,
sessionKey: params.sessionKey,
parentSessionKey: params.ctx.ParentSessionKey,
});
return (
senderScopedConversationId ??
buildFeishuConversationId({
chatId: parentConversationId,
scope: "group_topic",
topicId: threadId,
})
);
}
return (
parseFeishuDirectConversationId(params.ctx.OriginatingTo) ??
parseFeishuDirectConversationId(params.command.to) ??
parseFeishuDirectConversationId(params.ctx.To)
);
}
if (channel === "bluebubbles") {
return (
resolveBlueBubblesConversationIdFromTarget(params.ctx.OriginatingTo ?? "") ??
resolveBlueBubblesConversationIdFromTarget(params.command.to ?? "") ??
resolveBlueBubblesConversationIdFromTarget(params.ctx.To ?? "")
);
}
if (channel === "imessage") {
return (
resolveIMessageConversationIdFromTarget(params.ctx.OriginatingTo ?? "") ??
resolveIMessageConversationIdFromTarget(params.command.to ?? "") ??
resolveIMessageConversationIdFromTarget(params.ctx.To ?? "")
);
}
return resolveConversationIdFromTargets({
threadId: params.ctx.MessageThreadId,
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],
const threadId = resolveAcpCommandThreadId(params);
const provider = resolveChannelConfiguredBindingProviderByChannel(channel);
const resolvedByProvider = provider?.resolveCommandConversation?.({
accountId: resolveAcpCommandAccountId(params),
threadId,
threadParentId: normalizeConversationText(params.ctx.ThreadParentId),
senderId: normalizeConversationText(params.command.senderId ?? params.ctx.SenderId),
sessionKey: params.sessionKey,
parentSessionKey: normalizeConversationText(params.ctx.ParentSessionKey),
originatingTo: params.ctx.OriginatingTo,
commandTo: params.command.to,
fallbackTo: params.ctx.To,
});
if (resolvedByProvider?.conversationId) {
return resolvedByProvider;
}
const targets = [params.ctx.OriginatingTo, params.command.to, params.ctx.To];
const conversationId = resolveConversationIdFromTargets({
threadId,
targets,
});
if (!conversationId) {
return null;
}
const parentConversationId = threadId
? resolveConversationIdFromTargets({
targets,
})
: undefined;
return {
conversationId,
...(parentConversationId && parentConversationId !== conversationId
? { parentConversationId }
: {}),
};
}
function parseDiscordParentChannelFromContext(raw: unknown): string | undefined {
const parentId = normalizeConversationText(raw);
if (!parentId) {
return undefined;
}
return parentId;
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
return resolveAcpCommandConversationRef(params)?.conversationId;
}
export function resolveAcpCommandParentConversationId(
params: HandleCommandsParams,
): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "matrix") {
return resolveMatrixParentConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
}
if (channel === "telegram") {
return (
parseTelegramChatIdFromTarget(params.ctx.OriginatingTo) ??
parseTelegramChatIdFromTarget(params.command.to) ??
parseTelegramChatIdFromTarget(params.ctx.To)
);
}
if (channel === "feishu") {
const threadId = resolveAcpCommandThreadId(params);
if (!threadId) {
return undefined;
}
return (
parseFeishuTargetId(params.ctx.OriginatingTo) ??
parseFeishuTargetId(params.command.to) ??
parseFeishuTargetId(params.ctx.To)
);
}
if (channel === DISCORD_THREAD_BINDING_CHANNEL) {
const threadId = resolveAcpCommandThreadId(params);
if (!threadId) {
return undefined;
}
const fromContext = parseDiscordParentChannelFromContext(params.ctx.ThreadParentId);
if (fromContext && fromContext !== threadId) {
return fromContext;
}
const fromParentSession = parseDiscordParentChannelFromSessionKey(params.ctx.ParentSessionKey);
if (fromParentSession && fromParentSession !== threadId) {
return fromParentSession;
}
const fromTargets = resolveConversationIdFromTargets({
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],
});
if (fromTargets && fromTargets !== threadId) {
return fromTargets;
}
}
return undefined;
}
export function isAcpCommandDiscordChannel(params: HandleCommandsParams): boolean {
return resolveAcpCommandChannel(params) === DISCORD_THREAD_BINDING_CHANNEL;
return resolveAcpCommandConversationRef(params)?.parentConversationId;
}
export function resolveAcpCommandBindingContext(params: HandleCommandsParams): {
@@ -258,12 +84,21 @@ export function resolveAcpCommandBindingContext(params: HandleCommandsParams): {
conversationId?: string;
parentConversationId?: string;
} {
const parentConversationId = resolveAcpCommandParentConversationId(params);
const conversationRef = resolveAcpCommandConversationRef(params);
if (!conversationRef) {
return {
channel: resolveAcpCommandChannel(params),
accountId: resolveAcpCommandAccountId(params),
threadId: resolveAcpCommandThreadId(params),
};
}
return {
channel: resolveAcpCommandChannel(params),
accountId: resolveAcpCommandAccountId(params),
threadId: resolveAcpCommandThreadId(params),
conversationId: resolveAcpCommandConversationId(params),
...(parentConversationId ? { parentConversationId } : {}),
conversationId: conversationRef.conversationId,
...(conversationRef.parentConversationId
? { parentConversationId: conversationRef.parentConversationId }
: {}),
};
}

View File

@@ -23,8 +23,10 @@ import {
import {
formatThreadBindingDisabledError,
formatThreadBindingSpawnDisabledError,
requiresNativeThreadContextForThreadHere,
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
resolveThreadBindingPlacementForCurrentContext,
resolveThreadBindingSpawnPolicy,
} from "../../../channels/thread-bindings-policy.js";
import type { OpenClawConfig } from "../../../config/config.js";
@@ -55,23 +57,17 @@ import {
import { resolveAcpTargetSessionKey } from "./targets.js";
function resolveAcpBindingLabelNoun(params: {
channel: string;
conversationId?: string;
placement: "current" | "child";
threadId?: string;
bindMode: "thread" | "current";
}): string {
if (params.bindMode === "current") {
if (params.channel === "discord" && !params.threadId) {
return "channel";
}
if (
params.channel === "telegram" ||
params.channel === "bluebubbles" ||
params.channel === "imessage"
) {
return "conversation";
}
if (params.placement === "child") {
return "thread";
}
return params.channel === "telegram" ? "conversation" : "thread";
if (!params.threadId) {
return "conversation";
}
return params.conversationId === params.threadId ? "thread" : "conversation";
}
async function bindSpawnedAcpSessionToCurrentConversation(params: {
@@ -158,9 +154,14 @@ async function bindSpawnedAcpSessionToCurrentConversation(params: {
? existingBinding.metadata.boundBy.trim()
: "";
if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
const currentLabel = resolveAcpBindingLabelNoun({
placement: "current",
threadId: bindingContext.threadId,
conversationId: currentConversationId,
});
return {
ok: false,
error: `Only ${boundBy} can rebind this ${channel === "discord" && !bindingContext.threadId ? "channel" : "conversation"}.`,
error: `Only ${boundBy} can rebind this ${currentLabel}.`,
};
}
@@ -287,7 +288,7 @@ async function bindSpawnedAcpSessionToThread(params: {
const currentThreadId = bindingContext.threadId ?? "";
const currentConversationId = bindingContext.conversationId?.trim() || "";
const requiresThreadIdForHere = channel !== "telegram" && channel !== "feishu";
const requiresThreadIdForHere = requiresNativeThreadContextForThreadHere(channel);
if (
threadMode === "here" &&
((requiresThreadIdForHere && !currentThreadId) ||
@@ -299,12 +300,10 @@ async function bindSpawnedAcpSessionToThread(params: {
};
}
const placement =
channel === "telegram" || channel === "feishu"
? "current"
: currentThreadId
? "current"
: "child";
const placement = resolveThreadBindingPlacementForCurrentContext({
channel,
threadId: currentThreadId || undefined,
});
if (!capabilities.placements.includes(placement)) {
return {
ok: false,
@@ -335,9 +334,14 @@ async function bindSpawnedAcpSessionToThread(params: {
? existingBinding.metadata.boundBy.trim()
: "";
if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
const currentLabel = resolveAcpBindingLabelNoun({
placement,
threadId: currentThreadId || undefined,
conversationId: currentConversationId,
});
return {
ok: false,
error: `Only ${boundBy} can rebind this ${channel === "telegram" ? "conversation" : "thread"}.`,
error: `Only ${boundBy} can rebind this ${currentLabel}.`,
};
}
}
@@ -537,9 +541,12 @@ export async function handleAcpSpawnAction(
const currentConversationId = resolveAcpCommandConversationId(params)?.trim() || "";
const boundConversationId = binding.conversation.conversationId.trim();
const placementLabel = resolveAcpBindingLabelNoun({
channel: binding.conversation.channel,
conversationId: currentConversationId,
placement:
currentConversationId && boundConversationId === currentConversationId
? "current"
: "child",
threadId: resolveAcpCommandThreadId(params),
bindMode: spawn.bind !== "off" ? "current" : "thread",
});
if (currentConversationId && boundConversationId === currentConversationId) {
parts.push(`Bound this ${placementLabel} to ${sessionKey}.`);

View File

@@ -2,10 +2,7 @@ import { randomUUID } from "node:crypto";
import { toAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js";
import type { AcpRuntimeError } from "../../../acp/runtime/errors.js";
import type { AcpRuntimeSessionMode } from "../../../acp/runtime/types.js";
import {
DISCORD_THREAD_BINDING_CHANNEL,
MATRIX_THREAD_BINDING_CHANNEL,
} from "../../../channels/thread-bindings-policy.js";
import { supportsAutomaticThreadBindingSpawn } from "../../../channels/thread-bindings-policy.js";
import type { AcpSessionRuntimeOptions } from "../../../config/sessions/types.js";
import { normalizeAgentId } from "../../../routing/session-key.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
@@ -174,7 +171,7 @@ function normalizeAcpOptionToken(raw: string): string {
function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnThreadMode {
const channel = resolveAcpCommandChannel(params);
if (channel !== DISCORD_THREAD_BINDING_CHANNEL && channel !== MATRIX_THREAD_BINDING_CHANNEL) {
if (!supportsAutomaticThreadBindingSpawn(channel)) {
return "off";
}
const currentThreadId = resolveAcpCommandThreadId(params);

View File

@@ -1,3 +1,4 @@
import { getChannelPlugin, normalizeChannelId } from "./registry.js";
import type { ChannelConfiguredBindingProvider } from "./types.adapters.js";
import type { ChannelPlugin } from "./types.plugin.js";
@@ -12,3 +13,13 @@ export function resolveChannelConfiguredBindingProvider(
): ChannelConfiguredBindingProvider | undefined {
return plugin?.bindings;
}
export function resolveChannelConfiguredBindingProviderByChannel(
channel: string,
): ChannelConfiguredBindingProvider | undefined {
const normalizedChannel = normalizeChannelId(channel);
if (!normalizedChannel) {
return undefined;
}
return resolveChannelConfiguredBindingProvider(getChannelPlugin(normalizedChannel));
}

View File

@@ -575,6 +575,18 @@ export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingConversation
matchPriority?: number;
};
export type ChannelCommandConversationContext = {
accountId: string;
threadId?: string;
threadParentId?: string;
senderId?: string;
sessionKey?: string;
parentSessionKey?: string;
originatingTo?: string;
commandTo?: string;
fallbackTo?: string;
};
export type ChannelConfiguredBindingProvider = {
compileConfiguredBinding: (params: {
binding: ConfiguredBindingRule;
@@ -586,6 +598,9 @@ export type ChannelConfiguredBindingProvider = {
conversationId: string;
parentConversationId?: string;
}) => ChannelConfiguredBindingMatch | null;
resolveCommandConversation?: (
params: ChannelCommandConversationContext,
) => ChannelConfiguredBindingConversationRef | null;
};
export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {

View File

@@ -33,6 +33,7 @@ export type {
ChannelOutboundAdapter,
ChannelOutboundContext,
ChannelAllowlistAdapter,
ChannelCommandConversationContext,
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingMatch,
ChannelConfiguredBindingProvider,

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
requiresNativeThreadContextForThreadHere,
resolveThreadBindingPlacementForCurrentContext,
supportsAutomaticThreadBindingSpawn,
} from "./thread-bindings-policy.js";
describe("thread binding spawn policy helpers", () => {
it("treats Discord and Matrix as automatic child-thread spawn channels", () => {
expect(supportsAutomaticThreadBindingSpawn("discord")).toBe(true);
expect(supportsAutomaticThreadBindingSpawn("matrix")).toBe(true);
expect(supportsAutomaticThreadBindingSpawn("telegram")).toBe(false);
});
it("allows thread-here on threadless conversation channels without a native thread id", () => {
expect(requiresNativeThreadContextForThreadHere("telegram")).toBe(false);
expect(requiresNativeThreadContextForThreadHere("feishu")).toBe(false);
expect(requiresNativeThreadContextForThreadHere("discord")).toBe(true);
});
it("resolves current vs child placement from the current channel context", () => {
expect(
resolveThreadBindingPlacementForCurrentContext({
channel: "discord",
}),
).toBe("child");
expect(
resolveThreadBindingPlacementForCurrentContext({
channel: "discord",
threadId: "thread-1",
}),
).toBe("current");
expect(
resolveThreadBindingPlacementForCurrentContext({
channel: "telegram",
}),
).toBe("current");
});
});

View File

@@ -34,6 +34,28 @@ function normalizeChannelId(value: string | undefined | null): string {
.toLowerCase();
}
export function supportsAutomaticThreadBindingSpawn(channel: string): boolean {
const normalized = normalizeChannelId(channel);
return (
normalized === DISCORD_THREAD_BINDING_CHANNEL || normalized === MATRIX_THREAD_BINDING_CHANNEL
);
}
export function requiresNativeThreadContextForThreadHere(channel: string): boolean {
const normalized = normalizeChannelId(channel);
return normalized !== "telegram" && normalized !== "feishu";
}
export function resolveThreadBindingPlacementForCurrentContext(params: {
channel: string;
threadId?: string;
}): "current" | "child" {
if (!requiresNativeThreadContextForThreadHere(params.channel)) {
return "current";
}
return params.threadId ? "current" : "child";
}
function normalizeBoolean(value: unknown): boolean | undefined {
if (typeof value !== "boolean") {
return undefined;
@@ -180,9 +202,7 @@ export function resolveThreadBindingSpawnPolicy(params: {
const spawnFlagKey = resolveSpawnFlagKey(params.kind);
const spawnEnabledRaw =
normalizeBoolean(account?.[spawnFlagKey]) ?? normalizeBoolean(root?.[spawnFlagKey]);
const spawnEnabled =
spawnEnabledRaw ??
(channel !== DISCORD_THREAD_BINDING_CHANNEL && channel !== MATRIX_THREAD_BINDING_CHANNEL);
const spawnEnabled = spawnEnabledRaw ?? !supportsAutomaticThreadBindingSpawn(channel);
return {
channel,
accountId,

View File

@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "../../agents/test-helpers/fast-coding-tools.js";
import { createOpenClawCodingTools } from "../../agents/pi-tools.js";
import {
loadRunCronIsolatedAgentTurn,
resetRunCronIsolatedAgentTurnHarness,
@@ -67,12 +66,6 @@ describe("runCronIsolatedAgentTurn owner auth", () => {
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
const senderIsOwner = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.senderIsOwner;
expect(senderIsOwner).toBe(true);
const toolNames = createOpenClawCodingTools({ senderIsOwner }).map(
(tool: { name: string }) => tool.name,
);
expect(toolNames).toContain("cron");
expect(toolNames).toContain("gateway");
},
);
});

View File

@@ -4,6 +4,7 @@ export type {
BaseTokenResolution,
ChannelAgentTool,
ChannelAccountSnapshot,
ChannelCommandConversationContext,
ChannelGroupContext,
ChannelMessageActionAdapter,
ChannelMessageActionContext,

View File

@@ -28,7 +28,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'export * from "./src/send.js";',
],
"extensions/imessage/runtime-api.ts": [
'export { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, buildComputedAccountStatusSnapshot, buildChannelConfigSchema, collectStatusIssuesFromLastError, formatTrimmedAllowFromEntries, getChatChannelMeta, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, resolveChannelMediaMaxBytes, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, IMessageConfigSchema, type ChannelPlugin, type IMessageAccountConfig } from "openclaw/plugin-sdk/imessage";',
'export { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, buildComputedAccountStatusSnapshot, buildChannelConfigSchema, chunkTextForOutbound, collectStatusIssuesFromLastError, formatTrimmedAllowFromEntries, getChatChannelMeta, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, resolveChannelMediaMaxBytes, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, IMessageConfigSchema, type ChannelPlugin, type IMessageAccountConfig } from "openclaw/plugin-sdk/imessage";',
'export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy } from "./src/group-policy.js";',
'export { monitorIMessageProvider } from "./src/monitor.js";',
'export type { MonitorIMessageOpts } from "./src/monitor.js";',