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.
- package/lib/bridge_core.ts +46 -1
- package/lib/messages.ts +31 -0
- package/package.json +1 -1
- package/viber-channel.ts +41 -1
package/lib/bridge_core.ts
CHANGED
|
@@ -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.
|
|
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`);
|