viber-channel 0.5.0 → 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/connect.ts +3 -2
- package/lib/conversation.ts +95 -0
- package/lib/lockfile.ts +25 -0
- package/lib/messages.ts +58 -1
- package/lib/token_refresh.ts +104 -0
- package/package.json +1 -1
- package/viber-channel.ts +219 -31
package/lib/connect.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { clientFingerprint } from "./fingerprint.js";
|
|
22
|
+
import { cfAccessHeaders } from "./cfAccess.js";
|
|
22
23
|
|
|
23
24
|
interface InitResponse {
|
|
24
25
|
confirm_url: string;
|
|
@@ -141,7 +142,7 @@ export async function runConnect(
|
|
|
141
142
|
|
|
142
143
|
const initResp = await fetch(`${baseUrl}/api/connect/${claimId}`, {
|
|
143
144
|
method: "POST",
|
|
144
|
-
headers: { "Content-Type": "application/json" },
|
|
145
|
+
headers: { "Content-Type": "application/json", ...cfAccessHeaders() },
|
|
145
146
|
body: JSON.stringify({ client_fingerprint: fingerprint }),
|
|
146
147
|
});
|
|
147
148
|
if (!initResp.ok) {
|
|
@@ -158,7 +159,7 @@ export async function runConnect(
|
|
|
158
159
|
const deadline = Date.now() + MAX_POLL_DURATION_MS;
|
|
159
160
|
while (Date.now() < deadline) {
|
|
160
161
|
await sleep(POLL_INTERVAL_MS);
|
|
161
|
-
const pollResp = await fetch(pollUrl);
|
|
162
|
+
const pollResp = await fetch(pollUrl, { headers: cfAccessHeaders() });
|
|
162
163
|
if (!pollResp.ok) {
|
|
163
164
|
const body = await pollResp.text().catch(() => "");
|
|
164
165
|
throw new Error(`Poll failed (HTTP ${pollResp.status}): ${body}`);
|
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.
|
package/lib/lockfile.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lockfile.ts — derive the channel's lock file path from VIBER_BASE_URL.
|
|
3
|
+
*
|
|
4
|
+
* The lock prevents two instances of the channel from racing on stdin when
|
|
5
|
+
* Claude Code accidentally spawns duplicates. But two channels targeting
|
|
6
|
+
* *different* backends (e.g. staging + dev) are legitimately different
|
|
7
|
+
* processes — they shouldn't share a lock.
|
|
8
|
+
*
|
|
9
|
+
* Namespacing the lock by base URL lets them coexist.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Return the lock file path for a given base URL, under `lockDir`.
|
|
16
|
+
*
|
|
17
|
+
* The filename is `channel-{8-hex}.lock` where the hex is a stable SHA-256
|
|
18
|
+
* prefix of the base URL. Two callers with the same base URL get the same
|
|
19
|
+
* path (same process collision still detected); different base URLs get
|
|
20
|
+
* different paths (no false collision).
|
|
21
|
+
*/
|
|
22
|
+
export function lockFilePath(baseUrl: string, lockDir: string): string {
|
|
23
|
+
const suffix = createHash("sha256").update(baseUrl).digest("hex").slice(0, 8);
|
|
24
|
+
return join(lockDir, `channel-${suffix}.lock`);
|
|
25
|
+
}
|
package/lib/messages.ts
CHANGED
|
@@ -15,6 +15,62 @@ export class ConversationTokenExpiredError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export type ArtifactFormat = "markdown" | "code" | "json" | "html";
|
|
19
|
+
|
|
20
|
+
export interface Artifact {
|
|
21
|
+
content: string;
|
|
22
|
+
format?: ArtifactFormat;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ALLOWED_ARTIFACT_FORMATS: readonly ArtifactFormat[] = [
|
|
26
|
+
"markdown",
|
|
27
|
+
"code",
|
|
28
|
+
"json",
|
|
29
|
+
"html",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export type ParseArtifactResult =
|
|
33
|
+
| { ok: true; artifact: Artifact | undefined }
|
|
34
|
+
| { ok: false; error: string };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validate and normalize a raw `artifact` value coming from MCP tool input.
|
|
38
|
+
*
|
|
39
|
+
* - `undefined` / `null` → `{ ok: true, artifact: undefined }` (no artifact attached).
|
|
40
|
+
* - Anything else must be a plain object with a non-empty trimmed `content` string and,
|
|
41
|
+
* optionally, a `format` from the whitelist (`markdown` | `code` | `json` | `html`).
|
|
42
|
+
* An omitted `format` is normalized to `"markdown"`.
|
|
43
|
+
*
|
|
44
|
+
* Kept as a pure function so the CallTool handler in viber-channel.ts stays small
|
|
45
|
+
* and the validation rules are unit-testable on their own.
|
|
46
|
+
*/
|
|
47
|
+
export function parseArtifact(raw: unknown): ParseArtifactResult {
|
|
48
|
+
if (raw === undefined || raw === null) {
|
|
49
|
+
return { ok: true, artifact: undefined };
|
|
50
|
+
}
|
|
51
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
52
|
+
return { ok: false, error: "artifact must be an object" };
|
|
53
|
+
}
|
|
54
|
+
const rawArtifact = raw as Record<string, unknown>;
|
|
55
|
+
const content = rawArtifact.content;
|
|
56
|
+
if (typeof content !== "string" || content.trim().length === 0) {
|
|
57
|
+
return { ok: false, error: "artifact.content must be a non-empty string" };
|
|
58
|
+
}
|
|
59
|
+
if (rawArtifact.format === undefined) {
|
|
60
|
+
return { ok: true, artifact: { content, format: "markdown" } };
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
typeof rawArtifact.format !== "string" ||
|
|
64
|
+
!ALLOWED_ARTIFACT_FORMATS.includes(rawArtifact.format as ArtifactFormat)
|
|
65
|
+
) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: `artifact.format must be one of ${ALLOWED_ARTIFACT_FORMATS.join(", ")}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, artifact: { content, format: rawArtifact.format as ArtifactFormat } };
|
|
72
|
+
}
|
|
73
|
+
|
|
18
74
|
export interface MessagePostSuccess {
|
|
19
75
|
ok: true;
|
|
20
76
|
message_id: string;
|
|
@@ -40,6 +96,7 @@ export async function postMessage(
|
|
|
40
96
|
conversationId: string,
|
|
41
97
|
conversationToken: string,
|
|
42
98
|
text: string,
|
|
99
|
+
artifact?: Artifact,
|
|
43
100
|
): Promise<MessagePostResult> {
|
|
44
101
|
if (!text || text.trim().length === 0) {
|
|
45
102
|
return {
|
|
@@ -60,7 +117,7 @@ export async function postMessage(
|
|
|
60
117
|
"Content-Type": "application/json",
|
|
61
118
|
...cfAccessHeaders(),
|
|
62
119
|
},
|
|
63
|
-
body: JSON.stringify({ content: text }),
|
|
120
|
+
body: JSON.stringify(artifact ? { content: text, artifact } : { content: text }),
|
|
64
121
|
});
|
|
65
122
|
} catch (err) {
|
|
66
123
|
return {
|
|
@@ -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
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* viber-channel MCP channel server (#217)
|
|
3
|
+
* viber-channel MCP channel server (#217, #232)
|
|
4
4
|
*
|
|
5
5
|
* Reads .viber/auth.json for authentication, then connects to
|
|
6
6
|
* /api/conversations/<id>/events SSE stream and pushes transcripts and
|
|
7
7
|
* messages as channel events.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Launch via Claude Code — two MCP server names are in use:
|
|
10
|
+
* - server:viber-channel — npm-published build (bunx viber-channel@latest),
|
|
11
|
+
* staging backend (viber.dgypx.dev), wired by
|
|
12
|
+
* external consumers via `claude mcp add`.
|
|
13
|
+
* - server:viber-dev-channel — local-source build, dev backend
|
|
14
|
+
* (viber-dev.dgypx.dev). Defined in this repo's
|
|
15
|
+
* committed .mcp.json (or override locally).
|
|
16
|
+
*
|
|
17
|
+
* claude --dangerously-load-development-channels server:<name>
|
|
18
|
+
*
|
|
19
|
+
* Two PowerShell launchers wrap the right flag:
|
|
20
|
+
* - viber.ps1 → server:viber-channel (staging)
|
|
21
|
+
* - viber-dev.ps1 → server:viber-dev-channel (this repo, dev)
|
|
22
|
+
*
|
|
23
|
+
* CLI subcommand: `bunx viber-channel connect <claim_url>` runs the one-shot
|
|
24
|
+
* claim flow that writes .viber/auth.json without requiring any Claude Code
|
|
25
|
+
* recipe in CLAUDE.md.
|
|
11
26
|
*/
|
|
12
27
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
13
28
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -15,6 +30,11 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprot
|
|
|
15
30
|
import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
16
31
|
import { join } from "node:path";
|
|
17
32
|
import { runConnect } from "./lib/connect.ts";
|
|
33
|
+
import { lockFilePath } from "./lib/lockfile.ts";
|
|
34
|
+
import {
|
|
35
|
+
createTokenRefreshScheduler,
|
|
36
|
+
type TokenRefreshScheduler,
|
|
37
|
+
} from "./lib/token_refresh.ts";
|
|
18
38
|
|
|
19
39
|
// ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
|
|
20
40
|
//
|
|
@@ -53,13 +73,16 @@ import { runConnect } from "./lib/connect.ts";
|
|
|
53
73
|
}
|
|
54
74
|
}
|
|
55
75
|
|
|
56
|
-
// Lock file path: %APPDATA%/viber/ (Windows) or ~/.config/viber/ (Linux/Mac)
|
|
76
|
+
// Lock file path: %APPDATA%/viber/ (Windows) or ~/.config/viber/ (Linux/Mac).
|
|
77
|
+
// Namespaced by VIBER_BASE_URL so channels targeting different backends
|
|
78
|
+
// (staging + dev) coexist on the same machine — only same-backend duplicates
|
|
79
|
+
// still collide, which is the intended guard.
|
|
57
80
|
const LOCK_DIR =
|
|
58
81
|
process.env.APPDATA
|
|
59
82
|
? join(process.env.APPDATA, "viber")
|
|
60
83
|
: join(process.env.HOME ?? "/tmp", ".config", "viber");
|
|
61
|
-
|
|
62
|
-
const LOCK_FILE =
|
|
84
|
+
const LOCK_BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
|
|
85
|
+
const LOCK_FILE = lockFilePath(LOCK_BASE_URL, LOCK_DIR);
|
|
63
86
|
|
|
64
87
|
function isProcessAlive(pid: number): boolean {
|
|
65
88
|
try {
|
|
@@ -119,10 +142,15 @@ function releaseLock(): void {
|
|
|
119
142
|
}
|
|
120
143
|
}
|
|
121
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
|
+
|
|
122
150
|
// Best-effort cleanup — on Windows, signals may not fire (TerminateProcess)
|
|
123
|
-
process.on("exit", releaseLock);
|
|
124
|
-
process.on("SIGINT", () => { releaseLock(); process.exit(0); });
|
|
125
|
-
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); });
|
|
126
154
|
|
|
127
155
|
// stdin EOF — Claude Code closes its end of the pipe when the session
|
|
128
156
|
// terminates (or the parent process is killed via TerminateProcess on
|
|
@@ -130,13 +158,16 @@ process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
|
|
|
130
158
|
// the bun subprocess outlives Claude Code and leaks (the orphan that
|
|
131
159
|
// taunts us on every restart). The MCP transport uses stdin too, but
|
|
132
160
|
// it doesn't propagate close as a process exit, so we wire it ourselves.
|
|
161
|
+
//
|
|
162
|
+
// NOTE: we deliberately listen to `end` only, not `close`. On Windows/Bun,
|
|
163
|
+
// registering an MCP `StdioServerTransport` reader appears to trigger a
|
|
164
|
+
// spurious `close` event ~2 ms after the transport's read loop starts,
|
|
165
|
+
// which would kill the channel mid-mint before MCP traffic even begins.
|
|
166
|
+
// `end` fires on real EOF (parent closes write end) and is sufficient for
|
|
167
|
+
// orphan detection. See plan #236 step-09 for the diagnosis.
|
|
133
168
|
process.stdin.on("end", () => {
|
|
134
169
|
process.stderr.write("[viber-channel] stdin closed (parent exited), shutting down\n");
|
|
135
|
-
|
|
136
|
-
process.exit(0);
|
|
137
|
-
});
|
|
138
|
-
process.stdin.on("close", () => {
|
|
139
|
-
process.stderr.write("[viber-channel] stdin close event, shutting down\n");
|
|
170
|
+
scheduler?.cancel();
|
|
140
171
|
releaseLock();
|
|
141
172
|
process.exit(0);
|
|
142
173
|
});
|
|
@@ -164,8 +195,15 @@ if (computedFp !== auth.client_fingerprint) {
|
|
|
164
195
|
|
|
165
196
|
// ---- Imports for mint ----
|
|
166
197
|
|
|
167
|
-
import {
|
|
168
|
-
|
|
198
|
+
import {
|
|
199
|
+
defaultLabel,
|
|
200
|
+
mintConversation,
|
|
201
|
+
refreshConversationToken,
|
|
202
|
+
RefreshHttpError,
|
|
203
|
+
RefreshNetworkError,
|
|
204
|
+
ReverifyRequiredError,
|
|
205
|
+
} from "./lib/conversation.ts";
|
|
206
|
+
import { postMessage, ConversationTokenExpiredError, parseArtifact } from "./lib/messages.ts";
|
|
169
207
|
import { cfAccessHeaders } from "./lib/cfAccess.ts";
|
|
170
208
|
|
|
171
209
|
const BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
|
|
@@ -181,10 +219,13 @@ const mcp = new Server(
|
|
|
181
219
|
tools: {},
|
|
182
220
|
},
|
|
183
221
|
instructions: [
|
|
184
|
-
'Voice transcripts arrive as <channel source="viber-channel"
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"
|
|
222
|
+
'Voice transcripts arrive as <channel source="viber-channel"> events carrying the user\'s microphone speech.',
|
|
223
|
+
"Reply with send_message. The `text` field is read aloud to the user, so keep it a short, natural spoken sentence — no markdown symbols, no bullet lists, no tables, no code blocks.",
|
|
224
|
+
"Put technical content (code, lists, JSON, tables, command output, long file paths) in the optional `artifact` field with the matching `format`; it renders in a side viewer the user can read.",
|
|
225
|
+
"Available formats: `markdown` (default), `code`, `json`, `html`.",
|
|
226
|
+
"Use `html` only when the rendering needs structural HTML markdown can't express (styled cards, badges, mixed layouts). Scripts and event handlers are stripped server-side; don't try to send executable JS.",
|
|
227
|
+
"Rule of thumb: if it would sound bad read aloud, it belongs in the artifact, not the text.",
|
|
228
|
+
"On exit intent (bye, au revoir, stop) call stop_conversation(), speak a brief farewell, and stop.",
|
|
188
229
|
].join(" "),
|
|
189
230
|
}
|
|
190
231
|
);
|
|
@@ -196,14 +237,37 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
196
237
|
{
|
|
197
238
|
name: "send_message",
|
|
198
239
|
description:
|
|
199
|
-
"Send a message into the Viber conversation this channel is bound to.
|
|
240
|
+
"Send a message into the Viber conversation this channel is bound to. " +
|
|
241
|
+
"`text` is the spoken/conversational reply (read aloud — keep it short and natural, no markdown). " +
|
|
242
|
+
"`artifact` is optional and carries technical content (markdown, code, JSON, or sanitized HTML) rendered in a side viewer. " +
|
|
243
|
+
"Use `html` only when structural HTML markdown can't express is required; scripts and event handlers are stripped before display.",
|
|
200
244
|
inputSchema: {
|
|
201
245
|
type: "object" as const,
|
|
202
246
|
properties: {
|
|
203
247
|
text: {
|
|
204
248
|
type: "string",
|
|
205
249
|
minLength: 1,
|
|
206
|
-
description: "
|
|
250
|
+
description: "Spoken/conversational reply. Plain sentences, no markdown symbols or lists.",
|
|
251
|
+
},
|
|
252
|
+
artifact: {
|
|
253
|
+
type: "object",
|
|
254
|
+
description: "Optional technical content rendered in the artifact viewer alongside the spoken text.",
|
|
255
|
+
properties: {
|
|
256
|
+
content: {
|
|
257
|
+
type: "string",
|
|
258
|
+
minLength: 1,
|
|
259
|
+
description: "Artifact body — markdown, code, JSON, or HTML.",
|
|
260
|
+
},
|
|
261
|
+
format: {
|
|
262
|
+
type: "string",
|
|
263
|
+
enum: ["markdown", "code", "json", "html"],
|
|
264
|
+
description:
|
|
265
|
+
"Render format for the artifact body. Defaults to markdown if omitted. " +
|
|
266
|
+
"Use `html` only when structural HTML is required (styled cards, badges, mixed layouts); " +
|
|
267
|
+
"scripts, event handlers, iframes, and inline styles are stripped server-side before rendering.",
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
required: ["content"],
|
|
207
271
|
},
|
|
208
272
|
},
|
|
209
273
|
required: ["text"],
|
|
@@ -232,9 +296,18 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
232
296
|
};
|
|
233
297
|
}
|
|
234
298
|
|
|
299
|
+
const parsed = parseArtifact(args.artifact);
|
|
300
|
+
if (!parsed.ok) {
|
|
301
|
+
return {
|
|
302
|
+
isError: true,
|
|
303
|
+
content: [{ type: "text" as const, text: `Invalid input: ${parsed.error}` }],
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const artifact = parsed.artifact;
|
|
307
|
+
|
|
235
308
|
let result;
|
|
236
309
|
try {
|
|
237
|
-
result = await postMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, text);
|
|
310
|
+
result = await postMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, text, artifact);
|
|
238
311
|
} catch (err) {
|
|
239
312
|
if (err instanceof ConversationTokenExpiredError) {
|
|
240
313
|
// Notify Claude (and the user) that the channel token has expired
|
|
@@ -320,6 +393,93 @@ try {
|
|
|
320
393
|
CONVERSATION_TOKEN = minted.conversation_token;
|
|
321
394
|
VOICE_BASE_URL = minted.ws_url;
|
|
322
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);
|
|
323
483
|
} catch (err) {
|
|
324
484
|
if (err instanceof ReverifyRequiredError) {
|
|
325
485
|
await mcp.notification({
|
|
@@ -379,19 +539,36 @@ async function pushTranscript(text: string, lang: string): Promise<void> {
|
|
|
379
539
|
});
|
|
380
540
|
}
|
|
381
541
|
|
|
382
|
-
/**
|
|
383
|
-
|
|
384
|
-
|
|
542
|
+
/**
|
|
543
|
+
* Forward a saved conversation message from the event bus to Claude.
|
|
544
|
+
*
|
|
545
|
+
* Wire field name is `content` — the SSE payload mirrors the Worker's
|
|
546
|
+
* `MessageResponse` shape (viber-api/src/db/types.ts), republished verbatim by
|
|
547
|
+
* Python (`ws_server.internal_notify_message` → `publish_to_conversation`).
|
|
548
|
+
* The optional `artifact` field is accepted here and threaded through the meta
|
|
549
|
+
* so downstream consumers (step-03 React viewer) can opt-in without a second
|
|
550
|
+
* round-trip; the channel itself does no special rendering of it yet.
|
|
551
|
+
*/
|
|
552
|
+
async function pushMessage(msg: {
|
|
553
|
+
content: string;
|
|
554
|
+
id?: string | null;
|
|
555
|
+
source?: string;
|
|
556
|
+
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
557
|
+
}): Promise<void> {
|
|
558
|
+
process.stderr.write(`[viber-channel] pushMessage ENTER: "${msg.content.slice(0, 60)}"\n`);
|
|
385
559
|
// MCP notification handler (Claude Code v2.1.143) validates meta with Zod;
|
|
386
560
|
// null fields are rejected as invalid type. Omit message_id when missing.
|
|
387
|
-
const meta: Record<string,
|
|
561
|
+
const meta: Record<string, unknown> = { source: msg.source ?? "conversation" };
|
|
388
562
|
if (typeof msg.id === "string" && msg.id.length > 0) {
|
|
389
563
|
meta.message_id = msg.id;
|
|
390
564
|
}
|
|
565
|
+
if (msg.artifact && typeof msg.artifact === "object" && typeof msg.artifact.content === "string") {
|
|
566
|
+
meta.artifact = msg.artifact;
|
|
567
|
+
}
|
|
391
568
|
try {
|
|
392
569
|
await mcp.notification({
|
|
393
570
|
method: "notifications/claude/channel",
|
|
394
|
-
params: { content: msg.
|
|
571
|
+
params: { content: msg.content, meta },
|
|
395
572
|
});
|
|
396
573
|
process.stderr.write(`[viber-channel] pushMessage OK\n`);
|
|
397
574
|
} catch (err) {
|
|
@@ -456,6 +633,7 @@ async function sseLoop(): Promise<void> {
|
|
|
456
633
|
|
|
457
634
|
case "stop":
|
|
458
635
|
process.stderr.write(`[viber-channel] Received stop signal, exiting.\n`);
|
|
636
|
+
scheduler?.cancel();
|
|
459
637
|
releaseLock();
|
|
460
638
|
process.exit(0);
|
|
461
639
|
break;
|
|
@@ -477,11 +655,21 @@ async function sseLoop(): Promise<void> {
|
|
|
477
655
|
process.stderr.write(`[viber-channel] SSE event 'message' received, data_len=${data.length}\n`);
|
|
478
656
|
if (data) {
|
|
479
657
|
try {
|
|
480
|
-
|
|
481
|
-
|
|
658
|
+
// Wire shape mirrors viber-api `MessageResponse` (db/types.ts):
|
|
659
|
+
// primary text is `content`, not `text`. `artifact` is threaded
|
|
660
|
+
// through opaquely so step-03 (React viewer) can consume it.
|
|
661
|
+
const msg = JSON.parse(data) as {
|
|
662
|
+
content: string;
|
|
663
|
+
id?: string;
|
|
664
|
+
source?: string;
|
|
665
|
+
role?: string;
|
|
666
|
+
timestamp?: string;
|
|
667
|
+
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
668
|
+
};
|
|
669
|
+
if (msg.content) {
|
|
482
670
|
await pushMessage(msg);
|
|
483
671
|
} else {
|
|
484
|
-
process.stderr.write(`[viber-channel] 'message' event has empty
|
|
672
|
+
process.stderr.write(`[viber-channel] 'message' event has empty content\n`);
|
|
485
673
|
}
|
|
486
674
|
} catch (err) {
|
|
487
675
|
process.stderr.write(`[viber-channel] Failed to parse message data: ${String(err)}\n`);
|