diff --git a/docs/BOT_ACCESS_SETUP.md b/docs/BOT_ACCESS_SETUP.md index a48edd6..6e62451 100644 --- a/docs/BOT_ACCESS_SETUP.md +++ b/docs/BOT_ACCESS_SETUP.md @@ -48,10 +48,24 @@ oco pairing list --instance --channel telegram --account support - oco pairing approve --instance --channel telegram --account support --code ``` -Optional read-only group mode (enabled accounts can read group traffic but never reply in groups): +Read-only group mode for selected Telegram accounts that still need to ingest group traffic: ```json5 { + channels: { + telegram: { + accounts: { + support: { + groupPolicy: "open", + groups: { + "*": { + requireMention: false, + }, + }, + }, + }, + }, + }, plugins: { entries: { "telegram-group-allowlist-guard": { @@ -66,6 +80,16 @@ Optional read-only group mode (enabled accounts can read group traffic but never } ``` +This keeps Telegram open for inbound group visibility, so the bot can still ingest messages and maintain group context. + +For a strict no-send guarantee, do not rely on the plugin alone: +- The reply dispatcher must suppress all outbound payloads for the protected Telegram group sessions before they reach the channel adapter. +- The `telegram-group-allowlist-guard` plugin should remain enabled as a second layer so normal group replies and tool paths still fail closed if runtime metadata is incomplete. + +Operational hardening outside OCO: +- Disable new group joins for these bots with BotFather `/setjoingroups`. +- Remove the bots from existing Telegram groups or restrict them to read-only if they must stay present. + ## 2. Discord Setup 1. Create app + bot in Discord Developer Portal. diff --git a/instances/core-human/config/Dockerfile.custom b/instances/core-human/config/Dockerfile.custom index dec425f..be71d31 100644 --- a/instances/core-human/config/Dockerfile.custom +++ b/instances/core-human/config/Dockerfile.custom @@ -17,5 +17,9 @@ RUN git clone https://github.com/googleworkspace/cli.git /tmp/gws \ FROM ghcr.io/openclaw/openclaw:2026.2.22 COPY --from=gws-builder /usr/local/cargo/bin/gws /usr/local/bin/gws +COPY instances/core-human/config/openclaw-runtime-patches/apply-protected-group-reply-suppression.mjs /tmp/apply-protected-group-reply-suppression.mjs -RUN gws --version +RUN mkdir -p /tmp/corepack /tmp/.cache /tmp/pnpm \ + && node /tmp/apply-protected-group-reply-suppression.mjs \ + && HOME=/tmp COREPACK_HOME=/tmp/corepack XDG_CACHE_HOME=/tmp/.cache PNPM_HOME=/tmp/pnpm pnpm build \ + && gws --version diff --git a/instances/core-human/config/extensions/telegram-group-allowlist-guard/index.ts b/instances/core-human/config/extensions/telegram-group-allowlist-guard/index.ts index dfe2bf1..702b85a 100644 --- a/instances/core-human/config/extensions/telegram-group-allowlist-guard/index.ts +++ b/instances/core-human/config/extensions/telegram-group-allowlist-guard/index.ts @@ -581,6 +581,31 @@ function resolveDefaultEnabledAccount(enabledAccounts: Set): string | un return enabledAccounts.values().next().value as string; } +function hasExplicitIdentityCandidate(...candidates: unknown[]): boolean { + return candidates.some((candidate) => Boolean(asNonEmptyString(candidate))); +} + +function resolveEnabledAccount( + enabledAccounts: Set, + ...candidates: unknown[] +): string | undefined { + let sawExplicitCandidate = false; + for (const candidate of candidates) { + const normalized = asNonEmptyString(candidate)?.toLowerCase(); + if (!normalized) { + continue; + } + sawExplicitCandidate = true; + if (isAccountEnabled(normalized, enabledAccounts)) { + return normalized; + } + } + if (sawExplicitCandidate) { + return; + } + return resolveDefaultEnabledAccount(enabledAccounts); +} + export default function register(api: OpenClawPluginApi): void { const pluginConfig = (api.pluginConfig ?? {}) as GuardPluginConfig; const enabledAccounts = new Set( @@ -726,8 +751,14 @@ export default function register(api: OpenClawPluginApi): void { cleanupExpiredContext(maxSenderAgeMs); const cached = findLatestInboundForTarget(parsedSession.toTarget, maxSenderAgeMs, parsedSession.accountId); - const accountId = - parsedSession.accountId ?? cached?.accountId ?? resolveDefaultEnabledAccount(enabledAccounts); + const accountId = resolveEnabledAccount( + enabledAccounts, + parsedSession.accountId, + parsedSession.agentId, + ctx.accountId, + ctx.agentId, + cached?.accountId, + ); if (!accountId || !isAccountEnabled(accountId, enabledAccounts)) { return; } @@ -770,8 +801,8 @@ export default function register(api: OpenClawPluginApi): void { } const rawContent = asNonEmptyString(event.content) ?? ""; + const accountLabel = asNonEmptyString(ctx.accountId) ?? asNonEmptyString(ctx.agentId) ?? "unknown"; if (isReasoningOnlyMessage(rawContent)) { - const accountLabel = asNonEmptyString(ctx.accountId) ?? "unknown"; api.logger.info?.(`telegram-group-allowlist-guard: cancelled reasoning-only message for ${accountLabel}`); return { cancel: true }; } @@ -779,7 +810,7 @@ export default function register(api: OpenClawPluginApi): void { const sanitizedContent = stripReasoningArtifacts(rawContent); if (hasThinkArtifacts(rawContent) && sanitizedContent.length === 0) { api.logger.info?.( - `telegram-group-allowlist-guard: cancelled think-artifact-only message for ${accountId}`, + `telegram-group-allowlist-guard: cancelled think-artifact-only message for ${accountLabel}`, ); return { cancel: true }; } @@ -796,16 +827,48 @@ export default function register(api: OpenClawPluginApi): void { const parsedTo = parseTelegramTarget(toTargetRaw); const metadata = asRecord(event.metadata); - let accountId = - asNonEmptyString(ctx.accountId)?.toLowerCase() ?? - asNonEmptyString(metadata?.accountId)?.toLowerCase(); - if (!accountId && parsedTo.kind === "group") { + const sessionKey = + asNonEmptyString(ctx.sessionKey) ?? + asNonEmptyString(event.sessionKey) ?? + asNonEmptyString(event.session_key) ?? + asNonEmptyString(metadata?.sessionKey) ?? + asNonEmptyString(metadata?.session_key); + const parsedSession = sessionKey ? parseTelegramSessionKey(sessionKey) : undefined; + const hasExplicitIdentity = hasExplicitIdentityCandidate( + ctx.accountId, + ctx.agentId, + parsedSession?.accountId, + parsedSession?.agentId, + metadata?.accountId, + metadata?.account_id, + metadata?.agentId, + metadata?.agent_id, + ); + let accountId = resolveEnabledAccount( + enabledAccounts, + ctx.accountId, + ctx.agentId, + parsedSession?.accountId, + parsedSession?.agentId, + metadata?.accountId, + metadata?.account_id, + metadata?.agentId, + metadata?.agent_id, + ); + if (!accountId && parsedTo.kind === "group" && !hasExplicitIdentity) { cleanupExpiredContext(maxSenderAgeMs); - accountId = - findLatestInboundForTarget(toTarget, maxSenderAgeMs)?.accountId ?? - resolveDefaultEnabledAccount(enabledAccounts); + accountId = resolveEnabledAccount( + enabledAccounts, + findLatestInboundForTarget(toTarget, maxSenderAgeMs)?.accountId, + ); } if (!accountId || !isAccountEnabled(accountId, enabledAccounts)) { + if (parsedTo.kind === "group" && blockAllGroupReplies && enabledAccounts.size > 0) { + api.logger.info?.( + `telegram-group-allowlist-guard: cancelled group send -> ${toTarget} (missing protected account context; failing closed)`, + ); + return { cancel: true }; + } return; } diff --git a/instances/core-human/config/openclaw-runtime-patches/apply-protected-group-reply-suppression.mjs b/instances/core-human/config/openclaw-runtime-patches/apply-protected-group-reply-suppression.mjs new file mode 100644 index 0000000..fbed686 --- /dev/null +++ b/instances/core-human/config/openclaw-runtime-patches/apply-protected-group-reply-suppression.mjs @@ -0,0 +1,280 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; + +export const PATCH_MARKER = "/* oco protected telegram group reply suppression */"; + +function replaceOnce(source, searchValue, replacement) { + if (!source.includes(searchValue)) { + throw new Error(`patch anchor not found: ${searchValue.slice(0, 80)}`); + } + return source.replace(searchValue, replacement); +} + +export function applyProtectedGroupReplySuppression(source) { + if (source.includes(PATCH_MARKER)) { + return source; + } + + let patched = source; + + patched = replaceOnce( + patched, + `const resolveSessionTtsAuto = ( + ctx: FinalizedMsgContext, + cfg: OpenClawConfig, +): string | undefined => {`, + `${PATCH_MARKER} +const TELEGRAM_GROUP_ALLOWLIST_GUARD_PLUGIN_ID = "telegram-group-allowlist-guard"; + +type ParsedTelegramSession = { + kind: "group" | "direct" | "unknown"; + accountId?: string; + agentId?: string; +}; + +const asRecord = (value: unknown): Record | undefined => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +}; + +const asLowerStringList = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + return value + .map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : "")) + .filter((entry) => Boolean(entry)); +}; + +const parseTelegramSessionKey = (sessionKey: string): ParsedTelegramSession => { + const tokens = sessionKey.trim().toLowerCase().split(":"); + const agentId = tokens.length >= 2 && tokens[0] === "agent" ? tokens[1] : undefined; + const telegramIndex = tokens.indexOf("telegram"); + if (telegramIndex < 0) { + return { kind: "unknown", agentId }; + } + + const suffix = tokens.slice(telegramIndex + 1); + if (suffix.length >= 2 && (suffix[0] === "group" || suffix[0] === "direct")) { + return { kind: suffix[0], agentId }; + } + if (suffix.length >= 3 && (suffix[1] === "group" || suffix[1] === "direct")) { + return { + kind: suffix[1], + accountId: suffix[0], + agentId, + }; + } + return { kind: "unknown", agentId }; +}; + +const resolveProtectedTelegramGroupAccountId = ( + ctx: FinalizedMsgContext, + cfg: OpenClawConfig, +): string | undefined => { + const pluginEntry = cfg.plugins?.entries?.[TELEGRAM_GROUP_ALLOWLIST_GUARD_PLUGIN_ID]; + if (!pluginEntry || pluginEntry.enabled === false) { + return undefined; + } + + const pluginConfig = asRecord(pluginEntry.config); + if (pluginConfig?.blockAllGroupReplies !== true) { + return undefined; + } + + const enabledAccounts = new Set(asLowerStringList(pluginConfig.enabledAccounts)); + if (enabledAccounts.size === 0) { + return undefined; + } + + const targetSessionKey = + ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined; + const sessionKey = (targetSessionKey ?? ctx.SessionKey)?.trim(); + const parsedSession = sessionKey + ? parseTelegramSessionKey(sessionKey) + : ({ kind: "unknown" } satisfies ParsedTelegramSession); + + for (const candidate of [ctx.AccountId, parsedSession.accountId, parsedSession.agentId]) { + if (typeof candidate !== "string") { + continue; + } + const normalized = candidate.trim().toLowerCase(); + if (normalized && enabledAccounts.has(normalized)) { + return normalized; + } + } + + return undefined; +}; + +const shouldSuppressProtectedTelegramGroupReplies = ( + ctx: FinalizedMsgContext, + cfg: OpenClawConfig, +): boolean => { + const targetSessionKey = + ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined; + const sessionKey = (targetSessionKey ?? ctx.SessionKey)?.trim(); + const parsedSession = sessionKey + ? parseTelegramSessionKey(sessionKey) + : ({ kind: "unknown" } satisfies ParsedTelegramSession); + const channel = String( + ctx.OriginatingChannel ?? + ctx.Surface ?? + ctx.Provider ?? + (parsedSession.kind !== "unknown" ? "telegram" : ""), + ).toLowerCase(); + + if (channel !== "telegram") { + return false; + } + if (parsedSession.kind !== "group" && ctx.ChatType !== "group") { + return false; + } + + return Boolean(resolveProtectedTelegramGroupAccountId(ctx, cfg)); +}; + +const resolveSessionTtsAuto = ( + ctx: FinalizedMsgContext, + cfg: OpenClawConfig, +): string | undefined => {`, + ); + + patched = replaceOnce( + patched, + ` const sessionTtsAuto = resolveSessionTtsAuto(ctx, cfg); + const hookRunner = getGlobalHookRunner();`, + ` const sessionTtsAuto = resolveSessionTtsAuto(ctx, cfg); + const hookRunner = getGlobalHookRunner(); + const suppressProtectedTelegramGroupReplies = shouldSuppressProtectedTelegramGroupReplies( + ctx, + cfg, + ); + if (suppressProtectedTelegramGroupReplies) { + logVerbose( + \`dispatch-from-config: suppressing outbound payloads for protected telegram group session \${ctx.CommandTargetSessionKey ?? ctx.SessionKey ?? "unknown"}\`, + ); + }`, + ); + + patched = replaceOnce( + patched, + ` const sendPayloadAsync = async ( + payload: ReplyPayload, + abortSignal?: AbortSignal, + mirror?: boolean, + ): Promise => { + // TypeScript doesn't narrow these from the shouldRouteToOriginating check,`, + ` const sendPayloadAsync = async ( + payload: ReplyPayload, + abortSignal?: AbortSignal, + mirror?: boolean, + ): Promise => { + if (suppressProtectedTelegramGroupReplies) { + return; + } + // TypeScript doesn't narrow these from the shouldRouteToOriginating check,`, + ); + + patched = replaceOnce( + patched, + ` if (fastAbort.handled) { + const payload = { + text: formatAbortReplyText(fastAbort.stoppedSubagents), + } satisfies ReplyPayload;`, + ` if (fastAbort.handled) { + if (suppressProtectedTelegramGroupReplies) { + const counts = dispatcher.getQueuedCounts(); + recordProcessed("completed", { reason: "protected_group_fast_abort_suppressed" }); + markIdle("message_completed"); + return { queuedFinal: false, counts }; + } + const payload = { + text: formatAbortReplyText(fastAbort.stoppedSubagents), + } satisfies ReplyPayload;`, + ); + + patched = replaceOnce( + patched, + ` const resolveToolDeliveryPayload = (payload: ReplyPayload): ReplyPayload | null => { + if (shouldSendToolSummaries) { + return payload; + }`, + ` const resolveToolDeliveryPayload = (payload: ReplyPayload): ReplyPayload | null => { + if (suppressProtectedTelegramGroupReplies) { + return null; + } + if (shouldSendToolSummaries) { + return payload; + }`, + ); + + patched = replaceOnce( + patched, + ` onToolResult: (payload: ReplyPayload) => { + const run = async () => { + const ttsPayload = await maybeApplyTtsToPayload({`, + ` onToolResult: (payload: ReplyPayload) => { + const run = async () => { + if (suppressProtectedTelegramGroupReplies) { + return; + } + const ttsPayload = await maybeApplyTtsToPayload({`, + ); + + patched = replaceOnce( + patched, + ` onBlockReply: (payload: ReplyPayload, context) => { + const run = async () => { + // Accumulate block text for TTS generation after streaming`, + ` onBlockReply: (payload: ReplyPayload, context) => { + const run = async () => { + if (suppressProtectedTelegramGroupReplies) { + return; + } + // Accumulate block text for TTS generation after streaming`, + ); + + patched = replaceOnce( + patched, + ` const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : []; + + let queuedFinal = false;`, + ` const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : []; + const dispatchableReplies = suppressProtectedTelegramGroupReplies ? [] : replies; + + let queuedFinal = false;`, + ); + + patched = replaceOnce( + patched, + ` for (const reply of replies) {`, + ` for (const reply of dispatchableReplies) {`, + ); + + patched = replaceOnce( + patched, + ` replies.length === 0 &&`, + ` dispatchableReplies.length === 0 &&`, + ); + + return patched; +} + +export function patchProtectedGroupReplySuppression(runtimeRoot = process.env.OPENCLAW_RUNTIME_ROOT ?? "/app") { + const targetPath = join(runtimeRoot, "src/auto-reply/reply/dispatch-from-config.ts"); + const original = readFileSync(targetPath, "utf8"); + const patched = applyProtectedGroupReplySuppression(original); + if (patched !== original) { + writeFileSync(targetPath, patched); + } + return targetPath; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const targetPath = patchProtectedGroupReplySuppression(); + process.stdout.write(`patched ${targetPath}\n`); +} diff --git a/tests/apply-protected-group-reply-suppression.test.ts b/tests/apply-protected-group-reply-suppression.test.ts new file mode 100644 index 0000000..c9c1822 --- /dev/null +++ b/tests/apply-protected-group-reply-suppression.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, test } from "bun:test"; +import { + applyProtectedGroupReplySuppression, + PATCH_MARKER, +} from "../instances/core-human/config/openclaw-runtime-patches/apply-protected-group-reply-suppression.mjs"; + +const FIXTURE = `import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; +import { logVerbose } from "../../globals.js"; +import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; +import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; +import { + logMessageProcessed, + logMessageQueued, + logSessionStateChange, +} from "../../logging/diagnostic.js"; +import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; +import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js"; +import { getReplyFromConfig } from "../reply.js"; +import type { FinalizedMsgContext } from "../templating.js"; +import type { GetReplyOptions, ReplyPayload } from "../types.js"; +import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js"; +import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js"; +import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js"; +import { isRoutableChannel, routeReply } from "./route-reply.js"; + +const resolveSessionTtsAuto = ( + ctx: FinalizedMsgContext, + cfg: OpenClawConfig, +): string | undefined => { + const targetSessionKey = + ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined; + const sessionKey = (targetSessionKey ?? ctx.SessionKey)?.trim(); + if (!sessionKey) { + return undefined; + } + const agentId = resolveSessionAgentId({ sessionKey, config: cfg }); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + try { + const store = loadSessionStore(storePath); + const entry = store[sessionKey.toLowerCase()] ?? store[sessionKey]; + return normalizeTtsAutoMode(entry?.ttsAuto); + } catch { + return undefined; + } +}; + +export type DispatchFromConfigResult = { + queuedFinal: boolean; + counts: Record; +}; + +export async function dispatchReplyFromConfig(params: { + ctx: FinalizedMsgContext; + cfg: OpenClawConfig; + dispatcher: ReplyDispatcher; + replyOptions?: Omit; + replyResolver?: typeof getReplyFromConfig; +}): Promise { + const { ctx, cfg, dispatcher } = params; + const diagnosticsEnabled = isDiagnosticsEnabled(cfg); + const channel = String(ctx.Surface ?? ctx.Provider ?? "unknown").toLowerCase(); + const chatId = ctx.To ?? ctx.From; + const messageId = ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast; + const sessionKey = ctx.SessionKey; + const startTime = diagnosticsEnabled ? Date.now() : 0; + const canTrackSession = diagnosticsEnabled && Boolean(sessionKey); + const recordProcessed = ( + outcome: "completed" | "skipped" | "error", + opts?: { + reason?: string; + error?: string; + }, + ) => {}; + const markProcessing = () => {}; + const markIdle = (reason: string) => {}; + + if (shouldSkipDuplicateInbound(ctx)) { + recordProcessed("skipped", { reason: "duplicate" }); + return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; + } + + const inboundAudio = false; + const sessionTtsAuto = resolveSessionTtsAuto(ctx, cfg); + const hookRunner = getGlobalHookRunner(); + const shouldRouteToOriginating = false; + const originatingChannel = ctx.OriginatingChannel; + const originatingTo = ctx.OriginatingTo; + const ttsChannel = "telegram"; + + const sendPayloadAsync = async ( + payload: ReplyPayload, + abortSignal?: AbortSignal, + mirror?: boolean, + ): Promise => { + // TypeScript doesn't narrow these from the shouldRouteToOriginating check, + // but they're guaranteed non-null when this function is called. + if (!originatingChannel || !originatingTo) { + return; + } + const result = await routeReply({ + payload, + channel: originatingChannel, + to: originatingTo, + sessionKey: ctx.SessionKey, + accountId: ctx.AccountId, + threadId: ctx.MessageThreadId, + cfg, + abortSignal, + mirror, + }); + if (!result.ok) { + logVerbose(\`dispatch-from-config: route-reply failed: \${result.error ?? "unknown error"}\`); + } + }; + + markProcessing(); + + try { + const fastAbort = await tryFastAbortFromMessage({ ctx, cfg }); + if (fastAbort.handled) { + const payload = { + text: formatAbortReplyText(fastAbort.stoppedSubagents), + } satisfies ReplyPayload; + let queuedFinal = false; + let routedFinalCount = 0; + if (shouldRouteToOriginating && originatingChannel && originatingTo) { + const result = await routeReply({ + payload, + channel: originatingChannel, + to: originatingTo, + sessionKey: ctx.SessionKey, + accountId: ctx.AccountId, + threadId: ctx.MessageThreadId, + cfg, + }); + queuedFinal = result.ok; + if (result.ok) { + routedFinalCount += 1; + } + } else { + queuedFinal = dispatcher.sendFinalReply(payload); + } + const counts = dispatcher.getQueuedCounts(); + counts.final += routedFinalCount; + recordProcessed("completed", { reason: "fast_abort" }); + markIdle("message_completed"); + return { queuedFinal, counts }; + } + + let accumulatedBlockText = ""; + let blockCount = 0; + const shouldSendToolSummaries = ctx.ChatType !== "group" && ctx.CommandSource !== "native"; + + const resolveToolDeliveryPayload = (payload: ReplyPayload): ReplyPayload | null => { + if (shouldSendToolSummaries) { + return payload; + } + const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + if (!hasMedia) { + return null; + } + return { ...payload, text: undefined }; + }; + + const replyResult = await (params.replyResolver ?? getReplyFromConfig)( + ctx, + { + ...params.replyOptions, + onToolResult: (payload: ReplyPayload) => { + const run = async () => { + const ttsPayload = await maybeApplyTtsToPayload({ + payload, + cfg, + channel: ttsChannel, + kind: "tool", + inboundAudio, + ttsAuto: sessionTtsAuto, + }); + const deliveryPayload = resolveToolDeliveryPayload(ttsPayload); + if (!deliveryPayload) { + return; + } + if (shouldRouteToOriginating) { + await sendPayloadAsync(deliveryPayload, undefined, false); + } else { + dispatcher.sendToolResult(deliveryPayload); + } + }; + return run(); + }, + onBlockReply: (payload: ReplyPayload, context) => { + const run = async () => { + // Accumulate block text for TTS generation after streaming + if (payload.text) { + if (accumulatedBlockText.length > 0) { + accumulatedBlockText += "\\n"; + } + accumulatedBlockText += payload.text; + blockCount++; + } + const ttsPayload = await maybeApplyTtsToPayload({ + payload, + cfg, + channel: ttsChannel, + kind: "block", + inboundAudio, + ttsAuto: sessionTtsAuto, + }); + if (shouldRouteToOriginating) { + await sendPayloadAsync(ttsPayload, context?.abortSignal, false); + } else { + dispatcher.sendBlockReply(ttsPayload); + } + }; + return run(); + }, + }, + cfg, + ); + + const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : []; + + let queuedFinal = false; + let routedFinalCount = 0; + for (const reply of replies) { + const ttsReply = await maybeApplyTtsToPayload({ + payload: reply, + cfg, + channel: ttsChannel, + kind: "final", + inboundAudio, + ttsAuto: sessionTtsAuto, + }); + if (shouldRouteToOriginating && originatingChannel && originatingTo) { + const result = await routeReply({ + payload: ttsReply, + channel: originatingChannel, + to: originatingTo, + sessionKey: ctx.SessionKey, + accountId: ctx.AccountId, + threadId: ctx.MessageThreadId, + cfg, + }); + if (!result.ok) { + logVerbose( + \`dispatch-from-config: route-reply (final) failed: \${result.error ?? "unknown error"}\`, + ); + } + queuedFinal = result.ok || queuedFinal; + if (result.ok) { + routedFinalCount += 1; + } + } else { + queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal; + } + } + + const ttsMode = resolveTtsConfig(cfg).mode ?? "final"; + if ( + ttsMode === "final" && + replies.length === 0 && + blockCount > 0 && + accumulatedBlockText.trim() + ) { + const ttsSyntheticReply = await maybeApplyTtsToPayload({ + payload: { text: accumulatedBlockText }, + cfg, + channel: ttsChannel, + kind: "final", + inboundAudio, + ttsAuto: sessionTtsAuto, + }); + if (ttsSyntheticReply.mediaUrl) { + const ttsOnlyPayload: ReplyPayload = { + mediaUrl: ttsSyntheticReply.mediaUrl, + audioAsVoice: ttsSyntheticReply.audioAsVoice, + }; + if (shouldRouteToOriginating && originatingChannel && originatingTo) { + const result = await routeReply({ + payload: ttsOnlyPayload, + channel: originatingChannel, + to: originatingTo, + sessionKey: ctx.SessionKey, + accountId: ctx.AccountId, + threadId: ctx.MessageThreadId, + cfg, + }); + queuedFinal = result.ok || queuedFinal; + if (result.ok) { + routedFinalCount += 1; + } + } else { + const didQueue = dispatcher.sendFinalReply(ttsOnlyPayload); + queuedFinal = didQueue || queuedFinal; + } + } + } + + const counts = dispatcher.getQueuedCounts(); + counts.final += routedFinalCount; + recordProcessed("completed"); + markIdle("message_completed"); + return { queuedFinal, counts }; + } catch (err) { + recordProcessed("error", { error: String(err) }); + markIdle("message_error"); + throw err; + } +}`; + +describe("applyProtectedGroupReplySuppression", () => { + test("injects protected Telegram group suppression into the runtime dispatcher", () => { + const patched = applyProtectedGroupReplySuppression(FIXTURE); + + expect(patched).toContain(PATCH_MARKER); + expect(patched).toContain( + `const suppressProtectedTelegramGroupReplies = shouldSuppressProtectedTelegramGroupReplies(`, + ); + expect(patched).toContain(`if (suppressProtectedTelegramGroupReplies) {`); + expect(patched).toContain(`return null;`); + expect(patched).toContain(`return;`); + expect(patched).toContain( + `const dispatchableReplies = suppressProtectedTelegramGroupReplies ? [] : replies;`, + ); + expect(patched).toContain(`for (const reply of dispatchableReplies) {`); + expect(patched).toContain(`dispatchableReplies.length === 0 &&`); + }); + + test("is idempotent when the marker is already present", () => { + const patched = applyProtectedGroupReplySuppression(FIXTURE); + + expect(applyProtectedGroupReplySuppression(patched)).toBe(patched); + }); +}); diff --git a/tests/telegram-group-allowlist-guard.test.ts b/tests/telegram-group-allowlist-guard.test.ts index cd28c12..f4d9955 100644 --- a/tests/telegram-group-allowlist-guard.test.ts +++ b/tests/telegram-group-allowlist-guard.test.ts @@ -196,6 +196,45 @@ describe('telegram-group-allowlist-guard', () => { expect(result).toEqual({ cancel: true }); }); + test('fails closed for protected group sends when outbound account metadata is missing', async () => { + const { sending } = createHarness({ + enabledAccounts: ['primary_bot', 'secondary_bot'], + blockAllGroupReplies: true, + }); + + const result = await sending( + { + to: 'telegram:-5114267406', + content: 'fallback error text', + }, + { + channelId: 'telegram', + }, + ); + + expect(result).toEqual({ cancel: true }); + }); + + test('uses agentId fallback to block protected group sends when accountId is absent', async () => { + const { sending } = createHarness({ + enabledAccounts: ['primary_bot', 'secondary_bot'], + blockAllGroupReplies: true, + }); + + const result = await sending( + { + to: 'telegram:-5114267406', + content: 'fallback error text', + }, + { + channelId: 'telegram', + agentId: 'primary_bot', + }, + ); + + expect(result).toEqual({ cancel: true }); + }); + test('only applies to configured accounts', async () => { const { received, sending } = createHarness({ enabledAccounts: ['primary_bot'], @@ -426,6 +465,29 @@ describe('telegram-group-allowlist-guard', () => { }); }); + test('uses agentId fallback to block protected group tool execution when accountId is absent', async () => { + const { beforeToolCall } = createHarness({ + enabledAccounts: ['primary_bot', 'secondary_bot'], + blockAllGroupReplies: true, + }); + + const result = await beforeToolCall( + { + toolName: 'web_search', + params: { query: 'latest updates' }, + }, + { + toolName: 'web_search', + sessionKey: 'agent:primary_bot:telegram:group:-5114267406', + }, + ); + + expect(result).toEqual({ + block: true, + blockReason: 'tool execution blocked: group replies are disabled for this account', + }); + }); + test('does not block tool execution in direct sessions', async () => { const { beforeToolCall } = createHarness({ enabledAccounts: ['primary_bot'],