viber-channel 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,7 @@ import {
22
22
  RefreshNetworkError,
23
23
  refreshConversationToken,
24
24
  } from "./conversation.js";
25
- import { ConversationTokenExpiredError, fetchMessages, postMessage } from "./messages.js";
25
+ import { ackMessage, ConversationTokenExpiredError, fetchMessages, postMessage } from "./messages.js";
26
26
  import { buildSseUrl } from "./urls.js";
27
27
  import { cfAccessHeaders } from "./cfAccess.js";
28
28
  import { sessionFilePath, writeHandle } from "./channel_session.js";
@@ -534,8 +534,49 @@ export async function sseLoop(opts: {
534
534
  const primeHistory = options.awaitInvite !== true;
535
535
  let primed = false;
536
536
 
537
+ // #346 read-ACK bookkeeping. Tracked SEPARATELY from handledIds so a transient
538
+ // ACK failure is RETRIED on the next reconnect/catch-up instead of being lost
539
+ // forever (codex step-02 review): handledIds gates the reply (at-most-once),
540
+ // ackedIds gates the ACK (at-least-once-until-confirmed). ackInFlightIds avoids
541
+ // spamming concurrent ACKs for the same message within a run.
542
+ const ackedIds = new Set<string>();
543
+ const ackInFlightIds = new Set<string>();
544
+
545
+ // Emit a read-acknowledgement for a web→agent message. Run by the bridge CODE
546
+ // (not the LLM), fire-and-forget, BEFORE any reply — so the web "reçu" checkmark
547
+ // has zero impact on agent reply latency. Only role=user|user_voice with no
548
+ // sender_instance_id qualify (mirrors the Worker; the Worker is the source of
549
+ // truth and 404s anything else). Idempotent server-side, so re-acking on
550
+ // reconnect is safe; we only mark ackedIds on a confirmed success so a failed
551
+ // ACK is retried. Never throws (ackMessage swallows).
552
+ // TODO(#346/#280 s19): when addressed_to is revived (per-instance targeting),
553
+ // gate this on "this instance is an intended recipient" — otherwise an agent Y
554
+ // receiving the per-conversation SSE fan-out would ACK a message addressed to
555
+ // agent X, lighting "reçu" while X never received it. Mirror the same guard in
556
+ // the Worker's insertMessageAck qualification. N/A today: addressed_to is
557
+ // dormant and fan-out is per-conversation, so every connected bridge truly
558
+ // receives the message (consistent with decision A = delivered-to-bridge).
559
+ function ackInboundReceipt(msg: ConversationMessage): void {
560
+ if (msg.role !== "user" && msg.role !== "user_voice") return;
561
+ if (msg.sender_instance_id) return;
562
+ const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
563
+ if (!idStr) return;
564
+ if (ackedIds.has(idStr) || ackInFlightIds.has(idStr)) return;
565
+ ackInFlightIds.add(idStr);
566
+ void ackMessage(baseUrl, runtime.id, runtime.token, idStr, signal)
567
+ .then((ok) => {
568
+ if (ok) ackedIds.add(idStr);
569
+ else process.stderr.write(`${logPrefix} ack failed for message ${idStr} (will retry on reconnect)\n`);
570
+ })
571
+ .finally(() => ackInFlightIds.delete(idStr));
572
+ }
573
+
537
574
  async function handleInboundMessage(msg: ConversationMessage): Promise<boolean> {
538
575
  const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
576
+ // Confirm receipt to the web INDEPENDENTLY of handled/reply state, BEFORE the
577
+ // handledIds early-return — so a previously-failed ACK is retried when this
578
+ // message comes back through catch-up on reconnect (codex step-02 review).
579
+ ackInboundReceipt(msg);
539
580
  if (idStr && handledIds.has(idStr)) return false;
540
581
  if (!shouldHandleMessage(msg, ownInstanceKey, ownPostedIds, options)) {
541
582
  if (idStr) handledIds.add(idStr); // skip decision is final — role won't change
@@ -575,6 +616,10 @@ export async function sseLoop(opts: {
575
616
  const seed = (msg: ConversationMessage): void => {
576
617
  const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
577
618
  if (idStr) handledIds.add(idStr);
619
+ // #346: a bridge that was offline still RECEIVED these messages on
620
+ // reconnect — ACK seeded web→agent messages so offline→online shows
621
+ // "reçu" even though we won't reply to backlog.
622
+ ackInboundReceipt(msg);
578
623
  };
579
624
 
580
625
  // FIRST await-invite pass: bound the replay to the TRIGGER only — forward
package/lib/messages.ts CHANGED
@@ -95,6 +95,37 @@ export async function fetchMessages(
95
95
  return Array.isArray(body.messages) ? body.messages : [];
96
96
  }
97
97
 
98
+ /**
99
+ * POST /api/conversations/:id/messages/:messageId/ack — read-acknowledgement (#346).
100
+ *
101
+ * The bridge CODE (not the LLM) confirms receipt of a web→agent message so the
102
+ * web UI can show a "reçu" checkmark, with zero impact on agent reply latency.
103
+ *
104
+ * Best-effort and NON-BLOCKING: never throws. Any failure (network, 401 expired
105
+ * token, 404 for a message the Worker deems non-ackable) resolves to `false` so
106
+ * the message-handling flow is never disrupted by the ACK. Idempotence is owned
107
+ * by the Worker (PK + INSERT OR IGNORE), so re-acking on reconnect is safe.
108
+ */
109
+ export async function ackMessage(
110
+ baseUrl: string,
111
+ conversationId: string,
112
+ conversationToken: string,
113
+ messageId: string | number,
114
+ signal?: AbortSignal,
115
+ ): Promise<boolean> {
116
+ const url = `${baseUrl}/api/conversations/${conversationId}/messages/${messageId}/ack`;
117
+ try {
118
+ const resp = await fetch(url, {
119
+ method: "POST",
120
+ headers: { Authorization: `Bearer ${conversationToken}`, ...cfAccessHeaders() },
121
+ signal,
122
+ });
123
+ return resp.ok;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
98
129
  export interface MessagePostSuccess {
99
130
  ok: true;
100
131
  message_id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
5
5
  "type": "module",
6
6
  "bin": {
package/viber-channel.ts CHANGED
@@ -319,7 +319,7 @@ import {
319
319
  RefreshHttpError,
320
320
  RefreshNetworkError,
321
321
  } from "./lib/conversation.ts";
322
- import { ConversationTokenExpiredError } from "./lib/messages.ts";
322
+ import { ackMessage, ConversationTokenExpiredError } from "./lib/messages.ts";
323
323
  import {
324
324
  listAgents as libListAgents,
325
325
  messageAgent as libMessageAgent,
@@ -876,6 +876,43 @@ async function pushTranscript(text: string, lang: string): Promise<void> {
876
876
  // mechanism, surfaced by the #254 Active section that keeps the UI open).
877
877
  const messageDedup = new MessageDedup();
878
878
 
879
+ // #346 read-ACK bookkeeping for the Claude MCP channel (mirrors bridge_core's
880
+ // ackInboundReceipt, which only covers the Codex/Gemma bridges). Tracks confirmed
881
+ // ACKs so we don't re-POST on SSE replay; in-flight set avoids a double-POST race.
882
+ const ackedMessageIds = new Set<string>();
883
+ const ackInFlightMessageIds = new Set<string>();
884
+
885
+ /**
886
+ * Emit a read-acknowledgement for a web→agent message received on the main
887
+ * conversation (#346). Run by the channel CODE (not the LLM), fire-and-forget,
888
+ * so the web "reçu" checkmark has zero impact on reply latency. Only
889
+ * role=user|user_voice with no sender_instance_id qualify (mirrors the Worker,
890
+ * which is the source of truth and 404s anything else). Idempotent server-side,
891
+ * so re-acking on reconnect is safe; ackedMessageIds is only set on a confirmed
892
+ * success so a failed ACK is retried on the next delivery. Never throws.
893
+ */
894
+ function ackInboundReceipt(msg: {
895
+ id?: string | number | null;
896
+ role?: string;
897
+ sender_instance_id?: string | null;
898
+ }): void {
899
+ if (msg.role !== "user" && msg.role !== "user_voice") return;
900
+ if (msg.sender_instance_id) return;
901
+ // The SSE "message" payload carries the D1 id as a NUMBER (MessageResponse.id).
902
+ // Coerce like bridge_core's ackInboundReceipt — a strict string check would
903
+ // bail on every real message and never ack.
904
+ const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
905
+ if (!idStr) return;
906
+ if (ackedMessageIds.has(idStr) || ackInFlightMessageIds.has(idStr)) return;
907
+ ackInFlightMessageIds.add(idStr);
908
+ void ackMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, idStr)
909
+ .then((ok) => {
910
+ if (ok) ackedMessageIds.add(idStr);
911
+ else process.stderr.write(`[viber-channel] ack failed for message ${idStr} (will retry on next delivery)\n`);
912
+ })
913
+ .finally(() => ackInFlightMessageIds.delete(idStr));
914
+ }
915
+
879
916
  /**
880
917
  * Forward a saved conversation message from the event bus to Claude.
881
918
  *
@@ -1084,6 +1121,9 @@ async function sseLoop(): Promise<void> {
1084
1121
  sender_instance_id?: string | null;
1085
1122
  };
1086
1123
  if (msg.content) {
1124
+ // #346: confirm receipt to the web BEFORE forwarding to the LLM
1125
+ // and independently of any reply — decoupled from reply latency.
1126
+ ackInboundReceipt(msg);
1087
1127
  await pushMessage(msg);
1088
1128
  } else {
1089
1129
  process.stderr.write(`[viber-channel] 'message' event has empty content\n`);