diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b62ab953e..377e63f8fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Active Memory: degrade gracefully when memory recall fails during prompt building, logging a warning and letting the reply continue without memory context instead of failing the whole turn. (#69485) Thanks @Magicray1217. - Ollama: add provider-policy defaults for `baseUrl` and `models` so implicit local discovery can run before config validation rejects a minimal Ollama provider config. (#69370) Thanks @PratikRai0101. - Agents/model selection: clear transient auto-failover session overrides before each turn so recovered primary models are retried immediately without emitting user-override reset warnings. (#69365) Thanks @hitesh-github99. +- Auto-reply: apply silent `NO_REPLY` policy per conversation type, so direct chats get a helpful rewritten reply while groups and internal deliveries can remain quiet. (#68644) Thanks @Takhoffman. - Telegram/status reactions: honor `messages.removeAckAfterReply` when lifecycle status reactions are enabled, clearing or restoring the reaction after success/error using the configured hold timings. (#68067) Thanks @poiskgit. - Web search/plugins: resolve plugin-scoped SecretRef API keys for bundled Exa, Firecrawl, Gemini, Kimi, Perplexity, Tavily, and Grok web-search providers when they are selected through the shared web-search config. (#68424) Thanks @afurm. - Telegram/polling: raise the default polling watchdog threshold from 90s to 120s and add configurable `channels.telegram.pollingStallThresholdMs` (also per-account) so long-running Telegram work gets more room before polling is treated as stalled. (#57737) Thanks @Vitalcheffe. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 549c0a09bcb..b046027869b 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -580abc79677d84fa66cb55e42ea093bfa9681655861166c02dfaa5a313d44310 config-baseline.json -04a82c2208bf69e0a195e7712e3a518a8255c1bb002c31f712cb95003325635b config-baseline.core.json +e3a16ceb9e933c5b707b717c18a1d9d50f98e687a98e6c35f4f3a290f7036a62 config-baseline.json +ae1ab87635e7bf613c84fee04425af901ceeb67fb5dbcf1c74095aa00a59ee88 config-baseline.core.json e239cc20f20f8d0172812bc0ad3ee6df52da88e2e2702e3d03a47e01561132ae config-baseline.channel.json 8fb3a1cf5fe56ab8fc2cb46341c3403aed32b0d1f0aaeac0e96cd3599db4f06e config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 310733b38f0..ad04d7d6fe7 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -e6da774a43c16fddc77e04b0d2888d06454d1adb84814c8db4fee0f495c1eec1 plugin-sdk-api-baseline.json -ef8b5fd8081dfa05740f6a609144e755d95a19196a1617037dba1213134699df plugin-sdk-api-baseline.jsonl +f135ddc1802b7f8b2d29bf495fd0ac1f497a89bab8164ca8c7c8f18efc010e6e plugin-sdk-api-baseline.json +a47d06095ec5c3701a94888a11e89700d8a8511db46fa3122fb9407e160707b6 plugin-sdk-api-baseline.jsonl diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index bcd2fc9da1f..4e9240c0cff 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -153,6 +153,20 @@ Outbound message formatting is centralized in `messages`: Details: [Configuration](/gateway/configuration-reference#messages) and channel docs. +## Silent replies + +The exact silent token `NO_REPLY` / `no_reply` means “do not deliver a user-visible reply”. +OpenClaw resolves that behavior by conversation type: + +- Direct conversations disallow silence by default and rewrite a bare silent + reply to a short visible fallback. +- Groups/channels allow silence by default. +- Internal orchestration allows silence by default. + +Defaults live under `agents.defaults.silentReply` and +`agents.defaults.silentReplyRewrite`; `surfaces..silentReply` and +`surfaces..silentReplyRewrite` can override them per surface. + ## Related - [Streaming](/concepts/streaming) — real-time message delivery diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index b799f3e1bb6..8a7091af4ff 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -167,7 +167,7 @@ surfaces: - `openclaw/plugin-sdk/messaging-targets` for target parsing/matching - `openclaw/plugin-sdk/outbound-media` and `openclaw/plugin-sdk/outbound-runtime` for media loading plus outbound - identity/send delegates + identity/send delegates and payload planning - `openclaw/plugin-sdk/thread-bindings-runtime` for thread-binding lifecycle and adapter registration - `openclaw/plugin-sdk/agent-media-payload` only when a legacy agent/media diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index d17ac0cafe5..cc03ff7aba8 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -207,7 +207,7 @@ Current bundled provider examples: | `plugin-sdk/inbound-reply-dispatch` | Inbound reply helpers | Shared record-and-dispatch helpers | | `plugin-sdk/messaging-targets` | Messaging target parsing | Target parsing/matching helpers | | `plugin-sdk/outbound-media` | Outbound media helpers | Shared outbound media loading | - | `plugin-sdk/outbound-runtime` | Outbound runtime helpers | Outbound identity/send delegate helpers | + | `plugin-sdk/outbound-runtime` | Outbound runtime helpers | Outbound identity/send delegate and payload planning helpers | | `plugin-sdk/thread-bindings-runtime` | Thread-binding helpers | Thread-binding lifecycle and adapter helpers | | `plugin-sdk/agent-media-payload` | Legacy media payload helpers | Agent media payload builder for legacy field layouts | | `plugin-sdk/channel-runtime` | Deprecated compatibility shim | Legacy channel runtime utilities only | diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index f08d6d2289b..9613d5f9e95 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -95,7 +95,7 @@ explicitly promotes one as public. | `plugin-sdk/inbound-reply-dispatch` | Shared inbound record-and-dispatch helpers | | `plugin-sdk/messaging-targets` | Target parsing/matching helpers | | `plugin-sdk/outbound-media` | Shared outbound media loading helpers | - | `plugin-sdk/outbound-runtime` | Outbound identity/send delegate helpers | + | `plugin-sdk/outbound-runtime` | Outbound identity, send delegate, and payload planning helpers | | `plugin-sdk/poll-runtime` | Narrow poll normalization helpers | | `plugin-sdk/thread-bindings-runtime` | Thread-binding lifecycle and adapter helpers | | `plugin-sdk/agent-media-payload` | Legacy agent media payload builder | diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index c07a8034b61..5010550823d 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -3,6 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveChunkMode as resolveChunkModeRuntime } from "../../../src/auto-reply/chunk.js"; import { resolveMarkdownTableMode as resolveMarkdownTableModeRuntime } from "../../../src/config/markdown-tables.js"; import { resolveSessionStoreEntry as resolveSessionStoreEntryRuntime } from "../../../src/config/sessions/store.js"; +import type { OpenClawConfig } from "../../../src/config/types.openclaw.js"; import { getAgentScopedMediaLocalRoots as getAgentScopedMediaLocalRootsRuntime } from "../../../src/media/local-roots.js"; import { resolveAutoTopicLabelConfig as resolveAutoTopicLabelConfigRuntime } from "./auto-topic-label.js"; import type { TelegramBotDeps } from "./bot-deps.js"; @@ -2563,6 +2564,124 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.clear).toHaveBeenCalledTimes(1); }); + it("rewrites a no-visible-response DM turn through silent-reply fallback", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ + queuedFinal: false, + }); + deliverReplies.mockResolvedValueOnce({ delivered: true }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { + SessionKey: "agent:main:telegram:direct:123", + } as unknown as TelegramMessageContext["ctxPayload"], + }), + cfg: { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + } as unknown as OpenClawConfig, + }); + + expect(deliverReplies).toHaveBeenCalledTimes(1); + const deliveredReplies = deliverReplies.mock.calls[0]?.[0]?.replies; + expect(Array.isArray(deliveredReplies)).toBe(true); + expect(deliveredReplies?.[0]?.text).toEqual(expect.any(String)); + expect(deliveredReplies?.[0]?.text?.trim()).not.toBe("NO_REPLY"); + }); + + it("does not add silent-reply fallback after visible block delivery", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "visible block" }, { kind: "block" }); + return { queuedFinal: false }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { + SessionKey: "agent:main:telegram:direct:123", + } as unknown as TelegramMessageContext["ctxPayload"], + }), + cfg: { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + } as unknown as OpenClawConfig, + }); + + expect(deliverReplies).toHaveBeenCalledTimes(1); + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ text: "visible block" })], + }), + ); + }); + + it("keeps no-visible-response group turns silent when policy allows silence", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ + queuedFinal: false, + }); + + await dispatchWithContext({ + context: createContext({ + isGroup: true, + primaryCtx: { + message: { chat: { id: 123, type: "supergroup" } }, + } as TelegramMessageContext["primaryCtx"], + msg: { + chat: { id: 123, type: "supergroup" }, + message_id: 456, + message_thread_id: 777, + } as TelegramMessageContext["msg"], + threadSpec: { id: 777, scope: "forum" }, + ctxPayload: { + SessionKey: "agent:main:telegram:group:123", + } as unknown as TelegramMessageContext["ctxPayload"], + }), + cfg: { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + } as unknown as OpenClawConfig, + }); + + expect(deliverReplies).not.toHaveBeenCalled(); + }); + it("sends fallback and clears preview when deliver throws (dispatcher swallows error)", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 35c22051c30..bd71380866f 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -13,11 +13,20 @@ import type { TelegramAccountConfig, } from "openclaw/plugin-sdk/config-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { + createOutboundPayloadPlan, + projectOutboundPayloadPlanForDelivery, +} from "openclaw/plugin-sdk/outbound-runtime"; import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { isAbortRequestText, type ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { danger, logVerbose, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; +import { + createSubsystemLogger, + danger, + logVerbose, + sleepWithAbort, +} from "openclaw/plugin-sdk/runtime-env"; import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js"; import type { TelegramMessageContext } from "./bot-message-context.js"; import { @@ -66,6 +75,7 @@ import { editMessageTelegram } from "./send.js"; import { cacheSticker, describeStickerImage } from "./sticker-cache.js"; const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; +const silentReplyDispatchLogger = createSubsystemLogger("telegram/silent-reply-dispatch"); /** Minimum chars before sending first streaming message (improves push notification UX) */ const DRAFT_MIN_INITIAL_CHARS = 30; @@ -1107,7 +1117,36 @@ export const dispatchTelegramMessage = async ({ sentFallback = result.delivered; } - const hasFinalResponse = queuedFinal || sentFallback; + if (!queuedFinal && !sentFallback && !dispatchError && !deliverySummary.delivered) { + const policySessionKey = + ctxPayload.CommandSource === "native" + ? (ctxPayload.CommandTargetSessionKey ?? ctxPayload.SessionKey) + : ctxPayload.SessionKey; + const silentReplyFallback = projectOutboundPayloadPlanForDelivery( + createOutboundPayloadPlan([{ text: "NO_REPLY" }], { + cfg, + sessionKey: policySessionKey, + surface: "telegram", + }), + ); + if (silentReplyFallback.length > 0) { + const result = await (telegramDeps.deliverReplies ?? deliverReplies)({ + replies: silentReplyFallback, + ...deliveryBaseOptions, + silent: false, + mediaLoader: telegramDeps.loadWebMedia, + }); + sentFallback = result.delivered; + } + silentReplyDispatchLogger.debug("telegram turn ended without visible final response", { + hasSessionKey: Boolean(policySessionKey), + hasChatId: chatId != null, + queuedFinal, + sentFallback, + }); + } + + const hasFinalResponse = queuedFinal || sentFallback || deliverySummary.delivered; if (statusReactionController && !hasFinalResponse) { void finalizeTelegramStatusReaction({ outcome: "error", hasFinalResponse: false }).catch( diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index dd9bc72c800..318573d4596 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -90,6 +90,10 @@ type TelegramNativeReplyChannelData = { buttons?: TelegramInlineButtons; pin?: boolean; }; +type TelegramResolvedGroupConfig = { + groupConfig?: TelegramGroupConfig | TelegramDirectConfig; + topicConfig?: TelegramTopicConfig; +}; type TelegramCommandAuthResult = { chatId: number; @@ -98,7 +102,7 @@ type TelegramCommandAuthResult = { resolvedThreadId?: number; senderId: string; senderUsername: string; - groupConfig?: TelegramGroupConfig; + groupConfig?: TelegramGroupConfig | TelegramDirectConfig; topicConfig?: TelegramTopicConfig; commandAuthorized: boolean; }; @@ -187,7 +191,7 @@ export type RegisterTelegramHandlerParams = { resolveTelegramGroupConfig: ( chatId: string | number, messageThreadId?: number, - ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig }; + ) => TelegramResolvedGroupConfig; shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean; processMessage: ( ctx: TelegramContext, @@ -240,7 +244,7 @@ export type RegisterTelegramNativeCommandsParams = { resolveTelegramGroupConfig: ( chatId: string | number, messageThreadId?: number, - ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig }; + ) => TelegramResolvedGroupConfig; shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean; telegramDeps?: TelegramNativeCommandDeps; opts: { token: string }; @@ -260,7 +264,7 @@ async function resolveTelegramCommandAuth(params: { resolveTelegramGroupConfig: ( chatId: string | number, messageThreadId?: number, - ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig }; + ) => TelegramResolvedGroupConfig; requireAuth: boolean; }): Promise { const { @@ -322,7 +326,8 @@ async function resolveTelegramCommandAuth(params: { !isGroup && groupConfig && "dmPolicy" in groupConfig ? (groupConfig.dmPolicy ?? telegramCfg.dmPolicy ?? "pairing") : (telegramCfg.dmPolicy ?? "pairing"); - const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic; + const requireTopic = + !isGroup && groupConfig && "requireTopic" in groupConfig ? groupConfig.requireTopic : undefined; if (!isGroup && requireTopic === true && dmThreadId == null) { logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`); return null; @@ -683,9 +688,11 @@ export const registerTelegramNativeCommands = ({ return { chatId, threadSpec, route, mediaLocalRoots, tableMode, chunkMode }; }; const buildCommandDeliveryBaseOptions = (params: { + cfg: OpenClawConfig; chatId: string | number; accountId: string; sessionKeyForInternalHooks?: string; + policySessionKey?: string; mirrorIsGroup?: boolean; mirrorGroupId?: string; mediaLocalRoots?: readonly string[]; @@ -694,9 +701,11 @@ export const registerTelegramNativeCommands = ({ chunkMode: TelegramChunkMode; linkPreview?: boolean; }) => ({ + cfg: params.cfg, chatId: String(params.chatId), accountId: params.accountId, sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, + policySessionKey: params.policySessionKey, mirrorIsGroup: params.mirrorIsGroup, mirrorGroupId: params.mirrorGroupId, token: opts.token, @@ -851,9 +860,11 @@ export const registerTelegramNativeCommands = ({ targetSessionKey: sessionKey, }); const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ + cfg: executionCfg, chatId, accountId: route.accountId, sessionKeyForInternalHooks: commandSessionKey, + policySessionKey: commandTargetSessionKey, mirrorIsGroup: isGroup, mirrorGroupId: isGroup ? String(chatId) : undefined, mediaLocalRoots, @@ -1036,9 +1047,11 @@ export const registerTelegramNativeCommands = ({ } const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext; const deliveryBaseOptions = buildCommandDeliveryBaseOptions({ + cfg: runtimeCfg, chatId, accountId: route.accountId, sessionKeyForInternalHooks: route.sessionKey, + policySessionKey: route.sessionKey, mirrorIsGroup: isGroup, mirrorGroupId: isGroup ? String(chatId) : undefined, mediaLocalRoots, diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 38c41318743..12ee1ec9d04 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -11,11 +11,16 @@ import { } from "openclaw/plugin-sdk/hook-runtime"; import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime"; import { isGifMedia, kindFromMime } from "openclaw/plugin-sdk/media-runtime"; +import { + createOutboundPayloadPlan, + projectOutboundPayloadPlanForDelivery, +} from "openclaw/plugin-sdk/outbound-runtime"; import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import type { TelegramInlineButtons } from "../button-types.js"; @@ -45,6 +50,7 @@ const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; const CAPTION_TOO_LONG_RE = /caption is too long/i; const GrammyErrorCtor: typeof GrammyError | undefined = typeof GrammyError === "function" ? GrammyError : undefined; +const silentReplyLogger = createSubsystemLogger("telegram/silent-reply"); type DeliveryProgress = ReplyThreadDeliveryProgress & { deliveredCount: number; @@ -581,9 +587,11 @@ export function emitTelegramMessageSentHooks(params: EmitMessageSentHookParams): export async function deliverReplies(params: { replies: ReplyPayload[]; + cfg?: import("openclaw/plugin-sdk/config-runtime").OpenClawConfig; chatId: string; accountId?: string; sessionKeyForInternalHooks?: string; + policySessionKey?: string; mirrorIsGroup?: boolean; mirrorGroupId?: string; token: string; @@ -620,7 +628,34 @@ export async function deliverReplies(params: { chunkMode: params.chunkMode ?? "length", tableMode: params.tableMode, }); - for (const originalReply of params.replies) { + const candidateReplies: ReplyPayload[] = []; + for (const reply of params.replies) { + if (!reply || typeof reply !== "object") { + params.runtime.error?.(danger("reply missing text/media")); + continue; + } + candidateReplies.push(reply); + } + const normalizedReplies = projectOutboundPayloadPlanForDelivery( + createOutboundPayloadPlan(candidateReplies, { + cfg: params.cfg, + sessionKey: params.policySessionKey ?? params.sessionKeyForInternalHooks, + surface: "telegram", + }), + ); + const originalExactSilentCount = candidateReplies.filter( + (reply) => typeof reply.text === "string" && reply.text.trim().toUpperCase() === "NO_REPLY", + ).length; + if (originalExactSilentCount > 0) { + silentReplyLogger.debug("telegram delivery normalized NO_REPLY candidates", { + hasSessionKey: Boolean(params.sessionKeyForInternalHooks), + hasChatId: params.chatId.length > 0, + originalCount: candidateReplies.length, + normalizedCount: normalizedReplies.length, + originalExactSilentCount, + }); + } + for (const originalReply of normalizedReplies) { let reply = originalReply; const mediaList = reply?.mediaUrls?.length ? reply.mediaUrls diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index 705910f6cbc..f374c937452 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -1,7 +1,6 @@ import type { Bot } from "grammy"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { beforeEach, describe, expect, it, vi } from "vitest"; - const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -294,6 +293,56 @@ describe("deliverReplies", () => { expect(triggerInternalHook).not.toHaveBeenCalled(); }); + it("rewrites exact NO_REPLY for direct Telegram sessions", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 12, chat: { id: "123" } }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + sessionKeyForInternalHooks: "agent:test:telegram:direct:123", + replies: [{ text: "NO_REPLY" }], + runtime, + bot, + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage.mock.calls[0]?.[1]).toEqual(expect.any(String)); + expect(sendMessage.mock.calls[0]?.[1]?.trim()).not.toBe("NO_REPLY"); + }); + + it("uses the policy session key for exact NO_REPLY policy", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 121, chat: { id: "123" } }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + sessionKeyForInternalHooks: "agent:test:telegram:slash:123", + policySessionKey: "agent:test:telegram:direct:123", + replies: [{ text: "NO_REPLY" }], + runtime, + bot, + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage.mock.calls[0]?.[1]).toEqual(expect.any(String)); + expect(sendMessage.mock.calls[0]?.[1]?.trim()).not.toBe("NO_REPLY"); + }); + + it("suppresses exact NO_REPLY for group Telegram sessions", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 13, chat: { id: "123" } }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + sessionKeyForInternalHooks: "agent:test:telegram:group:123", + replies: [{ text: "NO_REPLY" }], + runtime, + bot, + }); + + expect(sendMessage).not.toHaveBeenCalled(); + }); + it("emits internal message:sent with success=false on delivery failure", async () => { const runtime = createRuntime(false); const sendMessage = vi.fn().mockRejectedValue(new Error("network error")); diff --git a/scripts/check-gateway-watch-regression.mjs b/scripts/check-gateway-watch-regression.mjs index 55345a042b3..cdf6cff0801 100644 --- a/scripts/check-gateway-watch-regression.mjs +++ b/scripts/check-gateway-watch-regression.mjs @@ -32,6 +32,8 @@ const WATCH_GATEWAY_SKIP_ENV = { OPENCLAW_SKIP_CHANNELS: "1", OPENCLAW_SKIP_CRON: "1", OPENCLAW_SKIP_GMAIL_WATCHER: "1", + OPENCLAW_TEST_MINIMAL_GATEWAY: "1", + NODE_ENV: "test", }; function parseArgs(argv) { diff --git a/src/auto-reply/dispatch.test.ts b/src/auto-reply/dispatch.test.ts index 16429ece672..7245c53fca8 100644 --- a/src/auto-reply/dispatch.test.ts +++ b/src/auto-reply/dispatch.test.ts @@ -167,4 +167,37 @@ describe("withReplyDispatcher", () => { expect(typing.markRunComplete).toHaveBeenCalledTimes(1); expect(typing.markDispatchIdle).toHaveBeenCalled(); }); + + it("uses CommandTargetSessionKey for silent-reply policy on native command turns", async () => { + hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({ + dispatcher: createDispatcher([]), + replyOptions: {}, + markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), + }); + hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" }); + + await dispatchInboundMessageWithBufferedDispatcher({ + ctx: buildTestCtx({ + SessionKey: "agent:test:telegram:slash:8231046597", + CommandSource: "native", + CommandTargetSessionKey: "agent:test:telegram:direct:8231046597", + Surface: "telegram", + }), + cfg: {} as OpenClawConfig, + dispatcherOptions: { + deliver: async () => undefined, + }, + replyResolver: async () => ({ text: "ok" }), + }); + + expect(hoisted.createReplyDispatcherWithTypingMock).toHaveBeenCalledWith( + expect.objectContaining({ + silentReplyContext: expect.objectContaining({ + sessionKey: "agent:test:telegram:direct:8231046597", + surface: "telegram", + }), + }), + ); + }); }); diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts index bfec4555706..d531d7cba92 100644 --- a/src/auto-reply/dispatch.ts +++ b/src/auto-reply/dispatch.ts @@ -14,6 +14,22 @@ import type { ReplyDispatcher } from "./reply/reply-dispatcher.types.js"; import type { FinalizedMsgContext, MsgContext } from "./templating.js"; import type { GetReplyOptions } from "./types.js"; +function resolveDispatcherSilentReplyContext( + ctx: MsgContext | FinalizedMsgContext, + cfg: OpenClawConfig, +) { + const finalized = finalizeInboundContext(ctx); + const policySessionKey = + finalized.CommandSource === "native" + ? (finalized.CommandTargetSessionKey ?? finalized.SessionKey) + : finalized.SessionKey; + return { + cfg, + sessionKey: policySessionKey, + surface: finalized.Surface ?? finalized.Provider, + }; +} + export type DispatchInboundResult = DispatchFromConfigResult; export { withReplyDispatcher } from "./dispatch-dispatcher.js"; @@ -45,8 +61,12 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: { replyOptions?: Omit; replyResolver?: GetReplyFromConfig; }): Promise { + const silentReplyContext = resolveDispatcherSilentReplyContext(params.ctx, params.cfg); const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = - createReplyDispatcherWithTyping(params.dispatcherOptions); + createReplyDispatcherWithTyping({ + ...params.dispatcherOptions, + silentReplyContext: params.dispatcherOptions.silentReplyContext ?? silentReplyContext, + }); try { return await dispatchInboundMessage({ ctx: params.ctx, @@ -71,7 +91,11 @@ export async function dispatchInboundMessageWithDispatcher(params: { replyOptions?: Omit; replyResolver?: GetReplyFromConfig; }): Promise { - const dispatcher = createReplyDispatcher(params.dispatcherOptions); + const silentReplyContext = resolveDispatcherSilentReplyContext(params.ctx, params.cfg); + const dispatcher = createReplyDispatcher({ + ...params.dispatcherOptions, + silentReplyContext: params.dispatcherOptions.silentReplyContext ?? silentReplyContext, + }); return await dispatchInboundMessage({ ctx: params.ctx, cfg: params.cfg, diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 5c9e5a5500d..0ed2177118c 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -381,6 +381,10 @@ export async function dispatchReplyFromConfig( channel: originatingChannel, to: originatingTo, sessionKey: ctx.SessionKey, + policySessionKey: + ctx.CommandSource === "native" + ? (ctx.CommandTargetSessionKey ?? ctx.SessionKey) + : ctx.SessionKey, accountId: ctx.AccountId, requesterSenderId: ctx.SenderId, requesterSenderName: ctx.SenderName, diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index 616f0ca4560..ad895cce244 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -1,7 +1,12 @@ import type { TypingCallbacks } from "../../channels/typing.js"; +import { resolveSilentReplyPolicy } from "../../config/silent-reply.js"; import type { HumanDelayConfig } from "../../config/types.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { generateSecureInt } from "../../infra/secure-random.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { SilentReplyConversationType } from "../../shared/silent-reply-policy.js"; import { sleep } from "../../utils.js"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { registerDispatcher } from "./dispatcher-registry.js"; import { normalizeReplyPayload, type NormalizeReplySkipReason } from "./normalize-reply.js"; @@ -25,6 +30,7 @@ type ReplyDispatchDeliverer = ( const DEFAULT_HUMAN_DELAY_MIN_MS = 800; const DEFAULT_HUMAN_DELAY_MAX_MS = 2500; +const silentReplyLogger = createSubsystemLogger("silent-reply/dispatcher"); /** Generate a random delay within the configured range. */ function getHumanDelay(config: HumanDelayConfig | undefined): number { @@ -44,6 +50,12 @@ function getHumanDelay(config: HumanDelayConfig | undefined): number { export type ReplyDispatcherOptions = { deliver: ReplyDispatchDeliverer; + silentReplyContext?: { + cfg?: OpenClawConfig; + sessionKey?: string; + surface?: string; + conversationType?: SilentReplyConversationType; + }; responsePrefix?: string; transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null; /** Static context for response prefix template interpolation. */ @@ -103,6 +115,39 @@ function normalizeReplyPayloadInternal( }); } +function shouldPreserveSilentFinalPayload(params: { + kind: ReplyDispatchKind; + payload: ReplyPayload; + silentReplyContext?: ReplyDispatcherOptions["silentReplyContext"]; +}): boolean { + if (params.kind !== "final") { + return false; + } + if (!isSilentReplyText(params.payload.text, SILENT_REPLY_TOKEN)) { + return false; + } + const context = params.silentReplyContext; + if (!context) { + return false; + } + const resolvedPolicy = resolveSilentReplyPolicy({ + cfg: context.cfg, + sessionKey: context.sessionKey, + surface: context.surface, + conversationType: context.conversationType, + }); + const shouldPreserve = resolvedPolicy !== "allow"; + if (shouldPreserve) { + silentReplyLogger.debug("preserving exact NO_REPLY final payload before normalization", { + hasSessionKey: Boolean(context.sessionKey), + surface: context.surface, + conversationType: context.conversationType, + resolvedPolicy, + }); + } + return shouldPreserve; +} + export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDispatcher { let sendChain: Promise = Promise.resolve(); // Track in-flight deliveries so we can emit a reliable "idle" signal. @@ -131,15 +176,32 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis }); const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => { - const normalized = normalizeReplyPayloadInternal(payload, { - responsePrefix: options.responsePrefix, - responsePrefixContext: options.responsePrefixContext, - responsePrefixContextProvider: options.responsePrefixContextProvider, - transformReplyPayload: options.transformReplyPayload, - onHeartbeatStrip: options.onHeartbeatStrip, - onSkip: (reason) => options.onSkip?.(payload, { kind, reason }), - }); + const originalWasExactSilent = isSilentReplyText(payload.text, SILENT_REPLY_TOKEN); + const normalized = shouldPreserveSilentFinalPayload({ + kind, + payload, + silentReplyContext: options.silentReplyContext, + }) + ? { + ...payload, + text: payload.text?.trim() || SILENT_REPLY_TOKEN, + } + : normalizeReplyPayloadInternal(payload, { + responsePrefix: options.responsePrefix, + responsePrefixContext: options.responsePrefixContext, + responsePrefixContextProvider: options.responsePrefixContextProvider, + transformReplyPayload: options.transformReplyPayload, + onHeartbeatStrip: options.onHeartbeatStrip, + onSkip: (reason) => options.onSkip?.(payload, { kind, reason }), + }); if (!normalized) { + if (kind === "final" && originalWasExactSilent) { + silentReplyLogger.debug("exact NO_REPLY final payload was skipped before delivery", { + hasSessionKey: Boolean(options.silentReplyContext?.sessionKey), + surface: options.silentReplyContext?.surface, + conversationType: options.silentReplyContext?.conversationType, + }); + } return false; } queuedCounts[kind] += 1; diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index d18b1e53dbd..8a36850a216 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js"; import { createReplyDispatcher } from "./reply-dispatcher.js"; import { createReplyToModeFilter } from "./reply-threading.js"; @@ -20,6 +21,69 @@ describe("createReplyDispatcher", () => { expect(deliver.mock.calls[1]?.[0]?.text).toBe(`interject.${SILENT_REPLY_TOKEN}`); }); + it("preserves exact NO_REPLY final payloads for direct sessions where silence is disallowed", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }; + const dispatcher = createReplyDispatcher({ + deliver, + silentReplyContext: { + cfg, + sessionKey: "agent:main:telegram:direct:123", + surface: "telegram", + }, + }); + + expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(true); + + await dispatcher.waitForIdle(); + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver.mock.calls[0]?.[0]?.text).toBe(SILENT_REPLY_TOKEN); + }); + + it("still drops exact NO_REPLY final payloads for group sessions where silence is allowed", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }; + const dispatcher = createReplyDispatcher({ + deliver, + silentReplyContext: { + cfg, + sessionKey: "agent:main:telegram:group:123", + surface: "telegram", + }, + }); + + expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false); + + await dispatcher.waitForIdle(); + expect(deliver).not.toHaveBeenCalled(); + }); + it("strips heartbeat tokens and applies responsePrefix", async () => { const deliver = vi.fn().mockResolvedValue(undefined); const onHeartbeatStrip = vi.fn(); diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 0b3e0454485..faa2084e7e3 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -222,6 +222,41 @@ describe("routeReply", () => { }); }); + it("passes policySessionKey through to outbound delivery targets", async () => { + const cfg = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + } as unknown as OpenClawConfig; + + const res = await routeReply({ + payload: { text: "native command response" }, + channel: "slack", + to: "channel:C123", + cfg, + sessionKey: "agent:main:main", + policySessionKey: "agent:main:direct:U123", + }); + + expect(res.ok).toBe(true); + expectLastDelivery({ + payloads: [expect.objectContaining({ text: "native command response" })], + session: expect.objectContaining({ + key: "agent:main:main", + policyKey: "agent:main:direct:U123", + }), + }); + }); + it("applies responsePrefix when routing", async () => { const cfg = { messages: { responsePrefix: "[openclaw]" }, diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index f580199bc7e..7452fbe9a9b 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -12,6 +12,7 @@ import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { getBundledChannelPlugin } from "../../channels/plugins/bundled.js"; import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { normalizeChatChannelId } from "../../channels/registry.js"; +import { resolveSilentReplyPolicy } from "../../config/silent-reply.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; @@ -19,6 +20,7 @@ import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import type { OriginatingChannelType } from "../templating.js"; +import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { ReplyPayload } from "../types.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; import { @@ -44,6 +46,8 @@ export type RouteReplyParams = { to: string; /** Session key for deriving agent identity defaults (multi-agent). */ sessionKey?: string; + /** Session key for policy resolution when native-command delivery targets a different session. */ + policySessionKey?: string; /** Provider account id (multi-account). */ accountId?: string; /** Originating sender id for sender-scoped outbound media policy. */ @@ -93,11 +97,10 @@ export async function routeReply(params: RouteReplyParams): Promise - messaging.transformReplyPayload?.({ - payload: nextPayload, - cfg, - accountId, - }) ?? nextPayload - : undefined, - }); + const policySessionKey = params.policySessionKey ?? params.sessionKey; + const shouldPreserveSilentPayload = + isSilentReplyPayloadText(payload.text) && + resolveSilentReplyPolicy({ + cfg, + sessionKey: policySessionKey, + surface: channelId ?? String(channel), + }) !== "allow"; + const normalized = shouldPreserveSilentPayload + ? { + ...payload, + text: payload.text?.trim() || SILENT_REPLY_TOKEN, + } + : normalizeReplyPayload(payload, { + responsePrefix, + transformReplyPayload: messaging?.transformReplyPayload + ? (nextPayload) => + messaging.transformReplyPayload?.({ + payload: nextPayload, + cfg, + accountId, + }) ?? nextPayload + : undefined, + }); if (!normalized) { return { ok: true }; } @@ -196,6 +212,7 @@ export async function routeReply(params: RouteReplyParams): Promise { + it("uses the default direct/group/internal policy and rewrite flags", () => { + expect(resolveSilentReplyPolicy({ surface: "webchat" })).toBe("disallow"); + expect(resolveSilentReplyRewriteEnabled({ surface: "webchat" })).toBe(true); + expect( + resolveSilentReplyPolicy({ + sessionKey: "agent:main:telegram:group:123", + surface: "telegram", + }), + ).toBe("allow"); + expect( + resolveSilentReplyRewriteEnabled({ + sessionKey: "agent:main:telegram:group:123", + surface: "telegram", + }), + ).toBe(false); + expect( + resolveSilentReplyPolicy({ + sessionKey: "agent:main:subagent:abc", + }), + ).toBe("allow"); + }); + + it("applies configured defaults by conversation type", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "disallow", + internal: "allow", + }, + silentReplyRewrite: { + direct: false, + group: true, + internal: false, + }, + }, + }, + }; + + expect(resolveSilentReplyPolicy({ cfg, surface: "webchat" })).toBe("disallow"); + expect(resolveSilentReplyRewriteEnabled({ cfg, surface: "webchat" })).toBe(false); + expect( + resolveSilentReplyPolicy({ + cfg, + sessionKey: "agent:main:discord:group:123", + surface: "discord", + }), + ).toBe("disallow"); + expect( + resolveSilentReplyRewriteEnabled({ + cfg, + sessionKey: "agent:main:discord:group:123", + surface: "discord", + }), + ).toBe(true); + }); + + it("lets surface overrides beat the default policy and rewrite flags", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + group: false, + internal: false, + }, + }, + }, + surfaces: { + telegram: { + silentReply: { + direct: "allow", + }, + silentReplyRewrite: { + direct: false, + }, + }, + }, + }; + + expect( + resolveSilentReplyPolicy({ + cfg, + sessionKey: "agent:main:telegram:direct:123", + surface: "telegram", + }), + ).toBe("allow"); + expect( + resolveSilentReplyRewriteEnabled({ + cfg, + sessionKey: "agent:main:telegram:direct:123", + surface: "telegram", + }), + ).toBe(false); + }); +}); diff --git a/src/config/silent-reply.ts b/src/config/silent-reply.ts new file mode 100644 index 00000000000..577a8e0cb97 --- /dev/null +++ b/src/config/silent-reply.ts @@ -0,0 +1,60 @@ +import { + classifySilentReplyConversationType, + resolveSilentReplyPolicyFromPolicies, + resolveSilentReplyRewriteFromPolicies, + type SilentReplyConversationType, + type SilentReplyPolicy, + type SilentReplyPolicyShape, + type SilentReplyRewriteShape, +} from "../shared/silent-reply-policy.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; + +type ResolveSilentReplyParams = { + cfg?: OpenClawConfig; + sessionKey?: string; + surface?: string; + conversationType?: SilentReplyConversationType; +}; + +function resolveSilentReplyConversationContext(params: ResolveSilentReplyParams): { + conversationType: SilentReplyConversationType; + defaultPolicy?: SilentReplyPolicyShape; + defaultRewrite?: SilentReplyRewriteShape; + surfacePolicy?: SilentReplyPolicyShape; + surfaceRewrite?: SilentReplyRewriteShape; +} { + const conversationType = classifySilentReplyConversationType({ + sessionKey: params.sessionKey, + surface: params.surface, + conversationType: params.conversationType, + }); + const normalizedSurface = normalizeLowercaseStringOrEmpty(params.surface); + const surface = normalizedSurface ? params.cfg?.surfaces?.[normalizedSurface] : undefined; + return { + conversationType, + defaultPolicy: params.cfg?.agents?.defaults?.silentReply, + defaultRewrite: params.cfg?.agents?.defaults?.silentReplyRewrite, + surfacePolicy: surface?.silentReply, + surfaceRewrite: surface?.silentReplyRewrite, + }; +} + +export function resolveSilentReplySettings(params: ResolveSilentReplyParams): { + policy: SilentReplyPolicy; + rewrite: boolean; +} { + const context = resolveSilentReplyConversationContext(params); + return { + policy: resolveSilentReplyPolicyFromPolicies(context), + rewrite: resolveSilentReplyRewriteFromPolicies(context), + }; +} + +export function resolveSilentReplyPolicy(params: ResolveSilentReplyParams): SilentReplyPolicy { + return resolveSilentReplySettings(params).policy; +} + +export function resolveSilentReplyRewriteEnabled(params: ResolveSilentReplyParams): boolean { + return resolveSilentReplySettings(params).rewrite; +} diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index dff4d97027c..c7ef65f4c3c 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -1,3 +1,7 @@ +import type { + SilentReplyPolicyShape, + SilentReplyRewriteShape, +} from "../shared/silent-reply-policy.js"; import type { AgentEmbeddedHarnessConfig, AgentModelConfig, @@ -191,6 +195,10 @@ export type AgentDefaultsConfig = { workspace?: string; /** Optional default allowlist of skills for agents that do not set agents.list[].skills. */ skills?: string[]; + /** Silent-reply policy by conversation type. */ + silentReply?: SilentReplyPolicyShape; + /** Whether disallowed silent replies should be rewritten by conversation type. */ + silentReplyRewrite?: SilentReplyRewriteShape; /** Optional repository root for system prompt runtime line (overrides auto-detect). */ repoRoot?: string; /** Optional full system prompt replacement. Primarily for prompt debugging and controlled experiments. */ @@ -205,9 +213,9 @@ export type AgentDefaultsConfig = { * transcript already contains a completed assistant turn */ contextInjection?: AgentContextInjection; - /** Max chars for injected bootstrap files before truncation (default: 12000). */ + /** Max chars for injected bootstrap files before truncation (default: 20000). */ bootstrapMaxChars?: number; - /** Max total chars across all injected bootstrap files (default: 60000). */ + /** Max total chars across all injected bootstrap files (default: 150000). */ bootstrapTotalMaxChars?: number; /** Experimental agent-default flags. Keep off unless you are intentionally testing a preview surface. */ experimental?: { diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index bac95be0d48..52a5494690e 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -1,3 +1,7 @@ +import type { + SilentReplyPolicyShape, + SilentReplyRewriteShape, +} from "../shared/silent-reply-policy.js"; import type { AcpConfig } from "./types.acp.js"; import type { AgentBinding, AgentsConfig } from "./types.agents.js"; import type { ApprovalsConfig } from "./types.approvals.js"; @@ -29,8 +33,12 @@ import type { SecretsConfig } from "./types.secrets.js"; import type { SkillsConfig } from "./types.skills.js"; import type { ToolsConfig } from "./types.tools.js"; +export type SurfaceConfigEntry = { + silentReply?: SilentReplyPolicyShape; + silentReplyRewrite?: SilentReplyRewriteShape; +}; + export type OpenClawConfig = { - /** JSON Schema URL for editor tooling (VS Code, etc.). Preserved across config rewrites. */ $schema?: string; meta?: { /** Last OpenClaw version that wrote this config. */ @@ -97,6 +105,7 @@ export type OpenClawConfig = { secrets?: SecretsConfig; skills?: SkillsConfig; plugins?: PluginsConfig; + surfaces?: Record; models?: ModelsConfig; nodeHost?: NodeHostConfig; agents?: AgentsConfig; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 4a553a5e226..28370af8784 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -17,6 +17,24 @@ import { TypingModeSchema, } from "./zod-schema.core.js"; +export const SilentReplyPolicySchema = z.union([z.literal("allow"), z.literal("disallow")]); + +export const SilentReplyPolicyConfigSchema = z + .object({ + direct: SilentReplyPolicySchema.optional(), + group: SilentReplyPolicySchema.optional(), + internal: SilentReplyPolicySchema.optional(), + }) + .strict(); + +export const SilentReplyRewriteConfigSchema = z + .object({ + direct: z.boolean().optional(), + group: z.boolean().optional(), + internal: z.boolean().optional(), + }) + .strict(); + export const AgentDefaultsSchema = z .object({ /** Global default provider params applied to all models before per-model and per-agent overrides. */ @@ -47,6 +65,8 @@ export const AgentDefaultsSchema = z .optional(), workspace: z.string().optional(), skills: z.array(z.string()).optional(), + silentReply: SilentReplyPolicyConfigSchema.optional(), + silentReplyRewrite: SilentReplyRewriteConfigSchema.optional(), repoRoot: z.string().optional(), systemPromptOverride: z.string().optional(), skipBootstrap: z.boolean().optional(), diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index f80b9f7c892..ce8c7a1d63c 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -5,6 +5,10 @@ import { normalizeLowercaseStringOrEmpty, normalizeStringifiedOptionalString, } from "../shared/string-coerce.js"; +import { + SilentReplyPolicyConfigSchema, + SilentReplyRewriteConfigSchema, +} from "./zod-schema.agent-defaults.js"; import { ToolsSchema } from "./zod-schema.agent-runtime.js"; import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js"; import { ApprovalsSchema } from "./zod-schema.approvals.js"; @@ -964,6 +968,17 @@ export const OpenClawSchema = z }) .strict() .optional(), + surfaces: z + .record( + z.string(), + z + .object({ + silentReply: SilentReplyPolicyConfigSchema.optional(), + silentReplyRewrite: SilentReplyRewriteConfigSchema.optional(), + }) + .strict(), + ) + .optional(), }) .strict() .superRefine((cfg, ctx) => { diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index d3eb0365e07..0f01869cd92 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -903,6 +903,57 @@ describe("deliverOutboundPayloads", () => { ); }); + it("applies silent-reply policy from the outbound session", async () => { + const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m-silent", roomId: "!room" }); + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }; + + await deliverOutboundPayloads({ + cfg, + channel: "matrix", + to: "!room:example", + payloads: [{ text: "NO_REPLY" }], + deps: { matrix: sendMatrix }, + session: { + key: "agent:main:matrix:slash:!room", + policyKey: "agent:main:matrix:direct:!room", + }, + }); + + expect(sendMatrix).toHaveBeenCalledTimes(1); + expect(sendMatrix.mock.calls[0]?.[1]).toEqual(expect.any(String)); + expect(sendMatrix.mock.calls[0]?.[1]).not.toBe("NO_REPLY"); + }); + + it("keeps allowed group silent replies silent during outbound delivery", async () => { + const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m-silent", roomId: "!room" }); + + await deliverOutboundPayloads({ + cfg: matrixChunkConfig, + channel: "matrix", + to: "!room:example", + payloads: [{ text: "NO_REPLY" }], + deps: { matrix: sendMatrix }, + session: { + key: "agent:main:matrix:group:ops", + }, + }); + + expect(sendMatrix).not.toHaveBeenCalled(); + }); + it("acks the queue entry when delivery is aborted", async () => { const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" }); const abortController = new AbortController(); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index c5066e0ea76..1636fb56b11 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -553,7 +553,11 @@ async function deliverOutboundPayloadsCore( params: DeliverOutboundPayloadsCoreParams, ): Promise { const { cfg, channel, to, payloads } = params; - const outboundPayloadPlan = createOutboundPayloadPlan(payloads); + const outboundPayloadPlan = createOutboundPayloadPlan(payloads, { + cfg, + sessionKey: params.session?.policyKey ?? params.session?.key, + surface: channel, + }); const accountId = params.accountId; const deps = params.deps; const abortSignal = params.abortSignal; diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts index 4fd4ff267d6..eeefec8377a 100644 --- a/src/infra/outbound/payloads.test.ts +++ b/src/infra/outbound/payloads.test.ts @@ -1,6 +1,7 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { describe, expect, it } from "vitest"; import type { ReplyPayload } from "../../auto-reply/types.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { typedCases } from "../../test-utils/typed-cases.js"; import { createOutboundPayloadPlan, @@ -187,6 +188,124 @@ describe("normalizeReplyPayloadsForDelivery", () => { ]); }); + it("rewrites bare silent replies for direct conversations when requested", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }; + + const sessionKey = "agent:main:telegram:direct:123"; + const projected = projectOutboundPayloadPlanForDelivery( + createOutboundPayloadPlan([{ text: "NO_REPLY" }], { + cfg, + sessionKey, + surface: "telegram", + }), + ); + expect(projected).toHaveLength(1); + expect(projected[0]?.text).toEqual(expect.any(String)); + expect(projected[0]?.text?.trim()).not.toBe("NO_REPLY"); + }); + + it("drops bare silent replies for groups when policy allows silence", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }; + + expect( + projectOutboundPayloadPlanForDelivery( + createOutboundPayloadPlan([{ text: "NO_REPLY" }], { + cfg, + sessionKey: "agent:main:telegram:group:123", + surface: "telegram", + }), + ), + ).toEqual([]); + }); + + it("does not add rewrite chatter when visible content is already being delivered", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }; + + expect( + projectOutboundPayloadPlanForDelivery( + createOutboundPayloadPlan([{ text: "NO_REPLY" }, { text: "visible reply" }], { + cfg, + sessionKey: "agent:main:telegram:direct:123", + surface: "telegram", + }), + ), + ).toEqual([ + expect.objectContaining({ + text: "visible reply", + }), + ]); + }); + + it("keeps bare NO_REPLY visible when silence is disallowed but rewrite is off", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: false, + }, + }, + }, + }; + + expect( + projectOutboundPayloadPlanForDelivery( + createOutboundPayloadPlan([{ text: "NO_REPLY" }], { + cfg, + sessionKey: "agent:main:telegram:direct:123", + surface: "telegram", + }), + ), + ).toEqual([ + expect.objectContaining({ + text: "NO_REPLY", + }), + ]); + }); + it("is idempotent for already-normalized delivery payloads", () => { const once = normalizeReplyPayloadsForDelivery([ { diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index dfa47dd9795..d9ed5f47e66 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -6,12 +6,18 @@ import { shouldSuppressReasoningPayload, } from "../../auto-reply/reply/reply-payloads.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; +import { resolveSilentReplySettings } from "../../config/silent-reply.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { hasInteractiveReplyBlocks, hasReplyChannelData, hasReplyPayloadContent, type InteractiveReply, } from "../../interactive/payload.js"; +import { + resolveSilentReplyRewriteText, + type SilentReplyConversationType, +} from "../../shared/silent-reply-policy.js"; export type NormalizedOutboundPayload = { text: string; @@ -37,6 +43,13 @@ export type OutboundPayloadPlan = { hasChannelData: boolean; }; +type OutboundPayloadPlanContext = { + cfg?: OpenClawConfig; + sessionKey?: string; + surface?: string; + conversationType?: SilentReplyConversationType; +}; + export type OutboundPayloadMirror = { text: string; mediaUrls: string[]; @@ -89,7 +102,16 @@ function mergeMediaUrls(...lists: Array | unde return merged; } -function createOutboundPayloadPlanEntry(payload: ReplyPayload): OutboundPayloadPlan | null { +type PreparedOutboundPayloadPlanEntry = { + payload: ReplyPayload; + hasInteractive: boolean; + hasChannelData: boolean; + isSilent: boolean; +}; + +function createOutboundPayloadPlanEntry( + payload: ReplyPayload, +): PreparedOutboundPayloadPlanEntry | null { if (shouldSuppressReasoningPayload(payload)) { return null; } @@ -104,9 +126,7 @@ function createOutboundPayloadPlanEntry(payload: ReplyPayload): OutboundPayloadP if (isSuppressedRelayStatusText(parsedText) && mergedMedia.length === 0) { return null; } - if (parsed.isSilent && mergedMedia.length === 0) { - return null; - } + const isSilent = parsed.isSilent && mergedMedia.length === 0; const hasMultipleMedia = (explicitMediaUrls?.length ?? 0) > 1; const resolvedMediaUrl = hasMultipleMedia ? undefined : explicitMediaUrl; const normalizedPayload: ReplyPayload = { @@ -123,32 +143,94 @@ function createOutboundPayloadPlanEntry(payload: ReplyPayload): OutboundPayloadP replyToCurrent: payload.replyToCurrent || parsed.replyToCurrent, audioAsVoice: Boolean(payload.audioAsVoice || parsed.audioAsVoice), }; - if (!isRenderablePayload(normalizedPayload)) { + if (!isRenderablePayload(normalizedPayload) && !isSilent) { return null; } - const parts = resolveSendableOutboundReplyParts(normalizedPayload); const hasChannelData = hasReplyChannelData(normalizedPayload.channelData); return { payload: normalizedPayload, - parts, hasInteractive: hasInteractiveReplyBlocks(normalizedPayload.interactive), hasChannelData, + isSilent, }; } export function createOutboundPayloadPlan( payloads: readonly ReplyPayload[], + context: OutboundPayloadPlanContext = {}, ): OutboundPayloadPlan[] { // Intentionally scoped to channel-agnostic normalization and projection inputs. // Transport concerns (queueing, hooks, retries), channel transforms, and // heartbeat-specific token semantics remain outside this plan boundary. - const plan: OutboundPayloadPlan[] = []; + const resolvedSilentReplySettings = resolveSilentReplySettings({ + cfg: context.cfg, + sessionKey: context.sessionKey, + surface: context.surface, + conversationType: context.conversationType, + }); + const prepared: PreparedOutboundPayloadPlanEntry[] = []; for (const payload of payloads) { const entry = createOutboundPayloadPlanEntry(payload); if (!entry) { continue; } - plan.push(entry); + prepared.push(entry); + } + const hasVisibleNonSilentContent = prepared.some((entry) => { + if (entry.isSilent) { + return false; + } + const parts = resolveSendableOutboundReplyParts(entry.payload); + return hasReplyPayloadContent( + { ...entry.payload, text: parts.text, mediaUrls: parts.mediaUrls }, + { hasChannelData: entry.hasChannelData }, + ); + }); + const plan: OutboundPayloadPlan[] = []; + for (const entry of prepared) { + if (!entry.isSilent) { + plan.push({ + payload: entry.payload, + parts: resolveSendableOutboundReplyParts(entry.payload), + hasInteractive: entry.hasInteractive, + hasChannelData: entry.hasChannelData, + }); + continue; + } + if (hasVisibleNonSilentContent || resolvedSilentReplySettings.policy === "allow") { + continue; + } + if (!resolvedSilentReplySettings.rewrite) { + const visibleSilentPayload: ReplyPayload = { + ...entry.payload, + text: entry.payload.text?.trim() || "NO_REPLY", + }; + if (!isRenderablePayload(visibleSilentPayload)) { + continue; + } + plan.push({ + payload: visibleSilentPayload, + parts: resolveSendableOutboundReplyParts(visibleSilentPayload), + hasInteractive: entry.hasInteractive, + hasChannelData: entry.hasChannelData, + }); + continue; + } + const rewrittenPayload: ReplyPayload = { + ...entry.payload, + text: resolveSilentReplyRewriteText({ + seed: `${context.sessionKey ?? context.surface ?? "silent-reply"}:${entry.payload.text ?? ""}`, + }), + }; + if (!isRenderablePayload(rewrittenPayload)) { + continue; + } + plan.push({ + payload: rewrittenPayload, + parts: resolveSendableOutboundReplyParts(rewrittenPayload), + hasInteractive: entry.hasInteractive, + hasChannelData: entry.hasChannelData, + }); } return plan; } diff --git a/src/infra/outbound/session-context.ts b/src/infra/outbound/session-context.ts index ce367a656ed..4f4ae41f1c9 100644 --- a/src/infra/outbound/session-context.ts +++ b/src/infra/outbound/session-context.ts @@ -5,6 +5,8 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js"; export type OutboundSessionContext = { /** Canonical session key used for internal hook dispatch. */ key?: string; + /** Session key used for policy resolution when delivery differs from the control session. */ + policyKey?: string; /** Active agent id used for workspace-scoped media roots. */ agentId?: string; /** Originating account id used for requester-scoped group policy resolution. */ @@ -22,6 +24,7 @@ export type OutboundSessionContext = { export function buildOutboundSessionContext(params: { cfg: OpenClawConfig; sessionKey?: string | null; + policySessionKey?: string | null; agentId?: string | null; requesterAccountId?: string | null; requesterSenderId?: string | null; @@ -30,6 +33,7 @@ export function buildOutboundSessionContext(params: { requesterSenderE164?: string | null; }): OutboundSessionContext | undefined { const key = normalizeOptionalString(params.sessionKey); + const policyKey = normalizeOptionalString(params.policySessionKey); const explicitAgentId = normalizeOptionalString(params.agentId); const requesterAccountId = normalizeOptionalString(params.requesterAccountId); const requesterSenderId = normalizeOptionalString(params.requesterSenderId); @@ -42,6 +46,7 @@ export function buildOutboundSessionContext(params: { const agentId = explicitAgentId ?? derivedAgentId; if ( !key && + !policyKey && !agentId && !requesterAccountId && !requesterSenderId && @@ -53,6 +58,7 @@ export function buildOutboundSessionContext(params: { } return { ...(key ? { key } : {}), + ...(policyKey ? { policyKey } : {}), ...(agentId ? { agentId } : {}), ...(requesterAccountId ? { requesterAccountId } : {}), ...(requesterSenderId ? { requesterSenderId } : {}), diff --git a/src/plugin-sdk/outbound-runtime.ts b/src/plugin-sdk/outbound-runtime.ts index 1440c5da544..7b1aec40769 100644 --- a/src/plugin-sdk/outbound-runtime.ts +++ b/src/plugin-sdk/outbound-runtime.ts @@ -2,3 +2,7 @@ export { createRuntimeOutboundDelegates } from "../channels/plugins/runtime-forw export { resolveOutboundSendDep, type OutboundSendDeps } from "../infra/outbound/send-deps.js"; export { resolveAgentOutboundIdentity, type OutboundIdentity } from "../infra/outbound/identity.js"; export { sanitizeForPlainText } from "../infra/outbound/sanitize-text.js"; +export { + createOutboundPayloadPlan, + projectOutboundPayloadPlanForDelivery, +} from "../infra/outbound/payloads.js"; diff --git a/src/shared/silent-reply-policy.test.ts b/src/shared/silent-reply-policy.test.ts new file mode 100644 index 00000000000..bdd23623013 --- /dev/null +++ b/src/shared/silent-reply-policy.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SILENT_REPLY_POLICY, + DEFAULT_SILENT_REPLY_REWRITE, + classifySilentReplyConversationType, + resolveSilentReplyPolicyFromPolicies, + resolveSilentReplyRewriteFromPolicies, + resolveSilentReplyRewriteText, +} from "./silent-reply-policy.js"; + +describe("classifySilentReplyConversationType", () => { + it("prefers an explicit conversation type", () => { + expect( + classifySilentReplyConversationType({ + sessionKey: "agent:main:group:123", + conversationType: "internal", + }), + ).toBe("internal"); + }); + + it("classifies direct and group session keys", () => { + expect( + classifySilentReplyConversationType({ + sessionKey: "agent:main:telegram:direct:123", + }), + ).toBe("direct"); + expect( + classifySilentReplyConversationType({ + sessionKey: "agent:main:discord:group:123", + }), + ).toBe("group"); + }); + + it("treats webchat as direct by default and unknown surfaces as internal", () => { + expect(classifySilentReplyConversationType({ surface: "webchat" })).toBe("direct"); + expect(classifySilentReplyConversationType({ surface: "subagent" })).toBe("internal"); + }); +}); + +describe("resolveSilentReplyPolicyFromPolicies", () => { + it("uses defaults when no overrides exist", () => { + expect(resolveSilentReplyPolicyFromPolicies({ conversationType: "direct" })).toBe( + DEFAULT_SILENT_REPLY_POLICY.direct, + ); + expect(resolveSilentReplyPolicyFromPolicies({ conversationType: "group" })).toBe( + DEFAULT_SILENT_REPLY_POLICY.group, + ); + }); + + it("prefers surface policy over defaults", () => { + expect( + resolveSilentReplyPolicyFromPolicies({ + conversationType: "direct", + defaultPolicy: { direct: "disallow" }, + surfacePolicy: { direct: "allow" }, + }), + ).toBe("allow"); + }); +}); + +describe("resolveSilentReplyRewriteFromPolicies", () => { + it("uses default rewrite flags when no overrides exist", () => { + expect(resolveSilentReplyRewriteFromPolicies({ conversationType: "direct" })).toBe( + DEFAULT_SILENT_REPLY_REWRITE.direct, + ); + expect(resolveSilentReplyRewriteFromPolicies({ conversationType: "group" })).toBe( + DEFAULT_SILENT_REPLY_REWRITE.group, + ); + }); + + it("prefers surface rewrite flags over defaults", () => { + expect( + resolveSilentReplyRewriteFromPolicies({ + conversationType: "direct", + defaultRewrite: { direct: true }, + surfaceRewrite: { direct: false }, + }), + ).toBe(false); + }); +}); + +describe("resolveSilentReplyRewriteText", () => { + it("picks a deterministic rewrite for a given seed", () => { + const first = resolveSilentReplyRewriteText({ seed: "main:NO_REPLY" }); + const second = resolveSilentReplyRewriteText({ seed: "main:NO_REPLY" }); + expect(first).toBe(second); + expect(first).not.toBe("NO_REPLY"); + expect(first.length).toBeGreaterThan(0); + }); +}); diff --git a/src/shared/silent-reply-policy.ts b/src/shared/silent-reply-policy.ts new file mode 100644 index 00000000000..1a7c526d275 --- /dev/null +++ b/src/shared/silent-reply-policy.ts @@ -0,0 +1,112 @@ +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js"; + +export type SilentReplyPolicy = "allow" | "disallow"; +export type SilentReplyMode = "allow" | "rewrite"; +export type SilentReplyConversationType = "direct" | "group" | "internal"; +export type SilentReplyPolicyShape = Partial< + Record +>; +export type SilentReplyRewriteShape = Partial>; + +export const DEFAULT_SILENT_REPLY_POLICY: Record = { + direct: "disallow", + group: "allow", + internal: "allow", +}; + +export const DEFAULT_SILENT_REPLY_REWRITE: Record = { + direct: true, + group: false, + internal: false, +}; + +const SILENT_REPLY_REWRITE_TEXTS = [ + "Nothing to add right now.", + "All quiet on my side.", + "No extra notes from me.", + "Standing by.", + "No update from me on this one.", + "Nothing further to report.", + "I have nothing else to add.", + "No follow-up needed from me.", + "No additional reply from me here.", + "No extra comment on my end.", + "No further note from me.", + "That is all from me for now.", + "No added response from me.", + "Nothing else to say here.", + "No extra message needed from me.", + "No additional note on this one.", + "No further response from me.", + "Nothing new to add from my side.", + "No extra update from me.", + "I have no further reply here.", + "Nothing additional from me.", + "No added note from my side.", + "No more to report from me.", + "No extra reply needed here.", + "No further word from me.", + "Nothing further on my end.", + "No extra answer from me.", + "No additional response from my side.", +] as const; + +function hashSeed(seed: string): number { + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; + } + return hash; +} + +export function classifySilentReplyConversationType(params: { + sessionKey?: string; + surface?: string; + conversationType?: SilentReplyConversationType; +}): SilentReplyConversationType { + if (params.conversationType) { + return params.conversationType; + } + const normalizedSessionKey = normalizeLowercaseStringOrEmpty(params.sessionKey); + if (normalizedSessionKey.includes(":group:") || normalizedSessionKey.includes(":channel:")) { + return "group"; + } + if (normalizedSessionKey.includes(":direct:") || normalizedSessionKey.includes(":dm:")) { + return "direct"; + } + const normalizedSurface = normalizeLowercaseStringOrEmpty(params.surface); + if (normalizedSurface === "webchat") { + return "direct"; + } + return "internal"; +} + +export function resolveSilentReplyPolicyFromPolicies(params: { + conversationType: SilentReplyConversationType; + defaultPolicy?: SilentReplyPolicyShape; + surfacePolicy?: SilentReplyPolicyShape; +}): SilentReplyPolicy { + return ( + params.surfacePolicy?.[params.conversationType] ?? + params.defaultPolicy?.[params.conversationType] ?? + DEFAULT_SILENT_REPLY_POLICY[params.conversationType] + ); +} + +export function resolveSilentReplyRewriteFromPolicies(params: { + conversationType: SilentReplyConversationType; + defaultRewrite?: SilentReplyRewriteShape; + surfaceRewrite?: SilentReplyRewriteShape; +}): boolean { + return ( + params.surfaceRewrite?.[params.conversationType] ?? + params.defaultRewrite?.[params.conversationType] ?? + DEFAULT_SILENT_REPLY_REWRITE[params.conversationType] + ); +} + +export function resolveSilentReplyRewriteText(params: { seed?: string }): string { + const seed = params.seed?.trim() || "silent-reply"; + const index = hashSeed(seed) % SILENT_REPLY_REWRITE_TEXTS.length; + return SILENT_REPLY_REWRITE_TEXTS[index] ?? SILENT_REPLY_REWRITE_TEXTS[0]; +}