Skip to content

feat(cli): add telegram channel adapter, scoped config, and diagnostics (replacement)#115

Open
cdenneen wants to merge 31 commits intohappier-dev:devfrom
cdenneen:feat/telegram-channel-adapter-v2
Open

feat(cli): add telegram channel adapter, scoped config, and diagnostics (replacement)#115
cdenneen wants to merge 31 commits intohappier-dev:devfrom
cdenneen:feat/telegram-channel-adapter-v2

Conversation

@cdenneen
Copy link

@cdenneen cdenneen commented Mar 6, 2026

Summary

  • rebased on latest upstream/dev
  • squashed replacement history for Telegram adapter path
  • includes scoped bridge config + diagnostics + Telegram adapter behavior

Supersedes

Depends on

Notes

  • Replacement branch: feat/telegram-channel-adapter-v2

Summary by CodeRabbit

  • New Features

    • Added a Channel Bridge for bi-directional Telegram sessions with webhook and polling modes, CLI bridge commands, daemon auto-start, and runtime config overlay.
  • Bug Fixes

    • Improved KV conflict/retry resilience and safer webhook/token validation and error recovery.
  • Tests

    • Broad new test coverage for adapters, worker, KV flows, overlays, CLI, and startup/shutdown edge cases.
  • Documentation

    • Added Channel Bridge docs, Telegram guide, and UAT checklist; README index updated.
  • UI/Diagnostics

    • Doctor enhancements: webhook validation, port parsing, secret redaction, and exit-code handling.

@cdenneen
Copy link
Author

cdenneen commented Mar 6, 2026

@coderabbitai review

@cdenneen
Copy link
Author

cdenneen commented Mar 6, 2026

@greptile review

@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a Channel Bridge subsystem: provider-agnostic worker runtime, Telegram adapter and webhook relay, server KV transport and cache-backed binding store, scoped account config and overlays, CLI and daemon wiring, diagnostics/redaction utilities, extensive tests, and documentation including a Telegram guide and README "Channel Integrations" entry.

Changes

Cohort / File(s) Summary
Core worker & runtime
apps/cli/src/channels/core/channelBridgeWorker.ts, apps/cli/src/channels/core/channelBridgeWorker.test.ts, apps/cli/src/channels/startChannelBridgeWorker.ts, apps/cli/src/channels/startChannelBridgeWorker.test.ts, apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts
Adds a provider-agnostic channel bridge worker, tick loop, slash-command parsing/handling, per-session runtime caching, lifecycle start/stop orchestration, public worker APIs and extensive unit/integration tests.
Telegram adapter & webhook relay
apps/cli/src/channels/telegram/telegramAdapter.ts, apps/cli/src/channels/telegram/telegramAdapter.test.ts, apps/cli/src/channels/telegram/telegramWebhookRelay.ts, apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts
Implements Telegram API client, polling/webhook adapter with inbound normalization, ack/queue semantics, outbound truncation/error handling, secure loopback Fastify webhook relay, and tests.
Server KV, binding store & concurrency
apps/cli/src/channels/channelBridgeServerKv.ts, apps/cli/src/channels/channelBridgeServerKv.test.ts, apps/cli/src/channels/channelBindingStore.server.ts, apps/cli/src/channels/channelBindingStore.server.test.ts
Adds ChannelBridge KV client/types, base64 payload handling, schema parsing/validation, optimistic read/mutate with version-mismatch errors, and a cache-backed server binding store with optimistic retries and comprehensive tests.
Scoped/account config & overlay
apps/cli/src/channels/channelBridgeAccountConfig.ts, apps/cli/src/channels/channelBridgeAccountConfig.test.ts, apps/cli/src/channels/channelBridgeServerConfigOverlay.ts, apps/cli/src/channels/channelBridgeServerConfigOverlay.test.ts
Implements scoped Telegram bridge config read/upsert/remove, split local/shared updates, pruning of empty nodes, and overlay helper to merge server KV config into settings; includes tests.
Runtime config resolution
apps/cli/src/channels/channelBridgeConfig.ts, apps/cli/src/channels/channelBridgeConfig.test.ts
Adds layered runtime config resolver merging env/global/server/account scopes with parsing, bounds checking, host/port rules, secret extraction, and tests.
CLI command surface & dispatch
apps/cli/src/cli/commands/bridge.ts, apps/cli/src/cli/commands/bridge.test.ts, apps/cli/src/cli/commandRegistry.ts, apps/cli/src/cli/dispatch.ts, apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts
Adds bridge CLI (list/set/clear) with validation, transactional server-KV vs local secret updates and rollback, registry wiring, tmux disallowed checks update, and tests.
Daemon integration & test harness
apps/cli/src/daemon/startDaemon.ts, apps/cli/src/daemon/startDaemon.automation.integration.test.ts
Wires best-effort channel bridge startup into daemon bootstrap, overlays server KV into settings, manages lifecycle and shutdown timeouts, and exposes test harness hooks/mocks.
Diagnostics, persistence types & UI
apps/cli/src/persistence.ts, apps/cli/src/ui/doctor.ts, apps/cli/src/ui/doctor.test.ts
Adds channelBridge settings type, webhook validation and strict port parsing, redaction helpers for channelBridge secrets, runtime overlay diagnostics and exit-code control, and tests.
Documentation & README
README.md, docs/README.md, docs/channel-bridge.md, docs/channel-bridge-uat.md, docs/telegram-channel-bridge.md
Adds Channel Bridge docs, UAT checklist, Telegram guide, and README index entries including a "Channel Integrations" subsection linking the Telegram guide.
Misc & broad tests
apps/cli/src/channels/.../*.test.ts, apps/cli/src/cli/.../*.test.ts (multiple new/updated test files)
Large suite of unit and integration tests added across adapters, KV, store, worker, CLI, webhook relay, and runtime covering many success and error scenarios.

Sequence Diagram(s)

sequenceDiagram
    participant Adapter as Telegram Adapter
    participant Worker as Channel Bridge Worker
    participant Store as Binding Store
    participant KV as Server KV
    participant Session as Session Service

    rect rgba(0, 100, 200, 0.5)
    Note over Worker: Tick begins
    Adapter->>Worker: pullInboundMessages()
    Worker->>Worker: deduplicateInbound()
    alt Slash command (attach/session)
        Worker->>Store: upsertBinding(conversation→session)
        Store->>KV: write(binding)
    else User message
        Worker->>Store: getBinding(conversation)
        Store->>KV: read(binding)
        Worker->>Session: forwardUserMessage(sessionId, text)
        Session-->>Worker: responseRows
        Worker->>Adapter: sendMessage(conversation, agentText)
        Adapter-->>Worker: deliveryAck
        Worker->>Store: updateLastForwardedSeq(binding)
        Store->>KV: write(binding.lastForwardedSeq)
    end
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.70% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description is sparse and vague, lacking required template sections (Why, How to test, Checklist). While it mentions rebasing and squashing, it does not clearly explain the problem being solved or provide testing instructions. Complete the PR description by filling in Why (problem/issue it solves), How to test (step-by-step verification), and Checklist sections per the repository template to improve clarity and reviewability.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely describes the main additions: a Telegram channel adapter, scoped configuration management, and diagnostic improvements, which aligns with the substantial code additions across multiple modules.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cdenneen
Copy link
Author

cdenneen commented Mar 6, 2026

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 6, 2026

Greptile Summary

This PR adds a full Telegram Channel Bridge to the Happier CLI: a provider-agnostic bridge worker (channelBridgeWorker.ts), a Telegram adapter with both polling and webhook modes, a scoped settings/KV config system, happier bridge CLI commands, daemon integration, and enhanced happier doctor diagnostics. It supersedes #110 and depends on #114.

The implementation is substantial (~12k lines added) and has received extensive prior review. Many hard correctness issues — permanent-delivery cursor stalls, reply-guard hardening, surrogate-safe truncation, typed error contracts, loopback validation at every write path, and daemon shutdown races — were addressed across multiple fix commits.

Three issues remain unaddressed:

  1. Dead parameters and messageId type-contract mismatch in createStableBridgeLocalId (startChannelBridgeWorker.ts:52): sessionId and text are declared in the parameter object but never contribute to the hash; more critically, messageId is typed ?: string (matching the ChannelBridgeDeps interface) but throws at runtime when absent, creating a hidden contract violation that TypeScript cannot catch.

  2. Settings-level webhook token not validated for the allowed character set (channelBridgeConfig.ts:216): settingsWebhookSecret is resolved through readTrimmedString (non-empty check only), while the env-var path uses readWebhookSecretToken (full pattern validation). A malformed token in settings.json silently causes a webhook startup failure and polling-mode fallback at daemon start.

  3. No operator warning when resolveSessionRuntime returns null (startChannelBridgeWorker.ts:290): when a bound session becomes inaccessible, outbound forwarding stalls silently with no log output — unlike the analogous store.getBinding failure path which both notifies the user in-channel and emits a warning.

Confidence Score: 2/5

  • PR has solid core bridge implementation but carries three unaddressed issues: type-contract violation in localId generation, silent operational stall with no diagnostics, and inconsistent webhook token validation creating config confusion.
  • Score of 2/5 reflects that the PR is functionally sound for polling-mode operation and has been well-hardened through multiple review rounds. However, three issues from the prior review cycle remain unaddressed: (1) dead parameters and type-contract mismatch in createStableBridgeLocalId create developer confusion and hidden runtime constraints; (2) resolveSessionRuntime returning null silently stalls outbound forwarding with no operator visibility into why; (3) settings-derived webhook token validation is inconsistent with env-var validation, making it easy to misconfigure when editing settings.json directly. None are critical regressions, but they represent observable gaps in type safety, operational diagnostics, and config validation consistency that should be resolved before merge.
  • apps/cli/src/channels/startChannelBridgeWorker.ts (dead parameters, missing warning on session null), apps/cli/src/channels/channelBridgeConfig.ts (settings webhook token validation gap)

Last reviewed commit: 74ae08b

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (9)
apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts (1)

139-142: Loosen this warning assertion to a stable substring/prefix.

This pins the full log copy, so harmless rewording will fail the test even if shutdown behavior is still correct. Assert on the error object plus a key substring or prefix instead. As per coding guidelines, "Avoid brittle 'content policing' tests that pin default config values, exact Markdown wording/format/length, or user-facing copy unless copy itself is the product requirement. Prefer stable identifiers, error codes/types, shapes, statuses, and key substrings."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts` around
lines 139 - 142, The test pins the full log message in the assertion for
warnSpy; replace the exact-string assertion in the
expect(warnSpy).toHaveBeenCalledWith(...) call with a looser check that verifies
the Error object and a stable substring or prefix (e.g., use
expect.stringContaining('Error stopping webhook relay') or a regex like
expect.stringMatching(/^\\[channelBridge\\] Error stopping webhook relay/)) so
the assertion still checks the right log intent without brittle copy-level
matching; update the assertion in the startupFailure test that currently
references warnSpy accordingly.
apps/cli/src/persistence.ts (1)

99-102: Replace channelBridge: unknown with a typed settings envelope.

This persisted subtree is now a first-class part of Settings, and leaving it as unknown is already forcing downstream as any access in the new channel-bridge tests/helpers. Even a narrow interface for the top-level shape would keep settings reads/writes type-safe without freezing every provider-specific field. As per coding guidelines, "No untyped code in TypeScript - use strict typing with explicit parameter and return types" and "Prefer interface over type for defining object shapes in TypeScript".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/persistence.ts` around lines 99 - 102, The channelBridge field
currently typed as unknown should be replaced with a concrete settings envelope
interface to avoid downstream any casts: define an interface (e.g.,
ChannelBridgeSettingsEnvelope) describing the top-level persisted shape
(optional provider-specific nested fields allowed) and use it for the
channelBridge property on the Settings/persistence type instead of unknown;
update any reads/writes (places referencing channelBridge) to use the new
interface so TypeScript enforces proper typing while still allowing
provider-specific optional entries.
apps/cli/src/channels/startChannelBridgeWorker.test.ts (1)

440-442: Assert the warning by stable substrings, not the full sentence.

This locks the test to user-facing copy, so harmless wording tweaks will fail it. Matching the invariant fragments is enough to keep the fallback behavior covered.

♻️ Suggested assertion
-    expect(warnSpy).toHaveBeenCalledWith(
-      '[channelBridge] Telegram webhook.enabled=true but webhook.secret is missing; falling back to polling mode',
-    );
+    expect(warnSpy).toHaveBeenCalledWith(
+      expect.stringMatching(
+        /webhook\.enabled=true.*webhook\.secret is missing.*falling back to polling mode/i,
+      ),
+    );

Based on learnings "Avoid brittle 'content policing' tests that pin default config values, exact Markdown wording/format/length, or user-facing copy unless copy itself is the product requirement. Prefer stable identifiers, error codes/types, shapes, statuses, and key substrings."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/startChannelBridgeWorker.test.ts` around lines 440 -
442, The test currently asserts the full warning message string via warnSpy;
change it to assert only stable substrings so wording tweaks don't break the
test—update the assertion in startChannelBridgeWorker.test to use jest matchers
(e.g. expect.stringContaining) with key fragments such as 'Telegram',
'webhook.enabled=true', and 'webhook.secret' when asserting warnSpy instead of
the entire sentence.
apps/cli/src/channels/channelBridgeConfig.ts (1)

1-1: Add the missing file/API JSDoc for the precedence rules.

This module is now the public entry point for env/global/server/account channel-bridge resolution, but the file and exported resolver do not document that merge order or the runtime defaults. A short header plus resolver doc block would make future config changes much safer.

As per coding guidelines "Include comprehensive JSDoc comments as file header comments explaining file responsibilities" and "Add JSDoc comments for public APIs and complex logic".

Also applies to: 85-90

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/channelBridgeConfig.ts` at line 1, Add a short
file-level JSDoc header to channelBridgeConfig.ts describing its responsibility
as the public entry point for env/global/server/account channel-bridge
resolution and listing the merge/precedence rules and runtime defaults; then add
a JSDoc block above the exported resolver function (resolveChannelBridgeConfig)
documenting the exact merge order (env > global > server > account or whatever
the implementation uses), expected input shape (referencing the RecordLike
type), return shape, and any default behavior for missing keys, so future
changes to precedence are clear and discoverable.
apps/cli/src/channels/channelBridgeServerConfigOverlay.ts (1)

18-23: Add JSDoc to the exported overlay helper.

This function bakes in a non-obvious merge contract—server KV overlays only non-secret Telegram fields and may intentionally return the original settings object. A short JSDoc would make that behavior much easier to preserve.

Suggested doc block
+/**
+ * Overlays server-KV Telegram fields onto scoped local settings.
+ * Secrets remain local-only; if the record has no usable non-secret fields,
+ * the original settings object is returned unchanged.
+ */
 export function overlayServerKvTelegramConfigInSettings(params: Readonly<{
As per coding guidelines "Add JSDoc comments for public APIs and complex logic".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/channelBridgeServerConfigOverlay.ts` around lines 18 -
23, Add a concise JSDoc to the exported helper
overlayServerKvTelegramConfigInSettings describing its merge contract: explain
that it overlays only non-secret Telegram fields from
ChannelBridgeServerTelegramConfigRecord into the provided settings, that it may
return the original settings object unchanged when no overlay is applied, and
document the params (settings, serverId, accountId, record) and the unknown
return type so callers understand side-effects and when identity is preserved.
Include a short note about secrecy handling (secret fields are intentionally not
merged) and reference the function name and
ChannelBridgeServerTelegramConfigRecord type.
apps/cli/src/cli/commands/bridge.test.ts (1)

55-57: Persist the updated settings state in this harness.

This mock calls the updater and throws its return value away, so the suite never checks the scoped settings tree that bridge.ts writes and falls back to asserting mock call order instead. Keep an in-memory settingsState here and assert the resulting object after each command.

Suggested harness tweak
+let settingsState: unknown;
+
 beforeEach(async () => {
   vi.resetModules();
   vi.clearAllMocks();
+  settingsState = {};-  updateSettingsMock.mockImplementation(async (updater: (current: unknown) => Promise<unknown> | unknown) => {
-    await updater({});
-  });
+  updateSettingsMock.mockImplementation(async (updater: (current: unknown) => Promise<unknown> | unknown) => {
+    settingsState = await updater(settingsState);
+    return settingsState;
+  });

Based on learnings "Assert on observable outcomes (return values, state changes, HTTP responses), not internal call sequences."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/cli/commands/bridge.test.ts` around lines 55 - 57, The mock
updateSettingsMock currently calls the updater and discards its return so the
test never observes the bridge.ts settings changes; create an in-memory
settingsState in the test harness and change
updateSettingsMock.mockImplementation to call updater with the current
settingsState, await its result, and assign the returned value back to
settingsState (or merge if updater mutates), then update assertions to check
settingsState after each command invocation instead of relying on mock call
order—refer to updateSettingsMock, updater, settingsState and the bridge.ts
behavior when making the change.
apps/cli/src/channels/channelBridgeServerKv.ts (3)

510-548: Add backoff delay between retry attempts.

The retry loop has no delay between attempts. Under contention, this could cause rapid-fire retries where multiple concurrent writers repeatedly collide. Adding exponential or jittered backoff would improve resilience.

Proposed fix with exponential backoff
+const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
+
 export async function upsertChannelBridgeTelegramConfigInKv(params: Readonly<{
   kv: ChannelBridgeKvClient;
   serverId: string;
   update: ScopedTelegramBridgeUpdate;
 }>): Promise<void> {
   if (!hasNonSecretTelegramConfigUpdate(params.update)) {
     return;
   }

   const key = telegramConfigKvKey(params.serverId);
   let current: Readonly<{
     record: ChannelBridgeServerTelegramConfigRecord | null;
     version: number;
   }> | null = null;
   for (let attempt = 0; attempt < 4; attempt += 1) {
+    if (attempt > 0) {
+      await sleep(50 * Math.pow(2, attempt - 1)); // 50ms, 100ms, 200ms
+    }
     if (current === null) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/channelBridgeServerKv.ts` around lines 510 - 548, The
retry loop in upserting the Telegram config (the for-loop that calls
readChannelBridgeTelegramConfigFromKv, applyTelegramConfigUpdate, and
writeJsonValue) needs an exponential-with-jitter backoff between attempts to
avoid hot-looping on ChannelBridgeKvVersionMismatchError; after catching a
ChannelBridgeKvVersionMismatchError and updating current (inside the catch
branch that sets conflictRecord/current), compute a delay based on attempt
(e.g., baseDelay * 2**attempt with random jitter) and await that delay before
continuing the loop, ensuring you do not block the event loop (use a
promise-based sleep) and keep the maximum delay capped to a sensible upper
bound.

564-583: Apply the same backoff pattern here.

This retry loop has the same no-backoff concern as upsertChannelBridgeTelegramConfigInKv. Apply consistent backoff logic across both retry loops.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/channelBridgeServerKv.ts` around lines 564 - 583, The
retry loop that calls writeJsonValue should include the same exponential/backoff
delay used in upsertChannelBridgeTelegramConfigInKv: when a transient failure
occurs (i.e., in the catch branch that does not throw
ChannelBridgeKvVersionMismatchError), wait before retrying, and increase delay
per attempt; keep the existing special-case handling for
ChannelBridgeKvVersionMismatchError (return when currentVersion < 0 and update
expectedVersion otherwise). Update the loop around writeJsonValue to
sleep/backoff between attempts (using the same backoff helper or pattern used by
upsertChannelBridgeTelegramConfigInKv) so retries are spaced instead of
immediate.

21-70: Consider using interface instead of type for object shapes.

Per coding guidelines, interface is preferred over type for defining object shapes. This provides better error messages and supports declaration merging when needed.

Example conversion for one type
-export type ChannelBridgeServerTelegramConfigRecord = Readonly<{
-  schemaVersion: 1;
-  tickMs?: number;
-  telegram: Readonly<{
-    allowedChatIds?: string[];
-    requireTopics?: boolean;
-    webhook?: Readonly<{
-      enabled?: boolean;
-      host?: string;
-      port?: number;
-    }>;
-  }>;
-  updatedAtMs: number;
-}>;
+export interface ChannelBridgeServerTelegramConfigRecord {
+  readonly schemaVersion: 1;
+  readonly tickMs?: number;
+  readonly telegram: Readonly<{
+    allowedChatIds?: string[];
+    requireTopics?: boolean;
+    webhook?: Readonly<{
+      enabled?: boolean;
+      host?: string;
+      port?: number;
+    }>;
+  }>;
+  readonly updatedAtMs: number;
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/channelBridgeServerKv.ts` around lines 21 - 70, Several
exported object-shaped type aliases should be converted to interfaces per the
coding guideline; replace the type aliases
ChannelBridgeServerTelegramConfigRecord, ChannelBridgeServerBindingRecord,
ChannelBridgeServerBindingsDocument, ChannelBridgeKvGetResponse,
ChannelBridgeKvMutateResponse, ChannelBridgeKvMutation, and
ChannelBridgeKvClient with equivalent interface declarations, preserving
readonly fields and optional properties (use readonly modifiers and ? where
used) and keep method signatures exactly for get/mutate on
ChannelBridgeKvClient; ensure exported names and structural shape remain
identical so callers are unaffected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/cli/src/channels/channelBridgeAccountConfig.ts`:
- Around line 229-240: The code unconditionally calls ensureRecord() for
providers and telegram, materializing a Telegram node even for tickMs-only
updates; update the logic in the channelBridge/account update path (around
channelBridge, byServerId, serverScope, byAccountId, accountScope) to only
create the providers subtree (and the telegram record) when the incoming
params.update contains Telegram-related fields (e.g., token or other provider
properties) or otherwise leave providers undefined; alternatively ensure
readScopedTelegramBridgeConfig() treats an empty providers.telegram node as
unconfigured—preferably perform a check for Telegram fields in params.update
before calling ensureRecord(providers,'telegram') so tickMs-only updates do not
create the provider node.

In `@apps/cli/src/channels/startChannelBridgeWorker.ts`:
- Around line 196-219: Both resolveLatestSessionSeq and
fetchAgentMessagesAfterSeq assume encrypted transcripts by calling
fetchEncryptedTranscriptPageLatest / fetchEncryptedTranscriptPageAfterSeq and
decryptTranscriptRows; change them to branch on the session's runtime.mode (use
resolveSessionContext(sessionId) or ctx.runtime.mode) and: for mode 'encrypted'
keep the current encrypted fetch+decrypt path, but for mode 'plain' call the
plain transcript fetchers (or the non-encrypted equivalents) and skip
decryptTranscriptRows — directly filter/map content.t/plain messages and
extractAssistantText. Ensure resolveLatestSessionSeq also acquires the session
context first and uses the mode to pick the correct fetch function and seq
parsing so plain sessions are handled without decryption.

In `@apps/cli/src/channels/telegram/telegramAdapter.ts`:
- Around line 176-185: The process-local update cursor (updateOffset) and the
5-minute TTL deduper (createChannelBridgeInboundDeduper) leave a window where
Telegram can replay updates after daemon restarts; persist and restore the
polling cursor or extend the deduper TTL to match Telegram's retention. Modify
the Telegram adapter to store updateOffset (and selfBotId if needed) to durable
storage on each successful poll/ack and reload it on startup, or increase the
deduper TTL in createChannelBridgeInboundDeduper to cover Telegram's retention
window (whichever strategy you choose), ensuring the adapter uses the persisted
offset before calling getUpdates so duplicates are not reprocessed after
restarts.

In `@apps/cli/src/channels/telegram/telegramWebhookRelay.ts`:
- Around line 33-36: The code currently sets secretHeaderToken =
String(params.secretHeaderToken ?? secretPathToken).trim(), which falls back to
the path token and nullifies the header check; change it to require an explicit
header token by using only params.secretHeaderToken (e.g.,
String(params.secretHeaderToken).trim()) and throw the existing Error if it's
empty, or alternatively derive/store a distinct header secret instead of reusing
secretPathToken; update references to secretHeaderToken,
params.secretHeaderToken, and secretPathToken accordingly so the header cannot
be satisfied by leaking the path token.

In `@apps/cli/src/cli/commands/bridge.ts`:
- Around line 257-275: The local settings are being persisted (via
updateSettings + upsertScopedTelegramBridgeConfig) before the server KV write
completes, causing possible divergence because split.localUpdate still contains
shared fields; change the flow to first perform the shared KV operation (use
hasSharedTelegramBridgeUpdate -> createAxiosChannelBridgeKvClient ->
upsertChannelBridgeTelegramConfigInKv) and only after it succeeds perform
updateSettings with a sanitized local-only payload (derive local-only update
from split via splitScopedTelegramBridgeUpdate or strip shared fields), and add
a compensating rollback: if the subsequent updateSettings fails after a
successful KV write, retry the settings write and if it continues to fail,
revert the KV change (or mark a reconciliation flag) so server and local remain
consistent; apply same change to the other block referenced (lines ~297-309).

In `@apps/cli/src/cli/dispatch.ts`:
- Around line 49-50: The guard that blocks disallowed commands uses subcommand
=== args[0] which fails when global flags like --tmux appear first; update the
logic that constructs subcommand (or add a small normalization step before the
disallowed check) to strip global flags/options from args and find the first
non-flag token (e.g., first arg not starting with '-' or matched against known
global flags) and assign that to subcommand before testing against the
disallowed Set; change the check around the disallowed Set (the variable named
disallowed and the existing if (subcommand && disallowed.has(subcommand))) to
use the normalized/resolved command so "bridge" is caught even when "--tmux" is
passed first.

In `@apps/cli/src/daemon/startDaemon.ts`:
- Around line 1154-1205: Current bridge startup (calls to readSettings,
readChannelBridgeTelegramConfigFromKv and startChannelBridgeFromEnv) runs
synchronously on the daemon readiness path and can block
ensureMachineRegistered; move the bridge bootstrap off the critical path by
invoking it after ensureMachineRegistered or as a fire-and-forget background
task with its own short timeout and error handling: remove awaits that block
readiness and instead spawn a detached promise (or schedule via setTimeout) that
performs readSettings, optional KV fetch (readChannelBridgeTelegramConfigFromKv)
and startChannelBridgeFromEnv while logging errors via
serializeAxiosErrorForLog; keep the same inputs (credentials, serverId,
accountId, settings) and preserve the existing catch/log-return-null logic
inside that detached task so daemon registration and writing of
daemon.state.json are not gated on bridge startup.

In `@apps/cli/src/ui/doctor.ts`:
- Around line 130-167: The redactSettingsForDisplay function only redacts
providers under channelBridge.byServerId[*].byAccountId[*].providers; extend it
to also walk and redact provider entries in channelBridge.providers (global
scope) and channelBridge.byServerId[*].providers (server scope) using the same
logic that replaces secrets.*, botToken, and webhook.secret with '<redacted>'
(refer to channelBridge, byServerId, byAccountId, providers, secrets, botToken,
webhook.secret to locate code paths), and add regression tests that exercise
global (channelBridge.providers) and server-scoped
(channelBridge.byServerId[*].providers) provider redaction so the CLI output
never prints raw credentials.

In `@docs/channel-bridge-uat.md`:
- Around line 83-85: Update the webhook auth section to explicitly document both
failure statuses: state that a header-token (Authorization or custom header)
mismatch returns 401, while an incorrect path token (path-based secret) returns
404; replace the phrase "typically 404 with path-token mismatch" with clear
sentences distinguishing the two cases and keep the existing steps about
changing allowedChatIds and using --allow-all unchanged.

In `@docs/channel-bridge.md`:
- Around line 53-57: The phrase "follow-up stacked PRs" is stale; update the
paragraph under the "Out of scope for this PR" section to remove that wording
and instead point readers to the companion docs that now cover the Telegram
adapter, scoped config sources, and bootstrapping/runtime details; specifically,
replace the sentence referencing "follow-up stacked PRs" with a concise pointer
to the companion documentation (or list the included topics) so readers know
where to find transport details, bridge runtime bootstrapping, scoped config
sources, and server-relay deployment modes.

In `@docs/README.md`:
- Around line 12-13: The docs index (README) is missing the new UAT guide entry;
update the docs index list in README.md to include "channel-bridge-uat.md"
alongside the existing entries (e.g., near "channel-bridge.md" and
"telegram-channel-bridge.md") so the new UAT validation flow is discoverable by
readers.

---

Nitpick comments:
In `@apps/cli/src/channels/channelBridgeConfig.ts`:
- Line 1: Add a short file-level JSDoc header to channelBridgeConfig.ts
describing its responsibility as the public entry point for
env/global/server/account channel-bridge resolution and listing the
merge/precedence rules and runtime defaults; then add a JSDoc block above the
exported resolver function (resolveChannelBridgeConfig) documenting the exact
merge order (env > global > server > account or whatever the implementation
uses), expected input shape (referencing the RecordLike type), return shape, and
any default behavior for missing keys, so future changes to precedence are clear
and discoverable.

In `@apps/cli/src/channels/channelBridgeServerConfigOverlay.ts`:
- Around line 18-23: Add a concise JSDoc to the exported helper
overlayServerKvTelegramConfigInSettings describing its merge contract: explain
that it overlays only non-secret Telegram fields from
ChannelBridgeServerTelegramConfigRecord into the provided settings, that it may
return the original settings object unchanged when no overlay is applied, and
document the params (settings, serverId, accountId, record) and the unknown
return type so callers understand side-effects and when identity is preserved.
Include a short note about secrecy handling (secret fields are intentionally not
merged) and reference the function name and
ChannelBridgeServerTelegramConfigRecord type.

In `@apps/cli/src/channels/channelBridgeServerKv.ts`:
- Around line 510-548: The retry loop in upserting the Telegram config (the
for-loop that calls readChannelBridgeTelegramConfigFromKv,
applyTelegramConfigUpdate, and writeJsonValue) needs an exponential-with-jitter
backoff between attempts to avoid hot-looping on
ChannelBridgeKvVersionMismatchError; after catching a
ChannelBridgeKvVersionMismatchError and updating current (inside the catch
branch that sets conflictRecord/current), compute a delay based on attempt
(e.g., baseDelay * 2**attempt with random jitter) and await that delay before
continuing the loop, ensuring you do not block the event loop (use a
promise-based sleep) and keep the maximum delay capped to a sensible upper
bound.
- Around line 564-583: The retry loop that calls writeJsonValue should include
the same exponential/backoff delay used in
upsertChannelBridgeTelegramConfigInKv: when a transient failure occurs (i.e., in
the catch branch that does not throw ChannelBridgeKvVersionMismatchError), wait
before retrying, and increase delay per attempt; keep the existing special-case
handling for ChannelBridgeKvVersionMismatchError (return when currentVersion < 0
and update expectedVersion otherwise). Update the loop around writeJsonValue to
sleep/backoff between attempts (using the same backoff helper or pattern used by
upsertChannelBridgeTelegramConfigInKv) so retries are spaced instead of
immediate.
- Around line 21-70: Several exported object-shaped type aliases should be
converted to interfaces per the coding guideline; replace the type aliases
ChannelBridgeServerTelegramConfigRecord, ChannelBridgeServerBindingRecord,
ChannelBridgeServerBindingsDocument, ChannelBridgeKvGetResponse,
ChannelBridgeKvMutateResponse, ChannelBridgeKvMutation, and
ChannelBridgeKvClient with equivalent interface declarations, preserving
readonly fields and optional properties (use readonly modifiers and ? where
used) and keep method signatures exactly for get/mutate on
ChannelBridgeKvClient; ensure exported names and structural shape remain
identical so callers are unaffected.

In `@apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts`:
- Around line 139-142: The test pins the full log message in the assertion for
warnSpy; replace the exact-string assertion in the
expect(warnSpy).toHaveBeenCalledWith(...) call with a looser check that verifies
the Error object and a stable substring or prefix (e.g., use
expect.stringContaining('Error stopping webhook relay') or a regex like
expect.stringMatching(/^\\[channelBridge\\] Error stopping webhook relay/)) so
the assertion still checks the right log intent without brittle copy-level
matching; update the assertion in the startupFailure test that currently
references warnSpy accordingly.

In `@apps/cli/src/channels/startChannelBridgeWorker.test.ts`:
- Around line 440-442: The test currently asserts the full warning message
string via warnSpy; change it to assert only stable substrings so wording tweaks
don't break the test—update the assertion in startChannelBridgeWorker.test to
use jest matchers (e.g. expect.stringContaining) with key fragments such as
'Telegram', 'webhook.enabled=true', and 'webhook.secret' when asserting warnSpy
instead of the entire sentence.

In `@apps/cli/src/cli/commands/bridge.test.ts`:
- Around line 55-57: The mock updateSettingsMock currently calls the updater and
discards its return so the test never observes the bridge.ts settings changes;
create an in-memory settingsState in the test harness and change
updateSettingsMock.mockImplementation to call updater with the current
settingsState, await its result, and assign the returned value back to
settingsState (or merge if updater mutates), then update assertions to check
settingsState after each command invocation instead of relying on mock call
order—refer to updateSettingsMock, updater, settingsState and the bridge.ts
behavior when making the change.

In `@apps/cli/src/persistence.ts`:
- Around line 99-102: The channelBridge field currently typed as unknown should
be replaced with a concrete settings envelope interface to avoid downstream any
casts: define an interface (e.g., ChannelBridgeSettingsEnvelope) describing the
top-level persisted shape (optional provider-specific nested fields allowed) and
use it for the channelBridge property on the Settings/persistence type instead
of unknown; update any reads/writes (places referencing channelBridge) to use
the new interface so TypeScript enforces proper typing while still allowing
provider-specific optional entries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 759a4d35-8071-4de3-9196-26ffee860ae8

📥 Commits

Reviewing files that changed from the base of the PR and between cd992ae and dde7d03.

⛔ Files ignored due to path filters (3)
  • docs/assets/doctor-critical-summary.png is excluded by !**/*.png
  • docs/assets/doctor-missing-host-port-summary.png is excluded by !**/*.png
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (33)
  • README.md
  • apps/cli/src/channels/channelBindingStore.server.test.ts
  • apps/cli/src/channels/channelBindingStore.server.ts
  • apps/cli/src/channels/channelBridgeAccountConfig.test.ts
  • apps/cli/src/channels/channelBridgeAccountConfig.ts
  • apps/cli/src/channels/channelBridgeConfig.test.ts
  • apps/cli/src/channels/channelBridgeConfig.ts
  • apps/cli/src/channels/channelBridgeServerConfigOverlay.test.ts
  • apps/cli/src/channels/channelBridgeServerConfigOverlay.ts
  • apps/cli/src/channels/channelBridgeServerKv.test.ts
  • apps/cli/src/channels/channelBridgeServerKv.ts
  • apps/cli/src/channels/core/channelBridgeWorker.test.ts
  • apps/cli/src/channels/core/channelBridgeWorker.ts
  • apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts
  • apps/cli/src/channels/startChannelBridgeWorker.test.ts
  • apps/cli/src/channels/startChannelBridgeWorker.ts
  • apps/cli/src/channels/telegram/telegramAdapter.test.ts
  • apps/cli/src/channels/telegram/telegramAdapter.ts
  • apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts
  • apps/cli/src/channels/telegram/telegramWebhookRelay.ts
  • apps/cli/src/cli/commandRegistry.ts
  • apps/cli/src/cli/commands/bridge.test.ts
  • apps/cli/src/cli/commands/bridge.ts
  • apps/cli/src/cli/dispatch.ts
  • apps/cli/src/daemon/startDaemon.automation.integration.test.ts
  • apps/cli/src/daemon/startDaemon.ts
  • apps/cli/src/persistence.ts
  • apps/cli/src/ui/doctor.test.ts
  • apps/cli/src/ui/doctor.ts
  • docs/README.md
  • docs/channel-bridge-uat.md
  • docs/channel-bridge.md
  • docs/telegram-channel-bridge.md

@cdenneen
Copy link
Author

cdenneen commented Mar 6, 2026

Addressed the latest review items in commit 8514428.

Highlights:

  • Added explicit senderId in Telegram inbound parsing.
  • Hardened webhook relay with explicit header secret token requirement and loopback-only host validation.
  • Prevented tick-only scoped updates from materializing empty provider trees.
  • Switched plain-session outbound transcript reads to bypass decryption path.
  • Reordered bridge CLI persistence so shared KV writes happen before local settings updates.
  • Added strict empty --bot-token validation.
  • Hardened doctor redaction for global/server/account provider scopes and exported strict webhook port parsing.
  • Clarified UAT/doc behavior for webhook 401 vs 404 outcomes and refreshed bridge docs/index links.

Targeted regression tests passed locally:

  • 10 files, 130 tests passed.

@coderabbitai review

@cdenneen
Copy link
Author

cdenneen commented Mar 6, 2026

@greptile review

@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

@cdenneen Thanks for the detailed summary of the changes in commit 8514428b! The hardening across webhook relay, doctor redaction, and scoped config persistence all sound like meaningful improvements. Triggering a fresh incremental review now to check the latest commit.


🧠 Learnings used
Learnt from: cdenneen
Repo: happier-dev/happier PR: 110
File: apps/cli/src/channels/telegram/telegramAdapter.test.ts:17-41
Timestamp: 2026-03-04T15:25:08.440Z
Learning: In apps/cli/src/channels/telegram/telegramAdapter.test.ts, the TelegramApiClient (getMe, getUpdates, sendMessage) is intentionally stubbed with vi.fn mocks. This is an accepted exception to the no-mocking guideline because TelegramApiClient is a true third-party API system boundary requiring external credentials/network. End-to-end Telegram transport validation is tracked separately as an integration suite to avoid CI flakiness.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cdenneen
Copy link
Author

cdenneen commented Mar 6, 2026

Added a follow-up fix in commit 1714afa to address daemon startup coupling feedback.

Change:

  • Moved channel bridge bootstrap into its own best-effort async task so machine registration/sync setup is no longer blocked by bridge startup awaits.

Validation rerun after this patch:

  • startDaemon.automation.integration.test.ts
  • bridge.test.ts
  • startChannelBridgeWorker.test.ts
  • telegramWebhookRelay.test.ts
  • channelBindingStore.server.test.ts
  • doctor.test.ts
  • dispatch.tmuxDisallowed.test.ts

All passed.

@coderabbitai review

@cdenneen
Copy link
Author

cdenneen commented Mar 6, 2026

@greptile review

@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

@cdenneen Thanks for the follow-up fix! Decoupling the channel bridge bootstrap into a best-effort async task is the right approach — machine registration and sync setup are on the critical startup path (per the daemon lifecycle requirements), so removing the blocking awaits from bridge initialization prevents any bridge startup latency or failure from delaying core daemon readiness. Triggering an incremental review of commit 1714afaa now.


🧠 Learnings used
Learnt from: CR
Repo: happier-dev/happier PR: 0
File: apps/cli/src/daemon/CLAUDE.md:0-0
Timestamp: 2026-02-16T05:35:08.653Z
Learning: Applies to apps/cli/src/daemon/src/daemon/**/*.ts : Daemon startup registration must send REST POST /v1/machines request with machine ID, encrypted metadata (base64), and encrypted daemonState (base64); server response includes metadataVersion and daemonStateVersion for independent versioning

Learnt from: CR
Repo: happier-dev/happier PR: 0
File: apps/cli/src/daemon/CLAUDE.md:0-0
Timestamp: 2026-02-16T05:35:08.653Z
Learning: Applies to apps/cli/src/daemon/src/daemon/startDaemon.ts : Daemon startup must call `authAndSetupMachineIfNeeded()` to ensure credentials exist before proceeding

Learnt from: CR
Repo: happier-dev/happier PR: 0
File: apps/cli/src/daemon/CLAUDE.md:0-0
Timestamp: 2026-02-16T05:35:08.653Z
Learning: Applies to apps/cli/src/daemon/src/daemon/**/*.ts : Daemon WebSocket connection must establish persistent connection to backend via `ApiMachineClient` and register RPC handlers: `spawn-happy-session`, `stop-session`, `requestShutdown`

Learnt from: CR
Repo: happier-dev/happier PR: 0
File: apps/cli/src/daemon/CLAUDE.md:0-0
Timestamp: 2026-02-16T05:35:08.653Z
Learning: Applies to apps/cli/src/daemon/src/**/*.ts : New Happier process spawned by daemon must call `notifyDaemonSessionStarted()` to POST to daemon's `/session-started` endpoint to report itself and receive `happySessionId`

Learnt from: CR
Repo: happier-dev/happier PR: 0
File: apps/cli/src/daemon/CLAUDE.md:0-0
Timestamp: 2026-02-16T05:35:08.653Z
Learning: Applies to apps/cli/src/daemon/src/daemon/**/*.ts : Daemon state updates via WebSocket must emit 'machine-update-state' with machineId, encrypted daemonState (base64), and expectedVersion; server response includes version-mismatch handling with current state

Learnt from: CR
Repo: happier-dev/happier PR: 0
File: apps/cli/src/daemon/CLAUDE.md:0-0
Timestamp: 2026-02-16T05:35:08.653Z
Learning: Applies to apps/cli/src/daemon/src/daemon/**/*.ts : On daemon version mismatch detection, spawn new daemon via `spawnHappyCLI(['daemon', 'start'])`, which will see old daemon.state.json version mismatch and call `stopDaemon()` to kill old daemon via HTTP `/stop` with SIGKILL fallback

Learnt from: CR
Repo: happier-dev/happier PR: 0
File: apps/cli/src/daemon/CLAUDE.md:0-0
Timestamp: 2026-02-16T05:35:08.653Z
Learning: Applies to apps/cli/src/daemon/src/daemon/**/*.ts : ApiMachineClient WebSocket communication must handle bidirectional messages: daemon to server (machine-alive, machine-update-metadata, machine-update-state), server to daemon (rpc-request with spawn-happy-session, stop-session, requestShutdown); all data encrypted with TweetNaCl

Learnt from: CR
Repo: happier-dev/happier PR: 0
File: apps/cli/src/daemon/CLAUDE.md:0-0
Timestamp: 2026-02-16T05:35:08.653Z
Learning: Applies to apps/cli/src/daemon/src/daemon/**/*.ts : Daemon-spawned sessions initiated via RPC `spawn-happy-session` must: create directory if needed, spawn detached Happier process with `--happy-starting-mode remote --started-by daemon`, add to `pidToTrackedSession` map, set up 10-second awaiter for session webhook

Learnt from: CR
Repo: happier-dev/happier PR: 0
File: apps/cli/src/daemon/CLAUDE.md:0-0
Timestamp: 2026-02-16T05:35:08.653Z
Learning: Applies to apps/cli/src/daemon/src/{index,daemon/startDaemon}.ts : Daemon lifecycle: Receive `daemon start` command in `src/index.ts`, spawn detached process via `spawnHappyCLI(['daemon', 'start-sync'], { detached: true })`, which calls `startDaemon()` from `src/daemon/startDaemon.ts`

Learnt from: CR
Repo: happier-dev/happier PR: 0
File: apps/cli/src/daemon/CLAUDE.md:0-0
Timestamp: 2026-02-16T05:35:08.653Z
Learning: Applies to apps/cli/src/daemon/src/index.ts : CLI startup must check if machine ID exists in settings; if not, create ID locally only (do NOT create machine on server); CLI does not manage machine details; all API and schema live in daemon subpackage

Learnt from: cdenneen
Repo: happier-dev/happier PR: 110
File: apps/cli/src/channels/telegram/telegramAdapter.test.ts:17-41
Timestamp: 2026-03-04T15:25:08.440Z
Learning: In apps/cli/src/channels/telegram/telegramAdapter.test.ts, the TelegramApiClient (getMe, getUpdates, sendMessage) is intentionally stubbed with vi.fn mocks. This is an accepted exception to the no-mocking guideline because TelegramApiClient is a true third-party API system boundary requiring external credentials/network. End-to-end Telegram transport validation is tracked separately as an integration suite to avoid CI flakiness.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cdenneen
Copy link
Author

cdenneen commented Mar 6, 2026

Addressed the latest Greptile findings in commit a264754 and resolved the corresponding threads.

Summary:

  • Added queue-overflow warning/counter for Telegram webhook update eviction.
  • Hardened HAPPIER_CHANNEL_BRIDGE_IO_TIMEOUT_MS parsing to strict numeric input.
  • Added regression coverage for the overflow warning behavior.

@coderabbitai review

@cdenneen
Copy link
Author

cdenneen commented Mar 6, 2026

@greptile review

@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

@cdenneen Thanks for the follow-up commit! The additions sound well-targeted:

  • Queue-overflow warning and eviction counter for the Telegram webhook update queue adds important observability for back-pressure scenarios.
  • Strict numeric parsing for HAPPIER_CHANNEL_BRIDGE_IO_TIMEOUT_MS closes the gap where non-numeric env var values could silently produce NaN-based timeouts.
  • Regression coverage for the overflow warning behavior ensures that path stays tested going forward.

Triggering an incremental review of commit a2647541 now.


🧠 Learnings used
Learnt from: cdenneen
Repo: happier-dev/happier PR: 110
File: apps/cli/src/channels/telegram/telegramAdapter.test.ts:17-41
Timestamp: 2026-03-04T15:25:08.440Z
Learning: In apps/cli/src/channels/telegram/telegramAdapter.test.ts, the TelegramApiClient (getMe, getUpdates, sendMessage) is intentionally stubbed with vi.fn mocks. This is an accepted exception to the no-mocking guideline because TelegramApiClient is a true third-party API system boundary requiring external credentials/network. End-to-end Telegram transport validation is tracked separately as an integration suite to avoid CI flakiness.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

♻️ Duplicate comments (1)
apps/cli/src/cli/commands/bridge.ts (1)

266-282: ⚠️ Potential issue | 🟠 Major

Handle the server-ahead-of-local rollback path too.

The ordering fix removed the local-ahead-of-server case, but these flows still diverge if the KV mutation succeeds and the subsequent updateSettings() call fails. Capture the previous KV state and revert it, or persist an explicit reconciliation marker, before returning an error.

Also applies to: 304-316

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/cli/commands/bridge.ts` around lines 266 - 282, When performing
the KV upsert in the branch guarded by hasSharedTelegramBridgeUpdate (using
createAxiosChannelBridgeKvClient and upsertChannelBridgeTelegramConfigInKv) you
must capture the previous KV value before writing and, if the subsequent
updateSettings call that runs upsertScopedTelegramBridgeConfig fails, revert the
KV to that previous value (or write an explicit reconciliation marker) and then
propagate the error; apply the same pattern in the other similar block that does
a KV upsert followed by updateSettings so you avoid the server-ahead-of-local
divergence.
🧹 Nitpick comments (2)
apps/cli/src/cli/commands/bridge.test.ts (1)

19-38: Run at least one bridge-command happy path against real persistence.

Right now the suite mostly proves mocked call ordering. Because @/persistence is stubbed, it will not catch regressions in the actual settings.json shape or local write behavior that this PR changes. Since you already create a temp HAPPIER_HOME_DIR, one higher-fidelity test using the real persistence module would give much better coverage here.

Based on learnings "The ChannelBridgeKvClient is treated as a true system boundary (network/HTTP KV backend), so local in-memory doubles are acceptable here for deterministic unit tests under CI runtime constraints."

Also applies to: 46-68

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/cli/commands/bridge.test.ts` around lines 19 - 38, Remove the
'@/persistence' mock (the vi.mock that stubs readCredentials, readSettings,
updateSettings) so the test uses the real persistence module and exercise at
least one bridge command happy-path against the temp HAPPIER_HOME_DIR already
created in the spec; keep the ChannelBridgeKvClient-related mocks
(createAxiosChannelBridgeKvClient, upsertChannelBridgeTelegramConfigInKv,
clearChannelBridgeTelegramConfigInKv, readChannelBridgeTelegramConfigFromKv)
as-is so the KV boundary remains mocked, and ensure the test asserts actual
settings.json reads/writes by invoking the code paths that call
readSettings/updateSettings (e.g., the bridge command execution flow) to
validate real persistence behavior.
apps/cli/src/channels/channelBridgeAccountConfig.ts (1)

46-64: Promote the normalized scoped config to a named interface.

The exported update aliases, the accumulator literals in splitScopedTelegramBridgeUpdate(), and the RecordLike | null return from readScopedTelegramBridgeConfig() are all describing closely related parts of the same public config surface with anonymous shapes. A ScopedTelegramBridgeConfig interface (plus a nested webhook interface) would make downstream usage type-safe and reduce drift between the read/split paths.

As per coding guidelines, "Prefer interface over type for defining object shapes in TypeScript" and "Always use types for function parameters and return values in TypeScript."

Also applies to: 74-91, 136-205

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/channelBridgeAccountConfig.ts` around lines 46 - 64,
The update and shared types (ScopedTelegramBridgeUpdate,
SharedTelegramBridgeUpdate) and the anonymous accumulator/return shapes used in
splitScopedTelegramBridgeUpdate() and readScopedTelegramBridgeConfig() should be
promoted to a named interface (e.g., ScopedTelegramBridgeConfig with a nested
WebhookConfig interface) so downstream code gets a single authoritative shape;
change the exported type aliases to interfaces, replace the anonymous
accumulator literals in splitScopedTelegramBridgeUpdate() with the new interface
types, and update readScopedTelegramBridgeConfig() to return the named interface
(or null) and use the named webhook interface for webhook fields to keep
type-safety and consistency across the read/split paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/cli/src/channels/channelBridgeConfig.ts`:
- Around line 45-52: parseStringArray currently turns an input like
[-1001234567890] into [] which the runtime treats as "allow all"; update
parseStringArray so it preserves numeric chat ids instead of discarding them: in
the mapping step, accept finite numbers by coercing them to strings (e.g., if
typeof entry === 'number' && Number.isFinite(entry) -> String(entry)), keep
trimming for string entries, and treat other types as invalid; then filter out
empty strings and return null only for completely invalid inputs (or keep
returning [] only when the final filtered array is truly empty), ensuring
parseStringArray (and any consumer of allowedChatIds) no longer collapses a
non-empty invalid array into the allow-all sentinel.

In `@apps/cli/src/channels/channelBridgeServerKv.ts`:
- Around line 95-98: The JSON parse in decodeBase64ToJson currently lets
malformed Base64 payloads throw raw SyntaxErrors; update decodeBase64ToJson to
catch Buffer/JSON.parse errors and throw or return a well-typed, recoverable
error (e.g., throw new ChannelBridgePayloadError or return null) so callers like
readChannelBridgeTelegramConfigFromKv (with allowUnsupportedSchema: true),
upsertChannelBridgeTelegramConfigInKv, clearChannelBridgeTelegramConfigInKv and
the bindings decoder can detect corrupted KV payloads and handle them (treat as
recoverable / overwriteable) instead of aborting; ensure the bindings decoder
maps the caught parse error to the channel-bridge payload error type so upstream
code can proceed with recovery.
- Around line 229-266: The parser is currently fabricating defaults for required
persisted fields (lastForwardedSeq, createdAtMs, updatedAtMs) instead of
failing; change the validation in the parsing logic so that if lastForwardedSeq,
createdAtMs, or updatedAtMs are missing or not finite numbers you throw
ChannelBridgeBadPayloadError (do not default to 0 or Date.now()). Keep the
existing type/finite checks but add presence checks (!== undefined) and remove
the fallbacks when building lastForwardedSeq, createdAtMs, and updatedAtMs so
the constructor/record creation for ChannelBridgeServerBindingRecord fails fast
on incomplete rows. Use the same error messages you already create for invalid
fields.

In `@apps/cli/src/channels/telegram/telegramAdapter.ts`:
- Around line 223-245: pullInboundMessages currently removes webhook items from
queuedWebhookUpdates and advances updateOffset as soon as parsing succeeds,
which causes drops if downstream handling fails; change it to only parse and
return the updates (including their original ids or raw update objects) without
mutating queuedWebhookUpdates or updating updateOffset, and add a separate
acknowledgment path (e.g., a new ackInboundMessages(ids) or have the caller call
a provided confirm function) that the worker invokes after attach/binding and
sendUserMessageToSession succeed; reference pullInboundMessages, parseUpdates,
queuedWebhookUpdates, updateOffset, parseHighestUpdateOffset, and
sendUserMessageToSession when making this change so consumption only happens
after successful processing.

In `@apps/cli/src/cli/commands/bridge.ts`:
- Around line 233-237: The current branch sets update.allowedChatIds = []
(meaning "allow all") when parseCsvList(allowedChatIdsRaw) returns an empty
array, which can happen for malformed inputs like ",,,"; change the logic in
bridge.ts so that when allowedChatIdsRaw is provided but
parseCsvList(allowedChatIdsRaw) yields an empty array you fail fast (e.g., throw
a CLI error or exit with a non-zero status) instead of assigning [], keep the
existing allowAll branch (when allowAll is true) that intentionally sets
update.allowedChatIds = [], and reference the symbols allowedChatIdsRaw,
parseCsvList, allowAll, and update.allowedChatIds when making the check and
error path.

In `@apps/cli/src/cli/dispatch.ts`:
- Around line 52-53: The disallowed Set used to guard tmux invocation (variable
disallowed and the if that checks disallowed.has(tmuxSubcommand)) is missing the
controller commands "bug-report" and "self-update", so those still bypass the
--tmux guard; update the Set initializer to include 'bug-report' and
'self-update' so the tmuxSubcommand check correctly rejects them (i.e., extend
the existing new Set([...]) used where disallowed is defined to contain
'bug-report' and 'self-update').

In `@docs/channel-bridge-uat.md`:
- Around line 5-10: The checklist's "clean local state" section only restarts
the daemon; update it to either require switching to a fresh HAPPIER_HOME_DIR or
add explicit cleanup commands: stop the daemon (`happier daemon stop`), remove
or rename the existing settings.json in HAPPIER_HOME_DIR, clear any bridge
config/bindings (delete bridge-related files or run the project-specific
unbind/cleanup command if available), then confirm no active servers (`happier
server list`) and start the daemon (`happier daemon start`) so the steps produce
deterministic results.

---

Duplicate comments:
In `@apps/cli/src/cli/commands/bridge.ts`:
- Around line 266-282: When performing the KV upsert in the branch guarded by
hasSharedTelegramBridgeUpdate (using createAxiosChannelBridgeKvClient and
upsertChannelBridgeTelegramConfigInKv) you must capture the previous KV value
before writing and, if the subsequent updateSettings call that runs
upsertScopedTelegramBridgeConfig fails, revert the KV to that previous value (or
write an explicit reconciliation marker) and then propagate the error; apply the
same pattern in the other similar block that does a KV upsert followed by
updateSettings so you avoid the server-ahead-of-local divergence.

---

Nitpick comments:
In `@apps/cli/src/channels/channelBridgeAccountConfig.ts`:
- Around line 46-64: The update and shared types (ScopedTelegramBridgeUpdate,
SharedTelegramBridgeUpdate) and the anonymous accumulator/return shapes used in
splitScopedTelegramBridgeUpdate() and readScopedTelegramBridgeConfig() should be
promoted to a named interface (e.g., ScopedTelegramBridgeConfig with a nested
WebhookConfig interface) so downstream code gets a single authoritative shape;
change the exported type aliases to interfaces, replace the anonymous
accumulator literals in splitScopedTelegramBridgeUpdate() with the new interface
types, and update readScopedTelegramBridgeConfig() to return the named interface
(or null) and use the named webhook interface for webhook fields to keep
type-safety and consistency across the read/split paths.

In `@apps/cli/src/cli/commands/bridge.test.ts`:
- Around line 19-38: Remove the '@/persistence' mock (the vi.mock that stubs
readCredentials, readSettings, updateSettings) so the test uses the real
persistence module and exercise at least one bridge command happy-path against
the temp HAPPIER_HOME_DIR already created in the spec; keep the
ChannelBridgeKvClient-related mocks (createAxiosChannelBridgeKvClient,
upsertChannelBridgeTelegramConfigInKv, clearChannelBridgeTelegramConfigInKv,
readChannelBridgeTelegramConfigFromKv) as-is so the KV boundary remains mocked,
and ensure the test asserts actual settings.json reads/writes by invoking the
code paths that call readSettings/updateSettings (e.g., the bridge command
execution flow) to validate real persistence behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 34124a2a-e2f8-4707-9851-6fd863a3eb6f

📥 Commits

Reviewing files that changed from the base of the PR and between cd992ae and 1714afa.

⛔ Files ignored due to path filters (3)
  • docs/assets/doctor-critical-summary.png is excluded by !**/*.png
  • docs/assets/doctor-missing-host-port-summary.png is excluded by !**/*.png
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (34)
  • README.md
  • apps/cli/src/channels/channelBindingStore.server.test.ts
  • apps/cli/src/channels/channelBindingStore.server.ts
  • apps/cli/src/channels/channelBridgeAccountConfig.test.ts
  • apps/cli/src/channels/channelBridgeAccountConfig.ts
  • apps/cli/src/channels/channelBridgeConfig.test.ts
  • apps/cli/src/channels/channelBridgeConfig.ts
  • apps/cli/src/channels/channelBridgeServerConfigOverlay.test.ts
  • apps/cli/src/channels/channelBridgeServerConfigOverlay.ts
  • apps/cli/src/channels/channelBridgeServerKv.test.ts
  • apps/cli/src/channels/channelBridgeServerKv.ts
  • apps/cli/src/channels/core/channelBridgeWorker.test.ts
  • apps/cli/src/channels/core/channelBridgeWorker.ts
  • apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts
  • apps/cli/src/channels/startChannelBridgeWorker.test.ts
  • apps/cli/src/channels/startChannelBridgeWorker.ts
  • apps/cli/src/channels/telegram/telegramAdapter.test.ts
  • apps/cli/src/channels/telegram/telegramAdapter.ts
  • apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts
  • apps/cli/src/channels/telegram/telegramWebhookRelay.ts
  • apps/cli/src/cli/commandRegistry.ts
  • apps/cli/src/cli/commands/bridge.test.ts
  • apps/cli/src/cli/commands/bridge.ts
  • apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts
  • apps/cli/src/cli/dispatch.ts
  • apps/cli/src/daemon/startDaemon.automation.integration.test.ts
  • apps/cli/src/daemon/startDaemon.ts
  • apps/cli/src/persistence.ts
  • apps/cli/src/ui/doctor.test.ts
  • apps/cli/src/ui/doctor.ts
  • docs/README.md
  • docs/channel-bridge-uat.md
  • docs/channel-bridge.md
  • docs/telegram-channel-bridge.md

@cdenneen
Copy link
Author

cdenneen commented Mar 6, 2026

Addressed all current open threads in commit a54e4b5 and resolved each thread with per-item notes.

Highlights:

  • Hardened chat-id parsing + env-empty behavior to avoid accidental allow-all widening.
  • Added deferred adapter ack flow for inbound webhook draining.
  • Hardened persisted bindings parsing (required cursor/timestamp fields).
  • Added write-time webhook host validation + expanded tmux command guard.
  • Mitigated oversized Telegram outbound message stalling.
  • Closed daemon bridge startup/shutdown race and added integration coverage.
  • Updated UAT clean-state documentation.

@coderabbitai review

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
apps/cli/src/channels/startChannelBridgeWorker.ts (1)

1-35: Add module/API docs for the new bridge entry point.

This file now owns webhook fallback, store selection, transcript behavior, and shutdown cleanup, but callers have to infer that contract from the implementation. A file header plus JSDoc on startChannelBridgeFromEnv() would make the public surface much easier to use safely.

As per coding guidelines "Include comprehensive JSDoc comments as file header comments explaining file responsibilities" and "Add JSDoc comments for public APIs and complex logic".

Also applies to: 349-358

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/startChannelBridgeWorker.ts` around lines 1 - 35, Add a
clear file-level JSDoc header describing this module's responsibilities (webhook
fallback behavior, channel binding store selection, transcript
fetching/decryption behavior, and shutdown/cleanup guarantees) and add a JSDoc
comment on the public entry startChannelBridgeFromEnv() that documents its
parameters/expected environment variables, its return type
ChannelBridgeRuntimeHandle, the contract around webhook fallback vs webhook
relay, how it chooses between createInMemoryChannelBindingStore and
createServerBackedChannelBindingStore, what transcript behavior callers should
expect (use of
fetchEncryptedTranscriptPageAfterSeq/fetchEncryptedTranscriptPageLatest and
decryptTranscriptRows), and the shutdown/cleanup semantics (what the caller must
call/await to stop background tasks such as startTelegramWebhookRelay). Include
references to the unique symbols startChannelBridgeFromEnv,
ChannelBridgeRuntimeHandle, createInMemoryChannelBindingStore,
createServerBackedChannelBindingStore,
fetchEncryptedTranscriptPageAfterSeq/fetchEncryptedTranscriptPageLatest,
decryptTranscriptRows, and startTelegramWebhookRelay to make the contract
discoverable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/cli/src/channels/channelBridgeServerKv.ts`:
- Around line 208-233: The current deserialization of telegram.webhook (via
asRecord(telegram.webhook) into webhook and outWebhook) silently drops malformed
or empty webhook state; update the parser to fail closed by first asserting
webhook is a plain object (not null/array) before proceeding, validate that host
when present is non-empty after trim and otherwise throw
ChannelBridgeBadPayloadError('Invalid telegram.webhook.host payload'), and
validate port as you already do; additionally ensure that if telegram.webhook is
present but none of the expected fields (enabled, host, port) produced valid
values then throw a ChannelBridgeBadPayloadError so you don’t silently return an
empty outWebhook to out.telegram.webhook.

In `@apps/cli/src/channels/core/channelBridgeWorker.ts`:
- Around line 313-357: createInMemoryChannelBindingStore currently keys bindings
directly from raw providerId/conversationId/threadId which can differ from the
server-backed store; update createInMemoryChannelBindingStore to normalize keys
the same way as channelBindingStore.server.ts before using bindingKey: trim
providerId and conversationId strings and treat empty or all-whitespace threadId
as null (the same normalization used by the server-backed store) when computing
key for getBinding, upsertBinding, updateLastForwardedSeq, and removeBinding;
reuse or replicate the normalization logic that produces the same inputs for
bindingKey so lookups, updates, and deletes behave identically to the
server-backed implementation.

In `@apps/cli/src/channels/startChannelBridgeWorker.ts`:
- Around line 441-456: The code in startChannelBridgeWorker (the block that
builds store from params.store, params.serverId and params.accountId) silently
falls back to createInMemoryChannelBindingStore when only one of
serverId/accountId is provided; detect the partial configuration and treat it as
misconfiguration instead of degrading: if one of params.serverId or
params.accountId is present but the other is missing, log/throw a clear error
(or return a failure) indicating both are required, otherwise proceed to
createAxiosChannelBridgeKvClient and createServerBackedChannelBindingStore when
both are present, and only use createInMemoryChannelBindingStore when neither ID
nor params.store is provided; update the logic around store, serverId,
accountId, createAxiosChannelBridgeKvClient,
createServerBackedChannelBindingStore, and createInMemoryChannelBindingStore
accordingly.
- Around line 52-60: The function createStableBridgeLocalId currently returns
randomUUID() when params.messageId is absent, causing instability; instead make
the fallback deterministic by deriving a stable ID from immutable event fields.
Update createStableBridgeLocalId to require or prefer params.messageId but if
missing compute a stable ID using a deterministic hash/UUID v5 of the
concatenation of params.providerId, params.conversationId and params.threadId
(or another immutable event field) so repeated retries yield the same localId;
reference the function name createStableBridgeLocalId and the fields
params.providerId, params.conversationId, params.threadId, params.messageId when
implementing the change.

---

Nitpick comments:
In `@apps/cli/src/channels/startChannelBridgeWorker.ts`:
- Around line 1-35: Add a clear file-level JSDoc header describing this module's
responsibilities (webhook fallback behavior, channel binding store selection,
transcript fetching/decryption behavior, and shutdown/cleanup guarantees) and
add a JSDoc comment on the public entry startChannelBridgeFromEnv() that
documents its parameters/expected environment variables, its return type
ChannelBridgeRuntimeHandle, the contract around webhook fallback vs webhook
relay, how it chooses between createInMemoryChannelBindingStore and
createServerBackedChannelBindingStore, what transcript behavior callers should
expect (use of
fetchEncryptedTranscriptPageAfterSeq/fetchEncryptedTranscriptPageLatest and
decryptTranscriptRows), and the shutdown/cleanup semantics (what the caller must
call/await to stop background tasks such as startTelegramWebhookRelay). Include
references to the unique symbols startChannelBridgeFromEnv,
ChannelBridgeRuntimeHandle, createInMemoryChannelBindingStore,
createServerBackedChannelBindingStore,
fetchEncryptedTranscriptPageAfterSeq/fetchEncryptedTranscriptPageLatest,
decryptTranscriptRows, and startTelegramWebhookRelay to make the contract
discoverable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 083fba6c-9cb9-4e49-854b-e8c021285f6b

📥 Commits

Reviewing files that changed from the base of the PR and between 352a86b and faf0510.

📒 Files selected for processing (6)
  • apps/cli/src/channels/channelBridgeServerKv.test.ts
  • apps/cli/src/channels/channelBridgeServerKv.ts
  • apps/cli/src/channels/core/channelBridgeWorker.test.ts
  • apps/cli/src/channels/core/channelBridgeWorker.ts
  • apps/cli/src/channels/startChannelBridgeWorker.test.ts
  • apps/cli/src/channels/startChannelBridgeWorker.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/cli/src/channels/startChannelBridgeWorker.test.ts

@cdenneen
Copy link
Author

cdenneen commented Mar 7, 2026

Addressed the latest greptile/coderabbit findings in commit f24135d5 (handleCommand signature cleanup, CLI help default port, fail-closed webhook parse, in-memory key normalization, deterministic localId fallback, and partial scope misconfig fail-fast) with focused tests.

@coderabbitai review

@cdenneen
Copy link
Author

cdenneen commented Mar 7, 2026

@greptile review

coderabbitai[bot]
coderabbitai bot previously approved these changes Mar 7, 2026
@coderabbitai
Copy link

coderabbitai bot commented Mar 7, 2026

@cdenneen Thanks for the continued iteration in commit f24135d5! The changes look well-targeted:

  • handleCommand signature cleanup — a cleaner signature improves readability and reduces the surface area for call-site errors.
  • CLI help default port — ensuring the help text reflects the correct default prevents operator confusion during setup.
  • Fail-closed webhook parse — consistent with the prior hardening pattern; any ambiguous parse result should default to the restrictive path.
  • In-memory key normalization — aligning in-memory key handling with the server-backed store prevents divergence between the two store paths.
  • Deterministic localId fallback — ensuring a stable fallback when the primary derivation path isn't available maintains idempotency guarantees under edge cases.
  • Partial scope misconfig fail-fast — surfacing misconfiguration early (at scope resolution time) rather than letting it propagate to runtime failures is the right approach.

Good call pairing each of these with focused tests. Triggering an incremental review of commit f24135d5 now.


🧠 Learnings used
Learnt from: cdenneen
Repo: happier-dev/happier PR: 110
File: apps/cli/src/channels/telegram/telegramAdapter.test.ts:17-41
Timestamp: 2026-03-04T15:25:08.440Z
Learning: In apps/cli/src/channels/telegram/telegramAdapter.test.ts, the TelegramApiClient (getMe, getUpdates, sendMessage) is intentionally stubbed with vi.fn mocks. This is an accepted exception to the no-mocking guideline because TelegramApiClient is a true third-party API system boundary requiring external credentials/network. End-to-end Telegram transport validation is tracked separately as an integration suite to avoid CI flakiness.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cdenneen
Copy link
Author

cdenneen commented Mar 7, 2026

Addressed remaining Greptile feedback in 533a284e (env webhook secret token validation + fallback + regression test).\n\n@coderabbitai review

@cdenneen
Copy link
Author

cdenneen commented Mar 7, 2026

@greptile review

@coderabbitai
Copy link

coderabbitai bot commented Mar 7, 2026

@cdenneen Thanks for the follow-up in commit 533a284e! Env webhook secret token validation with a proper fallback is an important hardening step — ensuring the secret token is validated against Telegram's allowed character set at the env-override path (in addition to the CLI path) closes the gap where a misconfigured env var could silently produce an invalid token that Telegram would reject at runtime. Pairing that with a regression test locks in the validation boundary going forward. Triggering an incremental review of commit 533a284e now.


🧠 Learnings used
Learnt from: cdenneen
Repo: happier-dev/happier PR: 110
File: apps/cli/src/channels/telegram/telegramAdapter.test.ts:17-41
Timestamp: 2026-03-04T15:25:08.440Z
Learning: In apps/cli/src/channels/telegram/telegramAdapter.test.ts, the TelegramApiClient (getMe, getUpdates, sendMessage) is intentionally stubbed with vi.fn mocks. This is an accepted exception to the no-mocking guideline because TelegramApiClient is a true third-party API system boundary requiring external credentials/network. End-to-end Telegram transport validation is tracked separately as an integration suite to avoid CI flakiness.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
apps/cli/src/channels/startChannelBridgeWorker.test.ts (1)

917-962: Cover the inverse partial-scope case too.

This exercises serverId without accountId, but not accountId without serverId. If the guard becomes one-sided, this suite will miss it. Please parameterize the test over both invalid combinations. As per coding guidelines apps/cli/**/*.{test,spec}.{js,jsx,ts,tsx}: Write unit tests for all public functions and components.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/startChannelBridgeWorker.test.ts` around lines 917 -
962, The test currently only asserts the guard when serverId is provided without
accountId; add the inverse case (accountId without serverId) so both invalid
combinations are covered: update the test in startChannelBridgeWorker.test.ts to
parameterize or loop over two inputs ({serverId: 'server-only', accountId:
undefined} and {serverId: undefined, accountId: 'account-only'}) and for each
call startChannelBridgeFromEnv with credentials and the corresponding env,
assert it rejects with 'require both serverId and accountId', and assert the
mocked startChannelBridgeWorker (startWorker) was not called; keep using the
existing mocks for createInMemoryChannelBindingStore and
createTelegramChannelAdapter and the same expectations for startWorker.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/cli/src/channels/core/channelBridgeWorker.ts`:
- Around line 195-214: The current withTimeout implementation only rejects but
does not cancel underlying operations, causing concurrent background work in
functions like ChannelBridgeAdapter.pullInboundMessages,
ChannelBridgeAdapter.sendMessage, ChannelBridgeAdapter.updateLastForwardedSeq
and ChannelBindingStore.* to continue and mutate shared state; add AbortSignal
support to the ChannelBridgeAdapter, ChannelBridgeDeps and ChannelBindingStore
method signatures (e.g., pullInboundMessages(signal?: AbortSignal),
sendMessage(signal?: AbortSignal), updateLastForwardedSeq(signal?: AbortSignal),
etc.), update their implementations to respect signal.aborted and throw on
abort, change callers in channelBridgeWorker to create an AbortController per
timed call, pass controller.signal into withTimeout and into the underlying
adapter/deps/store calls, and modify withTimeout to accept an AbortSignal and
call controller.abort() (or reject) on timeout so the in-flight operations are
actually cancelled rather than only rejected.

In `@apps/cli/src/channels/startChannelBridgeWorker.test.ts`:
- Around line 776-836: The test currently only checks the warning but not that
webhook mode wasn't started; update the test that calls
startChannelBridgeFromEnv to also mock the startTelegramWebhookRelay function
(the module that would start webhook relay) and assert that
startTelegramWebhookRelay was not called, and assert that
createTelegramChannelAdapter (the polling adapter factory) was used/created
instead; specifically mock/import startTelegramWebhookRelay alongside
createTelegramChannelAdapter in the vi.doMock for './telegram/telegramAdapter'
or its relay module and add an expectation that startTelegramWebhookRelay was
not invoked after startChannelBridgeFromEnv returns while warnSpy recorded the
warning.
- Around line 54-55: Add teardown to remove vi.doMock registrations: after each
test in this suite (including the test named 'fetches only the first sessions
page in default bridge deps') call vi.unmock() or vi.doUnmock() for modules
mocked via vi.doMock(), or add a global afterEach that resets the mock registry
to avoid order-dependent leaks; ensure vi.resetModules() remains for module
cache clearing. For the webhook test (the one that logs the missing-secret
warning), extend assertions to verify fallback behavior by asserting that
relay.start was not called or that the bridge entered polling mode (check the
mock relay.start spy or adapter fallback method). For scoped-config validation
add the inverse test: assert that providing accountId without serverId triggers
the same validation error as providing serverId without accountId (create a test
mirroring the existing 'serverId without accountId' case and assert the same
error message/exception).

In `@apps/cli/src/channels/startChannelBridgeWorker.ts`:
- Around line 52-71: The fallback that builds the hash in
createStableBridgeLocalId uses
`fallback:${params.sessionId}:${params.text.trim()}` when `params.messageId` is
blank, which causes distinct messages with identical trimmed text to collide;
change createStableBridgeLocalId to no longer use sessionId+text as the
uniqueness source—either (A) require a stable per-event identifier and throw an
error if `params.messageId` is missing/blank (replace the current fallback
branch that checks `normalizedMessageId.length > 0`) so callers must supply a
stable id, or (B) add a new required parameter like `eventId` (or
`adapterEventId`) and use that in the hash when `messageId` is empty, and update
all callers of createStableBridgeLocalId (and any other locations that use the
same fallback pattern) to provide the new id; reference the
`createStableBridgeLocalId` function and the `params.messageId` /
`fallback:${params.sessionId}:${params.text.trim()}` code path when making the
change.

In `@apps/cli/src/cli/commands/bridge.ts`:
- Around line 224-234: The parsing currently treats missing and explicit-empty
values the same because argvValue(...).trim() returns "" for both; add explicit
presence checks like hasAllowedChatIdsFlag = args.some(arg => arg ===
'--allowed-chat-ids' || arg.startsWith('--allowed-chat-ids=')) (same pattern
used by hasBotTokenFlag) for each value-taking flag (allowedChatIds,
requireTopics, tickMs, webhookEnabled, webhookSecret, webhookHost, webhookPort)
and then validate: if the presence flag is true and the corresponding raw value
is an empty string, throw or exit with a clear error; alternatively, adjust
argvValue usage to return undefined for missing flags and keep the same presence
check to reject '' when the flag was provided. Ensure you update the conditional
update blocks that currently check truthiness (e.g., if (allowedChatIdsRaw) {
... }) to instead use the presence flags and non-empty value checks so explicit
empty options are rejected.

---

Nitpick comments:
In `@apps/cli/src/channels/startChannelBridgeWorker.test.ts`:
- Around line 917-962: The test currently only asserts the guard when serverId
is provided without accountId; add the inverse case (accountId without serverId)
so both invalid combinations are covered: update the test in
startChannelBridgeWorker.test.ts to parameterize or loop over two inputs
({serverId: 'server-only', accountId: undefined} and {serverId: undefined,
accountId: 'account-only'}) and for each call startChannelBridgeFromEnv with
credentials and the corresponding env, assert it rejects with 'require both
serverId and accountId', and assert the mocked startChannelBridgeWorker
(startWorker) was not called; keep using the existing mocks for
createInMemoryChannelBindingStore and createTelegramChannelAdapter and the same
expectations for startWorker.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3f600912-d9c5-4672-8a44-f2b6dc5ea4f5

📥 Commits

Reviewing files that changed from the base of the PR and between faf0510 and f24135d.

📒 Files selected for processing (7)
  • apps/cli/src/channels/channelBridgeServerKv.test.ts
  • apps/cli/src/channels/channelBridgeServerKv.ts
  • apps/cli/src/channels/core/channelBridgeWorker.test.ts
  • apps/cli/src/channels/core/channelBridgeWorker.ts
  • apps/cli/src/channels/startChannelBridgeWorker.test.ts
  • apps/cli/src/channels/startChannelBridgeWorker.ts
  • apps/cli/src/cli/commands/bridge.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/cli/src/channels/channelBridgeServerKv.ts
  • apps/cli/src/channels/channelBridgeServerKv.test.ts

Comment on lines +224 to +234
const rawBotToken = argvValue(args, '--bot-token');
const hasBotTokenFlag = args.some((arg) => arg === '--bot-token' || arg.startsWith('--bot-token='));
const botToken = rawBotToken.trim();
const allowedChatIdsRaw = argvValue(args, '--allowed-chat-ids').trim();
const allowAll = args.includes('--allow-all');
const requireTopicsRaw = argvValue(args, '--require-topics').trim();
const tickMsRaw = argvValue(args, '--tick-ms').trim();
const webhookEnabledRaw = argvValue(args, '--webhook-enabled').trim();
const webhookSecret = argvValue(args, '--webhook-secret').trim();
const webhookHost = argvValue(args, '--webhook-host').trim();
const webhookPortRaw = argvValue(args, '--webhook-port').trim();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect argvValue semantics and the current empty-value handling in bridge.ts.
fd 'commandUtilities\.ts$' apps/cli/src --exec sed -n '1,220p' {}
sed -n '224,287p' apps/cli/src/cli/commands/bridge.ts
rg -n "hasBotTokenFlag|argvValue\(args, '--" apps/cli/src/cli/commands/bridge.ts

Repository: happier-dev/happier

Length of output: 5646


Explicit empty option values silently skip updates instead of being rejected.

Only --bot-token checks for flag presence with hasBotTokenFlag. Other value-taking flags rely on argvValue(...).trim(), which returns empty string for both missing and present-but-empty (e.g., --allowed-chat-ids=, --webhook-secret=). Since the update blocks use truthiness checks (if (allowedChatIdsRaw) { ... }), providing --allowed-chat-ids=, --tick-ms=, --webhook-enabled=, --webhook-host=, --webhook-port=, or --webhook-secret= with no value silently succeeds without updating config and without raising an error.

Add explicit presence tracking for all value-taking flags (consistent with --bot-token), or reject empty values at parse time.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/cli/commands/bridge.ts` around lines 224 - 234, The parsing
currently treats missing and explicit-empty values the same because
argvValue(...).trim() returns "" for both; add explicit presence checks like
hasAllowedChatIdsFlag = args.some(arg => arg === '--allowed-chat-ids' ||
arg.startsWith('--allowed-chat-ids=')) (same pattern used by hasBotTokenFlag)
for each value-taking flag (allowedChatIds, requireTopics, tickMs,
webhookEnabled, webhookSecret, webhookHost, webhookPort) and then validate: if
the presence flag is true and the corresponding raw value is an empty string,
throw or exit with a clear error; alternatively, adjust argvValue usage to
return undefined for missing flags and keep the same presence check to reject ''
when the flag was provided. Ensure you update the conditional update blocks that
currently check truthiness (e.g., if (allowedChatIdsRaw) { ... }) to instead use
the presence flags and non-empty value checks so explicit empty options are
rejected.

Comment on lines +288 to +293
const runtime = await resolveSessionRuntime(sessionId);
if (!runtime) {
return {
messages: [],
highestSeenSeq: null,
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent outbound stall when session becomes inaccessible

When resolveSessionRuntime returns null (e.g. the session has been deleted, revoked, or is temporarily unreachable), the function returns { messages: [], highestSeenSeq: null } without emitting any warning. The bridge worker receives zero messages and no highestSeenSeq to advance with, so the binding's lastForwardedSeq cursor is permanently frozen. Every subsequent tick repeats the same silent no-op: fetchSessionById returns null, fetchEncryptedTranscriptPageAfterSeq is never called, and the binding stalls indefinitely.

Compare this with the analogous store.getBinding failure path in channelBridgeWorker.ts, which both notifies the user in-channel and marks the event seen so polling batches are not replayed. Here the operator has no log-level signal at all to indicate why their Telegram conversation stopped receiving agent replies.

A minimal fix is to emit a warning via onWarning so operators can observe the condition in logs:

const runtime = await resolveSessionRuntime(sessionId);
if (!runtime) {
  onWarning(
    `Session not found or inaccessible; outbound forwarding stalled for session=${sessionId}`,
  );
  return {
    messages: [],
    highestSeenSeq: null,
  };
}

Comment on lines +190 to +192
if (typeof record.tickMs === 'number' && Number.isFinite(record.tickMs)) {
out.tickMs = Math.trunc(record.tickMs);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseTelegramConfigRecord accepts out-of-range tickMs from KV

The write path (applyTelegramConfigUpdate) now rejects tickMs outside [250, 60_000] with a ChannelBridgeBadPayloadError. The read path here accepts any finite number, so a KV record written by an external tool with tickMs: 50 would silently pass through parseTelegramConfigRecord and into the settings overlay. It is only discarded later in resolveChannelBridgeRuntimeConfig via parseInteger(value, 250, 60_000), which silently falls back to the 2500ms default with no diagnostic message.

While end-to-end correctness is preserved, the inconsistency means parseTelegramConfigRecord can return a value whose tickMs field is already known to be invalid. Aligning the read path with the write-path range check (or at minimum emitting a log warning when the KV value is clamped away downstream) would make the contract easier to reason about.

Suggested change
if (typeof record.tickMs === 'number' && Number.isFinite(record.tickMs)) {
out.tickMs = Math.trunc(record.tickMs);
}
if (typeof record.tickMs === 'number' && Number.isFinite(record.tickMs)) {
const truncatedTickMs = Math.trunc(record.tickMs);
if (truncatedTickMs < 250 || truncatedTickMs > 60_000) {
throw new ChannelBridgeBadPayloadError('Invalid tickMs payload: must be in [250, 60000]');
}
out.tickMs = truncatedTickMs;
}

Comment on lines +216 to +231
const settingsWebhookSecret =
firstParsed([
secretsAccount?.webhookSecret,
webhookAccount?.secret,
secretsServer?.webhookSecret,
webhookServer?.secret,
secretsGlobal?.webhookSecret,
webhookGlobal?.secret,
], readTrimmedString)
?? '';
const envWebhookSecretRaw =
typeof env.HAPPIER_TELEGRAM_WEBHOOK_SECRET === 'string'
? env.HAPPIER_TELEGRAM_WEBHOOK_SECRET.trim()
: null;
const envWebhookSecret = readWebhookSecretToken(envWebhookSecretRaw);
const webhookSecret = envWebhookSecret ?? settingsWebhookSecret;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settings webhook token not validated for allowed character set

settingsWebhookSecret is resolved through readTrimmedString, which only checks for a non-empty string. By contrast, the env-var path on line 230 runs the value through readWebhookSecretToken, which enforces the same [A-Za-z0-9_-] pattern required by startTelegramWebhookRelay.

If a user directly edits settings.json and sets a webhook.secret containing characters outside that set (e.g. spaces or punctuation), the value passes this resolution step and is forwarded to startTelegramWebhookRelay, which throws "Webhook secret token must match [A-Za-z0-9_-]". The catch block in startChannelBridgeFromEnv then silently falls back to polling mode with only a logger.warn.

The exact same gap was previously fixed for settingsWebhookHost — the settings-level host is now validated via isLoopbackHost and falls back to 127.0.0.1 when invalid (line 239). Applying the same pattern here would give consistent early-catch behaviour: run the resolved raw value through readWebhookSecretToken before assigning to settingsWebhookSecret, and fall back to '' (disabled) when the pattern check fails. This mirrors the env-var path and eliminates the silent polling fallback that only a buried logger.warn would signal.

@cdenneen
Copy link
Author

cdenneen commented Mar 7, 2026

Addressed latest review findings in 74ae08b3 (mock teardown cleanup, missing-secret webhook fallback assertions, account-only scope validation test, and stricter stable localId generation requiring messageId).\n\n@coderabbitai review

@cdenneen
Copy link
Author

cdenneen commented Mar 7, 2026

@greptile review

@coderabbitai
Copy link

coderabbitai bot commented Mar 7, 2026

@cdenneen Thanks for the follow-up in commit 74ae08b3! The changes look well-targeted:

  • Mock teardown cleanup — proper teardown between tests prevents state leakage and ensures test isolation, especially important given the number of shared mock modules across the test suite.
  • Missing-secret webhook fallback assertions — locking in the fallback behavior when the webhook secret is absent ensures operators get predictable, safe defaults rather than silent misconfiguration.
  • Account-only scope validation test — adding explicit coverage for partial-scope misconfiguration ensures the fail-fast path introduced in f24135d5 is tested end-to-end.
  • Stricter stable localId generation requiring messageId — requiring a real messageId as the basis for the deterministic localId strengthens the idempotency guarantee; without it, a fallback derivation could produce collisions across distinct messages that share other fields.

Triggering an incremental review of commit 74ae08b3 now.


🧠 Learnings used
Learnt from: cdenneen
Repo: happier-dev/happier PR: 110
File: apps/cli/src/channels/telegram/telegramAdapter.test.ts:17-41
Timestamp: 2026-03-04T15:25:08.440Z
Learning: In apps/cli/src/channels/telegram/telegramAdapter.test.ts, the TelegramApiClient (getMe, getUpdates, sendMessage) is intentionally stubbed with vi.fn mocks. This is an accepted exception to the no-mocking guideline because TelegramApiClient is a true third-party API system boundary requiring external credentials/network. End-to-end Telegram transport validation is tracked separately as an integration suite to avoid CI flakiness.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (2)
apps/cli/src/channels/core/channelBridgeWorker.ts (1)

63-68: ⚠️ Potential issue | 🟠 Major

Timeouts still don't cancel bridge I/O.

withTimeout() only rejects locally; none of these contracts carry cancellation, so the timed-out adapter/store/deps call keeps running in the background. The next tick can overlap it and duplicate sends or race cursor updates. Add a cancellation signal to the bridge contracts and have the timeout path abort in-flight work instead of only racing it.

#!/bin/bash
sed -n '63,68p' apps/cli/src/channels/core/channelBridgeWorker.ts
sed -n '108,147p' apps/cli/src/channels/core/channelBridgeWorker.ts
sed -n '195,214p' apps/cli/src/channels/core/channelBridgeWorker.ts
rg -n "AbortSignal|signal" apps/cli/src/channels/core/channelBridgeWorker.ts apps/cli/src/channels/startChannelBridgeWorker.ts

As per coding guidelines "Use graceful error handling with proper error messages, try-catch blocks with specific error logging, and abort controllers for cancellable operations".

Also applies to: 108-147, 195-214

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/core/channelBridgeWorker.ts` around lines 63 - 68, The
bridge I/O functions currently race with withTimeout() but continue running
because the ChannelBridgeAdapter contracts (pullInboundMessages,
ackInboundMessages, sendMessage, stop) carry no cancellation; update the adapter
type to accept an optional AbortSignal parameter on these methods and propagate
that signal through usages in channelBridgeWorker and startChannelBridgeWorker,
and where withTimeout() times out create an AbortController and call
controller.abort() to cancel in-flight work; ensure callers (e.g.,
pullInboundMessages, sendMessage, ackInboundMessages) check signal.aborted and
throw/return promptly and wrap calls in try/catch to log specific abort vs other
errors.
apps/cli/src/channels/startChannelBridgeWorker.ts (1)

52-71: ⚠️ Potential issue | 🟠 Major

Blank messageId still collides for repeated messages.

When messageId is empty, the fallback hash uses sessionId + text.trim(). Two distinct "ok" messages in the same conversation/thread/session generate the same localId, so the upstream idempotency key can collapse one of them. This needs a stable per-event identifier from the adapter, or a hard failure on blank IDs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/startChannelBridgeWorker.ts` around lines 52 - 71,
createStableBridgeLocalId currently falls back to hashing sessionId+text when
messageId is blank, causing distinct messages with identical text in the same
session to collide; update createStableBridgeLocalId to reject blank/empty
messageId instead of falling back: validate params.messageId (trimmed) at the
top of the function and throw a clear error (including
providerId/conversationId/threadId/sessionId) when it's missing so adapters must
supply a stable per-event identifier; keep the rest of the hash logic unchanged
for valid messageId values.
🧹 Nitpick comments (2)
apps/cli/src/cli/commands/bridge.ts (2)

445-467: Add JSDoc for public API export.

As per coding guidelines, public APIs should have JSDoc comments. This is the main exported entry point for the bridge CLI command.

📝 Suggested JSDoc
+/**
+ * Handles the `happier bridge` CLI command and its subcommands.
+ *
+ * Subcommands:
+ * - `list` - Display bridge configuration from all sources
+ * - `telegram set` - Configure Telegram bridge settings
+ * - `telegram clear` - Remove Telegram bridge configuration
+ *
+ * `@param` context - Command context containing args and environment
+ */
 export async function handleBridgeCliCommand(context: CommandContext): Promise<void> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/cli/commands/bridge.ts` around lines 445 - 467, Add a JSDoc
block above the exported function handleBridgeCliCommand describing its purpose
as the CLI entry point for the "bridge" command, documenting the parameter
(context: CommandContext) and its properties or role, and specifying the return
type Promise<void> and any thrown errors; include tags like `@param`, `@returns`,
and a short example or remarks if helpful so the function is documented as a
public API export.

1-26: Missing file header JSDoc comment.

As per coding guidelines, CLI TypeScript files should include comprehensive JSDoc comments as file header comments explaining file responsibilities. This file implements the account-scoped bridge CLI command handling but lacks this documentation.

📝 Suggested file header
+/**
+ * Bridge CLI command handler for account-scoped channel bridge configuration.
+ *
+ * Provides commands to:
+ * - List bridge state (server KV, local settings, effective runtime)
+ * - Configure Telegram bridge settings (set/clear)
+ *
+ * Handles transactional persistence to server KV with local settings overlay
+ * and rollback compensation on partial failures.
+ *
+ * `@module` bridge
+ */
 import chalk from 'chalk';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/cli/commands/bridge.ts` around lines 1 - 26, Add a comprehensive
top-of-file JSDoc header describing this module's responsibility as the CLI
command handler for account-scoped bridge operations (handling Telegram bridge
config reads/updates, KV overlay, and daemon checks), list the main exported
command and its purpose, and mention key collaborators used in the file such as
CommandContext, readScopedTelegramBridgeConfig,
upsertScopedTelegramBridgeConfig, readChannelBridgeTelegramConfigFromKv,
replaceChannelBridgeTelegramConfigRawInKv,
checkIfDaemonRunningAndCleanupStaleState, and decodeJwtPayload so readers know
the high-level flow; include usage notes for relevant CLI flags parsed via
argvValue and any important side effects (KV writes, daemon interactions),
authorship and any applicable license or lint tags to satisfy the repo JSDoc
guideline.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/cli/src/channels/channelBridgeConfig.ts`:
- Around line 168-171: The current botToken assignment treats a whitespace-only
HAPPIER_TELEGRAM_BOT_TOKEN as a valid (empty) override; change the logic so that
if env.HAPPIER_TELEGRAM_BOT_TOKEN is a string you trim it and only use it when
the trimmed value is non-empty, otherwise fall back to settingsBotToken; update
the code around the botToken variable (referencing HAPPIER_TELEGRAM_BOT_TOKEN
and settingsBotToken) to check trimmed.length > 0 before choosing the env value.
- Around line 216-248: The code selects the first non-empty scoped value and
then validates it, so invalid account-level values (e.g., bad secret or
non-loopback host) can wrongly mask valid lower-scope values; update selection
to validate each candidate before precedence. For webhookSecret: apply
readWebhookSecretToken (or a small validator wrapper) to each candidate in the
firstParsed call instead of using readTrimmedString, or pre-map/filter
[secretsAccount?.webhookSecret, webhookAccount?.secret, ...] through
readTrimmedString then readWebhookSecretToken and pass the validated list to
firstParsed so only valid tokens win; update env handling to keep using
readWebhookSecretToken for envWebhookSecretRaw. For webhookHost: replace
firstParsed([...], readTrimmedString) with a validator that trims then checks
isLoopbackHost (or pre-filter the candidates via trim+isLoopbackHost) so
settingsWebhookHostRaw is guaranteed valid before falling back; then compute
webhookHost using envWebhookHostRaw as now but ensure envWebhookHostRaw is
validated by isLoopbackHost first.

In `@apps/cli/src/channels/channelBridgeServerKv.ts`:
- Around line 271-297: The parser currently truncates fractional
lastForwardedSeq which can cause replay bugs; change the validation for
lastForwardedSeq to require an integer (e.g., use
Number.isInteger(item.lastForwardedSeq)) and throw ChannelBridgeBadPayloadError
if it is not an integer or is negative/invalid, and stop using Math.trunc for
lastForwardedSeq when constructing the binding; keep the existing
finite/negative checks for createdAtMs/updatedAtMs (or add integer checks if you
also want them strict) and ensure the error messages reference lastForwardedSeq
for clarity.

In `@apps/cli/src/channels/core/channelBridgeWorker.ts`:
- Around line 778-792: The current branches call replyToConversation() then
always call deduper.markSeen(...), push the event to ackableInbound, and set
processedSuccessfully = true even if the reply threw; change each fallback-reply
branch (the unknown-command branch and the other branches around
replyToConversation in channelBridgeWorker.ts — references: replyToConversation,
deduper.markSeen, ackableInbound, processedSuccessfully) so that
deduper.markSeen(...), ackableInbound.push(event) and processedSuccessfully =
true are executed only after the await replyToConversation(...) completes
successfully (i.e., move those three statements into the try block immediately
after the await), and leave the catch to call params.deps.onWarning(...) but not
ack or mark the event as processed; apply the same fix to the other reply
branches you noted (lines covering binding-read-failure, no-binding,
session-forward-failure).

---

Duplicate comments:
In `@apps/cli/src/channels/core/channelBridgeWorker.ts`:
- Around line 63-68: The bridge I/O functions currently race with withTimeout()
but continue running because the ChannelBridgeAdapter contracts
(pullInboundMessages, ackInboundMessages, sendMessage, stop) carry no
cancellation; update the adapter type to accept an optional AbortSignal
parameter on these methods and propagate that signal through usages in
channelBridgeWorker and startChannelBridgeWorker, and where withTimeout() times
out create an AbortController and call controller.abort() to cancel in-flight
work; ensure callers (e.g., pullInboundMessages, sendMessage,
ackInboundMessages) check signal.aborted and throw/return promptly and wrap
calls in try/catch to log specific abort vs other errors.

In `@apps/cli/src/channels/startChannelBridgeWorker.ts`:
- Around line 52-71: createStableBridgeLocalId currently falls back to hashing
sessionId+text when messageId is blank, causing distinct messages with identical
text in the same session to collide; update createStableBridgeLocalId to reject
blank/empty messageId instead of falling back: validate params.messageId
(trimmed) at the top of the function and throw a clear error (including
providerId/conversationId/threadId/sessionId) when it's missing so adapters must
supply a stable per-event identifier; keep the rest of the hash logic unchanged
for valid messageId values.

---

Nitpick comments:
In `@apps/cli/src/cli/commands/bridge.ts`:
- Around line 445-467: Add a JSDoc block above the exported function
handleBridgeCliCommand describing its purpose as the CLI entry point for the
"bridge" command, documenting the parameter (context: CommandContext) and its
properties or role, and specifying the return type Promise<void> and any thrown
errors; include tags like `@param`, `@returns`, and a short example or remarks if
helpful so the function is documented as a public API export.
- Around line 1-26: Add a comprehensive top-of-file JSDoc header describing this
module's responsibility as the CLI command handler for account-scoped bridge
operations (handling Telegram bridge config reads/updates, KV overlay, and
daemon checks), list the main exported command and its purpose, and mention key
collaborators used in the file such as CommandContext,
readScopedTelegramBridgeConfig, upsertScopedTelegramBridgeConfig,
readChannelBridgeTelegramConfigFromKv,
replaceChannelBridgeTelegramConfigRawInKv,
checkIfDaemonRunningAndCleanupStaleState, and decodeJwtPayload so readers know
the high-level flow; include usage notes for relevant CLI flags parsed via
argvValue and any important side effects (KV writes, daemon interactions),
authorship and any applicable license or lint tags to satisfy the repo JSDoc
guideline.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4d213e89-8aa0-4d4d-8b1b-a62574582def

📥 Commits

Reviewing files that changed from the base of the PR and between faf0510 and 533a284.

📒 Files selected for processing (9)
  • apps/cli/src/channels/channelBridgeConfig.test.ts
  • apps/cli/src/channels/channelBridgeConfig.ts
  • apps/cli/src/channels/channelBridgeServerKv.test.ts
  • apps/cli/src/channels/channelBridgeServerKv.ts
  • apps/cli/src/channels/core/channelBridgeWorker.test.ts
  • apps/cli/src/channels/core/channelBridgeWorker.ts
  • apps/cli/src/channels/startChannelBridgeWorker.test.ts
  • apps/cli/src/channels/startChannelBridgeWorker.ts
  • apps/cli/src/cli/commands/bridge.ts

Comment on lines +168 to +171
const botToken =
typeof env.HAPPIER_TELEGRAM_BOT_TOKEN === 'string'
? env.HAPPIER_TELEGRAM_BOT_TOKEN.trim()
: settingsBotToken;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Treat blank HAPPIER_TELEGRAM_BOT_TOKEN as unset.

Every other env override in this resolver ignores effectively empty values, but this branch resolves whitespace-only input to ''. That lets an accidentally defined empty env var mask a valid scoped token and disable bridge startup.

♻️ Suggested fix
+  const envBotToken = readTrimmedString(env.HAPPIER_TELEGRAM_BOT_TOKEN);
   const botToken =
-    typeof env.HAPPIER_TELEGRAM_BOT_TOKEN === 'string'
-      ? env.HAPPIER_TELEGRAM_BOT_TOKEN.trim()
-      : settingsBotToken;
+    envBotToken ?? settingsBotToken;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/channelBridgeConfig.ts` around lines 168 - 171, The
current botToken assignment treats a whitespace-only HAPPIER_TELEGRAM_BOT_TOKEN
as a valid (empty) override; change the logic so that if
env.HAPPIER_TELEGRAM_BOT_TOKEN is a string you trim it and only use it when the
trimmed value is non-empty, otherwise fall back to settingsBotToken; update the
code around the botToken variable (referencing HAPPIER_TELEGRAM_BOT_TOKEN and
settingsBotToken) to check trimmed.length > 0 before choosing the env value.

Comment on lines +216 to +248
const settingsWebhookSecret =
firstParsed([
secretsAccount?.webhookSecret,
webhookAccount?.secret,
secretsServer?.webhookSecret,
webhookServer?.secret,
secretsGlobal?.webhookSecret,
webhookGlobal?.secret,
], readTrimmedString)
?? '';
const envWebhookSecretRaw =
typeof env.HAPPIER_TELEGRAM_WEBHOOK_SECRET === 'string'
? env.HAPPIER_TELEGRAM_WEBHOOK_SECRET.trim()
: null;
const envWebhookSecret = readWebhookSecretToken(envWebhookSecretRaw);
const webhookSecret = envWebhookSecret ?? settingsWebhookSecret;

const settingsWebhookHostRaw =
firstParsed(
[webhookAccount?.host, webhookServer?.host, webhookGlobal?.host],
readTrimmedString,
)
|| '127.0.0.1';
const settingsWebhookHost = isLoopbackHost(settingsWebhookHostRaw) ? settingsWebhookHostRaw : '127.0.0.1';
const envWebhookHostRaw =
typeof env.HAPPIER_TELEGRAM_WEBHOOK_HOST === 'string'
? env.HAPPIER_TELEGRAM_WEBHOOK_HOST.trim()
: null;
const envWebhookHost =
envWebhookHostRaw && isLoopbackHost(envWebhookHostRaw)
? envWebhookHostRaw
: null;
const webhookHost = envWebhookHost ?? settingsWebhookHost;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate scoped webhook secret/host values before applying precedence.

Right now the first non-empty scoped string wins, and only then do we validate it. An account-level secret: 'bad token!' survives unchanged, and an account-level host: '0.0.0.0' masks a valid lower-scope host and then drops to the hardcoded default at Line 239 instead of the next valid scope. A scoped regression with an invalid account value and a valid server value would catch both paths.

🛡️ Suggested fix
   const settingsWebhookSecret =
     firstParsed([
       secretsAccount?.webhookSecret,
       webhookAccount?.secret,
       secretsServer?.webhookSecret,
       webhookServer?.secret,
       secretsGlobal?.webhookSecret,
       webhookGlobal?.secret,
-    ], readTrimmedString)
+    ], readWebhookSecretToken)
     ?? '';
@@
-  const settingsWebhookHostRaw =
-    firstParsed(
-      [webhookAccount?.host, webhookServer?.host, webhookGlobal?.host],
-      readTrimmedString,
-    )
-    || '127.0.0.1';
-  const settingsWebhookHost = isLoopbackHost(settingsWebhookHostRaw) ? settingsWebhookHostRaw : '127.0.0.1';
+  const settingsWebhookHost =
+    firstParsed(
+      [webhookAccount?.host, webhookServer?.host, webhookGlobal?.host],
+      (value) => {
+        const host = readTrimmedString(value);
+        return host && isLoopbackHost(host) ? host : null;
+      },
+    ) ?? '127.0.0.1';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/channelBridgeConfig.ts` around lines 216 - 248, The
code selects the first non-empty scoped value and then validates it, so invalid
account-level values (e.g., bad secret or non-loopback host) can wrongly mask
valid lower-scope values; update selection to validate each candidate before
precedence. For webhookSecret: apply readWebhookSecretToken (or a small
validator wrapper) to each candidate in the firstParsed call instead of using
readTrimmedString, or pre-map/filter [secretsAccount?.webhookSecret,
webhookAccount?.secret, ...] through readTrimmedString then
readWebhookSecretToken and pass the validated list to firstParsed so only valid
tokens win; update env handling to keep using readWebhookSecretToken for
envWebhookSecretRaw. For webhookHost: replace firstParsed([...],
readTrimmedString) with a validator that trims then checks isLoopbackHost (or
pre-filter the candidates via trim+isLoopbackHost) so settingsWebhookHostRaw is
guaranteed valid before falling back; then compute webhookHost using
envWebhookHostRaw as now but ensure envWebhookHostRaw is validated by
isLoopbackHost first.

Comment on lines +271 to +297
if (
typeof item.lastForwardedSeq !== 'number'
|| !Number.isFinite(item.lastForwardedSeq)
|| item.lastForwardedSeq < 0
) {
throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding lastForwardedSeq at index ${index}`);
}
if (typeof item.createdAtMs !== 'number' || !Number.isFinite(item.createdAtMs)) {
throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding createdAtMs at index ${index}`);
}
if (typeof item.updatedAtMs !== 'number' || !Number.isFinite(item.updatedAtMs)) {
throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding updatedAtMs at index ${index}`);
}

if (
item.threadId !== undefined
&& item.threadId !== null
&& typeof item.threadId !== 'string'
) {
throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding threadId at index ${index}`);
}

const threadIdRaw = typeof item.threadId === 'string' ? item.threadId.trim() : '';
const threadId = threadIdRaw.length > 0 ? threadIdRaw : null;
const lastForwardedSeq = Math.trunc(item.lastForwardedSeq);
const createdAtMs = Math.trunc(item.createdAtMs);
const updatedAtMs = Math.trunc(item.updatedAtMs);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject fractional persisted cursors instead of truncating them.

lastForwardedSeq is replay coordination state. Accepting 1.9 and normalizing it to 1 can replay already-forwarded agent output on the next sync. This parser is already fail-closed for missing cursor fields; it should also require an integer cursor before constructing the binding record.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/channelBridgeServerKv.ts` around lines 271 - 297, The
parser currently truncates fractional lastForwardedSeq which can cause replay
bugs; change the validation for lastForwardedSeq to require an integer (e.g.,
use Number.isInteger(item.lastForwardedSeq)) and throw
ChannelBridgeBadPayloadError if it is not an integer or is negative/invalid, and
stop using Math.trunc for lastForwardedSeq when constructing the binding; keep
the existing finite/negative checks for createdAtMs/updatedAtMs (or add integer
checks if you also want them strict) and ensure the error messages reference
lastForwardedSeq for clarity.

Comment on lines +778 to +792
if (event.text.trim().startsWith('/')) {
try {
await replyToConversation(adapter, {
conversationId: event.conversationId,
threadId: event.threadId,
}, 'Unknown command. Use /help for supported commands.');
} catch (replyError) {
params.deps.onWarning?.(
`Failed to send unknown-command reply for provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'}`,
replyError,
);
}
deduper.markSeen(event);
ackableInbound.push(event);
processedSuccessfully = true;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't mark these inbound messages handled when the fallback reply failed.

Each of these branches marks the event seen/ackable even when replyToConversation() throws. If the unknown-command, binding-read-failure, no-binding, or session-forward-failure reply cannot be sent, the original inbound message is dropped with no session forward and no visible feedback. Only acknowledge after the fallback reply succeeds.

Also applies to: 803-829, 831-847, 864-890

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/core/channelBridgeWorker.ts` around lines 778 - 792,
The current branches call replyToConversation() then always call
deduper.markSeen(...), push the event to ackableInbound, and set
processedSuccessfully = true even if the reply threw; change each fallback-reply
branch (the unknown-command branch and the other branches around
replyToConversation in channelBridgeWorker.ts — references: replyToConversation,
deduper.markSeen, ackableInbound, processedSuccessfully) so that
deduper.markSeen(...), ackableInbound.push(event) and processedSuccessfully =
true are executed only after the await replyToConversation(...) completes
successfully (i.e., move those three statements into the try block immediately
after the await), and leave the catch to call params.deps.onWarning(...) but not
ack or mark the event as processed; apply the same fix to the other reply
branches you noted (lines covering binding-read-failure, no-binding,
session-forward-failure).

@cdenneen
Copy link
Author

cdenneen commented Mar 7, 2026

All current review threads are resolved and checks are green; requesting a final pass update on latest head 74ae08b3.\n\n@coderabbitai review

@cdenneen
Copy link
Author

cdenneen commented Mar 7, 2026

@greptile review

@coderabbitai
Copy link

coderabbitai bot commented Mar 7, 2026

@cdenneen Thanks for the thorough iteration across all the commits leading up to 74ae08b3! Given the depth and breadth of this PR — the Telegram channel adapter, scoped bridge config, diagnostics hardening, KV scoping by server+account, webhook relay, lifecycle decoupling, and all the accompanying regression coverage — it's great to see all threads resolved and checks green. Triggering a final incremental review pass on the latest head now.


🧠 Learnings used
Learnt from: cdenneen
Repo: happier-dev/happier PR: 110
File: apps/cli/src/channels/telegram/telegramAdapter.test.ts:17-41
Timestamp: 2026-03-04T15:25:08.440Z
Learning: In apps/cli/src/channels/telegram/telegramAdapter.test.ts, the TelegramApiClient (getMe, getUpdates, sendMessage) is intentionally stubbed with vi.fn mocks. This is an accepted exception to the no-mocking guideline because TelegramApiClient is a true third-party API system boundary requiring external credentials/network. End-to-end Telegram transport validation is tracked separately as an integration suite to avoid CI flakiness.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment on lines +52 to +71
function createStableBridgeLocalId(params: Readonly<{
providerId: string;
conversationId: string;
threadId: string | null;
sessionId: string;
text: string;
messageId?: string;
}>): string {
const normalizedMessageId = typeof params.messageId === 'string' ? params.messageId.trim() : '';
if (normalizedMessageId.length === 0) {
throw new Error('Channel bridge inbound messageId is required for stable idempotency');
}

const digest = createHash('sha256')
.update(params.providerId)
.update('\u0000')
.update(params.conversationId)
.update('\u0000')
.update(params.threadId ?? '')
.update('\u0000')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

text and sessionId are declared but unused in the hash

createStableBridgeLocalId accepts sessionId: string and text: string in its parameter type but neither is fed into the SHA-256 digest — only providerId, conversationId, threadId, and messageId contribute. The two dead parameters increase cognitive overhead: a reader might reasonably assume they influence the localId or are reserved for a future version. Additionally, while messageId is typed as optional (?: string), the function throws immediately when it is absent, which is inconsistent with the ChannelBridgeDeps.sendUserMessageToSession interface (which also declares messageId?: string). Any caller that follows the interface contract and omits messageId will receive an unexpected runtime exception rather than a type error.

Consider removing the unused parameters and either making messageId required in both the interface and this function, or generating a UUID fallback when it is absent:

function createStableBridgeLocalId(params: Readonly<{
  providerId: string;
  conversationId: string;
  threadId: string | null;
  messageId: string; // required — callers must supply a stable message identity
}>): string {

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
apps/cli/src/channels/channelBridgeServerKv.ts (1)

271-297: ⚠️ Potential issue | 🟠 Major

Require an integer lastForwardedSeq.

This field is replay cursor state. Truncating 1.9 to 1 turns a corrupt persisted row into “forward from 1”, which can resend already-forwarded agent output after the next sync.

💡 Suggested fix
-    if (
-      typeof item.lastForwardedSeq !== 'number'
-      || !Number.isFinite(item.lastForwardedSeq)
-      || item.lastForwardedSeq < 0
-    ) {
+    const lastForwardedSeq = item.lastForwardedSeq;
+    if (
+      typeof lastForwardedSeq !== 'number'
+      || !Number.isFinite(lastForwardedSeq)
+      || !Number.isInteger(lastForwardedSeq)
+      || lastForwardedSeq < 0
+    ) {
       throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding lastForwardedSeq at index ${index}`);
     }
@@
-    const lastForwardedSeq = Math.trunc(item.lastForwardedSeq);
     const createdAtMs = Math.trunc(item.createdAtMs);
     const updatedAtMs = Math.trunc(item.updatedAtMs);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/channelBridgeServerKv.ts` around lines 271 - 297, The
code currently truncates item.lastForwardedSeq (Math.trunc) which silently
accepts non-integer values like 1.9 and can cause replay/duplicate forwards;
change the validation in the channelBridgeServerKv binding parsing so
lastForwardedSeq must be an integer (use Number.isInteger) and non-negative,
throw ChannelBridgeBadPayloadError if not, and stop using Math.trunc when
assigning lastForwardedSeq (use the validated value directly); update the
validation logic that references item.lastForwardedSeq and the assignment to
lastForwardedSeq to reflect this stricter integer requirement.
apps/cli/src/cli/commands/bridge.ts (1)

224-234: ⚠️ Potential issue | 🟠 Major

Reject explicit empty values for every value-taking flag.

Only --bot-token tracks flag presence separately. The other options collapse “missing” and “present but empty” into the same '' value, so inputs like --webhook-secret= or --tick-ms= are silently ignored if another flag in the same command is valid. That leaves stale config in place while the command still reports success.

💡 Suggested direction
   const rawBotToken = argvValue(args, '--bot-token');
   const hasBotTokenFlag = args.some((arg) => arg === '--bot-token' || arg.startsWith('--bot-token='));
+  const hasAllowedChatIdsFlag = args.some((arg) => arg === '--allowed-chat-ids' || arg.startsWith('--allowed-chat-ids='));
+  const hasRequireTopicsFlag = args.some((arg) => arg === '--require-topics' || arg.startsWith('--require-topics='));
+  const hasTickMsFlag = args.some((arg) => arg === '--tick-ms' || arg.startsWith('--tick-ms='));
+  const hasWebhookEnabledFlag = args.some((arg) => arg === '--webhook-enabled' || arg.startsWith('--webhook-enabled='));
+  const hasWebhookSecretFlag = args.some((arg) => arg === '--webhook-secret' || arg.startsWith('--webhook-secret='));
+  const hasWebhookHostFlag = args.some((arg) => arg === '--webhook-host' || arg.startsWith('--webhook-host='));
+  const hasWebhookPortFlag = args.some((arg) => arg === '--webhook-port' || arg.startsWith('--webhook-port='));
@@
-  } else if (allowedChatIdsRaw) {
+  } else if (hasAllowedChatIdsFlag) {
+    if (!allowedChatIdsRaw) {
+      throw new Error('Invalid --allowed-chat-ids value: cannot be empty');
+    }
     const parsedAllowedChatIds = parseCsvList(allowedChatIdsRaw);
@@
-  if (tickMsRaw) {
+  if (hasTickMsFlag) {
+    if (!tickMsRaw) {
+      throw new Error('Invalid --tick-ms value: cannot be empty');
+    }
     update.tickMs = parseIntegerInput(tickMsRaw, '--tick-ms', 250, 60_000);
   }

Apply the same presence-check pattern to --require-topics, --webhook-enabled, --webhook-secret, --webhook-host, and --webhook-port.

Also applies to: 251-287

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/cli/commands/bridge.ts` around lines 224 - 234, The flags other
than --bot-token are currently treated the same whether omitted or passed with
an empty value; update the parsing around argvValue/args to mirror the
--bot-token pattern: for each value-taking flag (requireTopicsRaw,
webhookEnabledRaw, webhookSecret, webhookHost, webhookPort, tickMsRaw,
allowedChatIdsRaw) add a hasXFlag boolean (e.g., hasRequireTopicsFlag =
args.some(arg => arg === '--require-topics' ||
arg.startsWith('--require-topics='))) and then if the flag is present but
argvValue returned an empty string, reject it (throw or processLogger.error +
exit) instead of treating it as “missing”; apply this change to the parsing code
that defines requireTopicsRaw, webhookEnabledRaw, webhookSecret, webhookHost,
webhookPort, tickMsRaw and allowedChatIdsRaw so explicit empty values are
detected and handled consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/cli/src/channels/channelBridgeServerKv.ts`:
- Around line 190-192: The KV helper currently truncates fractional tickMs on
write (record.tickMs -> out.tickMs via Math.trunc) and the read/parser accepts
any finite number, letting malformed values like 1.9, 0, or -5 pass; change both
paths to enforce a validated integer: on the write side (where record.tickMs is
handled) only accept Number.isInteger(record.tickMs) and a sensible range (e.g.
> 0) instead of truncating, and on the read/parser side ensure it uses
Number.isInteger(value) and the same range check so invalid values are rejected
(allowUnsupportedSchema can then handle the failure); update the checks around
record.tickMs and the corresponding parser logic (and remove Math.trunc usage)
to implement this strict validation.

---

Duplicate comments:
In `@apps/cli/src/channels/channelBridgeServerKv.ts`:
- Around line 271-297: The code currently truncates item.lastForwardedSeq
(Math.trunc) which silently accepts non-integer values like 1.9 and can cause
replay/duplicate forwards; change the validation in the channelBridgeServerKv
binding parsing so lastForwardedSeq must be an integer (use Number.isInteger)
and non-negative, throw ChannelBridgeBadPayloadError if not, and stop using
Math.trunc when assigning lastForwardedSeq (use the validated value directly);
update the validation logic that references item.lastForwardedSeq and the
assignment to lastForwardedSeq to reflect this stricter integer requirement.

In `@apps/cli/src/cli/commands/bridge.ts`:
- Around line 224-234: The flags other than --bot-token are currently treated
the same whether omitted or passed with an empty value; update the parsing
around argvValue/args to mirror the --bot-token pattern: for each value-taking
flag (requireTopicsRaw, webhookEnabledRaw, webhookSecret, webhookHost,
webhookPort, tickMsRaw, allowedChatIdsRaw) add a hasXFlag boolean (e.g.,
hasRequireTopicsFlag = args.some(arg => arg === '--require-topics' ||
arg.startsWith('--require-topics='))) and then if the flag is present but
argvValue returned an empty string, reject it (throw or processLogger.error +
exit) instead of treating it as “missing”; apply this change to the parsing code
that defines requireTopicsRaw, webhookEnabledRaw, webhookSecret, webhookHost,
webhookPort, tickMsRaw and allowedChatIdsRaw so explicit empty values are
detected and handled consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e2a57fb1-8747-4dee-88a6-7b17997214b1

📥 Commits

Reviewing files that changed from the base of the PR and between faf0510 and 74ae08b.

📒 Files selected for processing (9)
  • apps/cli/src/channels/channelBridgeConfig.test.ts
  • apps/cli/src/channels/channelBridgeConfig.ts
  • apps/cli/src/channels/channelBridgeServerKv.test.ts
  • apps/cli/src/channels/channelBridgeServerKv.ts
  • apps/cli/src/channels/core/channelBridgeWorker.test.ts
  • apps/cli/src/channels/core/channelBridgeWorker.ts
  • apps/cli/src/channels/startChannelBridgeWorker.test.ts
  • apps/cli/src/channels/startChannelBridgeWorker.ts
  • apps/cli/src/cli/commands/bridge.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/cli/src/channels/channelBridgeConfig.ts
  • apps/cli/src/channels/core/channelBridgeWorker.ts
  • apps/cli/src/channels/channelBridgeConfig.test.ts

Comment on lines +190 to +192
if (typeof record.tickMs === 'number' && Number.isFinite(record.tickMs)) {
out.tickMs = Math.trunc(record.tickMs);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Treat tickMs as a validated integer on both read and write paths.

The KV helper still truncates fractional tickMs, and the read path accepts any finite number at all. That means malformed KV can still smuggle values like 1.9 -> 1, 0, or -5 into runtime config, and allowUnsupportedSchema never gets a chance to recover because the parser does not fail.

💡 Suggested fix
-  if (typeof record.tickMs === 'number' && Number.isFinite(record.tickMs)) {
-    out.tickMs = Math.trunc(record.tickMs);
+  if (record.tickMs !== undefined) {
+    if (
+      typeof record.tickMs !== 'number'
+      || !Number.isFinite(record.tickMs)
+      || !Number.isInteger(record.tickMs)
+      || record.tickMs < 250
+      || record.tickMs > 60_000
+    ) {
+      throw new ChannelBridgeBadPayloadError('Invalid tickMs payload');
+    }
+    out.tickMs = record.tickMs;
   }
@@
-  if (typeof update.tickMs === 'number' && Number.isFinite(update.tickMs)) {
-    const normalizedTickMs = Math.trunc(update.tickMs);
-    if (normalizedTickMs < 250 || normalizedTickMs > 60_000) {
+  if (typeof update.tickMs === 'number') {
+    if (
+      !Number.isFinite(update.tickMs)
+      || !Number.isInteger(update.tickMs)
+      || update.tickMs < 250
+      || update.tickMs > 60_000
+    ) {
       throw new ChannelBridgeBadPayloadError('Invalid tickMs update payload: must be in [250, 60000]');
     }
-    next.tickMs = normalizedTickMs;
+    next.tickMs = update.tickMs;
   }

Also applies to: 563-568

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/cli/src/channels/channelBridgeServerKv.ts` around lines 190 - 192, The
KV helper currently truncates fractional tickMs on write (record.tickMs ->
out.tickMs via Math.trunc) and the read/parser accepts any finite number,
letting malformed values like 1.9, 0, or -5 pass; change both paths to enforce a
validated integer: on the write side (where record.tickMs is handled) only
accept Number.isInteger(record.tickMs) and a sensible range (e.g. > 0) instead
of truncating, and on the read/parser side ensure it uses
Number.isInteger(value) and the same range check so invalid values are rejected
(allowUnsupportedSchema can then handle the failure); update the checks around
record.tickMs and the corresponding parser logic (and remove Math.trunc usage)
to implement this strict validation.

@cdenneen cdenneen marked this pull request as ready for review March 7, 2026 05:29
@cdenneen
Copy link
Author

cdenneen commented Mar 7, 2026

@coderabbitai review

@cdenneen
Copy link
Author

cdenneen commented Mar 7, 2026

@greptile review

@coderabbitai
Copy link

coderabbitai bot commented Mar 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment on lines +52 to +75
function createStableBridgeLocalId(params: Readonly<{
providerId: string;
conversationId: string;
threadId: string | null;
sessionId: string;
text: string;
messageId?: string;
}>): string {
const normalizedMessageId = typeof params.messageId === 'string' ? params.messageId.trim() : '';
if (normalizedMessageId.length === 0) {
throw new Error('Channel bridge inbound messageId is required for stable idempotency');
}

const digest = createHash('sha256')
.update(params.providerId)
.update('\u0000')
.update(params.conversationId)
.update('\u0000')
.update(params.threadId ?? '')
.update('\u0000')
.update(normalizedMessageId)
.digest('hex');
return `bridge-${digest}`;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createStableBridgeLocalId declares sessionId: string and text: string in its parameter object (lines 56-57), but neither is ever fed into the SHA-256 digest. These dead parameters inflate cognitive overhead for future readers who may assume they influence the resulting localId.

More importantly, messageId is typed ?: string (optional) at line 58, but the function throws immediately when it is absent (line 61-62: throw new Error('Channel bridge inbound messageId is required for stable idempotency')). The ChannelBridgeDeps.sendUserMessageToSession interface declares messageId?: string (optional). Any implementation that follows the interface contract and omits messageId will receive an unexpected runtime exception rather than a compile-time error — breaking the contract the optional type is meant to convey.

Consider:

  1. Removing the unused sessionId and text parameters.
  2. Either making messageId required in both this function and ChannelBridgeDeps.sendUserMessageToSession to align the type with runtime reality, or providing a safe UUID fallback when it is absent.

Comment on lines +290 to +297
fetchAgentMessagesAfterSeq: async ({ sessionId, afterSeq }) => {
const runtime = await resolveSessionRuntime(sessionId);
if (!runtime) {
return {
messages: [],
highestSeenSeq: null,
};
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When resolveSessionRuntime returns null (e.g. the session was deleted, revoked, or is transiently unreachable), the function returns { messages: [], highestSeenSeq: null } without emitting any diagnostic. The bridge worker receives zero messages and no highestSeenSeq to advance with, so the binding's lastForwardedSeq cursor is permanently frozen. Every subsequent tick repeats the same silent no-op: fetchSessionById returns null, the transcript is never fetched, and the binding stalls indefinitely.

Compare this with the analogous store.getBinding failure path in channelBridgeWorker.ts, which both notifies the user in-channel and marks the event seen so polling batches are not replayed. An operator with a bound session that becomes inaccessible has no log-level signal at all to indicate why their Telegram conversation stopped receiving agent replies.

Consider emitting a warning via onWarning so the issue is visible in operator logs and logs can surface why forwarding stalled.

Comment on lines +216 to +225
const settingsWebhookSecret =
firstParsed([
secretsAccount?.webhookSecret,
webhookAccount?.secret,
secretsServer?.webhookSecret,
webhookServer?.secret,
secretsGlobal?.webhookSecret,
webhookGlobal?.secret,
], readTrimmedString)
?? '';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

settingsWebhookSecret is resolved via readTrimmedString (line 224), which only checks for a non-empty string. The env-var path below (line 230) correctly uses readWebhookSecretToken, enforcing the alphanumeric/dash/underscore pattern [A-Za-z0-9_-] that startTelegramWebhookRelay requires.

A user who edits settings.json directly (bypassing the CLI) and enters a token with characters outside that set (e.g., spaces or special characters) will see no validation error at config-resolution time. When startTelegramWebhookRelay is called at daemon startup, it will throw "Webhook secret token must match [A-Za-z0-9_-]", causing startChannelBridgeFromEnv to silently fall back to polling mode with only a logger.warn in daemon logs.

This is inconsistent with the webhook host validation (line 239), which validates settings-derived values with isLoopbackHost and falls back to a safe default when invalid. Consider applying the same pattern: validate the settings-derived token with readWebhookSecretToken and fall back to an empty string (no webhook) when the stored token is malformed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant