2026-02-10 15:33:57 -08:00
|
|
|
import {
|
|
|
|
|
resolveChannelGroupRequireMention,
|
|
|
|
|
resolveChannelGroupToolsPolicy,
|
|
|
|
|
} from "../config/group-policy.js";
|
2026-03-05 23:07:13 -06:00
|
|
|
import { inspectDiscordAccount } from "../discord/account-inspect.js";
|
2026-03-02 08:53:11 +00:00
|
|
|
import {
|
|
|
|
|
formatTrimmedAllowFromEntries,
|
|
|
|
|
formatWhatsAppConfigAllowFromEntries,
|
|
|
|
|
resolveIMessageConfigAllowFrom,
|
|
|
|
|
resolveIMessageConfigDefaultTo,
|
|
|
|
|
resolveWhatsAppConfigAllowFrom,
|
|
|
|
|
resolveWhatsAppConfigDefaultTo,
|
|
|
|
|
} from "../plugin-sdk/channel-config-helpers.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { requireActivePluginRegistry } from "../plugins/runtime.js";
|
|
|
|
|
import { normalizeAccountId } from "../routing/session-key.js";
|
2026-01-11 11:45:25 +00:00
|
|
|
import { resolveSignalAccount } from "../signal/accounts.js";
|
2026-03-05 23:07:13 -06:00
|
|
|
import { inspectSlackAccount } from "../slack/account-inspect.js";
|
|
|
|
|
import { resolveSlackReplyToMode } from "../slack/accounts.js";
|
2026-01-21 20:01:12 +00:00
|
|
|
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
2026-03-05 23:07:13 -06:00
|
|
|
import { inspectTelegramAccount } from "../telegram/account-inspect.js";
|
2026-02-23 21:25:20 +00:00
|
|
|
import { normalizeE164 } from "../utils.js";
|
2026-01-11 11:45:25 +00:00
|
|
|
import {
|
|
|
|
|
resolveDiscordGroupRequireMention,
|
2026-01-24 15:35:05 +13:00
|
|
|
resolveDiscordGroupToolPolicy,
|
2026-01-23 16:45:37 -06:00
|
|
|
resolveGoogleChatGroupRequireMention,
|
|
|
|
|
resolveGoogleChatGroupToolPolicy,
|
2026-01-11 11:45:25 +00:00
|
|
|
resolveIMessageGroupRequireMention,
|
2026-01-24 15:35:05 +13:00
|
|
|
resolveIMessageGroupToolPolicy,
|
2026-01-11 11:45:25 +00:00
|
|
|
resolveSlackGroupRequireMention,
|
2026-01-24 15:35:05 +13:00
|
|
|
resolveSlackGroupToolPolicy,
|
2026-01-11 11:45:25 +00:00
|
|
|
resolveTelegramGroupRequireMention,
|
2026-01-24 15:35:05 +13:00
|
|
|
resolveTelegramGroupToolPolicy,
|
2026-01-11 11:45:25 +00:00
|
|
|
resolveWhatsAppGroupRequireMention,
|
2026-01-24 15:35:05 +13:00
|
|
|
resolveWhatsAppGroupToolPolicy,
|
2026-01-11 11:45:25 +00:00
|
|
|
} from "./plugins/group-mentions.js";
|
2026-02-17 14:17:22 -08:00
|
|
|
import { normalizeSignalMessagingTarget } from "./plugins/normalize/signal.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
import type {
|
|
|
|
|
ChannelCapabilities,
|
|
|
|
|
ChannelCommandAdapter,
|
2026-02-22 07:37:54 +00:00
|
|
|
ChannelConfigAdapter,
|
2026-02-18 01:34:35 +00:00
|
|
|
ChannelElevatedAdapter,
|
|
|
|
|
ChannelGroupAdapter,
|
|
|
|
|
ChannelId,
|
|
|
|
|
ChannelAgentPromptAdapter,
|
|
|
|
|
ChannelMentionAdapter,
|
|
|
|
|
ChannelPlugin,
|
|
|
|
|
ChannelThreadingContext,
|
|
|
|
|
ChannelThreadingAdapter,
|
|
|
|
|
ChannelThreadingToolContext,
|
|
|
|
|
} from "./plugins/types.js";
|
2026-02-23 21:25:20 +00:00
|
|
|
import {
|
|
|
|
|
resolveWhatsAppGroupIntroHint,
|
|
|
|
|
resolveWhatsAppMentionStripPatterns,
|
|
|
|
|
} from "./plugins/whatsapp-shared.js";
|
2026-01-15 02:42:41 +00:00
|
|
|
import { CHAT_CHANNEL_ORDER, type ChatChannelId, getChatChannelMeta } from "./registry.js";
|
2026-01-11 11:45:25 +00:00
|
|
|
|
2026-01-13 06:16:43 +00:00
|
|
|
export type ChannelDock = {
|
|
|
|
|
id: ChannelId;
|
|
|
|
|
capabilities: ChannelCapabilities;
|
|
|
|
|
commands?: ChannelCommandAdapter;
|
2026-01-11 11:45:25 +00:00
|
|
|
outbound?: {
|
|
|
|
|
textChunkLimit?: number;
|
|
|
|
|
};
|
2026-01-13 06:16:43 +00:00
|
|
|
streaming?: ChannelDockStreaming;
|
|
|
|
|
elevated?: ChannelElevatedAdapter;
|
2026-02-22 07:37:54 +00:00
|
|
|
config?: Pick<
|
|
|
|
|
ChannelConfigAdapter<unknown>,
|
|
|
|
|
"resolveAllowFrom" | "formatAllowFrom" | "resolveDefaultTo"
|
|
|
|
|
>;
|
2026-01-13 06:16:43 +00:00
|
|
|
groups?: ChannelGroupAdapter;
|
|
|
|
|
mentions?: ChannelMentionAdapter;
|
|
|
|
|
threading?: ChannelThreadingAdapter;
|
2026-01-22 03:27:26 +00:00
|
|
|
agentPrompt?: ChannelAgentPromptAdapter;
|
2026-01-11 11:45:25 +00:00
|
|
|
};
|
|
|
|
|
|
2026-01-13 06:16:43 +00:00
|
|
|
type ChannelDockStreaming = {
|
2026-01-11 11:45:25 +00:00
|
|
|
blockStreamingCoalesceDefaults?: {
|
|
|
|
|
minChars?: number;
|
|
|
|
|
idleMs?: number;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatLower = (allowFrom: Array<string | number>) =>
|
|
|
|
|
allowFrom
|
|
|
|
|
.map((entry) => String(entry).trim())
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.map((entry) => entry.toLowerCase());
|
2026-02-15 18:30:34 +00:00
|
|
|
|
2026-02-22 07:37:54 +00:00
|
|
|
const stringifyAllowFrom = (allowFrom: Array<string | number>) =>
|
|
|
|
|
allowFrom.map((entry) => String(entry));
|
|
|
|
|
|
|
|
|
|
const trimAllowFromEntries = (allowFrom: Array<string | number>) =>
|
|
|
|
|
allowFrom.map((entry) => String(entry).trim()).filter(Boolean);
|
|
|
|
|
|
2026-02-22 14:05:46 +00:00
|
|
|
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 trimAllowFromEntries(allowFrom).map((entry) => {
|
|
|
|
|
let normalized = entry;
|
|
|
|
|
for (const replacement of replacements) {
|
|
|
|
|
normalized = normalized.replace(replacement, "");
|
|
|
|
|
}
|
|
|
|
|
return normalized.toLowerCase();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 08:45:12 -05:00
|
|
|
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);
|
|
|
|
|
|
2026-02-17 14:17:22 -08:00
|
|
|
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: {
|
2026-02-15 18:30:34 +00:00
|
|
|
context: ChannelThreadingContext;
|
|
|
|
|
hasRepliedRef: ChannelThreadingToolContext["hasRepliedRef"];
|
|
|
|
|
}): ChannelThreadingToolContext {
|
|
|
|
|
return {
|
2026-02-17 14:17:22 -08:00
|
|
|
currentChannelId: resolveDirectOrGroupChannelId(params.context),
|
2026-02-15 18:30:34 +00:00
|
|
|
currentThreadTs: params.context.ReplyToId,
|
|
|
|
|
hasRepliedRef: params.hasRepliedRef,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-02-18 19:04:57 +00:00
|
|
|
|
2026-02-22 07:37:54 +00:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 19:04:57 +00:00
|
|
|
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()) ?? ""
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-21 23:56:58 +00:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-02-22 14:05:46 +00:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-01-13 06:16:43 +00:00
|
|
|
// Channel docks: lightweight channel metadata/behavior for shared code paths.
|
2026-01-11 11:45:25 +00:00
|
|
|
//
|
|
|
|
|
// Rules:
|
|
|
|
|
// - keep this module *light* (no monitors, probes, puppeteer/web login, etc)
|
|
|
|
|
// - OK: config readers, allowFrom formatting, mention stripping patterns, threading defaults
|
2026-01-13 06:16:43 +00:00
|
|
|
// - shared code should import from here (and from `src/channels/registry.ts`), not from the plugins registry
|
2026-01-11 11:45:25 +00:00
|
|
|
//
|
2026-01-13 06:16:43 +00:00
|
|
|
// Adding a channel:
|
2026-01-11 11:45:25 +00:00
|
|
|
// - add a new entry to `DOCKS`
|
2026-01-13 06:16:43 +00:00
|
|
|
// - keep it cheap; push heavy logic into `src/channels/plugins/<id>.ts` or channel modules
|
2026-01-15 02:42:41 +00:00
|
|
|
const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
2026-01-11 11:45:25 +00:00
|
|
|
telegram: {
|
|
|
|
|
id: "telegram",
|
|
|
|
|
capabilities: {
|
|
|
|
|
chatTypes: ["direct", "group", "channel", "thread"],
|
|
|
|
|
nativeCommands: true,
|
|
|
|
|
blockStreaming: true,
|
|
|
|
|
},
|
2026-02-22 14:05:46 +00:00
|
|
|
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
2026-01-11 11:45:25 +00:00
|
|
|
config: {
|
|
|
|
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
2026-03-05 23:07:13 -06:00
|
|
|
stringifyAllowFrom(inspectTelegramAccount({ cfg, accountId }).config.allowFrom ?? []),
|
2026-01-11 11:45:25 +00:00
|
|
|
formatAllowFrom: ({ allowFrom }) =>
|
2026-02-22 07:37:54 +00:00
|
|
|
trimAllowFromEntries(allowFrom)
|
2026-01-11 11:45:25 +00:00
|
|
|
.map((entry) => entry.replace(/^(telegram|tg):/i, ""))
|
|
|
|
|
.map((entry) => entry.toLowerCase()),
|
2026-02-19 23:37:19 -05:00
|
|
|
resolveDefaultTo: ({ cfg, accountId }) => {
|
2026-03-05 23:07:13 -06:00
|
|
|
const val = inspectTelegramAccount({ cfg, accountId }).config.defaultTo;
|
2026-02-19 23:37:19 -05:00
|
|
|
return val != null ? String(val) : undefined;
|
|
|
|
|
},
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
groups: {
|
|
|
|
|
resolveRequireMention: resolveTelegramGroupRequireMention,
|
2026-01-24 15:35:05 +13:00
|
|
|
resolveToolPolicy: resolveTelegramGroupToolPolicy,
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
threading: {
|
2026-02-13 22:21:08 -08:00
|
|
|
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off",
|
2026-02-23 23:25:14 +08:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
},
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
whatsapp: {
|
|
|
|
|
id: "whatsapp",
|
|
|
|
|
capabilities: {
|
|
|
|
|
chatTypes: ["direct", "group"],
|
|
|
|
|
polls: true,
|
|
|
|
|
reactions: true,
|
|
|
|
|
media: true,
|
|
|
|
|
},
|
|
|
|
|
commands: {
|
|
|
|
|
enforceOwnerForCommands: true,
|
|
|
|
|
skipWhenConfigEmpty: true,
|
|
|
|
|
},
|
2026-02-22 14:05:46 +00:00
|
|
|
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
2026-01-11 11:45:25 +00:00
|
|
|
config: {
|
2026-03-02 08:53:11 +00:00
|
|
|
resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }),
|
|
|
|
|
formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom),
|
|
|
|
|
resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }),
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
groups: {
|
|
|
|
|
resolveRequireMention: resolveWhatsAppGroupRequireMention,
|
2026-01-24 15:35:05 +13:00
|
|
|
resolveToolPolicy: resolveWhatsAppGroupToolPolicy,
|
2026-02-23 21:25:20 +00:00
|
|
|
resolveGroupIntroHint: resolveWhatsAppGroupIntroHint,
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
mentions: {
|
2026-02-23 21:25:20 +00:00
|
|
|
stripPatterns: ({ ctx }) => resolveWhatsAppMentionStripPatterns(ctx),
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
2026-01-13 01:03:23 +00:00
|
|
|
threading: {
|
2026-01-21 20:01:12 +00:00
|
|
|
buildToolContext: ({ context, hasRepliedRef }) => {
|
|
|
|
|
const channelId = context.From?.trim() || context.To?.trim() || undefined;
|
|
|
|
|
return {
|
|
|
|
|
currentChannelId: channelId,
|
|
|
|
|
currentThreadTs: context.ReplyToId,
|
|
|
|
|
hasRepliedRef,
|
|
|
|
|
};
|
|
|
|
|
},
|
2026-01-13 01:03:23 +00:00
|
|
|
},
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
discord: {
|
|
|
|
|
id: "discord",
|
|
|
|
|
capabilities: {
|
|
|
|
|
chatTypes: ["direct", "channel", "thread"],
|
|
|
|
|
polls: true,
|
|
|
|
|
reactions: true,
|
|
|
|
|
media: true,
|
|
|
|
|
nativeCommands: true,
|
|
|
|
|
threads: true,
|
|
|
|
|
},
|
|
|
|
|
outbound: { textChunkLimit: 2000 },
|
2026-02-22 14:05:46 +00:00
|
|
|
streaming: DEFAULT_BLOCK_STREAMING_COALESCE,
|
2026-01-11 11:45:25 +00:00
|
|
|
elevated: {
|
2026-02-15 03:46:11 +01:00
|
|
|
allowFromFallback: ({ cfg }) =>
|
|
|
|
|
cfg.channels?.discord?.allowFrom ?? cfg.channels?.discord?.dm?.allowFrom,
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
config: {
|
2026-02-15 03:46:11 +01:00
|
|
|
resolveAllowFrom: ({ cfg, accountId }) => {
|
2026-03-05 23:07:13 -06:00
|
|
|
const account = inspectDiscordAccount({ cfg, accountId });
|
2026-02-15 03:46:11 +01:00
|
|
|
return (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map((entry) =>
|
2026-01-14 14:31:43 +00:00
|
|
|
String(entry),
|
2026-02-15 03:46:11 +01:00
|
|
|
);
|
|
|
|
|
},
|
2026-02-17 08:45:12 -05:00
|
|
|
formatAllowFrom: ({ allowFrom }) => formatDiscordAllowFrom(allowFrom),
|
2026-02-19 23:37:19 -05:00
|
|
|
resolveDefaultTo: ({ cfg, accountId }) =>
|
2026-03-05 23:07:13 -06:00
|
|
|
inspectDiscordAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
groups: {
|
|
|
|
|
resolveRequireMention: resolveDiscordGroupRequireMention,
|
2026-01-24 15:35:05 +13:00
|
|
|
resolveToolPolicy: resolveDiscordGroupToolPolicy,
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
mentions: {
|
|
|
|
|
stripPatterns: () => ["<@!?\\d+>"],
|
|
|
|
|
},
|
|
|
|
|
threading: {
|
2026-01-14 14:31:43 +00:00
|
|
|
resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off",
|
2026-01-13 01:03:23 +00:00
|
|
|
buildToolContext: ({ context, hasRepliedRef }) => ({
|
|
|
|
|
currentChannelId: context.To?.trim() || undefined,
|
|
|
|
|
currentThreadTs: context.ReplyToId,
|
|
|
|
|
hasRepliedRef,
|
|
|
|
|
}),
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
},
|
2026-02-10 15:33:57 -08:00
|
|
|
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;
|
2026-02-18 19:04:57 +00:00
|
|
|
const account = resolveCaseInsensitiveAccount(channel?.accounts, accountId);
|
2026-02-10 15:33:57 -08:00
|
|
|
return (account?.allowFrom ?? channel?.allowFrom ?? []).map((entry) => String(entry));
|
|
|
|
|
},
|
|
|
|
|
formatAllowFrom: ({ allowFrom }) =>
|
2026-02-22 14:05:46 +00:00
|
|
|
formatAllowFromWithReplacements(allowFrom, [/^irc:/i, /^user:/i]),
|
|
|
|
|
resolveDefaultTo: ({ cfg, accountId }) =>
|
|
|
|
|
resolveNamedChannelDefaultTo({
|
|
|
|
|
channels: cfg.channels as CaseInsensitiveDefaultToChannels | undefined,
|
|
|
|
|
channelId: "irc",
|
|
|
|
|
accountId,
|
|
|
|
|
}),
|
2026-02-10 15:33:57 -08:00
|
|
|
},
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-01-23 16:45:37 -06:00
|
|
|
googlechat: {
|
|
|
|
|
id: "googlechat",
|
|
|
|
|
capabilities: {
|
|
|
|
|
chatTypes: ["direct", "group", "thread"],
|
|
|
|
|
reactions: true,
|
|
|
|
|
media: true,
|
|
|
|
|
threads: true,
|
|
|
|
|
blockStreaming: true,
|
|
|
|
|
},
|
2026-02-22 14:05:46 +00:00
|
|
|
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
2026-01-23 16:45:37 -06:00
|
|
|
config: {
|
|
|
|
|
resolveAllowFrom: ({ cfg, accountId }) => {
|
|
|
|
|
const channel = cfg.channels?.googlechat as
|
|
|
|
|
| {
|
|
|
|
|
accounts?: Record<string, { dm?: { allowFrom?: Array<string | number> } }>;
|
|
|
|
|
dm?: { allowFrom?: Array<string | number> };
|
|
|
|
|
}
|
|
|
|
|
| undefined;
|
2026-02-18 19:04:57 +00:00
|
|
|
const account = resolveCaseInsensitiveAccount(channel?.accounts, accountId);
|
2026-01-23 16:45:37 -06:00
|
|
|
return (account?.dm?.allowFrom ?? channel?.dm?.allowFrom ?? []).map((entry) =>
|
|
|
|
|
String(entry),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
formatAllowFrom: ({ allowFrom }) =>
|
2026-02-22 14:05:46 +00:00
|
|
|
formatAllowFromWithReplacements(allowFrom, [
|
|
|
|
|
/^(googlechat|google-chat|gchat):/i,
|
|
|
|
|
/^user:/i,
|
|
|
|
|
/^users\//i,
|
|
|
|
|
]),
|
|
|
|
|
resolveDefaultTo: ({ cfg, accountId }) =>
|
|
|
|
|
resolveNamedChannelDefaultTo({
|
|
|
|
|
channels: cfg.channels as CaseInsensitiveDefaultToChannels | undefined,
|
|
|
|
|
channelId: "googlechat",
|
|
|
|
|
accountId,
|
|
|
|
|
}),
|
2026-01-23 16:45:37 -06:00
|
|
|
},
|
|
|
|
|
groups: {
|
|
|
|
|
resolveRequireMention: resolveGoogleChatGroupRequireMention,
|
|
|
|
|
resolveToolPolicy: resolveGoogleChatGroupToolPolicy,
|
|
|
|
|
},
|
|
|
|
|
threading: {
|
|
|
|
|
resolveReplyToMode: ({ cfg }) => cfg.channels?.googlechat?.replyToMode ?? "off",
|
2026-02-22 07:37:54 +00:00
|
|
|
buildToolContext: ({ context, hasRepliedRef }) =>
|
|
|
|
|
buildThreadToolContextFromMessageThreadOrReply({ context, hasRepliedRef }),
|
2026-01-23 16:45:37 -06:00
|
|
|
},
|
|
|
|
|
},
|
2026-01-11 11:45:25 +00:00
|
|
|
slack: {
|
|
|
|
|
id: "slack",
|
|
|
|
|
capabilities: {
|
|
|
|
|
chatTypes: ["direct", "channel", "thread"],
|
|
|
|
|
reactions: true,
|
|
|
|
|
media: true,
|
|
|
|
|
nativeCommands: true,
|
|
|
|
|
threads: true,
|
|
|
|
|
},
|
2026-02-22 14:05:46 +00:00
|
|
|
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
|
|
|
|
streaming: DEFAULT_BLOCK_STREAMING_COALESCE,
|
2026-01-11 11:45:25 +00:00
|
|
|
config: {
|
2026-02-15 03:46:11 +01:00
|
|
|
resolveAllowFrom: ({ cfg, accountId }) => {
|
2026-03-05 23:07:13 -06:00
|
|
|
const account = inspectSlackAccount({ cfg, accountId });
|
2026-02-15 03:46:11 +01:00
|
|
|
return (account.config.allowFrom ?? account.dm?.allowFrom ?? []).map((entry) =>
|
|
|
|
|
String(entry),
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-01-11 11:45:25 +00:00
|
|
|
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
|
2026-02-19 23:37:19 -05:00
|
|
|
resolveDefaultTo: ({ cfg, accountId }) =>
|
2026-03-05 23:07:13 -06:00
|
|
|
inspectSlackAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
groups: {
|
|
|
|
|
resolveRequireMention: resolveSlackGroupRequireMention,
|
2026-01-24 15:35:05 +13:00
|
|
|
resolveToolPolicy: resolveSlackGroupToolPolicy,
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
2026-02-05 18:29:07 -06:00
|
|
|
mentions: {
|
|
|
|
|
stripPatterns: () => ["<@[^>]+>"],
|
|
|
|
|
},
|
2026-01-11 11:45:25 +00:00
|
|
|
threading: {
|
2026-01-23 07:13:23 +02:00
|
|
|
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
2026-03-05 23:07:13 -06:00
|
|
|
resolveSlackReplyToMode(inspectSlackAccount({ cfg, accountId }), chatType),
|
fix(slack): finalize replyToMode off threading behavior (#23799)
* fix: make replyToMode 'off' actually prevent threading in Slack
Three independent bugs caused Slack replies to always create threads
even when replyToMode was set to 'off':
1. Typing indicator created threads via statusThreadTs fallback (#16868)
- resolveSlackThreadTargets fell back to messageTs for statusThreadTs
- 'is typing...' was posted as thread reply, creating a thread
- Fix: remove messageTs fallback, let statusThreadTs be undefined
2. [[reply_to_current]] tags bypassed replyToMode entirely (#16080)
- Slack dock had allowExplicitReplyTagsWhenOff: true
- Reply tags from system prompt always threaded regardless of config
- Fix: set allowExplicitReplyTagsWhenOff to false for Slack
3. Contradictory replyToMode defaults in codebase (#20827)
- monitor/provider.ts defaulted to 'all'
- accounts.ts defaulted to 'off' (matching docs)
- Fix: align provider.ts default to 'off' per documentation
Fixes: openclaw/openclaw#16868, openclaw/openclaw#16080, openclaw/openclaw#20827
* fix(slack): respect replyToMode in DMs even with typing indicator thread
When replyToMode is 'off' in DMs, replies should stay in the main
conversation even when the typing indicator creates a thread context.
Previously, when incomingThreadTs was set (from the typing indicator's
thread), replyToMode was forced to 'all', causing all replies to go
into the thread.
Now, for direct messages, the user's configured replyToMode is always
respected. For channels/groups, the existing behavior is preserved
(stay in thread if already in one).
This fix:
- Keeps the typing indicator working (statusThreadTs fallback preserved)
- Prevents DM replies from being forced into threads
- Maintains channel thread continuity
Fixes #16868
* refactor(slack): eliminate redundant resolveSlackThreadContext call
- Add isThreadReply to resolveSlackThreadTargets return value
- Remove duplicate call in dispatch.ts
- Addresses greptile review feedback with cleaner DRY approach
* docs(slack): add JSDoc to resolveSlackThreadTargets
Document return values including isThreadReply distinction between
genuine user thread replies vs bot status message thread context.
* docs(changelog): record Slack replyToMode off threading fixes
---------
Co-authored-by: James <jamesrp13@gmail.com>
Co-authored-by: theoseo <suhong.seo@gmail.com>
2026-02-22 13:27:50 -05:00
|
|
|
allowExplicitReplyTagsWhenOff: false,
|
2026-01-21 20:01:12 +00:00
|
|
|
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
signal: {
|
|
|
|
|
id: "signal",
|
|
|
|
|
capabilities: {
|
|
|
|
|
chatTypes: ["direct", "group"],
|
|
|
|
|
reactions: true,
|
|
|
|
|
media: true,
|
|
|
|
|
},
|
2026-02-22 14:05:46 +00:00
|
|
|
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
|
|
|
|
streaming: DEFAULT_BLOCK_STREAMING_COALESCE,
|
2026-01-11 11:45:25 +00:00
|
|
|
config: {
|
|
|
|
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
2026-02-22 07:37:54 +00:00
|
|
|
stringifyAllowFrom(resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []),
|
2026-01-11 11:45:25 +00:00
|
|
|
formatAllowFrom: ({ allowFrom }) =>
|
2026-02-22 07:37:54 +00:00
|
|
|
trimAllowFromEntries(allowFrom)
|
2026-01-14 14:31:43 +00:00
|
|
|
.map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, ""))))
|
2026-01-11 11:45:25 +00:00
|
|
|
.filter(Boolean),
|
2026-02-19 23:37:19 -05:00
|
|
|
resolveDefaultTo: ({ cfg, accountId }) =>
|
|
|
|
|
resolveSignalAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
2026-01-13 01:03:23 +00:00
|
|
|
threading: {
|
2026-02-15 18:30:34 +00:00
|
|
|
buildToolContext: ({ context, hasRepliedRef }) =>
|
2026-02-17 14:17:22 -08:00
|
|
|
buildSignalThreadToolContext({ context, hasRepliedRef }),
|
2026-01-13 01:03:23 +00:00
|
|
|
},
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
imessage: {
|
|
|
|
|
id: "imessage",
|
|
|
|
|
capabilities: {
|
|
|
|
|
chatTypes: ["direct", "group"],
|
|
|
|
|
reactions: true,
|
|
|
|
|
media: true,
|
|
|
|
|
},
|
2026-02-22 14:05:46 +00:00
|
|
|
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
2026-01-11 11:45:25 +00:00
|
|
|
config: {
|
2026-03-02 08:53:11 +00:00
|
|
|
resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }),
|
|
|
|
|
formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom),
|
|
|
|
|
resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }),
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
groups: {
|
|
|
|
|
resolveRequireMention: resolveIMessageGroupRequireMention,
|
2026-01-24 15:35:05 +13:00
|
|
|
resolveToolPolicy: resolveIMessageGroupToolPolicy,
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
2026-01-13 01:03:23 +00:00
|
|
|
threading: {
|
2026-02-15 18:30:34 +00:00
|
|
|
buildToolContext: ({ context, hasRepliedRef }) =>
|
2026-02-17 14:17:22 -08:00
|
|
|
buildIMessageThreadToolContext({ context, hasRepliedRef }),
|
2026-01-13 01:03:23 +00:00
|
|
|
},
|
2026-01-11 11:45:25 +00:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-15 02:42:41 +00:00
|
|
|
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,
|
2026-02-19 23:37:19 -05:00
|
|
|
resolveDefaultTo: plugin.config.resolveDefaultTo,
|
2026-01-15 02:42:41 +00:00
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
groups: plugin.groups,
|
|
|
|
|
mentions: plugin.mentions,
|
|
|
|
|
threading: plugin.threading,
|
2026-01-22 03:27:26 +00:00
|
|
|
agentPrompt: plugin.agentPrompt,
|
2026-01-15 02:42:41 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function listPluginDockEntries(): Array<{ id: ChannelId; dock: ChannelDock; order?: number }> {
|
2026-01-18 11:00:19 +00:00
|
|
|
const registry = requireActivePluginRegistry();
|
2026-01-15 02:42:41 +00:00
|
|
|
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();
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!id || seen.has(id)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-01-15 02:42:41 +00:00
|
|
|
seen.add(id);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (CHAT_CHANNEL_ORDER.includes(plugin.id as ChatChannelId)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-01-15 02:42:41 +00:00
|
|
|
const dock = entry.dock ?? buildDockFromPlugin(plugin);
|
|
|
|
|
entries.push({ id: plugin.id, dock, order: plugin.meta.order });
|
|
|
|
|
}
|
|
|
|
|
return entries;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 06:16:43 +00:00
|
|
|
export function listChannelDocks(): ChannelDock[] {
|
2026-01-15 02:42:41 +00:00
|
|
|
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);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (orderA !== orderB) {
|
|
|
|
|
return orderA - orderB;
|
|
|
|
|
}
|
2026-01-15 02:42:41 +00:00
|
|
|
return String(a.id).localeCompare(String(b.id));
|
|
|
|
|
});
|
|
|
|
|
return combined.map((entry) => entry.dock);
|
2026-01-11 11:45:25 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 06:16:43 +00:00
|
|
|
export function getChannelDock(id: ChannelId): ChannelDock | undefined {
|
2026-01-15 02:42:41 +00:00
|
|
|
const core = DOCKS[id as ChatChannelId];
|
2026-01-31 16:19:20 +09:00
|
|
|
if (core) {
|
|
|
|
|
return core;
|
|
|
|
|
}
|
2026-01-18 11:00:19 +00:00
|
|
|
const registry = requireActivePluginRegistry();
|
|
|
|
|
const pluginEntry = registry.channels.find((entry) => entry.plugin.id === id);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!pluginEntry) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
2026-01-15 02:42:41 +00:00
|
|
|
return pluginEntry.dock ?? buildDockFromPlugin(pluginEntry.plugin);
|
2026-01-11 11:45:25 +00:00
|
|
|
}
|