viber-channel 0.8.38 → 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,15 +190,27 @@ 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;
197
- if (outcome.detail === "target_offline") {
200
+ if (outcome.detail === "target_offline" && outcome.liveInstanceIds) {
201
+ // #692: the id is stale and its agent is online under several ids (a
202
+ // reconnect race) — the server refused to guess.
203
+ friendly =
204
+ `${trimmedPeerId} is a stale id: that agent reconnected and is online under ` +
205
+ `${outcome.liveInstanceIds.join(", ")}. Call message_agent again with one of them.`;
206
+ } else if (outcome.detail === "target_offline") {
198
207
  // #627: a just-launched agent comes online with its runner's first beat (≤ 30 s).
208
+ // #692: an agent that reconnected under a new id is followed by the server;
209
+ // reaching here means no live instance of it was found under any id.
199
210
  friendly =
200
211
  "That agent is offline right now — it must be running to receive a DM. " +
201
- "If it was just launched, it comes online with its runner's next beat (within 30 s): retry then.";
212
+ "If it was just launched, it comes online with its runner's next beat (within 30 s): retry then. " +
213
+ "If you expected it online, check list_agents: it may have been relaunched under another name.";
202
214
  } else if (outcome.detail === "dm_unavailable_retry") {
203
215
  friendly = "Could not open the DM due to a transient conflict — try message_agent again.";
204
216
  } else {
@@ -231,7 +243,16 @@ export async function messageAgent(
231
243
  return errorText(`message_agent post failed: ${String(err)}`);
232
244
  }
233
245
  if (!result.ok) {
234
- 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}`);
248
+ }
249
+ if (outcome.redirect) {
250
+ // #692: say which agent was reached, and that the id used is stale.
251
+ return text(
252
+ `Message sent to agent ${outcome.redirect.to} (DM ${outcome.dm.conversation_id}). ` +
253
+ `Note: ${outcome.redirect.from} is a stale id — the same agent reconnected as ` +
254
+ `${outcome.redirect.to}; use that id from now on.`,
255
+ );
235
256
  }
236
257
  return text(`Message sent to agent (DM ${outcome.dm.conversation_id}).`);
237
258
  }
@@ -115,7 +115,8 @@ export const TOOL_DEFS = [
115
115
  "(from list_agents) and the text. Opens or reuses a private 1:1 DM and posts your message; " +
116
116
  "the agent's reply arrives back on this channel. Targets are normally same-project; an " +
117
117
  "ORCHESTRATOR instance (#307) can also message agents of the owner's other projects. " +
118
- "Fails if the target agent is offline.",
118
+ "Fails if the target agent is offline. An id gone stale because the agent reconnected " +
119
+ "(same name, new id) still reaches it: the result then names the new id to use (#692).",
119
120
  inputSchema: {
120
121
  type: "object",
121
122
  properties: {
@@ -104,7 +104,8 @@ export const CLAUDE_TOOL_DEFS = [
104
104
  "the agent's reply arrives back on this channel tagged source=agent-dm. " +
105
105
  "Targets are normally same-project; an ORCHESTRATOR instance (#307) can also message agents " +
106
106
  "of the owner's other projects (ids from its cross-project list_agents). " +
107
- "Fails if the target agent is offline.",
107
+ "Fails if the target agent is offline. An id gone stale because the agent reconnected " +
108
+ "(same name, new id) still reaches it: the result then names the new id to use (#692).",
108
109
  inputSchema: {
109
110
  type: "object" as const,
110
111
  properties: {
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):
@@ -153,40 +154,76 @@ export interface OpenDmResult {
153
154
  invited: boolean;
154
155
  }
155
156
 
157
+ /** #692: the server opened the DM with the live instance of the identity the
158
+ * caller named by a stale id. */
159
+ export interface OpenDmRedirect {
160
+ from: string;
161
+ to: string;
162
+ }
163
+
156
164
  export type OpenDmOutcome =
157
- | { ok: true; dm: OpenDmResult }
158
- | { ok: false; detail: string; status: number };
165
+ | { ok: true; dm: OpenDmResult; redirect?: OpenDmRedirect }
166
+ | { ok: false; detail: string; status: number; liveInstanceIds?: string[] };
167
+
168
+ /** Server-supplied ids end up in the model's prompt: only an instance id shape
169
+ * (32 lowercase hex) is ever echoed back, never free text. */
170
+ function instanceIdOrNull(v: unknown): string | null {
171
+ return typeof v === "string" && /^[0-9a-f]{32}$/.test(v) ? v : null;
172
+ }
159
173
 
160
174
  /**
161
175
  * Open-or-create a 1:1 DM with `peerInstanceId` (POST /api/instances/dms).
162
176
  * Returns a structured outcome rather than throwing on the expected 4xx cases
163
177
  * (peer offline / not found) so the tool can surface a clear message to the
164
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").
165
184
  */
166
185
  export async function openDm(
167
186
  baseUrl: string,
168
187
  instanceToken: string,
169
188
  peerInstanceId: string,
189
+ /** Fetch seam (#694 tests). Defaults to the global `fetch`. */
190
+ fetchImpl: typeof fetch = fetch,
191
+ retryDelayMs: number = RESEND_DELAY_MS,
170
192
  ): Promise<OpenDmOutcome> {
171
- const resp = await fetch(`${baseUrl}/api/instances/dms`, {
172
- method: "POST",
173
- headers: {
174
- Authorization: `Bearer ${instanceToken}`,
175
- "Content-Type": "application/json",
176
- ...cfAccessHeaders(),
177
- },
178
- body: JSON.stringify({ peer_instance_id: peerInstanceId }),
179
- });
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
+ }
180
210
 
181
211
  if (!resp.ok) {
182
212
  let detail = `HTTP ${resp.status}`;
213
+ let liveInstanceIds: string[] | undefined;
183
214
  try {
184
- const json = (await resp.json()) as { detail?: string };
215
+ const json = (await resp.json()) as { detail?: string; live_instance_ids?: unknown };
185
216
  if (typeof json.detail === "string") detail = json.detail;
217
+ // #692: several live instances of a stale id's identity — the server
218
+ // refused to guess and named them.
219
+ if (Array.isArray(json.live_instance_ids)) {
220
+ const ids = json.live_instance_ids.map(instanceIdOrNull).filter((id): id is string => id !== null);
221
+ if (ids.length > 0) liveInstanceIds = ids;
222
+ }
186
223
  } catch {
187
224
  /* non-JSON body */
188
225
  }
189
- return { ok: false, detail, status: resp.status };
226
+ return { ok: false, detail, status: resp.status, ...(liveInstanceIds ? { liveInstanceIds } : {}) };
190
227
  }
191
228
 
192
229
  const body = (await resp.json()) as {
@@ -194,6 +231,8 @@ export async function openDm(
194
231
  conversation_token?: string;
195
232
  expires_at?: number;
196
233
  invited?: boolean;
234
+ peer_instance_id?: unknown;
235
+ redirected_from?: unknown;
197
236
  };
198
237
  if (!body.conversation?.id || typeof body.conversation_token !== "string") {
199
238
  throw new Error("openDm: malformed response (missing conversation id or token)");
@@ -206,5 +245,14 @@ export async function openDm(
206
245
  expires_at: typeof body.expires_at === "number" ? body.expires_at : 0,
207
246
  invited: body.invited === true,
208
247
  },
248
+ ...redirectOf(body.redirected_from, body.peer_instance_id),
209
249
  };
210
250
  }
251
+
252
+ /** #692: both ids well-formed and distinct, or no redirect at all. An older
253
+ * server sends neither field. */
254
+ function redirectOf(from: unknown, to: unknown): { redirect?: OpenDmRedirect } {
255
+ const f = instanceIdOrNull(from);
256
+ const t = instanceIdOrNull(to);
257
+ return f && t && f !== t ? { redirect: { from: f, to: t } } : {};
258
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.38",
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": {