← Back to PRs

#15841: Add Silent Ingest for Group Chat Messages

by napetrov open 2026-02-13 23:35 View on GitHub →
channel: signal channel: telegram size: L
# Add Silent Ingest for Mention-Gated Group Chats Fixes #15268 ## What this PR does Implements a native **silent ingest** path for group messages that are skipped by mention-gating (`requireMention: true`). When a non-mentioned group message is skipped: - no LLM run is started, - no tools are invoked by core auto-reply, - but plugins can still observe the message through a dedicated `message_ingest` hook. This removes the old "NO_REPLY prompt workaround" cost pattern and makes the behavior explicit in config/runtime. ## Why this is now more generalized (not channel-specific glue) This PR started as channel-local logic and was refactored into a shared pipeline: - `runSilentMessageIngest(...)` shared helper with common guardrails - shared group-policy resolver `resolveChannelGroupIngest(...)` - dedicated hook contract `message_ingest` in plugin types/runner - channel adapters (Telegram/Signal) only map inbound context into the shared path So the model is now **platform-aligned** and extendable to other group-capable channels without re-implementing safety logic. ## Design alignment with current OpenClaw architecture The implementation follows existing OpenClaw patterns: - policy resolution in `config/group-policy.ts` (group-specific > wildcard default) - channel handlers stay thin and delegate cross-cutting behavior to shared helpers - explicit hook typing + wiring in plugin runner (`message_ingest` vs overloading `message_received`) - fail-open operational behavior (ingest failures/timeouts do not block message routing) ## Key behavior and safety guarantees - Ingest runs only on mention-skip path for group messages. - `runSilentMessageIngest` enforces: - empty-content short-circuit, - `hasHooks("message_ingest")` short-circuit, - per-conversation + global inflight caps, - timeout wait cap, - inflight slot retention until hook settles (prevents timeout over-admission), - sender sanitization for hook event `from`. - Telegram topic isolation uses conversation key `chatId:threadId`. ## Config support ### Telegram - `TelegramGroupConfig.ingest?: boolean` - `TelegramTopicConfig.ingest?: boolean` - Zod schema updated for both group and topic. ### Signal - `SignalAccountConfig.groups?: Record<string, SignalGroupConfig>` - `SignalGroupConfig.ingest?: boolean` - Zod schema updated to accept `channels.signal.groups.*.ingest`. ## Hook contract Adds `message_ingest` hook to plugin hook names/handler map and exposes `runMessageIngest(...)` in hook runner. Semantics: - passive ingest event, - no automatic LLM/tool execution in this path, - async non-blocking from channel routing perspective. ## Tests added - `src/channels/silent-ingest.test.ts` - sanitization path - no-hook short-circuit - timeout timer cleanup - inflight retention under timeout - `src/config/group-policy.ingest.test.ts` - group vs wildcard precedence - `src/config/zod-schema.providers-core.telegram-ingest.test.ts` - `src/config/zod-schema.providers-core.signal-ingest.test.ts` - `src/utils/sanitize.test.ts` ## Practical impact For busy group chats, this enables memory/indexing hooks on non-mentioned traffic **without paying per-message LLM cost**, while keeping responses mention-gated and consistent with existing group policy behavior. <!-- greptile_comment --> <h3>Greptile Summary</h3> This PR adds a **silent ingest path** for group messages that are skipped by mention-gating. When `requireMention: true` and the message contains no mention, the new `message_ingest` hook fires asynchronously so plugins can observe the traffic (e.g. for memory/indexing) without paying LLM cost. The implementation is well-structured: a shared `runSilentMessageIngest` helper enforces per-conversation and global inflight caps, a configurable timeout, and sanitization of the `from` field; both the Telegram and Signal adapters delegate to it with minimal adapter-level glue. Key points from the review: - **Overall design is sound.** The shared helper, policy resolver, and hook type wiring all follow existing patterns in the codebase. The inflight/timeout logic is correct and the test for background-hook inflight retention is a nice edge-case catch. - **`sanitizeUserText` output can exceed `maxLength` by 3 characters** — the `"..."` suffix is appended *after* slicing to `maxLength`, so the result is `maxLength + 3` chars. This is a minor style issue, but the docstring says "limits length" which implies a strict cap. - **Message content is not sanitized for control characters.** Only the `from` field passes through `sanitizeUserText`; `content` receives only `.trim()`. Control characters (null bytes, embedded newlines, etc.) in the message body will reach `message_ingest` plugin handlers unchanged. This appears to be an intentional design choice (the PR description explicitly calls out "sender sanitization"), but it is worth a second look for log-injection safety in plugins. - **`enabled` parameter is always `true` at both call sites.** Each caller guards with `if (ingestEnabled)` before calling `runSilentMessageIngest({ enabled: true, ... })`, making the `enabled` parameter inside the function redundant. This is cosmetic but could cause confusion about where the guard lives. <h3>Confidence Score: 4/5</h3> - This PR is safe to merge with minor style concerns; no logic or security bugs that would break existing behavior. - The core inflight/timeout logic is correct, the hook wiring is clean, and the change is additive (off by default, requires explicit `ingest: true` in config). The three findings are all style-level: (1) `sanitizeUserText` outputs up to `maxLength + 3` chars, (2) message content isn't control-char sanitized before reaching plugins, and (3) the `enabled` param is always hardcoded to `true` at call sites. None of these break runtime behavior today. - src/utils/sanitize.ts (truncation length contract), src/channels/silent-ingest.ts (content sanitization gap and redundant `enabled` param) <sub>Last reviewed commit: fd7ed0a</sub> <!-- greptile_other_comments_section --> <!-- /greptile_comment -->

Most Similar PRs