viber-channel 0.8.39 → 0.8.40

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.
@@ -190,7 +190,10 @@ export async function messageAgent(
190
190
  try {
191
191
  outcome = await openDm(ctx.baseUrl(), ctx.instanceToken(), trimmedPeerId);
192
192
  } catch (err) {
193
- return errorText(`message_agent failed: ${String(err)}`);
193
+ // #694: openDm already retried a network failure once. No message was posted.
194
+ return errorText(
195
+ `message_agent could not open the DM (network error, retried once; nothing was posted): ${String(err)}`,
196
+ );
194
197
  }
195
198
  if (!outcome.ok) {
196
199
  let friendly: string;
@@ -240,7 +243,8 @@ export async function messageAgent(
240
243
  return errorText(`message_agent post failed: ${String(err)}`);
241
244
  }
242
245
  if (!result.ok) {
243
- return errorText(result.detail);
246
+ // #694: name the step — the DM opened, the post is what failed.
247
+ return errorText(`message_agent: the DM is open, but the post failed: ${result.detail}`);
244
248
  }
245
249
  if (outcome.redirect) {
246
250
  // #692: say which agent was reached, and that the id used is stale.
package/lib/messages.ts CHANGED
@@ -136,13 +136,144 @@ export interface MessagePostError {
136
136
  ok: false;
137
137
  status: number;
138
138
  detail: string;
139
- retriable: boolean;
140
139
  }
141
140
 
142
141
  export type MessagePostResult = MessagePostSuccess | MessagePostError;
143
142
 
144
143
  /**
145
- * POST /api/conversations/:conversationId/messages
144
+ * `detail` of viber-api's GLOBAL notFound (`viber-api/src/index.ts`): what a server
145
+ * that predates `POST …/messages/idempotent` answers on that route (#694). The
146
+ * ONLY cue that allows falling back to the legacy route. Tied to the server's text
147
+ * by a cross test (test/messages.test.ts), so a change there breaks the build here.
148
+ */
149
+ export const SERVER_NOT_FOUND_DETAIL = "Not found";
150
+
151
+ /** Statuses an edge or a tunnel answers for a request that may never have run: resent once (#694). */
152
+ const RESEND_STATUSES: ReadonlySet<number> = new Set([502, 503, 504]);
153
+
154
+ /**
155
+ * Pause before the single resend (#694). The failed attempt reused a keep-alive
156
+ * socket a Cloudflare datacenter switch had killed; Bun drops it from its pool, so
157
+ * the resend opens a fresh connection.
158
+ */
159
+ export const RESEND_DELAY_MS = 250;
160
+
161
+ /** Test seams of postMessage; production never passes them. */
162
+ export interface PostMessageOptions {
163
+ /** Pause before the resend. Default RESEND_DELAY_MS. */
164
+ resendDelayMs?: number;
165
+ /** Client message id generator. Default crypto.randomUUID. */
166
+ newClientMessageId?: () => string;
167
+ }
168
+
169
+ /** One HTTP attempt: a response whose body was read IN FULL, or a network failure. */
170
+ type Attempt =
171
+ | { kind: "response"; status: number; statusText: string; body: string }
172
+ | { kind: "network"; error: unknown };
173
+
174
+ async function attemptPost(
175
+ fetchImpl: typeof fetch,
176
+ url: string,
177
+ conversationToken: string,
178
+ payload: Record<string, unknown>,
179
+ signal: AbortSignal | undefined,
180
+ ): Promise<Attempt> {
181
+ try {
182
+ const resp = await fetchImpl(url, {
183
+ method: "POST",
184
+ headers: {
185
+ Authorization: `Bearer ${conversationToken}`,
186
+ "Content-Type": "application/json",
187
+ ...cfAccessHeaders(),
188
+ },
189
+ body: JSON.stringify(payload),
190
+ signal,
191
+ });
192
+ // Read the body here: a socket reset can also land mid-body, and a 404 whose body
193
+ // was lost must never be mistaken for the global notFound (#694).
194
+ const body = await resp.text();
195
+ return { kind: "response", status: resp.status, statusText: resp.statusText, body };
196
+ } catch (error) {
197
+ return { kind: "network", error };
198
+ }
199
+ }
200
+
201
+ /**
202
+ * True for the global notFound body BYTE FOR BYTE — `{"detail":"Not found"}`, as
203
+ * Hono's `c.json` serializes it — and nothing else: not a reformatted variant, not
204
+ * a different detail, not a body cut by a reset (Codex review, step-02).
205
+ */
206
+ function isServerNotFound(attempt: Attempt): boolean {
207
+ return (
208
+ attempt.kind === "response" &&
209
+ attempt.status === 404 &&
210
+ attempt.body === JSON.stringify({ detail: SERVER_NOT_FOUND_DETAIL })
211
+ );
212
+ }
213
+
214
+ /** Worth one resend with the same client id: a network failure (not an abort) or 502/503/504. */
215
+ function isResendable(attempt: Attempt, signal: AbortSignal | undefined): boolean {
216
+ if (signal?.aborted) return false;
217
+ return attempt.kind === "network" || RESEND_STATUSES.has(attempt.status);
218
+ }
219
+
220
+ /**
221
+ * Map one attempt to the result the tools see.
222
+ * @throws ConversationTokenExpiredError on HTTP 401
223
+ */
224
+ function toResult(attempt: Attempt, resent: boolean): MessagePostResult {
225
+ // After a resend the first attempt may have been written: say so, never "lost".
226
+ const resendNote = resent
227
+ ? " Resent once with the same client_message_id: delivery is NOT confirmed, it may already be delivered." +
228
+ " Do not resend unless the user asks: a resend is a new message and could appear twice."
229
+ : "";
230
+ if (attempt.kind === "network") {
231
+ return { ok: false, status: 0, detail: `Network error: ${String(attempt.error)}.${resendNote}` };
232
+ }
233
+ const { status, statusText, body } = attempt;
234
+ if (status === 401) throw new ConversationTokenExpiredError();
235
+ if (status >= 500) {
236
+ return {
237
+ ok: false,
238
+ status,
239
+ detail: `Server error (HTTP ${status}): ${body || statusText}.${resendNote || " Retry the call."}`,
240
+ };
241
+ }
242
+ if (status < 200 || status >= 300) {
243
+ let detail = `HTTP ${status}`;
244
+ try {
245
+ const json = JSON.parse(body) as { detail?: unknown };
246
+ if (typeof json.detail === "string") detail = `HTTP ${status}: ${json.detail}`;
247
+ } catch {
248
+ if (body) detail = `HTTP ${status}: ${body}`;
249
+ }
250
+ return { ok: false, status, detail: `${detail}${resendNote}` };
251
+ }
252
+ // 200 or 201 — the saved message (a 200 on the idempotent route is the one an
253
+ // earlier attempt wrote).
254
+ try {
255
+ const message = JSON.parse(body) as { id: string };
256
+ return { ok: true, message_id: message.id };
257
+ } catch (err) {
258
+ return { ok: false, status, detail: `Failed to parse response JSON: ${String(err)}` };
259
+ }
260
+ }
261
+
262
+ /**
263
+ * POST a message, at most once (#694).
264
+ *
265
+ * A Cloudflare datacenter switch kills idle keep-alive sockets; the next POST on
266
+ * one fails in ~20 ms with ECONNRESET, and Bun never resends a POST itself. So:
267
+ * 1. one client message id per logical send, sent to `…/messages/idempotent`;
268
+ * 2. a network failure (not an abort) or 502/503/504 → ONE resend, same id, after
269
+ * RESEND_DELAY_MS — the server answers the row the first attempt wrote, if any;
270
+ * 3. a server that predates the idempotent route answers its global notFound:
271
+ * ONLY on the FIRST attempt, and only on that exact, fully read body, the send
272
+ * falls back to the legacy `…/messages`, without resend (today's behaviour).
273
+ * After an ambiguous first attempt a notFound means a rollback between the two
274
+ * attempts: the first may have been written, so the legacy route would
275
+ * duplicate it — that is an error, never a fallback.
276
+ * No timer, no periodic request: a request is added only when a send fails.
146
277
  *
147
278
  * @throws ConversationTokenExpiredError on HTTP 401
148
279
  * @returns MessagePostResult — success with message_id, or error with detail
@@ -157,7 +288,7 @@ export async function postMessage(
157
288
  * Optional abort signal (#303 step-12.03): the codex bridge passes its stream's
158
289
  * signal so a reply in flight is cancelled atomically if the stream is aborted
159
290
  * (re-join / revoke / shutdown) — closing the check-then-post window that a bare
160
- * `signal.aborted` guard before the call cannot.
291
+ * `signal.aborted` guard before the call cannot. An aborted send is never resent.
161
292
  */
162
293
  signal?: AbortSignal,
163
294
  /**
@@ -167,85 +298,26 @@ export async function postMessage(
167
298
  * that cannot see the notice cannot prove the fix that exists to emit it.
168
299
  */
169
300
  fetchImpl: typeof fetch = fetch,
301
+ options: PostMessageOptions = {},
170
302
  ): Promise<MessagePostResult> {
171
303
  if (!text || text.trim().length === 0) {
172
- return {
173
- ok: false,
174
- status: 0,
175
- detail: "text must be a non-empty string",
176
- retriable: false,
177
- };
178
- }
179
-
180
- const url = `${baseUrl}/api/conversations/${conversationId}/messages`;
181
- let resp: Response;
182
- try {
183
- resp = await fetchImpl(url, {
184
- method: "POST",
185
- headers: {
186
- Authorization: `Bearer ${conversationToken}`,
187
- "Content-Type": "application/json",
188
- ...cfAccessHeaders(),
189
- },
190
- body: JSON.stringify(artifact ? { content: text, artifact } : { content: text }),
191
- signal,
192
- });
193
- } catch (err) {
194
- return {
195
- ok: false,
196
- status: 0,
197
- detail: `Network error: ${String(err)}`,
198
- retriable: true,
199
- };
200
- }
201
-
202
- if (resp.status === 401) {
203
- throw new ConversationTokenExpiredError();
304
+ return { ok: false, status: 0, detail: "text must be a non-empty string" };
204
305
  }
205
306
 
206
- if (resp.status >= 500) {
207
- let body = "";
208
- try {
209
- body = await resp.text();
210
- } catch {
211
- // ignore
212
- }
213
- return {
214
- ok: false,
215
- status: resp.status,
216
- detail: `Server error (HTTP ${resp.status}): ${body || resp.statusText}. Retry the call.`,
217
- retriable: true,
218
- };
219
- }
307
+ const messagesUrl = `${baseUrl}/api/conversations/${conversationId}/messages`;
308
+ const content: Record<string, unknown> = artifact ? { content: text, artifact } : { content: text };
309
+ const clientMessageId = (options.newClientMessageId ?? (() => crypto.randomUUID()))();
310
+ const idempotent = { ...content, client_message_id: clientMessageId };
220
311
 
221
- if (!resp.ok) {
222
- let detail = `HTTP ${resp.status}`;
223
- try {
224
- const json = (await resp.json()) as { detail?: string };
225
- if (typeof json.detail === "string") detail = `HTTP ${resp.status}: ${json.detail}`;
226
- } catch {
227
- try {
228
- const body = await resp.text();
229
- if (body) detail = `HTTP ${resp.status}: ${body}`;
230
- } catch {
231
- // ignore
232
- }
233
- }
234
- return { ok: false, status: resp.status, detail, retriable: false };
235
- }
236
-
237
- // 200 or 201 — parse the saved message
238
- let message: { id: string };
239
- try {
240
- message = (await resp.json()) as { id: string };
241
- } catch (err) {
242
- return {
243
- ok: false,
244
- status: resp.status,
245
- detail: `Failed to parse response JSON: ${String(err)}`,
246
- retriable: false,
247
- };
312
+ const first = await attemptPost(fetchImpl, `${messagesUrl}/idempotent`, conversationToken, idempotent, signal);
313
+ if (isServerNotFound(first)) {
314
+ const legacy = await attemptPost(fetchImpl, messagesUrl, conversationToken, content, signal);
315
+ return toResult(legacy, false);
248
316
  }
317
+ if (!isResendable(first, signal)) return toResult(first, false);
249
318
 
250
- return { ok: true, message_id: message.id };
319
+ await new Promise((resolve) => setTimeout(resolve, options.resendDelayMs ?? RESEND_DELAY_MS));
320
+ if (signal?.aborted) return toResult(first, false);
321
+ const second = await attemptPost(fetchImpl, `${messagesUrl}/idempotent`, conversationToken, idempotent, signal);
322
+ return toResult(second, true);
251
323
  }
package/lib/peers.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { cfAccessHeaders } from "./cfAccess.js";
13
+ import { RESEND_DELAY_MS } from "./messages.js";
13
14
 
14
15
  /** A peer agent as returned by GET /api/instances/peers.
15
16
  * project_id/project_name are present only in the user-scoped listing (#307):
@@ -175,21 +176,37 @@ function instanceIdOrNull(v: unknown): string | null {
175
176
  * Returns a structured outcome rather than throwing on the expected 4xx cases
176
177
  * (peer offline / not found) so the tool can surface a clear message to the
177
178
  * model; network/parse failures throw.
179
+ *
180
+ * #694: a network failure (a keep-alive socket killed by a Cloudflare datacenter
181
+ * switch) is retried ONCE after `retryDelayMs`. Safe because the route is
182
+ * open-or-create: a second call finds the pair the first one created and pushes
183
+ * no second join (viber-api instances.test.ts, "a resend in the SAME direction").
178
184
  */
179
185
  export async function openDm(
180
186
  baseUrl: string,
181
187
  instanceToken: string,
182
188
  peerInstanceId: string,
189
+ /** Fetch seam (#694 tests). Defaults to the global `fetch`. */
190
+ fetchImpl: typeof fetch = fetch,
191
+ retryDelayMs: number = RESEND_DELAY_MS,
183
192
  ): Promise<OpenDmOutcome> {
184
- const resp = await fetch(`${baseUrl}/api/instances/dms`, {
185
- method: "POST",
186
- headers: {
187
- Authorization: `Bearer ${instanceToken}`,
188
- "Content-Type": "application/json",
189
- ...cfAccessHeaders(),
190
- },
191
- body: JSON.stringify({ peer_instance_id: peerInstanceId }),
192
- });
193
+ const send = (): Promise<Response> =>
194
+ fetchImpl(`${baseUrl}/api/instances/dms`, {
195
+ method: "POST",
196
+ headers: {
197
+ Authorization: `Bearer ${instanceToken}`,
198
+ "Content-Type": "application/json",
199
+ ...cfAccessHeaders(),
200
+ },
201
+ body: JSON.stringify({ peer_instance_id: peerInstanceId }),
202
+ });
203
+ let resp: Response;
204
+ try {
205
+ resp = await send();
206
+ } catch {
207
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
208
+ resp = await send();
209
+ }
193
210
 
194
211
  if (!resp.ok) {
195
212
  let detail = `HTTP ${resp.status}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.39",
3
+ "version": "0.8.40",
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": {