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.
- package/lib/agent_tools.ts +6 -2
- package/lib/messages.ts +149 -77
- package/lib/peers.ts +26 -9
- package/package.json +1 -1
package/lib/agent_tools.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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.
|
|
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": {
|