diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 14db596072b..fe66637ec11 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -102,6 +102,11 @@ export const bluebubblesPlugin: ChannelPlugin + createBlueBubblesConversationBindingManager({ + cfg, + accountId: accountId ?? undefined, + }), }, actions: bluebubblesMessageActions, bindings: { diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 8ade752170f..92b2a67106e 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -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) => + await sendMessage(to, text, opts), + sendMedia: async ({ to, text, ...opts }: Record) => + 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", }, }, { diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 278c2de48fb..66f089abbe2 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -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; }; -type QrSendFn = (to: string, text: string, opts: Record) => Promise; - -function coerceQrSend(send: unknown): QrSendFn | undefined { - return typeof send === "function" ? (send as QrSendFn) : undefined; -} - const QR_CHANNEL_SENDERS: Record = { 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 = { }), }, 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 = { }), }, 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 = { }), }, 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?.( diff --git a/extensions/device-pair/notify.ts b/extensions/device-pair/notify.ts index e5d07174c2a..eb23565b04f 100644 --- a/extensions/device-pair/notify.ts +++ b/extensions/device-pair/notify.ts @@ -254,17 +254,23 @@ async function notifySubscriber(params: { subscriber: NotifySubscription; text: string; }): Promise { - 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; diff --git a/extensions/imessage/src/channel.runtime.ts b/extensions/imessage/src/channel.runtime.ts index ef67ef89be5..075e4652db8 100644 --- a/extensions/imessage/src/channel.runtime.ts +++ b/extensions/imessage/src/channel.runtime.ts @@ -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[0]["cfg"]; @@ -21,8 +19,7 @@ export async function sendIMessageOutbound(params: { replyToId?: string; }) { const send = - resolveOutboundSendDep(params.deps, "imessage") ?? - getIMessageRuntime().channel.imessage.sendMessageIMessage; + resolveOutboundSendDep(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 { - await getIMessageRuntime().channel.imessage.sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE); + await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE); } export async function probeIMessageAccount(timeoutMs?: number) { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 85ab77cf334..3e1312df655 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -136,6 +136,11 @@ export const imessagePlugin: ChannelPlugin + createIMessageConversationBindingManager({ + cfg, + accountId: accountId ?? undefined, + }), }, bindings: { compileConfiguredBinding: ({ conversationId }) => diff --git a/extensions/imessage/src/normalize.ts b/extensions/imessage/src/normalize.ts new file mode 100644 index 00000000000..2e97403fa28 --- /dev/null +++ b/extensions/imessage/src/normalize.ts @@ -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, + }); +} diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 49659f59246..d9651d1a829 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -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 = }, 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, diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index a2e25cf469c..dae8ec7c271 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -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 = 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), diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 0ca6a52245c..b963a048d1f 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -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[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(params.deps, "telegram") ?? - getTelegramRuntime().channel.telegram.sendMessageTelegram; + resolveOutboundSendDep(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: "", }, }, + 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(deps, "telegram") ?? - getTelegramRuntime().channel.telegram.sendMessageTelegram; + resolveOutboundSendDep(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), diff --git a/scripts/dev/test-device-pair-telegram.ts b/scripts/dev/test-device-pair-telegram.ts index 00a71c63982..c97199bd7b6 100644 --- a/scripts/dev/test-device-pair-telegram.ts +++ b/scripts/dev/test-device-pair-telegram.ts @@ -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"; diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index f9ad4f2f246..3738f0a4e32 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -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(); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 8212ebcc838..645e064bb54 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index fc8bea2048c..4ed805dfe94 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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); diff --git a/src/auto-reply/reply/commands-models.telegram.ts b/src/auto-reply/reply/commands-models.telegram.ts new file mode 100644 index 00000000000..d28da71f4cc --- /dev/null +++ b/src/auto-reply/reply/commands-models.telegram.ts @@ -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; +}; + +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); +} diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 31a10305924..c105f924c04 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -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; diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index 17d3e51ae72..2b2b997a6a6 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -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) => { + if (channelId === "telegram") { + return hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock(params); + } + if (channelId === "matrix") { + return hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock(params); + } + return hoisted.setThreadBindingIdleTimeoutBySessionKeyMock(params); + }, + setMaxAgeBySessionKey: ({ channelId, ...params }: Record) => { + 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(); +vi.mock("../../infra/outbound/session-binding-service.js", () => { return { - ...actual, getSessionBindingService: () => ({ bind: vi.fn(), getCapabilities: vi.fn(), diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 4199f14c6bc..659e1864870 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -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, diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 40263025150..ccaa58aefb3 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -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, diff --git a/src/auto-reply/reply/telegram-context.ts b/src/auto-reply/reply/telegram-context.ts index 03d34b6ba12..8ab905a44d1 100644 --- a/src/auto-reply/reply/telegram-context.ts +++ b/src/auto-reply/reply/telegram-context.ts @@ -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; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index aba2bca470e..f5601469e7d 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -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; + 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; diff --git a/src/channels/plugins/contracts/group-policy.contract.test.ts b/src/channels/plugins/contracts/group-policy.contract.test.ts index ecffc39d81a..19a87a784c0 100644 --- a/src/channels/plugins/contracts/group-policy.contract.test.ts +++ b/src/channels/plugins/contracts/group-policy.contract.test.ts @@ -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; + type ResolvedGroupPolicy = ReturnType; function expectResolvedGroupPolicyCase( resolved: Pick, @@ -31,12 +28,12 @@ describe("channel runtime group policy contract", () => { function expectResolvedDiscordGroupPolicyCase(params: { providerConfigPresent: Parameters< - typeof resolveDiscordRuntimeGroupPolicy + typeof resolveOpenProviderRuntimeGroupPolicy >[0]["providerConfigPresent"]; - groupPolicy: Parameters[0]["groupPolicy"]; + groupPolicy: Parameters[0]["groupPolicy"]; expected: Pick; }) { - 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", diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 0392898b2cf..f5429b07995 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -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", diff --git a/src/channels/plugins/conversation-bindings.ts b/src/channels/plugins/conversation-bindings.ts new file mode 100644 index 00000000000..a626c4caf8b --- /dev/null +++ b/src/channels/plugins/conversation-bindings.ts @@ -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 } | 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, + }); +} diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 0fbf503c2c7..6af79c2f70a 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -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; + } + | Promise<{ + stop: () => void | Promise; + }>; }; export type ChannelSecurityAdapter = { diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index c4126f1053a..476c866dfad 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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"; diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index e9f62201697..8e1202320e4 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -40,6 +40,7 @@ export type { ChannelConfiguredBindingConversationRef, ChannelConfiguredBindingMatch, ChannelConfiguredBindingProvider, + ChannelConversationBindingSupport, ChannelPairingAdapter, ChannelSecurityAdapter, ChannelSetupAdapter, diff --git a/src/channels/read-only-account-inspect.telegram.runtime.ts b/src/channels/read-only-account-inspect.telegram.runtime.ts deleted file mode 100644 index 77dbb31a35d..00000000000 --- a/src/channels/read-only-account-inspect.telegram.runtime.ts +++ /dev/null @@ -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 -): ReturnType { - return inspectTelegramAccountImpl(...args); -} diff --git a/src/channels/read-only-account-inspect.ts b/src/channels/read-only-account-inspect.ts index de5bcccbeb5..8edd6ad7741 100644 --- a/src/channels/read-only-account-inspect.ts +++ b/src/channels/read-only-account-inspect.ts @@ -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 | undefined; -let slackInspectModulePromise: Promise | undefined; -let telegramInspectModulePromise: Promise | 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> - | Awaited> - | Awaited>; +export type ReadOnlyInspectedAccount = Record; export async function inspectReadOnlyChannelAccount(params: { channelId: ChannelId; cfg: OpenClawConfig; accountId?: string | null; }): Promise { - 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; } diff --git a/src/cli/send-runtime/imessage.ts b/src/cli/send-runtime/imessage.ts index cdc91c0be74..1ac217ce47b 100644 --- a/src/cli/send-runtime/imessage.ts +++ b/src/cli/send-runtime/imessage.ts @@ -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; + 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, + }); + }, +}; diff --git a/src/cli/send-runtime/telegram.ts b/src/cli/send-runtime/telegram.ts index 499b62329e8..b64ccde99f3 100644 --- a/src/cli/send-runtime/telegram.ts +++ b/src/cli/send-runtime/telegram.ts @@ -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; + 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, + }); + }, +}; diff --git a/src/commands/doctor/providers/telegram.test.ts b/src/commands/doctor/providers/telegram.test.ts index f4544d477b1..8ce884bdd77 100644 --- a/src/commands/doctor/providers/telegram.test.ts +++ b/src/commands/doctor/providers/telegram.test.ts @@ -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(); +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(); - 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 | 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), }); }); }); diff --git a/src/commands/doctor/providers/telegram.ts b/src/commands/doctor/providers/telegram.ts index 580e91a93f5..175bfb022b3 100644 --- a/src/commands/doctor/providers/telegram.ts +++ b/src/commands/doctor/providers/telegram.ts @@ -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 }> { @@ -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(); + const resolverAccountIds: string[] = []; for (const accountId of listTelegramAccountIds(resolvedConfig)) { let inspected: ReturnType; 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 => { 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; diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index ac0d55405c4..970063e8efe 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -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); diff --git a/test/helpers/channels/group-policy-contract.ts b/test/helpers/channels/group-policy-contract.ts index e3fa6b26c71..0d844c800cb 100644 --- a/test/helpers/channels/group-policy-contract.ts +++ b/test/helpers/channels/group-policy-contract.ts @@ -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", }); diff --git a/test/helpers/channels/plugins-core-extension-contract.ts b/test/helpers/channels/plugins-core-extension-contract.ts index 1115c406fc1..35ab6571da6 100644 --- a/test/helpers/channels/plugins-core-extension-contract.ts +++ b/test/helpers/channels/plugins-core-extension-contract.ts @@ -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, diff --git a/test/helpers/channels/registry-backed-contract.ts b/test/helpers/channels/registry-backed-contract.ts index 7e63c7720dd..1b30bbe76c7 100644 --- a/test/helpers/channels/registry-backed-contract.ts +++ b/test/helpers/channels/registry-backed-contract.ts @@ -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; +}>("telegram"); function hasEntries( entries: readonly T[], diff --git a/test/helpers/plugins/plugin-runtime-mock.ts b/test/helpers/plugins/plugin-runtime-mock.ts index ea2d239764e..63f299d16ff 100644 --- a/test/helpers/plugins/plugin-runtime-mock.ts +++ b/test/helpers/plugins/plugin-runtime-mock.ts @@ -290,14 +290,21 @@ export function createPluginRuntimeMock(overrides: DeepPartial = 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: {