viber-channel 0.5.1 → 0.5.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/conversation.ts +95 -0
- package/lib/token_refresh.ts +104 -0
- package/package.json +1 -1
- package/viber-channel.ts +109 -4
package/lib/conversation.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { cfAccessHeaders } from "./cfAccess.js";
|
|
3
|
+
import { ConversationTokenExpiredError } from "./messages.js";
|
|
3
4
|
|
|
4
5
|
export interface ConversationMintResponse {
|
|
5
6
|
conversation_id: string;
|
|
@@ -76,6 +77,100 @@ export async function mintConversation(
|
|
|
76
77
|
return (await resp.json()) as ConversationMintResponse;
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
export interface RefreshTokenResponse {
|
|
81
|
+
conversation_token: string;
|
|
82
|
+
expires_at: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* HTTP failure from /refresh-token (any non-401 non-2xx). `.retryable` lets
|
|
87
|
+
* the scheduler distinguish a transient server hiccup (5xx, 408, 409) from a
|
|
88
|
+
* permanent state mismatch (4xx other than 401), so it can back off and try
|
|
89
|
+
* again instead of immediately ending the channel.
|
|
90
|
+
*/
|
|
91
|
+
export class RefreshHttpError extends Error {
|
|
92
|
+
status: number;
|
|
93
|
+
detail: string;
|
|
94
|
+
retryable: boolean;
|
|
95
|
+
constructor(status: number, detail: string) {
|
|
96
|
+
super(`Refresh failed (HTTP ${status}): ${detail}`);
|
|
97
|
+
this.name = "RefreshHttpError";
|
|
98
|
+
this.status = status;
|
|
99
|
+
this.detail = detail;
|
|
100
|
+
// 5xx, 408 (timeout), 409 (concurrent-refresh race) — server/transient.
|
|
101
|
+
// 4xx others (403 fingerprint mismatch, etc.) are permanent.
|
|
102
|
+
this.retryable = status >= 500 || status === 408 || status === 409;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Wrapper for `fetch` failures (DNS, connection reset, TLS, etc.). Always
|
|
108
|
+
* retryable — by definition we never got a server response to classify.
|
|
109
|
+
*/
|
|
110
|
+
export class RefreshNetworkError extends Error {
|
|
111
|
+
cause: unknown;
|
|
112
|
+
retryable: true = true as const;
|
|
113
|
+
constructor(cause: unknown) {
|
|
114
|
+
super(`Refresh failed: network error: ${String(cause)}`);
|
|
115
|
+
this.name = "RefreshNetworkError";
|
|
116
|
+
this.cause = cause;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* POST /api/conversations/:conversationId/refresh-token
|
|
122
|
+
*
|
|
123
|
+
* Rotates the per-conversation token in place. Worker updates the row's
|
|
124
|
+
* token+expiry and returns the new pair. The caller (scheduler) propagates
|
|
125
|
+
* the new token to module state so subsequent requests use it.
|
|
126
|
+
*
|
|
127
|
+
* @throws ConversationTokenExpiredError on HTTP 401 (token is unknown or has
|
|
128
|
+
* already been rotated away from the value sent in the Authorization header)
|
|
129
|
+
* @throws RefreshHttpError on any other non-2xx status — inspect `.retryable`
|
|
130
|
+
* @throws RefreshNetworkError on fetch-level failure (always retryable)
|
|
131
|
+
*/
|
|
132
|
+
export async function refreshConversationToken(
|
|
133
|
+
baseUrl: string,
|
|
134
|
+
conversationId: string,
|
|
135
|
+
currentToken: string,
|
|
136
|
+
fingerprint: string,
|
|
137
|
+
): Promise<RefreshTokenResponse> {
|
|
138
|
+
let resp: Response;
|
|
139
|
+
try {
|
|
140
|
+
resp = await fetch(
|
|
141
|
+
`${baseUrl}/api/conversations/${conversationId}/refresh-token`,
|
|
142
|
+
{
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
Authorization: `Bearer ${currentToken}`,
|
|
146
|
+
"X-Client-Fingerprint": fingerprint,
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
...cfAccessHeaders(),
|
|
149
|
+
},
|
|
150
|
+
body: "{}",
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
throw new RefreshNetworkError(err);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (resp.status === 401) {
|
|
158
|
+
throw new ConversationTokenExpiredError();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!resp.ok) {
|
|
162
|
+
let detail = "";
|
|
163
|
+
try {
|
|
164
|
+
detail = await resp.text();
|
|
165
|
+
} catch {
|
|
166
|
+
// ignore
|
|
167
|
+
}
|
|
168
|
+
throw new RefreshHttpError(resp.status, detail);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return (await resp.json()) as RefreshTokenResponse;
|
|
172
|
+
}
|
|
173
|
+
|
|
79
174
|
export function defaultLabel(folderPath: string): string {
|
|
80
175
|
const folder = basename(folderPath);
|
|
81
176
|
// Local time, not UTC — the user sees this label in the UI; UTC was confusing.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token refresh scheduler — single-responsibility module.
|
|
3
|
+
*
|
|
4
|
+
* Fires a `refresh()` callback shortly before the conversation token expires,
|
|
5
|
+
* re-schedules itself on success, and routes failures to `onFailure()`. No
|
|
6
|
+
* knowledge of the worker, MCP server, or any channel module state.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface TokenRefreshState {
|
|
10
|
+
/** Unix seconds — the new expiry returned by the refresh callback. */
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TokenRefreshDeps {
|
|
15
|
+
/** Returns current time in unix seconds. Defaults to Date.now()/1000. */
|
|
16
|
+
now?: () => number;
|
|
17
|
+
/** Schedules a callback after `ms` milliseconds. Defaults to setTimeout. */
|
|
18
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
19
|
+
/** Cancels a previously-scheduled timer. Defaults to clearTimeout. */
|
|
20
|
+
clearTimer?: (h: unknown) => void;
|
|
21
|
+
/** Seconds before expiry to trigger the refresh. Default 300 (5 minutes). */
|
|
22
|
+
leadSeconds?: number;
|
|
23
|
+
/** Log sink. Defaults to writing to process.stderr with a newline. */
|
|
24
|
+
log?: (line: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TokenRefreshScheduler {
|
|
28
|
+
/** Start the scheduler with the initial token expiry (unix seconds). */
|
|
29
|
+
start: (initialExpiresAt: number) => void;
|
|
30
|
+
/** Cancel any pending refresh. Idempotent. Stops re-scheduling. */
|
|
31
|
+
cancel: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_LEAD_SECONDS = 300;
|
|
35
|
+
|
|
36
|
+
type SchedulerState = "active" | "cancelled";
|
|
37
|
+
|
|
38
|
+
export function createTokenRefreshScheduler(
|
|
39
|
+
refresh: () => Promise<TokenRefreshState>,
|
|
40
|
+
onFailure: (err: unknown) => void | Promise<void>,
|
|
41
|
+
deps: TokenRefreshDeps = {},
|
|
42
|
+
): TokenRefreshScheduler {
|
|
43
|
+
const now = deps.now ?? (() => Date.now() / 1000);
|
|
44
|
+
const setTimer =
|
|
45
|
+
deps.setTimer ??
|
|
46
|
+
((fn: () => void, ms: number) => setTimeout(fn, ms) as unknown);
|
|
47
|
+
const clearTimer = deps.clearTimer ?? ((h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>));
|
|
48
|
+
const leadSeconds = deps.leadSeconds ?? DEFAULT_LEAD_SECONDS;
|
|
49
|
+
const log = deps.log ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
50
|
+
|
|
51
|
+
let handle: unknown = null;
|
|
52
|
+
let state: SchedulerState = "active";
|
|
53
|
+
|
|
54
|
+
const scheduleAt = (expiresAt: number): void => {
|
|
55
|
+
if (state === "cancelled") return;
|
|
56
|
+
const delayMs = Math.max(0, (expiresAt - leadSeconds - now()) * 1000);
|
|
57
|
+
handle = setTimer(fire, delayMs);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const fire = (): void => {
|
|
61
|
+
handle = null;
|
|
62
|
+
// void intentional — timer callback cannot be async.
|
|
63
|
+
void runRefresh();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const runRefresh = async (): Promise<void> => {
|
|
67
|
+
try {
|
|
68
|
+
const result = await refresh();
|
|
69
|
+
if (state === "cancelled") return;
|
|
70
|
+
const ttlSeconds = Math.max(0, result.expiresAt - now());
|
|
71
|
+
const nextSeconds = Math.max(0, ttlSeconds - leadSeconds);
|
|
72
|
+
const ttlMin = Math.round(ttlSeconds / 60);
|
|
73
|
+
const nextMin = Math.round(nextSeconds / 60);
|
|
74
|
+
log(
|
|
75
|
+
`[viber-channel] token refreshed, next refresh in ${nextMin}m (ttl ${ttlMin}m)`,
|
|
76
|
+
);
|
|
77
|
+
scheduleAt(result.expiresAt);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
try {
|
|
80
|
+
await onFailure(err);
|
|
81
|
+
} catch {
|
|
82
|
+
// Failure callback owns its own error path; swallow.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
start(initialExpiresAt: number): void {
|
|
89
|
+
if (state === "cancelled") return;
|
|
90
|
+
if (handle !== null) {
|
|
91
|
+
clearTimer(handle);
|
|
92
|
+
handle = null;
|
|
93
|
+
}
|
|
94
|
+
scheduleAt(initialExpiresAt);
|
|
95
|
+
},
|
|
96
|
+
cancel(): void {
|
|
97
|
+
state = "cancelled";
|
|
98
|
+
if (handle !== null) {
|
|
99
|
+
clearTimer(handle);
|
|
100
|
+
handle = null;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viber-channel",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.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
|
@@ -31,6 +31,10 @@ import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { runConnect } from "./lib/connect.ts";
|
|
33
33
|
import { lockFilePath } from "./lib/lockfile.ts";
|
|
34
|
+
import {
|
|
35
|
+
createTokenRefreshScheduler,
|
|
36
|
+
type TokenRefreshScheduler,
|
|
37
|
+
} from "./lib/token_refresh.ts";
|
|
34
38
|
|
|
35
39
|
// ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
|
|
36
40
|
//
|
|
@@ -138,10 +142,15 @@ function releaseLock(): void {
|
|
|
138
142
|
}
|
|
139
143
|
}
|
|
140
144
|
|
|
145
|
+
// Token-refresh scheduler — null until the initial mint succeeds. Declared at
|
|
146
|
+
// module scope so the early exit handlers below (which fire before the mint
|
|
147
|
+
// completes on slow boots) can cancel it idempotently via optional-chaining.
|
|
148
|
+
let scheduler: TokenRefreshScheduler | null = null;
|
|
149
|
+
|
|
141
150
|
// Best-effort cleanup — on Windows, signals may not fire (TerminateProcess)
|
|
142
|
-
process.on("exit", releaseLock);
|
|
143
|
-
process.on("SIGINT", () => { releaseLock(); process.exit(0); });
|
|
144
|
-
process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
|
|
151
|
+
process.on("exit", () => { scheduler?.cancel(); releaseLock(); });
|
|
152
|
+
process.on("SIGINT", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
|
|
153
|
+
process.on("SIGTERM", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
|
|
145
154
|
|
|
146
155
|
// stdin EOF — Claude Code closes its end of the pipe when the session
|
|
147
156
|
// terminates (or the parent process is killed via TerminateProcess on
|
|
@@ -158,6 +167,7 @@ process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
|
|
|
158
167
|
// orphan detection. See plan #236 step-09 for the diagnosis.
|
|
159
168
|
process.stdin.on("end", () => {
|
|
160
169
|
process.stderr.write("[viber-channel] stdin closed (parent exited), shutting down\n");
|
|
170
|
+
scheduler?.cancel();
|
|
161
171
|
releaseLock();
|
|
162
172
|
process.exit(0);
|
|
163
173
|
});
|
|
@@ -185,7 +195,14 @@ if (computedFp !== auth.client_fingerprint) {
|
|
|
185
195
|
|
|
186
196
|
// ---- Imports for mint ----
|
|
187
197
|
|
|
188
|
-
import {
|
|
198
|
+
import {
|
|
199
|
+
defaultLabel,
|
|
200
|
+
mintConversation,
|
|
201
|
+
refreshConversationToken,
|
|
202
|
+
RefreshHttpError,
|
|
203
|
+
RefreshNetworkError,
|
|
204
|
+
ReverifyRequiredError,
|
|
205
|
+
} from "./lib/conversation.ts";
|
|
189
206
|
import { postMessage, ConversationTokenExpiredError, parseArtifact } from "./lib/messages.ts";
|
|
190
207
|
import { cfAccessHeaders } from "./lib/cfAccess.ts";
|
|
191
208
|
|
|
@@ -376,6 +393,93 @@ try {
|
|
|
376
393
|
CONVERSATION_TOKEN = minted.conversation_token;
|
|
377
394
|
VOICE_BASE_URL = minted.ws_url;
|
|
378
395
|
CONVERSATION_ID = minted.conversation_id;
|
|
396
|
+
|
|
397
|
+
// Schedule silent token refresh ahead of expiry (#237 step-03). On success
|
|
398
|
+
// we mutate CONVERSATION_TOKEN in place — getHeaders() reads it by closure,
|
|
399
|
+
// so subsequent SSE reconnects and send_message calls pick up the new token
|
|
400
|
+
// automatically. On failure we fall through to the existing
|
|
401
|
+
// handleConversationTokenExpired path (notification + exit code 3).
|
|
402
|
+
//
|
|
403
|
+
// VIBER_REFRESH_LEAD_SECONDS overrides the default 300s lead window — used
|
|
404
|
+
// for shortening the refresh-before-expiry gap during E2E manual testing
|
|
405
|
+
// (set close to TTL to force a refresh seconds after startup). Out of band
|
|
406
|
+
// for normal operation; the scheduler module's default applies when unset.
|
|
407
|
+
const leadOverride = process.env.VIBER_REFRESH_LEAD_SECONDS;
|
|
408
|
+
const leadSeconds = leadOverride ? Number.parseInt(leadOverride, 10) : undefined;
|
|
409
|
+
if (leadOverride !== undefined) {
|
|
410
|
+
if (!Number.isFinite(leadSeconds) || (leadSeconds as number) < 0) {
|
|
411
|
+
process.stderr.write(
|
|
412
|
+
`[viber-channel] Invalid VIBER_REFRESH_LEAD_SECONDS=${leadOverride}, ignoring.\n`
|
|
413
|
+
);
|
|
414
|
+
} else {
|
|
415
|
+
process.stderr.write(
|
|
416
|
+
`[viber-channel] token refresh lead overridden to ${leadSeconds}s via VIBER_REFRESH_LEAD_SECONDS\n`
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const validLead =
|
|
421
|
+
leadSeconds !== undefined && Number.isFinite(leadSeconds) && leadSeconds >= 0
|
|
422
|
+
? leadSeconds
|
|
423
|
+
: undefined;
|
|
424
|
+
|
|
425
|
+
// Track the current token's expiry so the retry path can refuse to retry
|
|
426
|
+
// past it. Updated only after a successful refresh — failed attempts leave
|
|
427
|
+
// it pointing at the still-valid current token.
|
|
428
|
+
let currentExpiresAt = minted.expires_at;
|
|
429
|
+
|
|
430
|
+
scheduler = createTokenRefreshScheduler(
|
|
431
|
+
async () => {
|
|
432
|
+
// Retry transient failures (5xx, 408, 409, network) with exponential
|
|
433
|
+
// backoff so a momentary blip during the 5-min lead window doesn't kill
|
|
434
|
+
// a channel whose token is still valid. Permanent failures (401, 403)
|
|
435
|
+
// surface immediately.
|
|
436
|
+
const MAX_ATTEMPTS = 5;
|
|
437
|
+
const HEADROOM_SECONDS = 10;
|
|
438
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
439
|
+
try {
|
|
440
|
+
const refreshed = await refreshConversationToken(
|
|
441
|
+
BASE_URL,
|
|
442
|
+
CONVERSATION_ID,
|
|
443
|
+
CONVERSATION_TOKEN,
|
|
444
|
+
auth.client_fingerprint,
|
|
445
|
+
);
|
|
446
|
+
// SSE connection: the server accepts the new token immediately on
|
|
447
|
+
// subsequent requests, and any in-flight fetch still has the old
|
|
448
|
+
// token attached at the header level — the server keeps the open
|
|
449
|
+
// stream alive on the old token until its natural close. Safe to
|
|
450
|
+
// overwrite the module variable here.
|
|
451
|
+
CONVERSATION_TOKEN = refreshed.conversation_token;
|
|
452
|
+
currentExpiresAt = refreshed.expires_at;
|
|
453
|
+
return { expiresAt: refreshed.expires_at };
|
|
454
|
+
} catch (err) {
|
|
455
|
+
const retryable =
|
|
456
|
+
(err instanceof RefreshHttpError && err.retryable) ||
|
|
457
|
+
err instanceof RefreshNetworkError;
|
|
458
|
+
if (!retryable || attempt === MAX_ATTEMPTS) throw err;
|
|
459
|
+
// Bound retries by token validity: never sleep past expiry, leave
|
|
460
|
+
// a small headroom so the post-sleep request has time to land.
|
|
461
|
+
const proposedDelay = Math.min(30 * 2 ** (attempt - 1), 120);
|
|
462
|
+
const remaining = currentExpiresAt - Date.now() / 1000;
|
|
463
|
+
if (remaining - proposedDelay < HEADROOM_SECONDS) throw err;
|
|
464
|
+
process.stderr.write(
|
|
465
|
+
`[viber-channel] token refresh attempt ${attempt} failed (${String(err)}); retry in ${proposedDelay}s (${Math.round(remaining)}s until expiry)\n`,
|
|
466
|
+
);
|
|
467
|
+
await new Promise((r) => setTimeout(r, proposedDelay * 1000));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// Unreachable — the loop above either returns the refreshed state or
|
|
471
|
+
// throws on attempt === MAX_ATTEMPTS.
|
|
472
|
+
throw new Error("token refresh: retry loop exhausted without resolution");
|
|
473
|
+
},
|
|
474
|
+
async (err) => {
|
|
475
|
+
process.stderr.write(`[viber-channel] token refresh failed: ${String(err)}\n`);
|
|
476
|
+
// We don't call scheduler.cancel() here — we're inside the scheduler's
|
|
477
|
+
// failure path, and handleConversationTokenExpired ends the process anyway.
|
|
478
|
+
await handleConversationTokenExpired(mcp);
|
|
479
|
+
},
|
|
480
|
+
validLead !== undefined ? { leadSeconds: validLead } : undefined,
|
|
481
|
+
);
|
|
482
|
+
scheduler.start(minted.expires_at);
|
|
379
483
|
} catch (err) {
|
|
380
484
|
if (err instanceof ReverifyRequiredError) {
|
|
381
485
|
await mcp.notification({
|
|
@@ -529,6 +633,7 @@ async function sseLoop(): Promise<void> {
|
|
|
529
633
|
|
|
530
634
|
case "stop":
|
|
531
635
|
process.stderr.write(`[viber-channel] Received stop signal, exiting.\n`);
|
|
636
|
+
scheduler?.cancel();
|
|
532
637
|
releaseLock();
|
|
533
638
|
process.exit(0);
|
|
534
639
|
break;
|