#15841: Add Silent Ingest for Group Chat Messages
channel: signal
channel: telegram
size: L
Cluster:
Messaging Platform Improvements
# 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
#19648: fix: suppress silent-reply partial tokens during streaming
by bradleypriest · 2026-02-18
75.4%
#14057: feat(telegram): add ignoreMediaTypes config to skip specific inboun...
by pavelsamoylenko · 2026-02-11
74.6%
#7719: fix(slack): thread replies with @mentions dropped in requireMention...
by SocialNerd42069 · 2026-02-03
74.4%
#19435: fix(slack): properly handle group DM (MPIM) events
by Utkarshbhimte · 2026-02-17
74.3%
#8086: feat(security): Add prompt injection guard rail
by bobbythelobster · 2026-02-03
74.0%
#4878: fix: string/type handling and API fixes (#4537, #4380, #4373, #4547...
by lailoo · 2026-01-30
74.0%
#22293: Hooks: add message-filter bundled hook with inbound message pre-filter
by MegaPhoenix92 · 2026-02-21
73.9%
#15051: feat: Zulip channel plugin with concurrent message processing
by FtlC-ian · 2026-02-12
73.7%
#8271: feat(signal): Add full quoted message context support
by ProofOfReach · 2026-02-03
73.0%
#5201: Signal: add group mention gating with requireMention config
by csalvato · 2026-01-31
72.7%