viber-channel 0.7.0 → 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 +52 -2
- package/lib/dm_stream.ts +8 -1
- package/lib/messages.ts +31 -0
- package/package.json +1 -1
- package/viber-channel.ts +42 -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
|
|
@@ -561,7 +602,12 @@ export async function sseLoop(opts: {
|
|
|
561
602
|
async function reconcileMessages(): Promise<boolean> {
|
|
562
603
|
let msgs: Array<Record<string, unknown>>;
|
|
563
604
|
try {
|
|
564
|
-
|
|
605
|
+
// #333: catch-up is a REST call to GET /api/conversations/:id/messages,
|
|
606
|
+
// which is served by the Worker API (baseUrl) — NOT by minted.ws_url, which
|
|
607
|
+
// is the SSE/voice host (the CF tunnel in staging/prod). Using ws_url here
|
|
608
|
+
// 404s whenever the SSE host differs from the API host (masked in dev where
|
|
609
|
+
// they collapse to one origin). The SSE subscription still uses ws_url.
|
|
610
|
+
msgs = await fetchMessages(baseUrl, minted.conversation_id, runtime.token);
|
|
565
611
|
} catch (err) {
|
|
566
612
|
if (err instanceof ConversationTokenExpiredError) throw err;
|
|
567
613
|
process.stderr.write(`${logPrefix} catch-up fetch failed: ${safeErrorMessage(err)}\n`);
|
|
@@ -570,6 +616,10 @@ export async function sseLoop(opts: {
|
|
|
570
616
|
const seed = (msg: ConversationMessage): void => {
|
|
571
617
|
const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
|
|
572
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);
|
|
573
623
|
};
|
|
574
624
|
|
|
575
625
|
// FIRST await-invite pass: bound the replay to the TRIGGER only — forward
|
package/lib/dm_stream.ts
CHANGED
|
@@ -35,7 +35,14 @@ export type FetchMessagesFn = (
|
|
|
35
35
|
) => Promise<Array<Record<string, unknown>>>;
|
|
36
36
|
|
|
37
37
|
export interface DmStreamOptions {
|
|
38
|
+
/** SSE/voice host (CF tunnel in staging/prod) — used for the events stream. */
|
|
38
39
|
wsUrl: string;
|
|
40
|
+
/**
|
|
41
|
+
* Worker API host — used for the REST catch-up (GET /:id/messages). This is a
|
|
42
|
+
* DIFFERENT origin from `wsUrl` in staging/prod (the messages route lives on
|
|
43
|
+
* the Worker, not the SSE tunnel); collapsing them caused #333's catch-up 404.
|
|
44
|
+
*/
|
|
45
|
+
apiBaseUrl: string;
|
|
39
46
|
conversationId: string;
|
|
40
47
|
conversationToken: string;
|
|
41
48
|
onMessage: (msg: DmMessage) => void | Promise<void>;
|
|
@@ -135,7 +142,7 @@ async function catchUp(opts: DmStreamOptions, handledIds: Set<string>): Promise<
|
|
|
135
142
|
const doFetch = opts.fetchMessagesImpl ?? fetchMessages;
|
|
136
143
|
let msgs: Array<Record<string, unknown>>;
|
|
137
144
|
try {
|
|
138
|
-
msgs = await doFetch(opts.
|
|
145
|
+
msgs = await doFetch(opts.apiBaseUrl, opts.conversationId, opts.conversationToken);
|
|
139
146
|
} catch (err) {
|
|
140
147
|
if (err instanceof ConversationTokenExpiredError) throw new DmStreamAuthError();
|
|
141
148
|
opts.log?.(
|
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
|
*
|
|
@@ -977,6 +1014,7 @@ function startDmStream(minted: ConversationMintResponse): void {
|
|
|
977
1014
|
// per-DM conversation heartbeat (#298) was removed in #315.
|
|
978
1015
|
void runDmStream({
|
|
979
1016
|
wsUrl: minted.ws_url,
|
|
1017
|
+
apiBaseUrl: BASE_URL,
|
|
980
1018
|
conversationId: minted.conversation_id,
|
|
981
1019
|
conversationToken: minted.conversation_token,
|
|
982
1020
|
log: (m) => process.stderr.write(m),
|
|
@@ -1083,6 +1121,9 @@ async function sseLoop(): Promise<void> {
|
|
|
1083
1121
|
sender_instance_id?: string | null;
|
|
1084
1122
|
};
|
|
1085
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);
|
|
1086
1127
|
await pushMessage(msg);
|
|
1087
1128
|
} else {
|
|
1088
1129
|
process.stderr.write(`[viber-channel] 'message' event has empty content\n`);
|