viber-channel 0.5.1 → 0.5.3
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/auth.ts +20 -21
- package/lib/channel_errors.ts +2 -2
- package/lib/channel_session.ts +104 -0
- package/lib/connect.ts +25 -9
- package/lib/conversation.ts +237 -19
- package/lib/lockfile.ts +35 -10
- package/lib/message_dedup.ts +47 -0
- package/lib/startup_gate.ts +19 -0
- package/lib/token_refresh.ts +104 -0
- package/package.json +1 -1
- package/viber-channel.ts +259 -43
package/lib/auth.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFileSync
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
export interface AuthJson {
|
|
5
5
|
schema_version: number;
|
|
@@ -9,7 +9,6 @@ export interface AuthJson {
|
|
|
9
9
|
project_token: string;
|
|
10
10
|
client_fingerprint: string;
|
|
11
11
|
issued_at: string;
|
|
12
|
-
rotates_after?: string;
|
|
13
12
|
/**
|
|
14
13
|
* Optional — when present, the CLI must pass this id to the conversation
|
|
15
14
|
* mint endpoint so the worker UPDATES the existing standalone conversation
|
|
@@ -24,30 +23,30 @@ export interface AuthJson {
|
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* Resolve the auth file path.
|
|
27
|
+
*
|
|
28
|
+
* Defaults to `<cwd>/.viber/auth.json`. The optional `VIBER_AUTH_FILE` env var
|
|
29
|
+
* overrides it — absolute paths are used verbatim, relative paths are resolved
|
|
30
|
+
* against `cwd`. This lets a local launcher point the channel at an alternate
|
|
31
|
+
* credential file (e.g. `.viber/dev.auth.json`) WITHOUT swapping `auth.json`,
|
|
32
|
+
* so the default consumer flow (env var unset) is completely unaffected.
|
|
29
33
|
*/
|
|
30
|
-
export function
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
issued_at: new Date().toISOString(),
|
|
37
|
-
};
|
|
38
|
-
writeFileSync(tmp, JSON.stringify(updated, null, 2), "utf-8");
|
|
39
|
-
renameSync(tmp, path);
|
|
40
|
-
return updated;
|
|
34
|
+
export function authFilePath(cwd: string = process.cwd()): string {
|
|
35
|
+
const override = process.env.VIBER_AUTH_FILE;
|
|
36
|
+
if (override !== undefined && override.trim() !== "") {
|
|
37
|
+
return isAbsolute(override) ? override : join(cwd, override);
|
|
38
|
+
}
|
|
39
|
+
return join(cwd, ".viber", "auth.json");
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
export function loadAuth(cwd: string = process.cwd()): AuthJson {
|
|
44
|
-
const path =
|
|
43
|
+
const path = authFilePath(cwd);
|
|
45
44
|
let raw: string;
|
|
46
45
|
try {
|
|
47
46
|
raw = readFileSync(path, "utf-8");
|
|
48
47
|
} catch {
|
|
49
48
|
process.stderr.write(
|
|
50
|
-
`[viber-channel] No
|
|
49
|
+
`[viber-channel] No auth file found at \`${path}\`.\n` +
|
|
51
50
|
`Run the connect flow first: visit https://viber.dgypx.dev/projects → Create connection.\n`
|
|
52
51
|
);
|
|
53
52
|
process.exit(1);
|
|
@@ -56,15 +55,15 @@ export function loadAuth(cwd: string = process.cwd()): AuthJson {
|
|
|
56
55
|
try {
|
|
57
56
|
auth = JSON.parse(raw) as AuthJson;
|
|
58
57
|
} catch (err) {
|
|
59
|
-
process.stderr.write(`[viber-channel] Malformed
|
|
58
|
+
process.stderr.write(`[viber-channel] Malformed auth file \`${path}\`: ${String(err)}\n`);
|
|
60
59
|
process.exit(1);
|
|
61
60
|
}
|
|
62
61
|
if (auth.schema_version !== 1) {
|
|
63
|
-
process.stderr.write(`[viber-channel] Unsupported auth schema version
|
|
62
|
+
process.stderr.write(`[viber-channel] Unsupported auth schema version ${auth.schema_version} in \`${path}\`\n`);
|
|
64
63
|
process.exit(1);
|
|
65
64
|
}
|
|
66
65
|
if (!auth.project_token) {
|
|
67
|
-
process.stderr.write(`[viber-channel] Empty project_token in
|
|
66
|
+
process.stderr.write(`[viber-channel] Empty project_token in \`${path}\`\n`);
|
|
68
67
|
process.exit(1);
|
|
69
68
|
}
|
|
70
69
|
return auth;
|
package/lib/channel_errors.ts
CHANGED
|
@@ -5,8 +5,8 @@ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
5
5
|
*
|
|
6
6
|
* The CONVERSATION_TOKEN minted at startup has a 1h TTL. After expiry, the
|
|
7
7
|
* server starts responding with 401. We notify the user and exit with code 3
|
|
8
|
-
* (distinct from
|
|
9
|
-
* distinguish the cause.
|
|
8
|
+
* (distinct from the startup/mint failure exit code 1) so the process manager /
|
|
9
|
+
* user can distinguish the cause.
|
|
10
10
|
*
|
|
11
11
|
* We do NOT attempt to re-mint inline — the project_token is tied to the
|
|
12
12
|
* original auth flow. The user must restart the channel to get a fresh token.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channel_session.ts — persist the channel's active conversation handle to disk.
|
|
3
|
+
*
|
|
4
|
+
* When a channel process mints or reattaches to a conversation, it writes the
|
|
5
|
+
* conversation_id here. A respawned channel process reads this file to find the
|
|
6
|
+
* existing conversation instead of minting a new one — avoiding duplicates (#257).
|
|
7
|
+
*
|
|
8
|
+
* Namespaced by `(VIBER_BASE_URL, client_fingerprint, VIBER_CHANNEL_SESSION_ID)` so:
|
|
9
|
+
* - dev and staging channels coexist on the same machine without colliding;
|
|
10
|
+
* - two *different* projects on the same backend each have their own handle and
|
|
11
|
+
* never reattach to each other's conversation — the fingerprint axis is the
|
|
12
|
+
* only thing that isolates them for a normal bunx client (#267), where the
|
|
13
|
+
* sessionId is always "";
|
|
14
|
+
* - two concurrent launches of the *same* project (each with its own session id
|
|
15
|
+
* set by viber-dev.ps1 / viber.ps1) each have their own handle (#259);
|
|
16
|
+
* - channel respawns within one launch inherit the same session id and find
|
|
17
|
+
* the same handle — reattach still works (#257).
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
22
|
+
|
|
23
|
+
export interface ChannelHandle {
|
|
24
|
+
conversation_id: string;
|
|
25
|
+
saved_at: number; // unix seconds
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return the session file path for a given base URL, fingerprint, and session
|
|
30
|
+
* id, under `dir`.
|
|
31
|
+
*
|
|
32
|
+
* The filename is `channel-session-{8-hex}.json` where the hex is a stable
|
|
33
|
+
* SHA-256 prefix of `${baseUrl}\n${fingerprint}\n${sessionId}` — the same input
|
|
34
|
+
* shape as `lockFilePath`, so the lock and session files sit side by side for
|
|
35
|
+
* the same `(baseUrl, fingerprint, sessionId)` triple.
|
|
36
|
+
*/
|
|
37
|
+
export function sessionFilePath(
|
|
38
|
+
baseUrl: string,
|
|
39
|
+
fingerprint: string,
|
|
40
|
+
sessionId: string,
|
|
41
|
+
dir: string,
|
|
42
|
+
): string {
|
|
43
|
+
const suffix = createHash("sha256")
|
|
44
|
+
.update(`${baseUrl}\n${fingerprint}\n${sessionId}`)
|
|
45
|
+
.digest("hex")
|
|
46
|
+
.slice(0, 8);
|
|
47
|
+
return join(dir, `channel-session-${suffix}.json`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read a persisted handle from `path`.
|
|
52
|
+
*
|
|
53
|
+
* Returns `null` — NEVER throws — when the file is absent, unreadable,
|
|
54
|
+
* contains invalid JSON, or the parsed object does not match the expected
|
|
55
|
+
* shape.
|
|
56
|
+
*/
|
|
57
|
+
export function readHandle(path: string): ChannelHandle | null {
|
|
58
|
+
try {
|
|
59
|
+
const raw = readFileSync(path, "utf-8");
|
|
60
|
+
const parsed: unknown = JSON.parse(raw);
|
|
61
|
+
if (
|
|
62
|
+
parsed === null ||
|
|
63
|
+
typeof parsed !== "object" ||
|
|
64
|
+
Array.isArray(parsed)
|
|
65
|
+
) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const obj = parsed as Record<string, unknown>;
|
|
69
|
+
if (typeof obj.conversation_id !== "string" || obj.conversation_id.length === 0) return null;
|
|
70
|
+
if (!Number.isFinite(obj.saved_at)) return null;
|
|
71
|
+
return { conversation_id: obj.conversation_id, saved_at: obj.saved_at as number };
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Write the conversation handle to `path`.
|
|
79
|
+
*
|
|
80
|
+
* Overwrites any existing file. Propagates write errors (matches how
|
|
81
|
+
* `acquireLock` in viber-channel.ts propagates `writeFileSync` failures —
|
|
82
|
+
* the caller decides whether to handle them).
|
|
83
|
+
*/
|
|
84
|
+
export function writeHandle(path: string, conversationId: string): void {
|
|
85
|
+
const handle: ChannelHandle = {
|
|
86
|
+
conversation_id: conversationId,
|
|
87
|
+
saved_at: Math.floor(Date.now() / 1000),
|
|
88
|
+
};
|
|
89
|
+
writeFileSync(path, JSON.stringify(handle), "utf-8");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Remove the handle file at `path`.
|
|
94
|
+
*
|
|
95
|
+
* Best-effort — swallows errors if the file is absent, mirroring
|
|
96
|
+
* `releaseLock()` in viber-channel.ts.
|
|
97
|
+
*/
|
|
98
|
+
export function clearHandle(path: string): void {
|
|
99
|
+
try {
|
|
100
|
+
unlinkSync(path);
|
|
101
|
+
} catch {
|
|
102
|
+
// Best-effort — file may already be gone
|
|
103
|
+
}
|
|
104
|
+
}
|
package/lib/connect.ts
CHANGED
|
@@ -17,7 +17,8 @@ import {
|
|
|
17
17
|
readFileSync,
|
|
18
18
|
writeFileSync,
|
|
19
19
|
} from "node:fs";
|
|
20
|
-
import { join } from "node:path";
|
|
20
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
21
|
+
import { authFilePath } from "./auth.js";
|
|
21
22
|
import { clientFingerprint } from "./fingerprint.js";
|
|
22
23
|
import { cfAccessHeaders } from "./cfAccess.js";
|
|
23
24
|
|
|
@@ -33,7 +34,6 @@ interface ConsumedResponse {
|
|
|
33
34
|
user_email: string;
|
|
34
35
|
project_token: string;
|
|
35
36
|
issued_at: string;
|
|
36
|
-
rotates_after?: string;
|
|
37
37
|
target_conversation_id?: string | null;
|
|
38
38
|
readme_md: string;
|
|
39
39
|
}
|
|
@@ -80,6 +80,13 @@ function writeAuthJson(
|
|
|
80
80
|
const viberDir = join(cwd, ".viber");
|
|
81
81
|
mkdirSync(viberDir, { recursive: true });
|
|
82
82
|
|
|
83
|
+
// Honor VIBER_AUTH_FILE so connecting with the override set writes the
|
|
84
|
+
// credentials to the same file the channel will read (e.g. dev.auth.json),
|
|
85
|
+
// instead of silently writing auth.json while the channel reads elsewhere.
|
|
86
|
+
// readme.md + .gitignore stay in <cwd>/.viber regardless.
|
|
87
|
+
const authPath = authFilePath(cwd);
|
|
88
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
89
|
+
|
|
83
90
|
const auth: Record<string, unknown> = {
|
|
84
91
|
schema_version: 1,
|
|
85
92
|
project_id: data.project_id,
|
|
@@ -88,7 +95,6 @@ function writeAuthJson(
|
|
|
88
95
|
project_token: data.project_token,
|
|
89
96
|
client_fingerprint: fingerprint,
|
|
90
97
|
issued_at: data.issued_at,
|
|
91
|
-
rotates_after: data.rotates_after,
|
|
92
98
|
};
|
|
93
99
|
// Only emit target_conversation_id when the server explicitly returned a
|
|
94
100
|
// non-null value (attach-to-project flow). Absent / null → omit to keep
|
|
@@ -100,11 +106,7 @@ function writeAuthJson(
|
|
|
100
106
|
auth.target_conversation_id = data.target_conversation_id;
|
|
101
107
|
}
|
|
102
108
|
|
|
103
|
-
writeFileSync(
|
|
104
|
-
join(viberDir, "auth.json"),
|
|
105
|
-
JSON.stringify(auth, null, 2),
|
|
106
|
-
"utf-8",
|
|
107
|
-
);
|
|
109
|
+
writeFileSync(authPath, JSON.stringify(auth, null, 2), "utf-8");
|
|
108
110
|
writeFileSync(join(viberDir, "readme.md"), data.readme_md, "utf-8");
|
|
109
111
|
|
|
110
112
|
// Append `.viber/` to .gitignore if not already present.
|
|
@@ -123,6 +125,20 @@ function writeAuthJson(
|
|
|
123
125
|
const prefix = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
124
126
|
appendFileSync(gitignorePath, `${prefix}.viber/\n`);
|
|
125
127
|
}
|
|
128
|
+
|
|
129
|
+
// The `.viber/` entry only covers credentials inside `.viber/`. If
|
|
130
|
+
// VIBER_AUTH_FILE points the auth file elsewhere, it is NOT auto-ignored —
|
|
131
|
+
// warn loudly so a project_token doesn't get committed by a later `git add`.
|
|
132
|
+
const relFromViber = relative(viberDir, authPath);
|
|
133
|
+
const insideViber =
|
|
134
|
+
relFromViber !== "" && !relFromViber.startsWith("..") && !isAbsolute(relFromViber);
|
|
135
|
+
if (!insideViber) {
|
|
136
|
+
process.stderr.write(
|
|
137
|
+
`[viber-channel] Warning: VIBER_AUTH_FILE points outside .viber/ (${authPath}).\n` +
|
|
138
|
+
` This file holds your project token and is NOT covered by the .viber/ .gitignore entry.\n` +
|
|
139
|
+
` Make sure it is git-ignored so the token is not committed.\n`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
126
142
|
}
|
|
127
143
|
|
|
128
144
|
/**
|
|
@@ -169,7 +185,7 @@ export async function runConnect(
|
|
|
169
185
|
writeAuthJson(cwd, data, fingerprint);
|
|
170
186
|
process.stdout.write(
|
|
171
187
|
`\n✓ Connected as ${data.user_email} to project '${data.project_name}'.\n` +
|
|
172
|
-
` Wrote ${
|
|
188
|
+
` Wrote ${authFilePath(cwd)}\n\n` +
|
|
173
189
|
`Next steps:\n` +
|
|
174
190
|
` 1. Register the channel MCP server (one-time per machine):\n` +
|
|
175
191
|
` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +
|
package/lib/conversation.ts
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { cfAccessHeaders } from "./cfAccess.js";
|
|
3
|
+
import { ConversationTokenExpiredError } from "./messages.js";
|
|
4
|
+
import { readHandle, writeHandle } from "./channel_session.js";
|
|
3
5
|
|
|
4
6
|
export interface ConversationMintResponse {
|
|
5
7
|
conversation_id: string;
|
|
6
8
|
conversation_token: string;
|
|
7
9
|
ws_url: string;
|
|
8
10
|
expires_at: number;
|
|
9
|
-
new_project_token?: string;
|
|
10
|
-
new_project_token_expires_at?: number | null;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Thrown by `reattachConversation` when the server returns a 409 indicating
|
|
15
|
+
* the requested conversation cannot be reattached (not found, wrong owner,
|
|
16
|
+
* wrong project, fingerprint mismatch, revoked, or race condition).
|
|
17
|
+
* The caller should fall back to `mintConversation`.
|
|
18
|
+
*/
|
|
19
|
+
export class ReattachFailedError extends Error {
|
|
20
|
+
/** The `detail` code from the 409 body, e.g. `reattach_not_found` */
|
|
21
|
+
code: string;
|
|
22
|
+
constructor(code: string) {
|
|
23
|
+
super(`Reattach failed: ${code}`);
|
|
24
|
+
this.name = "ReattachFailedError";
|
|
25
|
+
this.code = code;
|
|
21
26
|
}
|
|
22
27
|
}
|
|
23
28
|
|
|
@@ -58,24 +63,151 @@ export async function mintConversation(
|
|
|
58
63
|
body: JSON.stringify(requestBody),
|
|
59
64
|
});
|
|
60
65
|
if (!resp.ok) {
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
const detail = await resp.text();
|
|
67
|
+
throw new Error(`Failed to mint conversation token (HTTP ${resp.status}): ${detail}`);
|
|
68
|
+
}
|
|
69
|
+
return (await resp.json()) as ConversationMintResponse;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* POST /api/projects/:id/conversations with `reattach_conversation_id` body field.
|
|
74
|
+
*
|
|
75
|
+
* On 201 returns the same `ConversationMintResponse` as `mintConversation`.
|
|
76
|
+
* On 409 throws `ReattachFailedError` with the `detail` code from the body.
|
|
77
|
+
* On any other non-2xx throws a generic Error.
|
|
78
|
+
*
|
|
79
|
+
* Reattach failure (409) is NEVER fatal — the caller should fall back to mint.
|
|
80
|
+
*/
|
|
81
|
+
export async function reattachConversation(
|
|
82
|
+
baseUrl: string,
|
|
83
|
+
projectId: number,
|
|
84
|
+
projectToken: string,
|
|
85
|
+
fingerprint: string,
|
|
86
|
+
conversationId: string,
|
|
87
|
+
): Promise<ConversationMintResponse> {
|
|
88
|
+
const resp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: {
|
|
91
|
+
Authorization: `Bearer ${projectToken}`,
|
|
92
|
+
"X-Client-Fingerprint": fingerprint,
|
|
93
|
+
"Content-Type": "application/json",
|
|
94
|
+
...cfAccessHeaders(),
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify({ reattach_conversation_id: conversationId }),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!resp.ok) {
|
|
100
|
+
if (resp.status === 409) {
|
|
101
|
+
let code = "unknown";
|
|
63
102
|
try {
|
|
64
|
-
body = (await resp.json()) as
|
|
103
|
+
const body = (await resp.json()) as { detail?: string };
|
|
104
|
+
if (typeof body.detail === "string") code = body.detail;
|
|
65
105
|
} catch {
|
|
66
|
-
|
|
106
|
+
// ignore — code stays "unknown"
|
|
67
107
|
}
|
|
68
|
-
|
|
69
|
-
throw new ReverifyRequiredError(body.reverify_url, body.reason ?? "unknown");
|
|
70
|
-
}
|
|
71
|
-
throw new Error(`Failed to mint conversation token (HTTP 403): ${JSON.stringify(body)}`);
|
|
108
|
+
throw new ReattachFailedError(code);
|
|
72
109
|
}
|
|
73
110
|
const detail = await resp.text();
|
|
74
|
-
throw new Error(`Failed to
|
|
111
|
+
throw new Error(`Failed to reattach conversation (HTTP ${resp.status}): ${detail}`);
|
|
75
112
|
}
|
|
113
|
+
|
|
76
114
|
return (await resp.json()) as ConversationMintResponse;
|
|
77
115
|
}
|
|
78
116
|
|
|
117
|
+
export interface RefreshTokenResponse {
|
|
118
|
+
conversation_token: string;
|
|
119
|
+
expires_at: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* HTTP failure from /refresh-token (any non-401 non-2xx). `.retryable` lets
|
|
124
|
+
* the scheduler distinguish a transient server hiccup (5xx, 408, 409) from a
|
|
125
|
+
* permanent state mismatch (4xx other than 401), so it can back off and try
|
|
126
|
+
* again instead of immediately ending the channel.
|
|
127
|
+
*/
|
|
128
|
+
export class RefreshHttpError extends Error {
|
|
129
|
+
status: number;
|
|
130
|
+
detail: string;
|
|
131
|
+
retryable: boolean;
|
|
132
|
+
constructor(status: number, detail: string) {
|
|
133
|
+
super(`Refresh failed (HTTP ${status}): ${detail}`);
|
|
134
|
+
this.name = "RefreshHttpError";
|
|
135
|
+
this.status = status;
|
|
136
|
+
this.detail = detail;
|
|
137
|
+
// 5xx, 408 (timeout), 409 (concurrent-refresh race) — server/transient.
|
|
138
|
+
// 4xx others (403 fingerprint mismatch, etc.) are permanent.
|
|
139
|
+
this.retryable = status >= 500 || status === 408 || status === 409;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wrapper for `fetch` failures (DNS, connection reset, TLS, etc.). Always
|
|
145
|
+
* retryable — by definition we never got a server response to classify.
|
|
146
|
+
*/
|
|
147
|
+
export class RefreshNetworkError extends Error {
|
|
148
|
+
cause: unknown;
|
|
149
|
+
retryable: true = true as const;
|
|
150
|
+
constructor(cause: unknown) {
|
|
151
|
+
super(`Refresh failed: network error: ${String(cause)}`);
|
|
152
|
+
this.name = "RefreshNetworkError";
|
|
153
|
+
this.cause = cause;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* POST /api/conversations/:conversationId/refresh-token
|
|
159
|
+
*
|
|
160
|
+
* Rotates the per-conversation token in place. Worker updates the row's
|
|
161
|
+
* token+expiry and returns the new pair. The caller (scheduler) propagates
|
|
162
|
+
* the new token to module state so subsequent requests use it.
|
|
163
|
+
*
|
|
164
|
+
* @throws ConversationTokenExpiredError on HTTP 401 (token is unknown or has
|
|
165
|
+
* already been rotated away from the value sent in the Authorization header)
|
|
166
|
+
* @throws RefreshHttpError on any other non-2xx status — inspect `.retryable`
|
|
167
|
+
* @throws RefreshNetworkError on fetch-level failure (always retryable)
|
|
168
|
+
*/
|
|
169
|
+
export async function refreshConversationToken(
|
|
170
|
+
baseUrl: string,
|
|
171
|
+
conversationId: string,
|
|
172
|
+
currentToken: string,
|
|
173
|
+
fingerprint: string,
|
|
174
|
+
): Promise<RefreshTokenResponse> {
|
|
175
|
+
let resp: Response;
|
|
176
|
+
try {
|
|
177
|
+
resp = await fetch(
|
|
178
|
+
`${baseUrl}/api/conversations/${conversationId}/refresh-token`,
|
|
179
|
+
{
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: {
|
|
182
|
+
Authorization: `Bearer ${currentToken}`,
|
|
183
|
+
"X-Client-Fingerprint": fingerprint,
|
|
184
|
+
"Content-Type": "application/json",
|
|
185
|
+
...cfAccessHeaders(),
|
|
186
|
+
},
|
|
187
|
+
body: "{}",
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
throw new RefreshNetworkError(err);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (resp.status === 401) {
|
|
195
|
+
throw new ConversationTokenExpiredError();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!resp.ok) {
|
|
199
|
+
let detail = "";
|
|
200
|
+
try {
|
|
201
|
+
detail = await resp.text();
|
|
202
|
+
} catch {
|
|
203
|
+
// ignore
|
|
204
|
+
}
|
|
205
|
+
throw new RefreshHttpError(resp.status, detail);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return (await resp.json()) as RefreshTokenResponse;
|
|
209
|
+
}
|
|
210
|
+
|
|
79
211
|
export function defaultLabel(folderPath: string): string {
|
|
80
212
|
const folder = basename(folderPath);
|
|
81
213
|
// Local time, not UTC — the user sees this label in the UI; UTC was confusing.
|
|
@@ -84,3 +216,89 @@ export function defaultLabel(folderPath: string): string {
|
|
|
84
216
|
const ts = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
85
217
|
return `${folder} • ${ts}`;
|
|
86
218
|
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Default reattach window in seconds — matches the server's CONVERSATION_TTL_SECONDS.
|
|
222
|
+
* A handle older than this points at an expired conversation that cannot be reattached.
|
|
223
|
+
*/
|
|
224
|
+
export const DEFAULT_REATTACH_WINDOW_SECONDS = 3600;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Decide whether to reattach to the persisted conversation or mint a fresh one.
|
|
228
|
+
*
|
|
229
|
+
* Decision logic:
|
|
230
|
+
* 1. Read the handle from `sessionPath`. If present AND `now - saved_at < reattachWindowSeconds`,
|
|
231
|
+
* attempt `reattachConversation`.
|
|
232
|
+
* 2. On reattach success: use it.
|
|
233
|
+
* 3. On `ReattachFailedError` (409): log and fall back to `mintConversation`.
|
|
234
|
+
* 4. On no/stale handle: `mintConversation` directly.
|
|
235
|
+
* 5. After either success: write the (possibly new) conversation_id back to `sessionPath`.
|
|
236
|
+
*
|
|
237
|
+
* @param sessionPath Path returned by `sessionFilePath(baseUrl, fingerprint, sessionId, dir)`
|
|
238
|
+
* @param baseUrl Backend base URL
|
|
239
|
+
* @param projectId Project ID from auth.json
|
|
240
|
+
* @param projectToken Bearer token from auth.json
|
|
241
|
+
* @param fingerprint Client fingerprint from auth.json
|
|
242
|
+
* @param label Conversation label (used only when minting)
|
|
243
|
+
* @param targetConversationId Optional target for the attach-to-project flow (passed to mint)
|
|
244
|
+
* @param reattachWindowSeconds Override the default TTL-based window (for testing / env override)
|
|
245
|
+
* @param log Optional stderr writer, defaults to process.stderr.write
|
|
246
|
+
*/
|
|
247
|
+
export async function acquireConversation(
|
|
248
|
+
sessionPath: string,
|
|
249
|
+
baseUrl: string,
|
|
250
|
+
projectId: number,
|
|
251
|
+
projectToken: string,
|
|
252
|
+
fingerprint: string,
|
|
253
|
+
label: string,
|
|
254
|
+
targetConversationId?: string | null,
|
|
255
|
+
reattachWindowSeconds: number = DEFAULT_REATTACH_WINDOW_SECONDS,
|
|
256
|
+
log: (msg: string) => void = (msg) => process.stderr.write(msg),
|
|
257
|
+
): Promise<ConversationMintResponse> {
|
|
258
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
259
|
+
const handle = readHandle(sessionPath);
|
|
260
|
+
|
|
261
|
+
// Branch on `handle` directly so TS narrows it to non-null inside — readHandle
|
|
262
|
+
// guarantees a finite saved_at, so handleAge is always a number here.
|
|
263
|
+
if (handle !== null) {
|
|
264
|
+
const handleAge = nowSeconds - handle.saved_at;
|
|
265
|
+
if (handleAge < reattachWindowSeconds) {
|
|
266
|
+
log(`[viber-channel] startup: session handle found (conv_id=${handle.conversation_id}, age=${handleAge}s < ${reattachWindowSeconds}s window), attempting reattach\n`);
|
|
267
|
+
try {
|
|
268
|
+
const result = await reattachConversation(baseUrl, projectId, projectToken, fingerprint, handle.conversation_id);
|
|
269
|
+
log(`[viber-channel] startup: reattach OK, conv_id=${result.conversation_id}\n`);
|
|
270
|
+
try {
|
|
271
|
+
writeHandle(sessionPath, result.conversation_id);
|
|
272
|
+
} catch (writeErr) {
|
|
273
|
+
// Best-effort — a failed write degrades to "next startup mints fresh"
|
|
274
|
+
// rather than crashing the channel. Disk full, AV lock, read-only mount.
|
|
275
|
+
log(`[viber-channel] Warning: failed to write session handle after reattach: ${String(writeErr)}\n`);
|
|
276
|
+
}
|
|
277
|
+
return result;
|
|
278
|
+
} catch (err) {
|
|
279
|
+
// Reattach failure is NEVER fatal. Everything (ReattachFailedError 409,
|
|
280
|
+
// unexpected 5xx, network errors from a thrown fetch) falls through to
|
|
281
|
+
// mint below, which surfaces a 401 to the caller if the token is dead.
|
|
282
|
+
const reason = err instanceof ReattachFailedError ? err.code : String(err);
|
|
283
|
+
log(`[viber-channel] startup: reattach failed (${reason}), falling back to mint\n`);
|
|
284
|
+
// Fall through to mint below
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
log(`[viber-channel] startup: session handle stale (conv_id=${handle.conversation_id}, age=${handleAge}s >= ${reattachWindowSeconds}s window), minting fresh\n`);
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
log(`[viber-channel] startup: no session handle found, minting fresh\n`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Mint a new conversation
|
|
294
|
+
const result = await mintConversation(baseUrl, projectId, projectToken, fingerprint, label, targetConversationId ?? null);
|
|
295
|
+
log(`[viber-channel] startup: mint OK, conv_id=${result.conversation_id}\n`);
|
|
296
|
+
try {
|
|
297
|
+
writeHandle(sessionPath, result.conversation_id);
|
|
298
|
+
} catch (writeErr) {
|
|
299
|
+
// Best-effort — a failed write degrades to "next startup mints fresh"
|
|
300
|
+
// rather than crashing the channel. Disk full, AV lock, read-only mount.
|
|
301
|
+
log(`[viber-channel] Warning: failed to write session handle after mint: ${String(writeErr)}\n`);
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
304
|
+
}
|
package/lib/lockfile.ts
CHANGED
|
@@ -1,25 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lockfile.ts — derive the channel's lock file path from VIBER_BASE_URL
|
|
2
|
+
* lockfile.ts — derive the channel's lock file path from VIBER_BASE_URL, the
|
|
3
|
+
* client fingerprint, and an optional per-launch session id.
|
|
3
4
|
*
|
|
4
5
|
* The lock prevents two instances of the channel from racing on stdin when
|
|
5
|
-
* Claude Code accidentally spawns duplicates.
|
|
6
|
+
* Claude Code accidentally spawns duplicates. Two channels targeting
|
|
6
7
|
* *different* backends (e.g. staging + dev) are legitimately different
|
|
7
|
-
* processes — they shouldn't share a lock
|
|
8
|
+
* processes — they shouldn't share a lock; namespacing by base URL lets
|
|
9
|
+
* them coexist.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
11
|
+
* #267 adds the project axis: the lock is namespaced by `client_fingerprint`
|
|
12
|
+
* (deterministic per machine + folder, from .viber/auth.json). Two *different*
|
|
13
|
+
* projects on the same backend therefore never share a lock — this is the only
|
|
14
|
+
* axis that isolates them for a normal bunx client, where sessionId is always
|
|
15
|
+
* "" (the #259 sessionId axis below provides no isolation there).
|
|
16
|
+
*
|
|
17
|
+
* #259 adds the per-instance axis: when the launcher (e.g. viber-dev.ps1)
|
|
18
|
+
* supplies a per-launch session id via VIBER_CHANNEL_SESSION_ID, the lock is
|
|
19
|
+
* additionally namespaced by it, so two concurrent launches of the *same*
|
|
20
|
+
* project each get their own lock. Channel respawns within one launch inherit
|
|
21
|
+
* the same env var and reuse the same lock — accidental duplicates are still
|
|
22
|
+
* blocked.
|
|
10
23
|
*/
|
|
11
24
|
import { createHash } from "node:crypto";
|
|
12
25
|
import { join } from "node:path";
|
|
13
26
|
|
|
14
27
|
/**
|
|
15
|
-
* Return the lock file path for a given base URL,
|
|
28
|
+
* Return the lock file path for a given base URL, fingerprint, and session id,
|
|
29
|
+
* under `lockDir`.
|
|
16
30
|
*
|
|
17
31
|
* The filename is `channel-{8-hex}.lock` where the hex is a stable SHA-256
|
|
18
|
-
* prefix of
|
|
19
|
-
*
|
|
20
|
-
*
|
|
32
|
+
* prefix of `${baseUrl}\n${fingerprint}\n${sessionId}` (newline-separated so
|
|
33
|
+
* distinct concatenations cannot collide).
|
|
34
|
+
*
|
|
35
|
+
* When `sessionId === ""` (external bunx clients), the hash is stable on
|
|
36
|
+
* `${baseUrl}\n${fingerprint}\n` — so each project (distinct fingerprint)
|
|
37
|
+
* still converges on its own consistent path.
|
|
21
38
|
*/
|
|
22
|
-
export function lockFilePath(
|
|
23
|
-
|
|
39
|
+
export function lockFilePath(
|
|
40
|
+
baseUrl: string,
|
|
41
|
+
fingerprint: string,
|
|
42
|
+
sessionId: string,
|
|
43
|
+
lockDir: string,
|
|
44
|
+
): string {
|
|
45
|
+
const suffix = createHash("sha256")
|
|
46
|
+
.update(`${baseUrl}\n${fingerprint}\n${sessionId}`)
|
|
47
|
+
.digest("hex")
|
|
48
|
+
.slice(0, 8);
|
|
24
49
|
return join(lockDir, `channel-${suffix}.lock`);
|
|
25
50
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageDedup (#254 follow-up) — drops the persisted echo of a user transcript.
|
|
3
|
+
*
|
|
4
|
+
* When a web UI is open, Python delivers the user's voice transcript to the
|
|
5
|
+
* channel TWICE: an ephemeral copy (`id: null`) the instant it is transcribed
|
|
6
|
+
* (the conversation event-bus path from #221), then the persisted copy (real
|
|
7
|
+
* `id`) once the browser saves it to the DB and the Worker republishes it.
|
|
8
|
+
* Both carry identical content, so Claude sees the utterance twice.
|
|
9
|
+
*
|
|
10
|
+
* This tracks recently-seen ephemeral (id-less) messages and reports a later
|
|
11
|
+
* id-bearing message with the same content as a duplicate. The channel forwards
|
|
12
|
+
* each utterance exactly once:
|
|
13
|
+
* - ephemeral message → recorded, forwarded
|
|
14
|
+
* - persisted echo of it → dropped
|
|
15
|
+
* - persisted message with no prior ephemeral (assistant/ai/channel) → forwarded
|
|
16
|
+
* - a genuine repeat (new ephemeral of the same text) → forwarded
|
|
17
|
+
*
|
|
18
|
+
* Content-only, time-bounded matching: a persisted message is only dropped if an
|
|
19
|
+
* ephemeral with the same trimmed content was seen within `ttlMs`.
|
|
20
|
+
*/
|
|
21
|
+
export class MessageDedup {
|
|
22
|
+
private readonly recent = new Map<string, number>();
|
|
23
|
+
|
|
24
|
+
constructor(private readonly ttlMs: number = 15_000) {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Records ephemeral (id-less) messages; returns `true` for a persisted
|
|
28
|
+
* (id-bearing) message that echoes a recent ephemeral one — i.e. drop it.
|
|
29
|
+
*/
|
|
30
|
+
isDuplicate(msg: { id?: string | null; content: string }, now: number = Date.now()): boolean {
|
|
31
|
+
this.prune(now);
|
|
32
|
+
const key = msg.content.trim();
|
|
33
|
+
if (!key) return false;
|
|
34
|
+
const hasId = typeof msg.id === "string" && msg.id.length > 0;
|
|
35
|
+
if (!hasId) {
|
|
36
|
+
this.recent.set(key, now);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return this.recent.has(key);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private prune(now: number): void {
|
|
43
|
+
for (const [key, ts] of this.recent) {
|
|
44
|
+
if (now - ts > this.ttlMs) this.recent.delete(key);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup stability gate (#257).
|
|
3
|
+
*
|
|
4
|
+
* Defers work by `delayMs` milliseconds, then reports whether the process
|
|
5
|
+
* is still alive (true = proceed, false = shutting down detected).
|
|
6
|
+
*
|
|
7
|
+
* A process whose stdin pipe closes during the window exits via the existing
|
|
8
|
+
* `end` handler BEFORE the timer resolves in practice — but the flag check
|
|
9
|
+
* is a defensive guard in case the timer wins the race.
|
|
10
|
+
*/
|
|
11
|
+
export async function awaitStableStartup(
|
|
12
|
+
delayMs: number,
|
|
13
|
+
isShuttingDown: () => boolean,
|
|
14
|
+
): Promise<boolean> {
|
|
15
|
+
if (delayMs > 0) {
|
|
16
|
+
await new Promise<void>((r) => setTimeout(r, delayMs));
|
|
17
|
+
}
|
|
18
|
+
return !isShuttingDown();
|
|
19
|
+
}
|
|
@@ -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.3",
|
|
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,18 @@ 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 { sessionFilePath, clearHandle } from "./lib/channel_session.ts";
|
|
35
|
+
import { clientFingerprint } from "./lib/fingerprint.ts";
|
|
36
|
+
import { MessageDedup } from "./lib/message_dedup.ts";
|
|
37
|
+
import {
|
|
38
|
+
acquireConversation,
|
|
39
|
+
DEFAULT_REATTACH_WINDOW_SECONDS,
|
|
40
|
+
} from "./lib/conversation.ts";
|
|
41
|
+
import {
|
|
42
|
+
createTokenRefreshScheduler,
|
|
43
|
+
type TokenRefreshScheduler,
|
|
44
|
+
} from "./lib/token_refresh.ts";
|
|
45
|
+
import { awaitStableStartup } from "./lib/startup_gate.ts";
|
|
34
46
|
|
|
35
47
|
// ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
|
|
36
48
|
//
|
|
@@ -70,15 +82,71 @@ import { lockFilePath } from "./lib/lockfile.ts";
|
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
// Lock file path: %APPDATA%/viber/ (Windows) or ~/.config/viber/ (Linux/Mac).
|
|
73
|
-
// Namespaced by VIBER_BASE_URL
|
|
74
|
-
// (staging + dev) coexist
|
|
75
|
-
//
|
|
85
|
+
// Namespaced by (VIBER_BASE_URL, client_fingerprint, VIBER_CHANNEL_SESSION_ID) so:
|
|
86
|
+
// - channels at different backends (staging + dev) coexist (different base_url);
|
|
87
|
+
// - two DIFFERENT projects on the same backend never collide — the fingerprint
|
|
88
|
+
// (deterministic per machine + folder) is the only axis that isolates them
|
|
89
|
+
// for a normal bunx client, where the session id is always "" (#267);
|
|
90
|
+
// - two concurrent launches of the SAME project coexist when each launcher
|
|
91
|
+
// supplies a per-launch session id (#259);
|
|
92
|
+
// - channel respawns within one launch inherit the same session id and
|
|
93
|
+
// re-acquire the same lock — accidental duplicates still blocked (#166).
|
|
94
|
+
//
|
|
95
|
+
// LOCK_FINGERPRINT is computed from the current folder (process.cwd()) rather
|
|
96
|
+
// than read from auth.json so the lock paths are available before loadAuth();
|
|
97
|
+
// it is verified equal to auth.client_fingerprint below — a mismatch exits.
|
|
76
98
|
const LOCK_DIR =
|
|
77
99
|
process.env.APPDATA
|
|
78
100
|
? join(process.env.APPDATA, "viber")
|
|
79
101
|
: join(process.env.HOME ?? "/tmp", ".config", "viber");
|
|
80
102
|
const LOCK_BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
|
|
81
|
-
const
|
|
103
|
+
const LOCK_SESSION_ID = process.env.VIBER_CHANNEL_SESSION_ID ?? "";
|
|
104
|
+
const LOCK_FINGERPRINT = clientFingerprint(process.cwd());
|
|
105
|
+
const LOCK_FILE = lockFilePath(LOCK_BASE_URL, LOCK_FINGERPRINT, LOCK_SESSION_ID, LOCK_DIR);
|
|
106
|
+
const SESSION_FILE = sessionFilePath(LOCK_BASE_URL, LOCK_FINGERPRINT, LOCK_SESSION_ID, LOCK_DIR);
|
|
107
|
+
|
|
108
|
+
// VIBER_REATTACH_WINDOW_SECONDS overrides the default TTL-based window.
|
|
109
|
+
// A handle older than this cannot point at a live conversation — skip reattach.
|
|
110
|
+
// Parsed once, mirroring the VIBER_REFRESH_LEAD_SECONDS pattern below: validate
|
|
111
|
+
// finite && >= 0, log invalid→ignore, log override, fall back to the default.
|
|
112
|
+
const REATTACH_WINDOW_SECONDS: number = (() => {
|
|
113
|
+
const raw = process.env.VIBER_REATTACH_WINDOW_SECONDS;
|
|
114
|
+
if (raw === undefined) return DEFAULT_REATTACH_WINDOW_SECONDS;
|
|
115
|
+
const parsed = Number.parseInt(raw, 10);
|
|
116
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
117
|
+
process.stderr.write(
|
|
118
|
+
`[viber-channel] Invalid VIBER_REATTACH_WINDOW_SECONDS=${raw}, ignoring.\n`
|
|
119
|
+
);
|
|
120
|
+
return DEFAULT_REATTACH_WINDOW_SECONDS;
|
|
121
|
+
}
|
|
122
|
+
process.stderr.write(
|
|
123
|
+
`[viber-channel] reattach window overridden to ${parsed}s via VIBER_REATTACH_WINDOW_SECONDS\n`
|
|
124
|
+
);
|
|
125
|
+
return parsed;
|
|
126
|
+
})();
|
|
127
|
+
|
|
128
|
+
// VIBER_STABILITY_DELAY_MS: how long to wait after mcp.connect() before
|
|
129
|
+
// creating a conversation. A doomed process (broken-pipe respawn) dies during
|
|
130
|
+
// this window via the stdin "end" handler, setting shuttingDown=true — the gate
|
|
131
|
+
// then skips acquireConversation and the conversation is never created.
|
|
132
|
+
// Default: 500ms (observed broken-pipe deaths are ~0s; 500ms is imperceptible).
|
|
133
|
+
// Set to 0 to disable (useful for tests / custom launchers).
|
|
134
|
+
const DEFAULT_STABILITY_DELAY_MS = 500;
|
|
135
|
+
const STABILITY_DELAY_MS: number = (() => {
|
|
136
|
+
const raw = process.env.VIBER_STABILITY_DELAY_MS;
|
|
137
|
+
if (raw === undefined) return DEFAULT_STABILITY_DELAY_MS;
|
|
138
|
+
const parsed = Number.parseInt(raw, 10);
|
|
139
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
140
|
+
process.stderr.write(
|
|
141
|
+
`[viber-channel] Invalid VIBER_STABILITY_DELAY_MS=${raw}, ignoring.\n`
|
|
142
|
+
);
|
|
143
|
+
return DEFAULT_STABILITY_DELAY_MS;
|
|
144
|
+
}
|
|
145
|
+
process.stderr.write(
|
|
146
|
+
`[viber-channel] stability delay overridden to ${parsed}ms via VIBER_STABILITY_DELAY_MS\n`
|
|
147
|
+
);
|
|
148
|
+
return parsed;
|
|
149
|
+
})();
|
|
82
150
|
|
|
83
151
|
function isProcessAlive(pid: number): boolean {
|
|
84
152
|
try {
|
|
@@ -138,10 +206,24 @@ function releaseLock(): void {
|
|
|
138
206
|
}
|
|
139
207
|
}
|
|
140
208
|
|
|
209
|
+
// Token-refresh scheduler — null until the initial mint succeeds. Declared at
|
|
210
|
+
// module scope so the early exit handlers below (which fire before the mint
|
|
211
|
+
// completes on slow boots) can cancel it idempotently via optional-chaining.
|
|
212
|
+
let scheduler: TokenRefreshScheduler | null = null;
|
|
213
|
+
|
|
214
|
+
// Stability gate flag (#257): set true as soon as any shutdown path begins so
|
|
215
|
+
// the gate can observe an in-flight shutdown and abort conversation creation.
|
|
216
|
+
let shuttingDown = false;
|
|
217
|
+
|
|
141
218
|
// Best-effort cleanup — on Windows, signals may not fire (TerminateProcess)
|
|
142
|
-
process.on("exit", releaseLock);
|
|
143
|
-
|
|
144
|
-
|
|
219
|
+
process.on("exit", () => { scheduler?.cancel(); releaseLock(); });
|
|
220
|
+
// NOTE: shuttingDown is NOT set here. SIGINT/SIGTERM call process.exit(0) synchronously,
|
|
221
|
+
// so the event loop ends before any setTimeout (the stability gate) can observe the flag.
|
|
222
|
+
// Only the stdin `end` handler sets shuttingDown=true, because it does NOT call
|
|
223
|
+
// process.exit() synchronously — it fires before the MCP transport's read loop begins,
|
|
224
|
+
// giving the gate's setTimeout a chance to run and check the flag.
|
|
225
|
+
process.on("SIGINT", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
|
|
226
|
+
process.on("SIGTERM", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
|
|
145
227
|
|
|
146
228
|
// stdin EOF — Claude Code closes its end of the pipe when the session
|
|
147
229
|
// terminates (or the parent process is killed via TerminateProcess on
|
|
@@ -157,27 +239,28 @@ process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
|
|
|
157
239
|
// `end` fires on real EOF (parent closes write end) and is sufficient for
|
|
158
240
|
// orphan detection. See plan #236 step-09 for the diagnosis.
|
|
159
241
|
process.stdin.on("end", () => {
|
|
242
|
+
shuttingDown = true;
|
|
160
243
|
process.stderr.write("[viber-channel] stdin closed (parent exited), shutting down\n");
|
|
244
|
+
scheduler?.cancel();
|
|
161
245
|
releaseLock();
|
|
162
246
|
process.exit(0);
|
|
163
247
|
});
|
|
164
248
|
|
|
165
249
|
// ---- Auth from .viber/auth.json ----
|
|
166
250
|
|
|
167
|
-
import {
|
|
251
|
+
import { authFilePath, loadAuth } from "./lib/auth.ts";
|
|
168
252
|
import { handleConversationTokenExpired } from "./lib/channel_errors.ts";
|
|
169
253
|
import { buildSseUrl } from "./lib/urls.ts";
|
|
170
254
|
|
|
171
|
-
|
|
255
|
+
const auth = loadAuth();
|
|
172
256
|
|
|
173
257
|
// ---- Fingerprint verification ----
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (computedFp !== auth.client_fingerprint) {
|
|
258
|
+
//
|
|
259
|
+
// LOCK_FINGERPRINT (computed above from process.cwd()) is the same value; this
|
|
260
|
+
// re-checks it against the fingerprint stored in auth.json and exits on drift.
|
|
261
|
+
if (LOCK_FINGERPRINT !== auth.client_fingerprint) {
|
|
179
262
|
process.stderr.write(
|
|
180
|
-
`[viber-channel] Folder fingerprint mismatch —
|
|
263
|
+
`[viber-channel] Folder fingerprint mismatch — \`${authFilePath()}\` was issued for a different machine/folder.\n` +
|
|
181
264
|
`Re-run the connect flow at https://viber.dgypx.dev/projects.\n`
|
|
182
265
|
);
|
|
183
266
|
process.exit(1);
|
|
@@ -185,7 +268,12 @@ if (computedFp !== auth.client_fingerprint) {
|
|
|
185
268
|
|
|
186
269
|
// ---- Imports for mint ----
|
|
187
270
|
|
|
188
|
-
import {
|
|
271
|
+
import {
|
|
272
|
+
defaultLabel,
|
|
273
|
+
refreshConversationToken,
|
|
274
|
+
RefreshHttpError,
|
|
275
|
+
RefreshNetworkError,
|
|
276
|
+
} from "./lib/conversation.ts";
|
|
189
277
|
import { postMessage, ConversationTokenExpiredError, parseArtifact } from "./lib/messages.ts";
|
|
190
278
|
import { cfAccessHeaders } from "./lib/cfAccess.ts";
|
|
191
279
|
|
|
@@ -204,6 +292,7 @@ const mcp = new Server(
|
|
|
204
292
|
instructions: [
|
|
205
293
|
'Voice transcripts arrive as <channel source="viber-channel"> events carrying the user\'s microphone speech.',
|
|
206
294
|
"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.",
|
|
295
|
+
"Exception for inline identifiers: when mentioning a file name, hostname, variable, or other short token inline (auth.json, viber-dev.dgypx.dev, CONVERSATION_TOKEN), write the literal form with its real dots and dashes — do NOT spell them out as 'point' or 'dash'. The 'no markdown' rule is about visual clutter (`**bold**`, `- bullets`, code fences, tables, long path lists), not the punctuation that's naturally part of identifiers.",
|
|
207
296
|
"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.",
|
|
208
297
|
"Available formats: `markdown` (default), `code`, `json`, `html`.",
|
|
209
298
|
"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.",
|
|
@@ -320,8 +409,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
320
409
|
};
|
|
321
410
|
});
|
|
322
411
|
|
|
323
|
-
// Prevent duplicate instances (#166)
|
|
324
|
-
|
|
412
|
+
// Prevent duplicate instances (#166); fingerprint namespacing for different
|
|
413
|
+
// projects (#267); session-id namespacing for concurrent launches of the same
|
|
414
|
+
// project (#259). The fingerprint and session-id prefixes are logged so a user
|
|
415
|
+
// with multiple channels can tell their stderr logs apart.
|
|
416
|
+
const sessionIdLogged = LOCK_SESSION_ID === "" ? "<unset>" : LOCK_SESSION_ID.slice(0, 8);
|
|
417
|
+
process.stderr.write(
|
|
418
|
+
`[viber-channel] startup: pid=${process.pid}, fp=${LOCK_FINGERPRINT.slice(0, 8)}, sessionId=${sessionIdLogged}, acquiring lock\n`,
|
|
419
|
+
);
|
|
325
420
|
const blockingPid = acquireLock();
|
|
326
421
|
process.stderr.write(`[viber-channel] startup: lock acquired (blockingPid=${blockingPid})\n`);
|
|
327
422
|
|
|
@@ -345,52 +440,160 @@ if (blockingPid !== null) {
|
|
|
345
440
|
process.exit(1);
|
|
346
441
|
}
|
|
347
442
|
|
|
348
|
-
// ----
|
|
443
|
+
// ---- Stability gate (#257): defer conversation creation so a doomed process exits first ----
|
|
444
|
+
//
|
|
445
|
+
// Claude Code respawns the channel on transport reconnects; a process that dies
|
|
446
|
+
// within the first second (broken-pipe) had already created a conversation
|
|
447
|
+
// because acquireConversation() ran immediately. The gate delays that call so
|
|
448
|
+
// the stdin "end" handler can set shuttingDown=true and let process.exit(0)
|
|
449
|
+
// race the timer. If the gate sees shuttingDown, it exits without creating a
|
|
450
|
+
// conversation. A healthy process proceeds after STABILITY_DELAY_MS with no
|
|
451
|
+
// user-perceptible delay (the user has not spoken yet at that point).
|
|
452
|
+
|
|
453
|
+
process.stderr.write(`[viber-channel] startup: stability gate (${STABILITY_DELAY_MS}ms)...\n`);
|
|
454
|
+
const gateOpen = await awaitStableStartup(STABILITY_DELAY_MS, () => shuttingDown);
|
|
455
|
+
if (!gateOpen) {
|
|
456
|
+
process.stderr.write(`[viber-channel] startup: stability gate detected shutdown — skipping conversation creation\n`);
|
|
457
|
+
process.exit(0);
|
|
458
|
+
}
|
|
459
|
+
process.stderr.write(`[viber-channel] startup: stability gate passed (${STABILITY_DELAY_MS}ms), acquiring conversation\n`);
|
|
460
|
+
|
|
461
|
+
// ---- Acquire conversation token (reattach or mint, after mcp.connect so a token-rejected notification can be sent) ----
|
|
349
462
|
|
|
350
463
|
let CONVERSATION_TOKEN: string;
|
|
351
464
|
let VOICE_BASE_URL: string;
|
|
352
465
|
let CONVERSATION_ID: string;
|
|
353
466
|
|
|
354
|
-
process.stderr.write(`[viber-channel] startup:
|
|
467
|
+
process.stderr.write(`[viber-channel] startup: acquiring conversation (project=${auth.project_id}, target=${auth.target_conversation_id ?? "null"}, session=${SESSION_FILE})\n`);
|
|
355
468
|
try {
|
|
356
|
-
const minted = await
|
|
469
|
+
const minted = await acquireConversation(
|
|
470
|
+
SESSION_FILE,
|
|
357
471
|
BASE_URL,
|
|
358
472
|
auth.project_id,
|
|
359
473
|
auth.project_token,
|
|
360
474
|
auth.client_fingerprint,
|
|
361
475
|
label,
|
|
362
476
|
auth.target_conversation_id ?? null,
|
|
477
|
+
REATTACH_WINDOW_SECONDS,
|
|
363
478
|
);
|
|
364
|
-
process.stderr.write(`[viber-channel] startup: mint OK, conv_id=${minted.conversation_id}\n`);
|
|
365
479
|
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
try {
|
|
369
|
-
auth = updateAuthToken(process.cwd(), auth, minted.new_project_token);
|
|
370
|
-
} catch (err) {
|
|
371
|
-
process.stderr.write(`[viber-channel] Warning: failed to update .viber/auth.json: ${String(err)}\n`);
|
|
372
|
-
// Don't exit — token is valid in memory; auth.json will be stale but channel keeps running
|
|
373
|
-
}
|
|
374
|
-
}
|
|
480
|
+
// project_token is now a stable, long-lived handshake credential (#274). It is
|
|
481
|
+
// never rotated by the mint response, so auth.json is never rewritten here.
|
|
375
482
|
|
|
376
483
|
CONVERSATION_TOKEN = minted.conversation_token;
|
|
377
484
|
VOICE_BASE_URL = minted.ws_url;
|
|
378
485
|
CONVERSATION_ID = minted.conversation_id;
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
486
|
+
|
|
487
|
+
// Schedule silent token refresh ahead of expiry (#237 step-03). On success
|
|
488
|
+
// we mutate CONVERSATION_TOKEN in place — getHeaders() reads it by closure,
|
|
489
|
+
// so subsequent SSE reconnects and send_message calls pick up the new token
|
|
490
|
+
// automatically. On failure we fall through to the existing
|
|
491
|
+
// handleConversationTokenExpired path (notification + exit code 3).
|
|
492
|
+
//
|
|
493
|
+
// VIBER_REFRESH_LEAD_SECONDS overrides the default 300s lead window — used
|
|
494
|
+
// for shortening the refresh-before-expiry gap during E2E manual testing
|
|
495
|
+
// (set close to TTL to force a refresh seconds after startup). Out of band
|
|
496
|
+
// for normal operation; the scheduler module's default applies when unset.
|
|
497
|
+
const leadOverride = process.env.VIBER_REFRESH_LEAD_SECONDS;
|
|
498
|
+
const leadSeconds = leadOverride ? Number.parseInt(leadOverride, 10) : undefined;
|
|
499
|
+
if (leadOverride !== undefined) {
|
|
500
|
+
if (!Number.isFinite(leadSeconds) || (leadSeconds as number) < 0) {
|
|
501
|
+
process.stderr.write(
|
|
502
|
+
`[viber-channel] Invalid VIBER_REFRESH_LEAD_SECONDS=${leadOverride}, ignoring.\n`
|
|
503
|
+
);
|
|
504
|
+
} else {
|
|
505
|
+
process.stderr.write(
|
|
506
|
+
`[viber-channel] token refresh lead overridden to ${leadSeconds}s via VIBER_REFRESH_LEAD_SECONDS\n`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
389
509
|
}
|
|
510
|
+
const validLead =
|
|
511
|
+
leadSeconds !== undefined && Number.isFinite(leadSeconds) && leadSeconds >= 0
|
|
512
|
+
? leadSeconds
|
|
513
|
+
: undefined;
|
|
514
|
+
|
|
515
|
+
// Track the current token's expiry so the retry path can refuse to retry
|
|
516
|
+
// past it. Updated only after a successful refresh — failed attempts leave
|
|
517
|
+
// it pointing at the still-valid current token.
|
|
518
|
+
let currentExpiresAt = minted.expires_at;
|
|
519
|
+
|
|
520
|
+
scheduler = createTokenRefreshScheduler(
|
|
521
|
+
async () => {
|
|
522
|
+
// Retry transient failures (5xx, 408, 409, network) with exponential
|
|
523
|
+
// backoff so a momentary blip during the 5-min lead window doesn't kill
|
|
524
|
+
// a channel whose token is still valid. Permanent failures (401, 403)
|
|
525
|
+
// surface immediately.
|
|
526
|
+
const MAX_ATTEMPTS = 5;
|
|
527
|
+
const HEADROOM_SECONDS = 10;
|
|
528
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
529
|
+
try {
|
|
530
|
+
const refreshed = await refreshConversationToken(
|
|
531
|
+
BASE_URL,
|
|
532
|
+
CONVERSATION_ID,
|
|
533
|
+
CONVERSATION_TOKEN,
|
|
534
|
+
auth.client_fingerprint,
|
|
535
|
+
);
|
|
536
|
+
// SSE connection: the server accepts the new token immediately on
|
|
537
|
+
// subsequent requests, and any in-flight fetch still has the old
|
|
538
|
+
// token attached at the header level — the server keeps the open
|
|
539
|
+
// stream alive on the old token until its natural close. Safe to
|
|
540
|
+
// overwrite the module variable here.
|
|
541
|
+
CONVERSATION_TOKEN = refreshed.conversation_token;
|
|
542
|
+
currentExpiresAt = refreshed.expires_at;
|
|
543
|
+
return { expiresAt: refreshed.expires_at };
|
|
544
|
+
} catch (err) {
|
|
545
|
+
const retryable =
|
|
546
|
+
(err instanceof RefreshHttpError && err.retryable) ||
|
|
547
|
+
err instanceof RefreshNetworkError;
|
|
548
|
+
if (!retryable || attempt === MAX_ATTEMPTS) throw err;
|
|
549
|
+
// Bound retries by token validity: never sleep past expiry, leave
|
|
550
|
+
// a small headroom so the post-sleep request has time to land.
|
|
551
|
+
const proposedDelay = Math.min(30 * 2 ** (attempt - 1), 120);
|
|
552
|
+
const remaining = currentExpiresAt - Date.now() / 1000;
|
|
553
|
+
if (remaining - proposedDelay < HEADROOM_SECONDS) throw err;
|
|
554
|
+
process.stderr.write(
|
|
555
|
+
`[viber-channel] token refresh attempt ${attempt} failed (${String(err)}); retry in ${proposedDelay}s (${Math.round(remaining)}s until expiry)\n`,
|
|
556
|
+
);
|
|
557
|
+
await new Promise((r) => setTimeout(r, proposedDelay * 1000));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// Unreachable — the loop above either returns the refreshed state or
|
|
561
|
+
// throws on attempt === MAX_ATTEMPTS.
|
|
562
|
+
throw new Error("token refresh: retry loop exhausted without resolution");
|
|
563
|
+
},
|
|
564
|
+
async (err) => {
|
|
565
|
+
process.stderr.write(`[viber-channel] token refresh failed: ${String(err)}\n`);
|
|
566
|
+
// We don't call scheduler.cancel() here — we're inside the scheduler's
|
|
567
|
+
// failure path, and handleConversationTokenExpired ends the process anyway.
|
|
568
|
+
await handleConversationTokenExpired(mcp);
|
|
569
|
+
},
|
|
570
|
+
validLead !== undefined ? { leadSeconds: validLead } : undefined,
|
|
571
|
+
);
|
|
572
|
+
scheduler.start(minted.expires_at);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
// The conversation mint failed. With the stable-token model (#274) a revoked
|
|
575
|
+
// or otherwise invalid project_token yields HTTP 401 — there is no reverify
|
|
576
|
+
// dance and no rotation, so the only recovery is to re-run the connect flow.
|
|
577
|
+
// Network errors and unexpected 5xx land here too; the message stays generic
|
|
578
|
+
// enough to cover them while pointing at the most common cause. The stderr
|
|
579
|
+
// line below is invisible in the Claude Code UI, which only sees the MCP
|
|
580
|
+
// server die ("tools fetch failed") — surface an actionable notification so
|
|
581
|
+
// the user knows what to do instead of failing opaquely. Clean exit, no retry.
|
|
390
582
|
process.stderr.write(`[viber-channel] ${String(err)}\n`);
|
|
391
583
|
process.stderr.write(
|
|
392
|
-
`[viber-channel] If your project_token is no longer valid, run the connect flow again at
|
|
584
|
+
`[viber-channel] If your project_token is no longer valid, run the connect flow again at ${BASE_URL}/projects.\n`
|
|
393
585
|
);
|
|
586
|
+
await mcp.notification({
|
|
587
|
+
method: "notifications/claude/channel",
|
|
588
|
+
params: {
|
|
589
|
+
content:
|
|
590
|
+
`⚠️ Could not start the voice channel: ${String(err)}. ` +
|
|
591
|
+
`If your project_token was revoked, reconnect at ${BASE_URL}/projects and run ` +
|
|
592
|
+
`\`bunx viber-channel connect <url>\` again, then restart this channel. ` +
|
|
593
|
+
`Otherwise this may be a temporary network issue — restart the channel to retry.`,
|
|
594
|
+
meta: { source: "system", type: "project_token_invalid" },
|
|
595
|
+
},
|
|
596
|
+
});
|
|
394
597
|
process.exit(1);
|
|
395
598
|
}
|
|
396
599
|
|
|
@@ -435,6 +638,11 @@ async function pushTranscript(text: string, lang: string): Promise<void> {
|
|
|
435
638
|
});
|
|
436
639
|
}
|
|
437
640
|
|
|
641
|
+
// Drops the persisted echo of a user transcript already delivered ephemerally
|
|
642
|
+
// (the conversation event bus delivers it twice when a web UI is open — #221
|
|
643
|
+
// mechanism, surfaced by the #254 Active section that keeps the UI open).
|
|
644
|
+
const messageDedup = new MessageDedup();
|
|
645
|
+
|
|
438
646
|
/**
|
|
439
647
|
* Forward a saved conversation message from the event bus to Claude.
|
|
440
648
|
*
|
|
@@ -452,6 +660,12 @@ async function pushMessage(msg: {
|
|
|
452
660
|
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
453
661
|
}): Promise<void> {
|
|
454
662
|
process.stderr.write(`[viber-channel] pushMessage ENTER: "${msg.content.slice(0, 60)}"\n`);
|
|
663
|
+
if (messageDedup.isDuplicate(msg)) {
|
|
664
|
+
process.stderr.write(
|
|
665
|
+
`[viber-channel] dropping duplicate persisted message (already delivered ephemerally)\n`,
|
|
666
|
+
);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
455
669
|
// MCP notification handler (Claude Code v2.1.143) validates meta with Zod;
|
|
456
670
|
// null fields are rejected as invalid type. Omit message_id when missing.
|
|
457
671
|
const meta: Record<string, unknown> = { source: msg.source ?? "conversation" };
|
|
@@ -528,7 +742,9 @@ async function sseLoop(): Promise<void> {
|
|
|
528
742
|
break;
|
|
529
743
|
|
|
530
744
|
case "stop":
|
|
531
|
-
process.stderr.write(`[viber-channel] Received stop signal, exiting.\n`);
|
|
745
|
+
process.stderr.write(`[viber-channel] Received stop signal, clearing session handle and exiting.\n`);
|
|
746
|
+
clearHandle(SESSION_FILE);
|
|
747
|
+
scheduler?.cancel();
|
|
532
748
|
releaseLock();
|
|
533
749
|
process.exit(0);
|
|
534
750
|
break;
|