refactor(channels): route core through registered plugin capabilities

This commit is contained in:
Peter Steinberger 2026-03-30 01:00:20 +01:00
parent 471e059b69
commit 63cbc097b5
No known key found for this signature in database
38 changed files with 1014 additions and 429 deletions

View File

@ -102,6 +102,11 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
},
conversationBindings: {
supportsCurrentConversationBinding: true,
createManager: ({ cfg, accountId }) =>
createBlueBubblesConversationBindingManager({
cfg,
accountId: accountId ?? undefined,
}),
},
actions: bluebubblesMessageActions,
bindings: {

View File

@ -112,8 +112,16 @@ function createChannelRuntime(
): OpenClawPluginApi["runtime"] {
return {
channel: {
[runtimeKey]: {
[sendKey]: sendMessage,
outbound: {
loadAdapter: async (channelId: string) =>
channelId === runtimeKey
? ({
sendText: async ({ to, text, ...opts }: Record<string, unknown>) =>
await sendMessage(to, text, opts),
sendMedia: async ({ to, text, ...opts }: Record<string, unknown>) =>
await sendMessage(to, text, opts),
} as const)
: undefined,
},
},
} as unknown as OpenClawPluginApi["runtime"];
@ -210,7 +218,7 @@ describe("device-pair /pair qr", () => {
expectedTarget: "123",
expectedOpts: {
accountId: "default",
messageThreadId: 271,
threadId: 271,
},
},
{
@ -240,7 +248,7 @@ describe("device-pair /pair qr", () => {
expectedTarget: "user:U123",
expectedOpts: {
accountId: "default",
threadTs: "1234567890.000001",
threadId: "1234567890.000001",
},
},
{

View File

@ -75,7 +75,6 @@ type QrCommandContext = {
};
type QrChannelSender = {
resolveSend: (api: OpenClawPluginApi) => QrSendFn | undefined;
createOpts: (params: {
ctx: QrCommandContext;
qrFilePath: string;
@ -84,24 +83,16 @@ type QrChannelSender = {
}) => Record<string, unknown>;
};
type QrSendFn = (to: string, text: string, opts: Record<string, unknown>) => Promise<unknown>;
function coerceQrSend(send: unknown): QrSendFn | undefined {
return typeof send === "function" ? (send as QrSendFn) : undefined;
}
const QR_CHANNEL_SENDERS: Record<string, QrChannelSender> = {
telegram: {
resolveSend: (api) => coerceQrSend(api.runtime?.channel?.telegram?.sendMessageTelegram),
createOpts: ({ ctx, qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
...(typeof ctx.messageThreadId === "number" ? { messageThreadId: ctx.messageThreadId } : {}),
...(ctx.messageThreadId != null ? { threadId: ctx.messageThreadId } : {}),
...(accountId ? { accountId } : {}),
}),
},
discord: {
resolveSend: (api) => coerceQrSend(api.runtime?.channel?.discord?.sendMessageDiscord),
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
@ -109,16 +100,14 @@ const QR_CHANNEL_SENDERS: Record<string, QrChannelSender> = {
}),
},
slack: {
resolveSend: (api) => coerceQrSend(api.runtime?.channel?.slack?.sendMessageSlack),
createOpts: ({ ctx, qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
...(ctx.messageThreadId != null ? { threadTs: String(ctx.messageThreadId) } : {}),
...(ctx.messageThreadId != null ? { threadId: String(ctx.messageThreadId) } : {}),
...(accountId ? { accountId } : {}),
}),
},
signal: {
resolveSend: (api) => coerceQrSend(api.runtime?.channel?.signal?.sendMessageSignal),
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
@ -126,7 +115,6 @@ const QR_CHANNEL_SENDERS: Record<string, QrChannelSender> = {
}),
},
imessage: {
resolveSend: (api) => coerceQrSend(api.runtime?.channel?.imessage?.sendMessageIMessage),
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
@ -134,7 +122,6 @@ const QR_CHANNEL_SENDERS: Record<string, QrChannelSender> = {
}),
},
whatsapp: {
resolveSend: (api) => coerceQrSend(api.runtime?.channel?.whatsapp?.sendMessageWhatsApp),
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
verbose: false,
mediaUrl: qrFilePath,
@ -518,20 +505,22 @@ async function sendQrPngToSupportedChannel(params: {
if (!sender) {
return false;
}
const send = sender.resolveSend(params.api);
const adapter = await params.api.runtime.channel.outbound.loadAdapter(params.ctx.channel);
const send = adapter?.sendMedia;
if (!send) {
return false;
}
await send(
params.target,
params.caption,
sender.createOpts({
await send({
cfg: params.api.config,
to: params.target,
text: params.caption,
...sender.createOpts({
ctx: params.ctx,
qrFilePath: params.qrFilePath,
mediaLocalRoots,
accountId,
}),
);
});
return true;
}
@ -769,7 +758,8 @@ export default definePluginEntry({
channelKeys.join(",") || "none"
}`,
);
const send = api.runtime?.channel?.telegram?.sendMessageTelegram;
const adapter = await api.runtime.channel.outbound.loadAdapter("telegram");
const send = adapter?.sendText;
if (!send) {
throw new Error(
`telegram runtime unavailable (runtime keys: ${runtimeKeys.join(",")}; channel keys: ${channelKeys.join(
@ -777,10 +767,11 @@ export default definePluginEntry({
)})`,
);
}
await send(target, formatSetupInstructions(payload.expiresAtMs), {
...(typeof ctx.messageThreadId === "number"
? { messageThreadId: ctx.messageThreadId }
: {}),
await send({
cfg: api.config,
to: target,
text: formatSetupInstructions(payload.expiresAtMs),
...(ctx.messageThreadId != null ? { threadId: ctx.messageThreadId } : {}),
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
});
api.logger.info?.(

View File

@ -254,17 +254,23 @@ async function notifySubscriber(params: {
subscriber: NotifySubscription;
text: string;
}): Promise<boolean> {
const send = params.api.runtime?.channel?.telegram?.sendMessageTelegram;
const adapter = await params.api.runtime.channel.outbound.loadAdapter("telegram");
const send = adapter?.sendText;
if (!send) {
params.api.logger.warn("device-pair: telegram runtime unavailable for pairing notifications");
params.api.logger.warn(
"device-pair: telegram outbound adapter unavailable for pairing notifications",
);
return false;
}
try {
await send(params.subscriber.to, params.text, {
await send({
cfg: params.api.config,
to: params.subscriber.to,
text: params.text,
...(params.subscriber.accountId ? { accountId: params.subscriber.accountId } : {}),
...(typeof params.subscriber.messageThreadId === "number"
? { messageThreadId: params.subscriber.messageThreadId }
...(params.subscriber.messageThreadId != null
? { threadId: params.subscriber.messageThreadId }
: {}),
});
return true;

View File

@ -3,12 +3,10 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes } from "../runtim
import type { ResolvedIMessageAccount } from "./accounts.js";
import { monitorIMessageProvider } from "./monitor.js";
import { probeIMessage } from "./probe.js";
import { getIMessageRuntime } from "./runtime.js";
import { sendMessageIMessage } from "./send.js";
import { imessageSetupWizard } from "./setup-surface.js";
type IMessageSendFn = ReturnType<
typeof getIMessageRuntime
>["channel"]["imessage"]["sendMessageIMessage"];
type IMessageSendFn = typeof sendMessageIMessage;
export async function sendIMessageOutbound(params: {
cfg: Parameters<typeof import("./accounts.js").resolveIMessageAccount>[0]["cfg"];
@ -21,8 +19,7 @@ export async function sendIMessageOutbound(params: {
replyToId?: string;
}) {
const send =
resolveOutboundSendDep<IMessageSendFn>(params.deps, "imessage") ??
getIMessageRuntime().channel.imessage.sendMessageIMessage;
resolveOutboundSendDep<IMessageSendFn>(params.deps, "imessage") ?? sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
@ -41,7 +38,7 @@ export async function sendIMessageOutbound(params: {
}
export async function notifyIMessageApproval(id: string): Promise<void> {
await getIMessageRuntime().channel.imessage.sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE);
await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE);
}
export async function probeIMessageAccount(timeoutMs?: number) {

View File

@ -136,6 +136,11 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
},
conversationBindings: {
supportsCurrentConversationBinding: true,
createManager: ({ cfg, accountId }) =>
createIMessageConversationBindingManager({
cfg,
accountId: accountId ?? undefined,
}),
},
bindings: {
compileConfiguredBinding: ({ conversationId }) =>

View File

@ -0,0 +1,100 @@
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
const SERVICE_PREFIXES = ["imessage:", "sms:", "auto:"] as const;
const CHAT_TARGET_PREFIX_RE =
/^(chat_id:|chatid:|chat:|chat_guid:|chatguid:|guid:|chat_identifier:|chatidentifier:|chatident:)/i;
function trimMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
return trimmed || undefined;
}
function looksLikeHandleOrPhoneTarget(params: {
raw: string;
prefixPattern: RegExp;
phonePattern?: RegExp;
}): boolean {
const trimmed = params.raw.trim();
if (!trimmed) {
return false;
}
if (params.prefixPattern.test(trimmed)) {
return true;
}
if (trimmed.includes("@")) {
return true;
}
return (params.phonePattern ?? /^\+?\d{3,}$/).test(trimmed);
}
export function normalizeIMessageHandle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("imessage:")) {
return normalizeIMessageHandle(trimmed.slice("imessage:".length));
}
if (lowered.startsWith("sms:")) {
return normalizeIMessageHandle(trimmed.slice("sms:".length));
}
if (lowered.startsWith("auto:")) {
return normalizeIMessageHandle(trimmed.slice("auto:".length));
}
if (CHAT_TARGET_PREFIX_RE.test(trimmed)) {
const prefix = trimmed.match(CHAT_TARGET_PREFIX_RE)?.[0];
if (!prefix) {
return "";
}
const value = trimmed.slice(prefix.length).trim();
return `${prefix.toLowerCase()}${value}`;
}
if (trimmed.includes("@")) {
return trimmed.toLowerCase();
}
const normalized = normalizeE164(trimmed);
if (normalized) {
return normalized;
}
return trimmed.replace(/\s+/g, "");
}
export function normalizeIMessageMessagingTarget(raw: string): string | undefined {
const trimmed = trimMessagingTarget(raw);
if (!trimmed) {
return undefined;
}
const lower = trimmed.toLowerCase();
for (const prefix of SERVICE_PREFIXES) {
if (lower.startsWith(prefix)) {
const remainder = trimmed.slice(prefix.length).trim();
const normalizedHandle = normalizeIMessageHandle(remainder);
if (!normalizedHandle) {
return undefined;
}
if (CHAT_TARGET_PREFIX_RE.test(normalizedHandle)) {
return normalizedHandle;
}
return `${prefix}${normalizedHandle}`;
}
}
const normalized = normalizeIMessageHandle(trimmed);
return normalized || undefined;
}
export function looksLikeIMessageTargetId(raw: string): boolean {
const trimmed = trimMessagingTarget(raw);
if (!trimmed) {
return false;
}
if (CHAT_TARGET_PREFIX_RE.test(trimmed)) {
return true;
}
return looksLikeHandleOrPhoneTarget({
raw: trimmed,
prefixPattern: /^(imessage:|sms:|auto:)/i,
});
}

View File

@ -54,6 +54,10 @@ import {
resolveMatrixDirectUserId,
resolveMatrixTargetIdentity,
} from "./matrix/target-ids.js";
import {
setMatrixThreadBindingIdleTimeoutBySessionKey,
setMatrixThreadBindingMaxAgeBySessionKey,
} from "./matrix/thread-bindings-shared.js";
import { getMatrixRuntime } from "./runtime.js";
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
import { matrixSetupAdapter } from "./setup-core.js";
@ -310,6 +314,46 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
},
conversationBindings: {
supportsCurrentConversationBinding: true,
setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) =>
setMatrixThreadBindingIdleTimeoutBySessionKey({
targetSessionKey,
accountId: accountId ?? "",
idleTimeoutMs,
}).map((binding) => ({
boundAt: binding.boundAt,
lastActivityAt:
typeof binding.metadata?.lastActivityAt === "number"
? binding.metadata.lastActivityAt
: binding.boundAt,
idleTimeoutMs:
typeof binding.metadata?.idleTimeoutMs === "number"
? binding.metadata.idleTimeoutMs
: undefined,
maxAgeMs:
typeof binding.metadata?.maxAgeMs === "number"
? binding.metadata.maxAgeMs
: undefined,
})),
setMaxAgeBySessionKey: ({ targetSessionKey, accountId, maxAgeMs }) =>
setMatrixThreadBindingMaxAgeBySessionKey({
targetSessionKey,
accountId: accountId ?? "",
maxAgeMs,
}).map((binding) => ({
boundAt: binding.boundAt,
lastActivityAt:
typeof binding.metadata?.lastActivityAt === "number"
? binding.metadata.lastActivityAt
: binding.boundAt,
idleTimeoutMs:
typeof binding.metadata?.idleTimeoutMs === "number"
? binding.metadata.idleTimeoutMs
: undefined,
maxAgeMs:
typeof binding.metadata?.maxAgeMs === "number"
? binding.metadata.maxAgeMs
: undefined,
})),
},
messaging: {
normalizeTarget: normalizeMatrixMessagingTarget,

View File

@ -16,6 +16,7 @@ import { signalMessageActions } from "./message-actions.js";
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js";
import { resolveSignalOutboundTarget } from "./outbound-session.js";
import { probeSignal, type SignalProbe } from "./probe.js";
import { resolveSignalReactionLevel } from "./reaction-level.js";
import {
buildBaseChannelStatusSummary,
chunkText,
@ -237,6 +238,15 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
resolveDmPolicy: (account) => account.config.dmPolicy,
resolveGroupPolicy: (account) => account.config.groupPolicy,
}),
agentPrompt: {
reactionGuidance: ({ cfg, accountId }) => {
const level = resolveSignalReactionLevel({
cfg,
accountId: accountId ?? undefined,
}).agentReactionGuidance;
return level ? { level, channelLabel: "Signal" } : undefined;
},
},
messaging: {
normalizeTarget: normalizeSignalMessagingTarget,
parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw),

View File

@ -40,8 +40,11 @@ import {
type ResolvedTelegramAccount,
} from "./accounts.js";
import { resolveTelegramAutoThreadId } from "./action-threading.js";
import { lookupTelegramChatId } from "./api-fetch.js";
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
import * as auditModule from "./audit.js";
import { buildTelegramGroupPeerId } from "./bot/helpers.js";
import { telegramMessageActions as telegramMessageActionsImpl } from "./channel-actions.js";
import {
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,
@ -58,14 +61,16 @@ import {
resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy,
} from "./group-policy.js";
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
import * as monitorModule from "./monitor.js";
import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js";
import { sendTelegramPayloadMessages } from "./outbound-adapter.js";
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
import * as probeModule from "./probe.js";
import type { TelegramProbe } from "./probe.js";
import { resolveTelegramReactionLevel } from "./reaction-level.js";
import { getTelegramRuntime } from "./runtime.js";
import { sendTypingTelegram } from "./send.js";
import { sendMessageTelegram, sendPollTelegram, sendTypingTelegram } from "./send.js";
import { telegramSetupAdapter } from "./setup-core.js";
import { telegramSetupWizard } from "./setup-surface.js";
import {
@ -76,54 +81,31 @@ import {
} from "./shared.js";
import { collectTelegramStatusIssues } from "./status-issues.js";
import { parseTelegramTarget } from "./targets.js";
import {
createTelegramThreadBindingManager,
setTelegramThreadBindingIdleTimeoutBySessionKey,
setTelegramThreadBindingMaxAgeBySessionKey,
} from "./thread-bindings.js";
import { resolveTelegramToken } from "./token.js";
type TelegramSendFn = ReturnType<
typeof getTelegramRuntime
>["channel"]["telegram"]["sendMessageTelegram"];
type TelegramSendFn = typeof sendMessageTelegram;
type TelegramSendOptions = NonNullable<Parameters<TelegramSendFn>[2]>;
type TelegramStatusRuntimeHelpers = {
probeTelegram?: typeof probeModule.probeTelegram;
collectTelegramUnmentionedGroupIds?: typeof auditModule.collectTelegramUnmentionedGroupIds;
auditTelegramGroupMembership?: typeof auditModule.auditTelegramGroupMembership;
monitorTelegramProvider?: typeof monitorModule.monitorTelegramProvider;
};
function getTelegramStatusRuntimeHelpers(): TelegramStatusRuntimeHelpers {
try {
return (getTelegramRuntime().channel?.telegram ?? {}) as TelegramStatusRuntimeHelpers;
} catch (error) {
if (error instanceof Error && error.message === "Telegram runtime not initialized") {
return {};
}
throw error;
}
}
function resolveTelegramProbe() {
return getTelegramStatusRuntimeHelpers().probeTelegram ?? probeModule.probeTelegram;
return probeModule.probeTelegram;
}
function resolveTelegramAuditCollector() {
return (
getTelegramStatusRuntimeHelpers().collectTelegramUnmentionedGroupIds ??
auditModule.collectTelegramUnmentionedGroupIds
);
return auditModule.collectTelegramUnmentionedGroupIds;
}
function resolveTelegramAuditMembership() {
return (
getTelegramStatusRuntimeHelpers().auditTelegramGroupMembership ??
auditModule.auditTelegramGroupMembership
);
return auditModule.auditTelegramGroupMembership;
}
function resolveTelegramMonitor() {
return (
getTelegramStatusRuntimeHelpers().monitorTelegramProvider ??
monitorModule.monitorTelegramProvider
);
return monitorModule.monitorTelegramProvider;
}
function buildTelegramSendOptions(params: {
@ -167,8 +149,7 @@ async function sendTelegramOutbound(params: {
gatewayClientScopes?: readonly string[] | null;
}) {
const send =
resolveOutboundSendDep<TelegramSendFn>(params.deps, "telegram") ??
getTelegramRuntime().channel.telegram.sendMessageTelegram;
resolveOutboundSendDep<TelegramSendFn>(params.deps, "telegram") ?? sendMessageTelegram;
return await send(
params.to,
params.text,
@ -321,6 +302,72 @@ function resolveTelegramOutboundSessionRoute(params: {
};
}
async function resolveTelegramTargets(params: {
cfg: OpenClawConfig;
accountId?: string | null;
inputs: string[];
kind: "user" | "group";
}) {
if (params.kind !== "user") {
return params.inputs.map((input) => ({
input,
resolved: false as const,
note: "Telegram runtime target resolution only supports usernames for direct-message lookups.",
}));
}
const account = resolveTelegramAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const token = account.token.trim();
if (!token) {
return params.inputs.map((input) => ({
input,
resolved: false as const,
note: "Telegram bot token is required to resolve @username targets.",
}));
}
return await Promise.all(
params.inputs.map(async (input) => {
const trimmed = input.trim();
if (!trimmed) {
return {
input,
resolved: false as const,
note: "Telegram target is required.",
};
}
const normalized = trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
try {
const id = await lookupTelegramChatId({
token,
chatId: normalized,
network: account.config.network,
});
if (!id) {
return {
input,
resolved: false as const,
note: "Telegram username could not be resolved by the configured bot.",
};
}
return {
input,
resolved: true as const,
id,
name: normalized,
};
} catch (error) {
return {
input,
resolved: false as const,
note: error instanceof Error ? error.message : String(error),
};
}
}),
);
}
const telegramNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({
channel: "telegram",
channelLabel: "Telegram",
@ -341,16 +388,13 @@ const telegramNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdap
});
const telegramMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: (ctx) =>
getTelegramRuntime().channel.telegram.messageActions?.describeMessageTool?.(ctx) ?? null,
extractToolSend: (ctx) =>
getTelegramRuntime().channel.telegram.messageActions?.extractToolSend?.(ctx) ?? null,
describeMessageTool: (ctx) => telegramMessageActionsImpl.describeMessageTool?.(ctx) ?? null,
extractToolSend: (ctx) => telegramMessageActionsImpl.extractToolSend?.(ctx) ?? null,
handleAction: async (ctx) => {
const ma = getTelegramRuntime().channel.telegram.messageActions;
if (!ma?.handleAction) {
if (!telegramMessageActionsImpl.handleAction) {
throw new Error("Telegram message actions not available");
}
return ma.handleAction(ctx);
return await telegramMessageActionsImpl.handleAction(ctx);
},
};
@ -418,10 +462,47 @@ export const telegramPlugin = createChatChannelPlugin({
fallbackTo,
}),
},
conversationBindings: {
supportsCurrentConversationBinding: true,
createManager: ({ accountId }) =>
createTelegramThreadBindingManager({
accountId: accountId ?? undefined,
persist: false,
enableSweeper: false,
}),
setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) =>
setTelegramThreadBindingIdleTimeoutBySessionKey({
targetSessionKey,
accountId: accountId ?? undefined,
idleTimeoutMs,
}),
setMaxAgeBySessionKey: ({ targetSessionKey, accountId, maxAgeMs }) =>
setTelegramThreadBindingMaxAgeBySessionKey({
targetSessionKey,
accountId: accountId ?? undefined,
maxAgeMs,
}),
},
groups: {
resolveRequireMention: resolveTelegramGroupRequireMention,
resolveToolPolicy: resolveTelegramGroupToolPolicy,
},
agentPrompt: {
messageToolCapabilities: ({ cfg, accountId }) => {
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg,
accountId: accountId ?? undefined,
});
return inlineButtonsScope === "off" ? [] : ["inlineButtons"];
},
reactionGuidance: ({ cfg, accountId }) => {
const level = resolveTelegramReactionLevel({
cfg,
accountId: accountId ?? undefined,
}).agentReactionGuidance;
return level ? { level, channelLabel: "Telegram" } : undefined;
},
},
messaging: {
normalizeTarget: normalizeTelegramMessagingTarget,
parseExplicitTarget: ({ raw }) => parseTelegramExplicitTarget(raw),
@ -450,6 +531,10 @@ export const telegramPlugin = createChatChannelPlugin({
hint: "<chatId>",
},
},
resolver: {
resolveTargets: async ({ cfg, accountId, inputs, kind }) =>
await resolveTelegramTargets({ cfg, accountId, inputs, kind }),
},
lifecycle: {
onAccountConfigChanged: async ({ prevCfg, nextCfg, accountId }) => {
const previousToken = resolveTelegramAccount({ cfg: prevCfg, accountId }).token.trim();
@ -685,16 +770,11 @@ export const telegramPlugin = createChatChannelPlugin({
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^(telegram|tg):/i),
notify: async ({ cfg, id, message, accountId }) => {
const { token } = getTelegramRuntime().channel.telegram.resolveTelegramToken(cfg, {
accountId,
});
const { token } = resolveTelegramToken(cfg, { accountId });
if (!token) {
throw new Error("telegram token not configured");
}
await getTelegramRuntime().channel.telegram.sendMessageTelegram(id, message, {
token,
accountId,
});
await sendMessageTelegram(id, message, { token, accountId });
},
},
},
@ -759,8 +839,7 @@ export const telegramPlugin = createChatChannelPlugin({
gatewayClientScopes,
}) => {
const send =
resolveOutboundSendDep<TelegramSendFn>(deps, "telegram") ??
getTelegramRuntime().channel.telegram.sendMessageTelegram;
resolveOutboundSendDep<TelegramSendFn>(deps, "telegram") ?? sendMessageTelegram;
const result = await sendTelegramPayloadMessages({
send,
to,
@ -839,7 +918,7 @@ export const telegramPlugin = createChatChannelPlugin({
isAnonymous,
gatewayClientScopes,
}) =>
await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, {
await sendPollTelegram(to, poll, {
cfg,
accountId: accountId ?? undefined,
messageThreadId: parseTelegramThreadId(threadId),

View File

@ -1,5 +1,5 @@
import { sendMessageTelegram } from "../../extensions/telegram/runtime-api.js";
import { loadConfig } from "../../src/config/config.js";
import { sendMessageTelegram } from "../../src/plugin-sdk/telegram-runtime.js";
import { matchPluginCommand, executePluginCommand } from "../../src/plugins/commands.js";
import { loadOpenClawPlugins } from "../../src/plugins/loader.js";

View File

@ -128,6 +128,49 @@ export function resolveChannelMessageToolHints(params: {
.filter(Boolean);
}
export function resolveChannelMessageToolCapabilities(params: {
cfg?: OpenClawConfig;
channel?: string | null;
accountId?: string | null;
}): string[] {
const channelId = normalizeAnyChannelId(params.channel);
if (!channelId) {
return [];
}
const resolve = getChannelPlugin(channelId)?.agentPrompt?.messageToolCapabilities;
if (!resolve) {
return [];
}
const cfg = params.cfg ?? ({} as OpenClawConfig);
return (resolve({ cfg, accountId: params.accountId }) ?? [])
.map((entry) => entry.trim())
.filter(Boolean);
}
export function resolveChannelReactionGuidance(params: {
cfg?: OpenClawConfig;
channel?: string | null;
accountId?: string | null;
}): { level: "minimal" | "extensive"; channel: string } | undefined {
const channelId = normalizeAnyChannelId(params.channel);
if (!channelId) {
return undefined;
}
const resolve = getChannelPlugin(channelId)?.agentPrompt?.reactionGuidance;
if (!resolve) {
return undefined;
}
const cfg = params.cfg ?? ({} as OpenClawConfig);
const resolved = resolve({ cfg, accountId: params.accountId });
if (!resolved?.level) {
return undefined;
}
return {
level: resolved.level,
channel: resolved.channelLabel?.trim() || channelId,
};
}
export const __testing = {
resetLoggedListActionErrors() {
messageActionTesting.resetLoggedMessageActionErrors();

View File

@ -17,11 +17,6 @@ import {
} from "../../context-engine/index.js";
import { getMachineDisplayName } from "../../infra/machine-name.js";
import { generateSecureToken } from "../../infra/secure-random.js";
import { resolveSignalReactionLevel } from "../../plugin-sdk/signal.js";
import {
resolveTelegramInlineButtonsScope,
resolveTelegramReactionLevel,
} from "../../plugin-sdk/telegram.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js";
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
@ -34,7 +29,12 @@ import { resolveOpenClawAgentDir } from "../agent-paths.js";
import { resolveSessionAgentIds } from "../agent-scope.js";
import type { ExecElevatedDefaults } from "../bash-tools.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
import { listChannelSupportedActions, resolveChannelMessageToolHints } from "../channel-tools.js";
import {
listChannelSupportedActions,
resolveChannelMessageToolCapabilities,
resolveChannelMessageToolHints,
resolveChannelReactionGuidance,
} from "../channel-tools.js";
import {
hasMeaningfulConversationContent,
isRealConversationMessage,
@ -521,43 +521,35 @@ export async function compactEmbeddedPiSessionDirect(
accountId: params.agentAccountId,
}) ?? [])
: undefined;
if (runtimeChannel === "telegram" && params.config) {
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
if (inlineButtonsScope !== "off") {
if (!runtimeCapabilities) {
runtimeCapabilities = [];
}
if (
!runtimeCapabilities.some((cap) => String(cap).trim().toLowerCase() === "inlinebuttons")
) {
runtimeCapabilities.push("inlineButtons");
const promptCapabilities =
runtimeChannel && params.config
? resolveChannelMessageToolCapabilities({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: [];
if (promptCapabilities.length > 0) {
runtimeCapabilities ??= [];
const seenCapabilities = new Set(
runtimeCapabilities.map((cap) => String(cap).trim().toLowerCase()),
);
for (const capability of promptCapabilities) {
const normalizedCapability = capability.trim().toLowerCase();
if (!normalizedCapability || seenCapabilities.has(normalizedCapability)) {
continue;
}
seenCapabilities.add(normalizedCapability);
runtimeCapabilities.push(capability);
}
}
const reactionGuidance =
runtimeChannel && params.config
? (() => {
if (runtimeChannel === "telegram") {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
}
if (runtimeChannel === "signal") {
const resolved = resolveSignalReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Signal" } : undefined;
}
return undefined;
})()
? resolveChannelReactionGuidance({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: undefined;
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,

View File

@ -21,11 +21,6 @@ import {
shouldInjectOllamaCompatNumCtx,
wrapOllamaCompatNumCtx,
} from "../../../plugin-sdk/ollama.js";
import { resolveSignalReactionLevel } from "../../../plugin-sdk/signal.js";
import {
resolveTelegramInlineButtonsScope,
resolveTelegramReactionLevel,
} from "../../../plugin-sdk/telegram-runtime.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import { resolveToolCallArgumentsEncoding } from "../../../plugins/provider-model-compat.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
@ -48,7 +43,9 @@ import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstra
import { createCacheTrace } from "../../cache-trace.js";
import {
listChannelSupportedActions,
resolveChannelMessageToolCapabilities,
resolveChannelMessageToolHints,
resolveChannelReactionGuidance,
} from "../../channel-tools.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js";
import { resolveOpenClawDocsPath } from "../../docs-path.js";
@ -532,43 +529,35 @@ export async function runEmbeddedAttempt(
accountId: params.agentAccountId,
}) ?? [])
: undefined;
if (runtimeChannel === "telegram" && params.config) {
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
if (inlineButtonsScope !== "off") {
if (!runtimeCapabilities) {
runtimeCapabilities = [];
}
if (
!runtimeCapabilities.some((cap) => String(cap).trim().toLowerCase() === "inlinebuttons")
) {
runtimeCapabilities.push("inlineButtons");
const promptCapabilities =
runtimeChannel && params.config
? resolveChannelMessageToolCapabilities({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: [];
if (promptCapabilities.length > 0) {
runtimeCapabilities ??= [];
const seenCapabilities = new Set(
runtimeCapabilities.map((cap) => String(cap).trim().toLowerCase()),
);
for (const capability of promptCapabilities) {
const normalizedCapability = capability.trim().toLowerCase();
if (!normalizedCapability || seenCapabilities.has(normalizedCapability)) {
continue;
}
seenCapabilities.add(normalizedCapability);
runtimeCapabilities.push(capability);
}
}
const reactionGuidance =
runtimeChannel && params.config
? (() => {
if (runtimeChannel === "telegram") {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
}
if (runtimeChannel === "signal") {
const resolved = resolveSignalReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Signal" } : undefined;
}
return undefined;
})()
? resolveChannelReactionGuidance({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: undefined;
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(params.provider);

View File

@ -0,0 +1,141 @@
type ButtonRow = Array<{ text: string; callback_data: string }>;
export type ProviderInfo = {
id: string;
count: number;
};
type ModelsKeyboardParams = {
provider: string;
models: readonly string[];
currentModel?: string;
currentPage: number;
totalPages: number;
pageSize?: number;
modelNames?: ReadonlyMap<string, string>;
};
const MODELS_PAGE_SIZE = 8;
const MAX_CALLBACK_DATA_BYTES = 64;
const CALLBACK_PREFIX = {
providers: "mdl_prov",
back: "mdl_back",
list: "mdl_list_",
selectStandard: "mdl_sel_",
selectCompact: "mdl_sel/",
} as const;
function truncateModelId(value: string, maxChars: number): string {
if (value.length <= maxChars) {
return value;
}
return `${value.slice(0, Math.max(0, maxChars - 3))}...`;
}
function buildModelSelectionCallbackData(params: {
provider: string;
model: string;
}): string | null {
const fullCallbackData = `${CALLBACK_PREFIX.selectStandard}${params.provider}/${params.model}`;
if (Buffer.byteLength(fullCallbackData, "utf8") <= MAX_CALLBACK_DATA_BYTES) {
return fullCallbackData;
}
const compactCallbackData = `${CALLBACK_PREFIX.selectCompact}${params.model}`;
return Buffer.byteLength(compactCallbackData, "utf8") <= MAX_CALLBACK_DATA_BYTES
? compactCallbackData
: null;
}
export function buildProviderKeyboard(providers: ProviderInfo[]): ButtonRow[] {
if (providers.length === 0) {
return [];
}
const rows: ButtonRow[] = [];
let currentRow: ButtonRow = [];
for (const provider of providers) {
currentRow.push({
text: `${provider.id} (${provider.count})`,
callback_data: `mdl_list_${provider.id}_1`,
});
if (currentRow.length === 2) {
rows.push(currentRow);
currentRow = [];
}
}
if (currentRow.length > 0) {
rows.push(currentRow);
}
return rows;
}
export function buildModelsKeyboard(params: ModelsKeyboardParams): ButtonRow[] {
const { provider, models, currentModel, currentPage, totalPages, modelNames } = params;
const pageSize = params.pageSize ?? MODELS_PAGE_SIZE;
if (models.length === 0) {
return [[{ text: "<< Back", callback_data: CALLBACK_PREFIX.back }]];
}
const rows: ButtonRow[] = [];
const startIndex = (currentPage - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, models.length);
const pageModels = models.slice(startIndex, endIndex);
const currentModelId = currentModel?.includes("/")
? currentModel.split("/").slice(1).join("/")
: currentModel;
for (const model of pageModels) {
const callbackData = buildModelSelectionCallbackData({ provider, model });
if (!callbackData) {
continue;
}
const isCurrentModel = model === currentModelId;
const displayLabel = modelNames?.get(`${provider}/${model}`) ?? model;
const displayText = truncateModelId(displayLabel, 38);
rows.push([
{
text: isCurrentModel ? `${displayText}` : displayText,
callback_data: callbackData,
},
]);
}
const navRow: ButtonRow = [];
if (currentPage > 1) {
navRow.push({
text: "Previous",
callback_data: `${CALLBACK_PREFIX.list}${provider}_${currentPage - 1}`,
});
}
if (currentPage < totalPages) {
navRow.push({
text: "Next",
callback_data: `${CALLBACK_PREFIX.list}${provider}_${currentPage + 1}`,
});
}
if (navRow.length > 0) {
rows.push(navRow);
}
rows.push([{ text: "<< Back", callback_data: CALLBACK_PREFIX.providers }]);
return rows;
}
export function buildBrowseProvidersButton(): ButtonRow[] {
return [[{ text: "Browse providers", callback_data: CALLBACK_PREFIX.providers }]];
}
export function getModelsPageSize(): number {
return MODELS_PAGE_SIZE;
}
export function calculateTotalPages(totalModels: number, pageSize = MODELS_PAGE_SIZE): number {
if (totalModels <= 0) {
return 0;
}
return Math.ceil(totalModels / pageSize);
}

View File

@ -10,15 +10,15 @@ import {
} from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { ReplyPayload } from "../types.js";
import { rejectUnauthorizedCommand } from "./command-gates.js";
import {
buildModelsKeyboard,
buildProviderKeyboard,
calculateTotalPages,
getModelsPageSize,
type ProviderInfo,
} from "../../plugin-sdk/telegram-runtime.js";
import type { ReplyPayload } from "../types.js";
import { rejectUnauthorizedCommand } from "./command-gates.js";
} from "./commands-models.telegram.js";
import type { CommandHandler } from "./commands-types.js";
const PAGE_SIZE_DEFAULT = 20;

View File

@ -1,12 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import {
resolveThreadBindingIdleTimeoutMs,
resolveThreadBindingInactivityExpiresAt,
resolveThreadBindingMaxAgeExpiresAt,
resolveThreadBindingMaxAgeMs,
} from "../../plugin-sdk/discord.js";
function resolveThreadBindingIdleTimeoutMs(params: {
record: { idleTimeoutMs?: number };
defaultIdleTimeoutMs: number;
}): number {
return typeof params.record.idleTimeoutMs === "number"
? Math.max(0, Math.floor(params.record.idleTimeoutMs))
: params.defaultIdleTimeoutMs;
}
function resolveThreadBindingInactivityExpiresAt(params: {
record: { boundAt: number; lastActivityAt: number; idleTimeoutMs?: number };
defaultIdleTimeoutMs: number;
}): number | undefined {
const idleTimeoutMs = resolveThreadBindingIdleTimeoutMs(params);
return idleTimeoutMs > 0
? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs
: undefined;
}
function resolveThreadBindingMaxAgeMs(params: {
record: { maxAgeMs?: number };
defaultMaxAgeMs: number;
}): number {
return typeof params.record.maxAgeMs === "number"
? Math.max(0, Math.floor(params.record.maxAgeMs))
: params.defaultMaxAgeMs;
}
function resolveThreadBindingMaxAgeExpiresAt(params: {
record: { boundAt: number; maxAgeMs?: number };
defaultMaxAgeMs: number;
}): number | undefined {
const maxAgeMs = resolveThreadBindingMaxAgeMs(params);
return maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined;
}
const hoisted = vi.hoisted(() => {
const getThreadBindingManagerMock = vi.fn();
@ -29,10 +59,30 @@ const hoisted = vi.hoisted(() => {
};
});
vi.mock("../../plugins/runtime/index.js", async () => {
vi.mock("../../plugins/runtime/index.js", () => {
return {
createPluginRuntime: () => ({
channel: {
threadBindings: {
setIdleTimeoutBySessionKey: ({ channelId, ...params }: Record<string, unknown>) => {
if (channelId === "telegram") {
return hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock(params);
}
if (channelId === "matrix") {
return hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock(params);
}
return hoisted.setThreadBindingIdleTimeoutBySessionKeyMock(params);
},
setMaxAgeBySessionKey: ({ channelId, ...params }: Record<string, unknown>) => {
if (channelId === "telegram") {
return hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock(params);
}
if (channelId === "matrix") {
return hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock(params);
}
return hoisted.setThreadBindingMaxAgeBySessionKeyMock(params);
},
},
discord: {
threadBindings: {
getManager: hoisted.getThreadBindingManagerMock,
@ -45,12 +95,6 @@ vi.mock("../../plugins/runtime/index.js", async () => {
unbindBySessionKey: vi.fn(),
},
},
telegram: {
threadBindings: {
setIdleTimeoutBySessionKey: hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
setMaxAgeBySessionKey: hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock,
},
},
matrix: {
threadBindings: {
setIdleTimeoutBySessionKey: hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
@ -62,11 +106,8 @@ vi.mock("../../plugins/runtime/index.js", async () => {
};
});
vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../infra/outbound/session-binding-service.js")>();
vi.mock("../../infra/outbound/session-binding-service.js", () => {
return {
...actual,
getSessionBindingService: () => ({
bind: vi.fn(),
getCapabilities: vi.fn(),

View File

@ -666,12 +666,14 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
});
}
return action === SESSION_ACTION_IDLE
? channelRuntime.telegram.threadBindings.setIdleTimeoutBySessionKey({
? channelRuntime.threadBindings.setIdleTimeoutBySessionKey({
channelId: "telegram",
targetSessionKey: telegramBinding!.targetSessionKey,
accountId,
idleTimeoutMs: durationMs,
})
: channelRuntime.telegram.threadBindings.setMaxAgeBySessionKey({
: channelRuntime.threadBindings.setMaxAgeBySessionKey({
channelId: "telegram",
targetSessionKey: telegramBinding!.targetSessionKey,
accountId,
maxAgeMs: durationMs,

View File

@ -8,11 +8,11 @@ import {
} from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { buildBrowseProvidersButton } from "../../plugin-sdk/telegram-runtime.js";
import { shortenHomePath } from "../../utils.js";
import { resolveSelectedAndActiveModel } from "../model-runtime.js";
import type { ReplyPayload } from "../types.js";
import { resolveModelsCommandReply } from "./commands-models.js";
import { buildBrowseProvidersButton } from "./commands-models.telegram.js";
import {
formatAuthLabel,
type ModelAuthDetailMode,

View File

@ -1,4 +1,4 @@
import { parseTelegramTarget } from "../../plugin-sdk/telegram-runtime.js";
import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js";
type TelegramConversationParams = {
ctx: {
@ -25,7 +25,7 @@ export function resolveTelegramConversationId(
.map((value) => value.trim())
.filter(Boolean);
const chatId = toCandidates
.map((candidate) => parseTelegramTarget(candidate).chatId.trim())
.map((candidate) => parseExplicitTargetForChannel("telegram", candidate)?.to.trim() ?? "")
.find((candidate) => candidate.length > 0);
if (!chatId) {
return undefined;

View File

@ -3,7 +3,6 @@ import type {
MediaUnderstandingDecision,
MediaUnderstandingOutput,
} from "../media-understanding/types.js";
import type { StickerMetadata } from "../plugin-sdk/telegram-runtime.js";
import type { InputProvenance } from "../sessions/input-provenance.js";
import type { InternalMessageChannel } from "../utils/message-channel.js";
import type { CommandArgs } from "./commands-registry.types.js";
@ -11,6 +10,18 @@ import type { CommandArgs } from "./commands-registry.types.js";
/** Valid message channels for routing. */
export type OriginatingChannelType = ChannelId | InternalMessageChannel;
export type StickerContextMetadata = {
cachedDescription?: string;
emoji?: string;
setName?: string;
description?: string;
fileId?: string;
fileUniqueId?: string;
uniqueFileId?: string;
isAnimated?: boolean;
isVideo?: boolean;
} & Record<string, unknown>;
export type MsgContext = {
Body?: string;
/**
@ -94,7 +105,7 @@ export type MsgContext = {
MediaUrls?: string[];
MediaTypes?: string[];
/** Telegram sticker metadata (emoji, set name, file IDs, cached description). */
Sticker?: StickerMetadata;
Sticker?: StickerContextMetadata;
/** True when current-turn sticker media is present in MediaPaths (false for cached-description path). */
StickerMediaIncluded?: boolean;
OutputDir?: string;

View File

@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveDiscordRuntimeGroupPolicy } from "../../../plugin-sdk/discord-surface.js";
import { resolveIMessageRuntimeGroupPolicy } from "../../../plugin-sdk/imessage-policy.js";
import { resolveSlackRuntimeGroupPolicy } from "../../../plugin-sdk/slack-surface.js";
import { resolveTelegramRuntimeGroupPolicy } from "../../../plugin-sdk/telegram-runtime-surface.js";
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../config/runtime-group-policy.js";
import { whatsappAccessControlTesting } from "../../../plugin-sdk/whatsapp-surface.js";
import {
evaluateZaloGroupAccess,
@ -11,7 +8,7 @@ import {
import { installChannelRuntimeGroupPolicyFallbackSuite } from "./suites.js";
describe("channel runtime group policy contract", () => {
type ResolvedGroupPolicy = ReturnType<typeof resolveDiscordRuntimeGroupPolicy>;
type ResolvedGroupPolicy = ReturnType<typeof resolveOpenProviderRuntimeGroupPolicy>;
function expectResolvedGroupPolicyCase(
resolved: Pick<ResolvedGroupPolicy, "groupPolicy" | "providerMissingFallbackApplied">,
@ -31,12 +28,12 @@ describe("channel runtime group policy contract", () => {
function expectResolvedDiscordGroupPolicyCase(params: {
providerConfigPresent: Parameters<
typeof resolveDiscordRuntimeGroupPolicy
typeof resolveOpenProviderRuntimeGroupPolicy
>[0]["providerConfigPresent"];
groupPolicy: Parameters<typeof resolveDiscordRuntimeGroupPolicy>[0]["groupPolicy"];
groupPolicy: Parameters<typeof resolveOpenProviderRuntimeGroupPolicy>[0]["groupPolicy"];
expected: Pick<ResolvedGroupPolicy, "groupPolicy" | "providerMissingFallbackApplied">;
}) {
expectResolvedGroupPolicyCase(resolveDiscordRuntimeGroupPolicy(params), params.expected);
expectResolvedGroupPolicyCase(resolveOpenProviderRuntimeGroupPolicy(params), params.expected);
}
function expectAllowedZaloGroupAccessCase(
@ -52,7 +49,7 @@ describe("channel runtime group policy contract", () => {
describe("slack", () => {
installChannelRuntimeGroupPolicyFallbackSuite({
resolve: resolveSlackRuntimeGroupPolicy,
resolve: resolveOpenProviderRuntimeGroupPolicy,
configuredLabel: "keeps open default when channels.slack is configured",
defaultGroupPolicyUnderTest: "open",
missingConfigLabel: "fails closed when channels.slack is missing and no defaults are set",
@ -62,7 +59,7 @@ describe("channel runtime group policy contract", () => {
describe("telegram", () => {
installChannelRuntimeGroupPolicyFallbackSuite({
resolve: resolveTelegramRuntimeGroupPolicy,
resolve: resolveOpenProviderRuntimeGroupPolicy,
configuredLabel: "keeps open fallback when channels.telegram is configured",
defaultGroupPolicyUnderTest: "disabled",
missingConfigLabel: "fails closed when channels.telegram is missing and no defaults are set",
@ -82,7 +79,7 @@ describe("channel runtime group policy contract", () => {
describe("imessage", () => {
installChannelRuntimeGroupPolicyFallbackSuite({
resolve: resolveIMessageRuntimeGroupPolicy,
resolve: resolveOpenProviderRuntimeGroupPolicy,
configuredLabel: "keeps open fallback when channels.imessage is configured",
defaultGroupPolicyUnderTest: "disabled",
missingConfigLabel: "fails closed when channels.imessage is missing and no defaults are set",
@ -92,7 +89,7 @@ describe("channel runtime group policy contract", () => {
describe("discord", () => {
installChannelRuntimeGroupPolicyFallbackSuite({
resolve: resolveDiscordRuntimeGroupPolicy,
resolve: resolveOpenProviderRuntimeGroupPolicy,
configuredLabel: "keeps open default when channels.discord is configured",
defaultGroupPolicyUnderTest: "open",
missingConfigLabel: "fails closed when channels.discord is missing and no defaults are set",

View File

@ -8,10 +8,8 @@ import {
type SessionBindingCapabilities,
type SessionBindingRecord,
} from "../../../infra/outbound/session-binding-service.js";
import { createBlueBubblesConversationBindingManager } from "../../../plugin-sdk/bluebubbles.js";
import { createDiscordThreadBindingManager } from "../../../plugin-sdk/discord.js";
import { createFeishuThreadBindingManager } from "../../../plugin-sdk/feishu.js";
import { createIMessageConversationBindingManager } from "../../../plugin-sdk/imessage.js";
import {
listLineAccountIds,
resolveDefaultLineAccountId,
@ -22,13 +20,13 @@ import {
resetMatrixThreadBindingsForTests,
setMatrixRuntime,
} from "../../../plugin-sdk/matrix.js";
import { createTelegramThreadBindingManager } from "../../../plugin-sdk/telegram-runtime.js";
import { loadBundledPluginTestApiSync } from "../../../test-utils/bundled-plugin-public-surface.js";
import {
listBundledChannelPlugins,
requireBundledChannelPlugin,
setBundledChannelRuntime,
} from "../bundled.js";
import { createChannelConversationBindingManager } from "../conversation-bindings.js";
import type { ChannelPlugin } from "../types.js";
import {
channelPluginSurfaceKeys,
@ -655,7 +653,8 @@ const sessionBindingContractEntries: Record<
placements: ["current"],
},
getCapabilities: () => {
createBlueBubblesConversationBindingManager({
void createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
@ -665,7 +664,8 @@ const sessionBindingContractEntries: Record<
});
},
bindAndResolve: async () => {
createBlueBubblesConversationBindingManager({
await createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
});
@ -694,10 +694,12 @@ const sessionBindingContractEntries: Record<
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
createBlueBubblesConversationBindingManager({
const manager = await createChannelConversationBindingManager({
channelId: "bluebubbles",
cfg: baseSessionBindingCfg,
accountId: "default",
}).stop();
});
await manager?.stop();
expectClearedSessionBinding({
channel: "bluebubbles",
accountId: "default",
@ -829,7 +831,8 @@ const sessionBindingContractEntries: Record<
placements: ["current"],
},
getCapabilities: () => {
createIMessageConversationBindingManager({
void createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
@ -839,7 +842,8 @@ const sessionBindingContractEntries: Record<
});
},
bindAndResolve: async () => {
createIMessageConversationBindingManager({
await createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
});
@ -868,10 +872,12 @@ const sessionBindingContractEntries: Record<
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
createIMessageConversationBindingManager({
const manager = await createChannelConversationBindingManager({
channelId: "imessage",
cfg: baseSessionBindingCfg,
accountId: "default",
}).stop();
});
await manager?.stop();
expectClearedSessionBinding({
channel: "imessage",
accountId: "default",
@ -937,10 +943,10 @@ const sessionBindingContractEntries: Record<
placements: ["current"],
},
getCapabilities: () => {
createTelegramThreadBindingManager({
void createChannelConversationBindingManager({
channelId: "telegram",
cfg: baseSessionBindingCfg,
accountId: "default",
persist: false,
enableSweeper: false,
});
return getSessionBindingService().getCapabilities({
channel: "telegram",
@ -948,10 +954,10 @@ const sessionBindingContractEntries: Record<
});
},
bindAndResolve: async () => {
createTelegramThreadBindingManager({
await createChannelConversationBindingManager({
channelId: "telegram",
cfg: baseSessionBindingCfg,
accountId: "default",
persist: false,
enableSweeper: false,
});
const service = getSessionBindingService();
const binding = await service.bind({
@ -977,12 +983,12 @@ const sessionBindingContractEntries: Record<
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = createTelegramThreadBindingManager({
const manager = await createChannelConversationBindingManager({
channelId: "telegram",
cfg: baseSessionBindingCfg,
accountId: "default",
persist: false,
enableSweeper: false,
});
manager.stop();
await manager?.stop();
expectClearedSessionBinding({
channel: "telegram",
accountId: "default",

View File

@ -0,0 +1,64 @@
import type { OpenClawConfig } from "../../config/config.js";
import { getChannelPlugin } from "./registry.js";
import type { ChannelId } from "./types.js";
export async function createChannelConversationBindingManager(params: {
channelId: ChannelId;
cfg: OpenClawConfig;
accountId?: string | null;
}): Promise<{ stop: () => void | Promise<void> } | null> {
const createManager = getChannelPlugin(params.channelId)?.conversationBindings?.createManager;
if (!createManager) {
return null;
}
return await createManager({
cfg: params.cfg,
accountId: params.accountId,
});
}
export function setChannelConversationBindingIdleTimeoutBySessionKey(params: {
channelId: ChannelId;
targetSessionKey: string;
accountId?: string | null;
idleTimeoutMs: number;
}): Array<{
boundAt: number;
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
}> {
const setIdleTimeoutBySessionKey = getChannelPlugin(params.channelId)?.conversationBindings
?.setIdleTimeoutBySessionKey;
if (!setIdleTimeoutBySessionKey) {
return [];
}
return setIdleTimeoutBySessionKey({
targetSessionKey: params.targetSessionKey,
accountId: params.accountId,
idleTimeoutMs: params.idleTimeoutMs,
});
}
export function setChannelConversationBindingMaxAgeBySessionKey(params: {
channelId: ChannelId;
targetSessionKey: string;
accountId?: string | null;
maxAgeMs: number;
}): Array<{
boundAt: number;
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
}> {
const setMaxAgeBySessionKey = getChannelPlugin(params.channelId)?.conversationBindings
?.setMaxAgeBySessionKey;
if (!setMaxAgeBySessionKey) {
return [];
}
return setMaxAgeBySessionKey({
targetSessionKey: params.targetSessionKey,
accountId: params.accountId,
maxAgeMs: params.maxAgeMs,
});
}

View File

@ -643,6 +643,33 @@ export type ChannelConfiguredBindingProvider = {
export type ChannelConversationBindingSupport = {
supportsCurrentConversationBinding?: boolean;
setIdleTimeoutBySessionKey?: (params: {
targetSessionKey: string;
accountId?: string | null;
idleTimeoutMs: number;
}) => Array<{
boundAt: number;
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
}>;
setMaxAgeBySessionKey?: (params: {
targetSessionKey: string;
accountId?: string | null;
maxAgeMs: number;
}) => Array<{
boundAt: number;
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
}>;
createManager?: (params: { cfg: OpenClawConfig; accountId?: string | null }) =>
| {
stop: () => void | Promise<void>;
}
| Promise<{
stop: () => void | Promise<void>;
}>;
};
export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {

View File

@ -463,6 +463,14 @@ export type ChannelMessagingAdapter = {
export type ChannelAgentPromptAdapter = {
messageToolHints?: (params: { cfg: OpenClawConfig; accountId?: string | null }) => string[];
messageToolCapabilities?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) => string[] | undefined;
reactionGuidance?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) => { level: "minimal" | "extensive"; channelLabel?: string } | undefined;
};
export type ChannelDirectoryEntryKind = "user" | "group" | "channel";

View File

@ -40,6 +40,7 @@ export type {
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingMatch,
ChannelConfiguredBindingProvider,
ChannelConversationBindingSupport,
ChannelPairingAdapter,
ChannelSecurityAdapter,
ChannelSetupAdapter,

View File

@ -1,12 +0,0 @@
import { inspectTelegramAccount as inspectTelegramAccountImpl } from "../plugin-sdk/telegram-runtime.js";
export type { InspectedTelegramAccount } from "../plugin-sdk/telegram-runtime.js";
type InspectTelegramAccount =
typeof import("../plugin-sdk/telegram-runtime.js").inspectTelegramAccount;
export function inspectTelegramAccount(
...args: Parameters<InspectTelegramAccount>
): ReturnType<InspectTelegramAccount> {
return inspectTelegramAccountImpl(...args);
}

View File

@ -1,59 +1,19 @@
import type { OpenClawConfig } from "../config/config.js";
import { getChannelPlugin } from "./plugins/registry.js";
import type { ChannelId } from "./plugins/types.js";
type DiscordInspectModule = typeof import("./read-only-account-inspect.discord.runtime.js");
type SlackInspectModule = typeof import("./read-only-account-inspect.slack.runtime.js");
type TelegramInspectModule = typeof import("./read-only-account-inspect.telegram.js");
let discordInspectModulePromise: Promise<DiscordInspectModule> | undefined;
let slackInspectModulePromise: Promise<SlackInspectModule> | undefined;
let telegramInspectModulePromise: Promise<TelegramInspectModule> | undefined;
function loadDiscordInspectModule() {
discordInspectModulePromise ??= import("./read-only-account-inspect.discord.runtime.js");
return discordInspectModulePromise;
}
function loadSlackInspectModule() {
slackInspectModulePromise ??= import("./read-only-account-inspect.slack.runtime.js");
return slackInspectModulePromise;
}
function loadTelegramInspectModule() {
telegramInspectModulePromise ??= import("./read-only-account-inspect.telegram.js");
return telegramInspectModulePromise;
}
export type ReadOnlyInspectedAccount =
| Awaited<ReturnType<DiscordInspectModule["inspectDiscordAccount"]>>
| Awaited<ReturnType<SlackInspectModule["inspectSlackAccount"]>>
| Awaited<ReturnType<TelegramInspectModule["inspectTelegramAccount"]>>;
export type ReadOnlyInspectedAccount = Record<string, unknown>;
export async function inspectReadOnlyChannelAccount(params: {
channelId: ChannelId;
cfg: OpenClawConfig;
accountId?: string | null;
}): Promise<ReadOnlyInspectedAccount | null> {
if (params.channelId === "discord") {
const { inspectDiscordAccount } = await loadDiscordInspectModule();
return inspectDiscordAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const inspectAccount = getChannelPlugin(params.channelId)?.config.inspectAccount;
if (!inspectAccount) {
return null;
}
if (params.channelId === "slack") {
const { inspectSlackAccount } = await loadSlackInspectModule();
return inspectSlackAccount({
cfg: params.cfg,
accountId: params.accountId,
});
}
if (params.channelId === "telegram") {
const { inspectTelegramAccount } = await loadTelegramInspectModule();
return inspectTelegramAccount({
cfg: params.cfg,
accountId: params.accountId,
});
}
return null;
return (await Promise.resolve(
inspectAccount(params.cfg, params.accountId),
)) as ReadOnlyInspectedAccount | null;
}

View File

@ -1,9 +1,28 @@
import { sendMessageIMessage as sendMessageIMessageImpl } from "../../plugin-sdk/imessage.js";
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
import { loadConfig } from "../../config/config.js";
type RuntimeSend = {
sendMessage: typeof import("../../plugin-sdk/imessage.js").sendMessageIMessage;
type IMessageRuntimeSendOpts = {
config?: ReturnType<typeof loadConfig>;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
accountId?: string;
replyToId?: string;
};
export const runtimeSend = {
sendMessage: sendMessageIMessageImpl,
} satisfies RuntimeSend;
sendMessage: async (to: string, text: string, opts: IMessageRuntimeSendOpts = {}) => {
const outbound = await loadChannelOutboundAdapter("imessage");
if (!outbound?.sendText) {
throw new Error("iMessage outbound adapter is unavailable.");
}
return await outbound.sendText({
cfg: opts.config ?? loadConfig(),
to,
text,
mediaUrl: opts.mediaUrl,
mediaLocalRoots: opts.mediaLocalRoots,
accountId: opts.accountId,
replyToId: opts.replyToId,
});
},
};

View File

@ -1,9 +1,39 @@
import { sendMessageTelegram as sendMessageTelegramImpl } from "../../plugin-sdk/telegram-runtime.js";
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
import { loadConfig } from "../../config/config.js";
type RuntimeSend = {
sendMessage: typeof import("../../plugin-sdk/telegram-runtime.js").sendMessageTelegram;
type TelegramRuntimeSendOpts = {
cfg?: ReturnType<typeof loadConfig>;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
accountId?: string;
messageThreadId?: string | number;
replyToMessageId?: string | number;
silent?: boolean;
forceDocument?: boolean;
gatewayClientScopes?: readonly string[];
};
export const runtimeSend = {
sendMessage: sendMessageTelegramImpl,
} satisfies RuntimeSend;
sendMessage: async (to: string, text: string, opts: TelegramRuntimeSendOpts = {}) => {
const outbound = await loadChannelOutboundAdapter("telegram");
if (!outbound?.sendText) {
throw new Error("Telegram outbound adapter is unavailable.");
}
return await outbound.sendText({
cfg: opts.cfg ?? loadConfig(),
to,
text,
mediaUrl: opts.mediaUrl,
mediaLocalRoots: opts.mediaLocalRoots,
accountId: opts.accountId,
threadId: opts.messageThreadId,
replyToId:
opts.replyToMessageId == null
? undefined
: String(opts.replyToMessageId).trim() || undefined,
silent: opts.silent,
forceDocument: opts.forceDocument,
gatewayClientScopes: opts.gatewayClientScopes,
});
},
};

View File

@ -1,34 +1,31 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import type { TelegramNetworkConfig } from "../../../config/types.telegram.js";
const resolveCommandSecretRefsViaGatewayMock = vi.hoisted(() => vi.fn());
const listTelegramAccountIdsMock = vi.hoisted(() => vi.fn());
const inspectTelegramAccountMock = vi.hoisted(() => vi.fn());
const lookupTelegramChatIdMock = vi.hoisted(() => vi.fn());
const resolveTelegramAccountMock = vi.hoisted(() => vi.fn());
const telegramResolverMock = vi.hoisted(() => vi.fn());
const getChannelPluginMock = vi.hoisted(() => vi.fn());
vi.mock("../../../cli/command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: resolveCommandSecretRefsViaGatewayMock,
}));
vi.mock("../../../plugin-sdk/telegram.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../plugin-sdk/telegram.js")>();
vi.mock("../../../channels/read-only-account-inspect.telegram.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../../channels/read-only-account-inspect.telegram.js")
>();
return {
...actual,
listTelegramAccountIds: listTelegramAccountIdsMock,
inspectTelegramAccount: inspectTelegramAccountMock,
lookupTelegramChatId: lookupTelegramChatIdMock,
};
});
vi.mock("../../../plugin-sdk/account-resolution.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../plugin-sdk/account-resolution.js")>();
return {
...actual,
resolveTelegramAccount: resolveTelegramAccountMock,
};
});
vi.mock("../../../channels/plugins/registry.js", () => ({
getChannelPlugin: getChannelPluginMock,
}));
import {
collectTelegramAllowFromUsernameWarnings,
@ -55,34 +52,16 @@ describe("doctor telegram provider warnings", () => {
.mockReset()
.mockImplementation((_params: { cfg: OpenClawConfig; accountId: string }) => ({
enabled: true,
token: "tok",
tokenSource: "config",
tokenStatus: "configured",
}));
resolveTelegramAccountMock
.mockReset()
.mockImplementation((params: { cfg: OpenClawConfig; accountId?: string | null }) => {
const accountId = params.accountId?.trim() || "default";
const telegram = params.cfg.channels?.telegram ?? {};
const account =
accountId === "default"
? telegram
: ((telegram.accounts?.[accountId] as Record<string, unknown> | undefined) ?? {});
const token =
typeof account.botToken === "string"
? account.botToken
: typeof telegram.botToken === "string"
? telegram.botToken
: "";
return {
accountId,
token,
tokenSource: token ? "config" : "none",
config:
account && typeof account === "object" && "network" in account
? { network: account.network as TelegramNetworkConfig | undefined }
: {},
};
});
lookupTelegramChatIdMock.mockReset();
telegramResolverMock.mockReset();
getChannelPluginMock.mockReset().mockReturnValue({
resolver: {
resolveTargets: telegramResolverMock,
},
});
});
it("shows first-run guidance when groups are not configured yet", () => {
@ -213,18 +192,18 @@ describe("doctor telegram provider warnings", () => {
});
it("repairs Telegram @username allowFrom entries to numeric ids", async () => {
lookupTelegramChatIdMock.mockImplementation(async ({ chatId }: { chatId: string }) => {
switch (chatId.toLowerCase()) {
telegramResolverMock.mockImplementation(async ({ inputs }: { inputs: string[] }) => {
switch (inputs[0]?.toLowerCase()) {
case "@testuser":
return "111";
return [{ input: inputs[0], resolved: true, id: "111" }];
case "@groupuser":
return "222";
return [{ input: inputs[0], resolved: true, id: "222" }];
case "@topicuser":
return "333";
return [{ input: inputs[0], resolved: true, id: "333" }];
case "@accountuser":
return "444";
return [{ input: inputs[0], resolved: true, id: "444" }];
default:
return null;
return [{ input: inputs[0], resolved: false }];
}
});
@ -268,11 +247,11 @@ describe("doctor telegram provider warnings", () => {
});
it("sanitizes Telegram allowFrom repair change lines before logging", async () => {
lookupTelegramChatIdMock.mockImplementation(async ({ chatId }: { chatId: string }) => {
if (chatId === "@\u001b[31mtestuser") {
return "12345";
telegramResolverMock.mockImplementation(async ({ inputs }: { inputs: string[] }) => {
if (inputs[0] === "@\u001b[31mtestuser") {
return [{ input: inputs[0], resolved: true, id: "12345" }];
}
return null;
return [{ input: inputs[0], resolved: false }];
});
const result = await maybeRepairTelegramAllowFromUsernames({
@ -296,13 +275,9 @@ describe("doctor telegram provider warnings", () => {
it("keeps Telegram allowFrom entries unchanged when configured credentials are unavailable", async () => {
inspectTelegramAccountMock.mockImplementation(() => ({
enabled: true,
tokenStatus: "configured_unavailable",
}));
resolveTelegramAccountMock.mockImplementation(() => ({
accountId: "default",
token: "",
tokenSource: "none",
config: {},
tokenSource: "env",
tokenStatus: "configured_unavailable",
}));
const result = await maybeRepairTelegramAllowFromUsernames({
@ -332,7 +307,7 @@ describe("doctor telegram provider warnings", () => {
line.includes("configured Telegram bot credentials are unavailable"),
),
).toBe(true);
expect(lookupTelegramChatIdMock).not.toHaveBeenCalled();
expect(telegramResolverMock).not.toHaveBeenCalled();
});
it("uses network settings for Telegram allowFrom repair but ignores apiRoot and proxy", async () => {
@ -357,15 +332,7 @@ describe("doctor telegram provider warnings", () => {
hadUnresolvedTargets: false,
});
listTelegramAccountIdsMock.mockImplementation(() => ["work"]);
resolveTelegramAccountMock.mockImplementation(() => ({
accountId: "work",
token: "tok",
tokenSource: "config",
config: {
network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" },
},
}));
lookupTelegramChatIdMock.mockResolvedValue("12345");
telegramResolverMock.mockResolvedValue([{ input: "@testuser", resolved: true, id: "12345" }]);
const result = await maybeRepairTelegramAllowFromUsernames({
channels: {
@ -388,11 +355,12 @@ describe("doctor telegram provider warnings", () => {
};
};
expect(cfg.channels?.telegram?.accounts?.work?.allowFrom).toEqual(["12345"]);
expect(lookupTelegramChatIdMock).toHaveBeenCalledWith({
token: "tok",
chatId: "@testuser",
signal: expect.any(AbortSignal),
network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" },
expect(telegramResolverMock).toHaveBeenCalledWith({
cfg: expect.any(Object),
accountId: "work",
inputs: ["@testuser"],
kind: "user",
runtime: expect.any(Object),
});
});
});

View File

@ -1,3 +1,4 @@
import { getChannelPlugin } from "../../../channels/plugins/registry.js";
import {
inspectTelegramAccount,
isNumericTelegramUserId,
@ -7,8 +8,7 @@ import {
import { resolveCommandSecretRefsViaGateway } from "../../../cli/command-secret-gateway.js";
import { getChannelsCommandSecretTargetIds } from "../../../cli/command-secret-targets.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { TelegramNetworkConfig } from "../../../config/types.telegram.js";
import { lookupTelegramChatId } from "../../../plugin-sdk/telegram.js";
import { createNonExitingRuntime } from "../../../runtime.js";
import { describeUnknownError } from "../../../secrets/shared.js";
import { sanitizeForLog } from "../../../terminal/ansi.js";
import { hasAllowFromEntries } from "../shared/allowlist.js";
@ -24,11 +24,6 @@ type TelegramAllowFromListRef = {
key: "allowFrom" | "groupAllowFrom";
};
type ResolvedTelegramLookupAccount = {
token: string;
network?: TelegramNetworkConfig;
};
export function collectTelegramAccountScopes(
cfg: OpenClawConfig,
): Array<{ prefix: string; account: Record<string, unknown> }> {
@ -160,8 +155,7 @@ export async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig)
return inspected.enabled && inspected.tokenStatus === "configured_unavailable";
});
const tokenResolutionWarnings: string[] = [];
const lookupAccounts: ResolvedTelegramLookupAccount[] = [];
const seenLookupAccounts = new Set<string>();
const resolverAccountIds: string[] = [];
for (const accountId of listTelegramAccountIds(resolvedConfig)) {
let inspected: ReturnType<typeof inspectTelegramAccount>;
try {
@ -181,27 +175,25 @@ export async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig)
if (!token) {
continue;
}
const network = inspected.config.network;
const cacheKey = `${token}::${JSON.stringify(network ?? {})}`;
if (seenLookupAccounts.has(cacheKey)) {
continue;
}
seenLookupAccounts.add(cacheKey);
lookupAccounts.push({ token, network });
resolverAccountIds.push(accountId);
}
if (lookupAccounts.length === 0) {
const telegramResolver = getChannelPlugin("telegram")?.resolver?.resolveTargets;
if (resolverAccountIds.length === 0 || !telegramResolver) {
return {
config: cfg,
changes: [
...tokenResolutionWarnings,
hasConfiguredUnavailableToken
? `- Telegram allowFrom contains @username entries, but configured Telegram bot credentials are unavailable in this command path; cannot auto-resolve (start the gateway or make the secret source available, then rerun doctor --fix).`
: `- Telegram allowFrom contains @username entries, but no Telegram bot token is configured; cannot auto-resolve (run setup or replace with numeric sender IDs).`,
: !telegramResolver
? `- Telegram allowFrom contains @username entries, but the Telegram channel resolver is unavailable; cannot auto-resolve in this command path.`
: `- Telegram allowFrom contains @username entries, but no Telegram bot token is configured; cannot auto-resolve (run setup or replace with numeric sender IDs).`,
],
};
}
const resolverRuntime = createNonExitingRuntime();
const resolveUserId = async (raw: string): Promise<string | null> => {
const trimmed = raw.trim();
if (!trimmed) {
@ -218,23 +210,20 @@ export async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig)
return null;
}
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
for (const account of lookupAccounts) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
for (const accountId of resolverAccountIds) {
try {
const id = await lookupTelegramChatId({
token: account.token,
chatId: username,
signal: controller.signal,
network: account.network,
const [resolved] = await telegramResolver({
cfg: resolvedConfig,
accountId,
inputs: [username],
kind: "user",
runtime: resolverRuntime,
});
if (id) {
return id;
if (resolved?.resolved && resolved.id) {
return resolved.id;
}
} catch {
// ignore and try next token
} finally {
clearTimeout(timeout);
// ignore and try next configured account
}
}
return null;

View File

@ -35,6 +35,11 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import {
setChannelConversationBindingIdleTimeoutBySessionKey,
setChannelConversationBindingMaxAgeBySessionKey,
} from "../../channels/plugins/conversation-bindings.js";
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
import { recordInboundSession } from "../../channels/session.js";
import {
resolveChannelGroupPolicy,
@ -56,15 +61,17 @@ import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import {
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
} from "../../plugin-sdk/discord.js";
import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js";
import { defineCachedValue } from "./runtime-cache.js";
import { createRuntimeDiscord } from "./runtime-discord.js";
import { createRuntimeIMessage } from "./runtime-imessage.js";
import { createRuntimeLine } from "./runtime-line.js";
import { createRuntimeMatrix } from "./runtime-matrix.js";
import { createRuntimeSignal } from "./runtime-signal.js";
import { createRuntimeSlack } from "./runtime-slack.js";
import { createRuntimeTelegram } from "./runtime-telegram.js";
import { createRuntimeWhatsApp } from "./runtime-whatsapp.js";
import type { PluginRuntime } from "./types.js";
@ -151,23 +158,74 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
shouldComputeCommandAuthorized,
shouldHandleTextCommands,
},
outbound: {
loadAdapter: loadChannelOutboundAdapter,
},
threadBindings: {
setIdleTimeoutBySessionKey: ({ channelId, targetSessionKey, accountId, idleTimeoutMs }) => {
switch (channelId) {
case "discord":
return setThreadBindingIdleTimeoutBySessionKey({
targetSessionKey,
accountId,
idleTimeoutMs,
});
case "matrix":
return setChannelConversationBindingIdleTimeoutBySessionKey({
channelId,
targetSessionKey,
accountId: accountId ?? "",
idleTimeoutMs,
});
case "telegram":
return setChannelConversationBindingIdleTimeoutBySessionKey({
channelId,
targetSessionKey,
accountId,
idleTimeoutMs,
});
}
},
setMaxAgeBySessionKey: ({ channelId, targetSessionKey, accountId, maxAgeMs }) => {
switch (channelId) {
case "discord":
return setThreadBindingMaxAgeBySessionKey({
targetSessionKey,
accountId,
maxAgeMs,
});
case "matrix":
return setChannelConversationBindingMaxAgeBySessionKey({
channelId,
targetSessionKey,
accountId: accountId ?? "",
maxAgeMs,
});
case "telegram":
return setChannelConversationBindingMaxAgeBySessionKey({
channelId,
targetSessionKey,
accountId,
maxAgeMs,
});
}
},
},
} satisfies Omit<
PluginRuntime["channel"],
"discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp" | "line"
"discord" | "slack" | "matrix" | "signal" | "whatsapp" | "line"
> &
Partial<
Pick<
PluginRuntime["channel"],
"discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp" | "line"
"discord" | "slack" | "matrix" | "signal" | "whatsapp" | "line"
>
>;
defineCachedValue(channelRuntime, "discord", createRuntimeDiscord);
defineCachedValue(channelRuntime, "slack", createRuntimeSlack);
defineCachedValue(channelRuntime, "telegram", createRuntimeTelegram);
defineCachedValue(channelRuntime, "matrix", createRuntimeMatrix);
defineCachedValue(channelRuntime, "signal", createRuntimeSignal);
defineCachedValue(channelRuntime, "imessage", createRuntimeIMessage);
defineCachedValue(channelRuntime, "whatsapp", createRuntimeWhatsApp);
defineCachedValue(channelRuntime, "line", createRuntimeLine);

View File

@ -1,9 +1,6 @@
import { expect, it } from "vitest";
import { installChannelRuntimeGroupPolicyFallbackSuite } from "../../../src/channels/plugins/contracts/suites.js";
import { resolveDiscordRuntimeGroupPolicy } from "../../../src/plugin-sdk/discord-surface.js";
import { resolveIMessageRuntimeGroupPolicy } from "../../../src/plugin-sdk/imessage-policy.js";
import { resolveSlackRuntimeGroupPolicy } from "../../../src/plugin-sdk/slack-surface.js";
import { resolveTelegramRuntimeGroupPolicy } from "../../../src/plugin-sdk/telegram-runtime-surface.js";
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
import { whatsappAccessControlTesting } from "../../../src/plugin-sdk/whatsapp-surface.js";
import {
evaluateZaloGroupAccess,
@ -12,7 +9,7 @@ import {
export function installSlackGroupPolicyContractSuite() {
installChannelRuntimeGroupPolicyFallbackSuite({
resolve: resolveSlackRuntimeGroupPolicy,
resolve: resolveOpenProviderRuntimeGroupPolicy,
configuredLabel: "keeps open default when channels.slack is configured",
defaultGroupPolicyUnderTest: "open",
missingConfigLabel: "fails closed when channels.slack is missing and no defaults are set",
@ -22,7 +19,7 @@ export function installSlackGroupPolicyContractSuite() {
export function installTelegramGroupPolicyContractSuite() {
installChannelRuntimeGroupPolicyFallbackSuite({
resolve: resolveTelegramRuntimeGroupPolicy,
resolve: resolveOpenProviderRuntimeGroupPolicy,
configuredLabel: "keeps open fallback when channels.telegram is configured",
defaultGroupPolicyUnderTest: "disabled",
missingConfigLabel: "fails closed when channels.telegram is missing and no defaults are set",
@ -42,7 +39,7 @@ export function installWhatsAppGroupPolicyContractSuite() {
export function installIMessageGroupPolicyContractSuite() {
installChannelRuntimeGroupPolicyFallbackSuite({
resolve: resolveIMessageRuntimeGroupPolicy,
resolve: resolveOpenProviderRuntimeGroupPolicy,
configuredLabel: "keeps open fallback when channels.imessage is configured",
defaultGroupPolicyUnderTest: "disabled",
missingConfigLabel: "fails closed when channels.imessage is missing and no defaults are set",
@ -52,7 +49,7 @@ export function installIMessageGroupPolicyContractSuite() {
export function installDiscordGroupPolicyContractSuite() {
installChannelRuntimeGroupPolicyFallbackSuite({
resolve: resolveDiscordRuntimeGroupPolicy,
resolve: resolveOpenProviderRuntimeGroupPolicy,
configuredLabel: "keeps open default when channels.discord is configured",
defaultGroupPolicyUnderTest: "open",
missingConfigLabel: "fails closed when channels.discord is missing and no defaults are set",
@ -60,7 +57,7 @@ export function installDiscordGroupPolicyContractSuite() {
});
it("respects explicit provider policy", () => {
const resolved = resolveDiscordRuntimeGroupPolicy({
const resolved = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: false,
groupPolicy: "disabled",
});

View File

@ -1,4 +1,11 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import type { IMessageProbe } from "../../../extensions/imessage/runtime-api.js";
import {
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,
type TelegramProbe,
type TelegramTokenResolution,
} from "../../../extensions/telegram/api.js";
import type {
BaseProbeResult,
BaseTokenResolution,
@ -11,7 +18,6 @@ import {
type DiscordProbe,
type DiscordTokenResolution,
} from "../../../src/plugin-sdk/discord-surface.js";
import type { IMessageProbe } from "../../../src/plugin-sdk/imessage.js";
import type { LineProbeResult } from "../../../src/plugin-sdk/line.js";
import type { SignalProbe } from "../../../src/plugin-sdk/signal-surface.js";
import {
@ -19,12 +25,6 @@ import {
listSlackDirectoryPeersFromConfig,
type SlackProbe,
} from "../../../src/plugin-sdk/slack-surface.js";
import {
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,
type TelegramProbe,
type TelegramTokenResolution,
} from "../../../src/plugin-sdk/telegram-surface.js";
import {
listWhatsAppDirectoryGroupsFromConfig,
listWhatsAppDirectoryPeersFromConfig,

View File

@ -22,7 +22,6 @@ import {
import { __testing as sessionBindingTesting } from "../../../src/infra/outbound/session-binding-service.js";
import { feishuThreadBindingTesting } from "../../../src/plugin-sdk/feishu-conversation.js";
import { resetMatrixThreadBindingsForTests } from "../../../src/plugin-sdk/matrix.js";
import { resetTelegramThreadBindingsForTests } from "../../../src/plugin-sdk/telegram-runtime-surface.js";
import { loadBundledPluginTestApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
const { discordThreadBindingTesting } = loadBundledPluginTestApiSync<{
@ -30,6 +29,9 @@ const { discordThreadBindingTesting } = loadBundledPluginTestApiSync<{
resetThreadBindingsForTests: () => void;
};
}>("discord");
const { resetTelegramThreadBindingsForTests } = loadBundledPluginTestApiSync<{
resetTelegramThreadBindingsForTests: () => Promise<void>;
}>("telegram");
function hasEntries<T extends { id: string }>(
entries: readonly T[],

View File

@ -290,14 +290,21 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
shouldHandleTextCommands:
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
},
outbound: {
loadAdapter: vi.fn() as unknown as PluginRuntime["channel"]["outbound"]["loadAdapter"],
},
threadBindings: {
setIdleTimeoutBySessionKey:
vi.fn() as unknown as PluginRuntime["channel"]["threadBindings"]["setIdleTimeoutBySessionKey"],
setMaxAgeBySessionKey:
vi.fn() as unknown as PluginRuntime["channel"]["threadBindings"]["setMaxAgeBySessionKey"],
},
discord: {} as PluginRuntime["channel"]["discord"],
activity: {} as PluginRuntime["channel"]["activity"],
line: {} as PluginRuntime["channel"]["line"],
slack: {} as PluginRuntime["channel"]["slack"],
telegram: {} as PluginRuntime["channel"]["telegram"],
matrix: {} as PluginRuntime["channel"]["matrix"],
signal: {} as PluginRuntime["channel"]["signal"],
imessage: {} as PluginRuntime["channel"]["imessage"],
whatsapp: {} as PluginRuntime["channel"]["whatsapp"],
},
events: {