viber-channel 0.5.2 → 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 +142 -19
- package/lib/lockfile.ts +35 -10
- package/lib/message_dedup.ts +47 -0
- package/lib/startup_gate.ts +19 -0
- package/package.json +1 -1
- package/viber-channel.ts +152 -41
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,24 +1,28 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { cfAccessHeaders } from "./cfAccess.js";
|
|
3
3
|
import { ConversationTokenExpiredError } from "./messages.js";
|
|
4
|
+
import { readHandle, writeHandle } from "./channel_session.js";
|
|
4
5
|
|
|
5
6
|
export interface ConversationMintResponse {
|
|
6
7
|
conversation_id: string;
|
|
7
8
|
conversation_token: string;
|
|
8
9
|
ws_url: string;
|
|
9
10
|
expires_at: number;
|
|
10
|
-
new_project_token?: string;
|
|
11
|
-
new_project_token_expires_at?: number | null;
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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;
|
|
22
26
|
}
|
|
23
27
|
}
|
|
24
28
|
|
|
@@ -59,21 +63,54 @@ export async function mintConversation(
|
|
|
59
63
|
body: JSON.stringify(requestBody),
|
|
60
64
|
});
|
|
61
65
|
if (!resp.ok) {
|
|
62
|
-
|
|
63
|
-
|
|
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";
|
|
64
102
|
try {
|
|
65
|
-
body = (await resp.json()) as
|
|
103
|
+
const body = (await resp.json()) as { detail?: string };
|
|
104
|
+
if (typeof body.detail === "string") code = body.detail;
|
|
66
105
|
} catch {
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
if (body.type === "reverify_required" && typeof body.reverify_url === "string") {
|
|
70
|
-
throw new ReverifyRequiredError(body.reverify_url, body.reason ?? "unknown");
|
|
106
|
+
// ignore — code stays "unknown"
|
|
71
107
|
}
|
|
72
|
-
throw new
|
|
108
|
+
throw new ReattachFailedError(code);
|
|
73
109
|
}
|
|
74
110
|
const detail = await resp.text();
|
|
75
|
-
throw new Error(`Failed to
|
|
111
|
+
throw new Error(`Failed to reattach conversation (HTTP ${resp.status}): ${detail}`);
|
|
76
112
|
}
|
|
113
|
+
|
|
77
114
|
return (await resp.json()) as ConversationMintResponse;
|
|
78
115
|
}
|
|
79
116
|
|
|
@@ -179,3 +216,89 @@ export function defaultLabel(folderPath: string): string {
|
|
|
179
216
|
const ts = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
180
217
|
return `${folder} • ${ts}`;
|
|
181
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
|
+
}
|
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,10 +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";
|
|
34
41
|
import {
|
|
35
42
|
createTokenRefreshScheduler,
|
|
36
43
|
type TokenRefreshScheduler,
|
|
37
44
|
} from "./lib/token_refresh.ts";
|
|
45
|
+
import { awaitStableStartup } from "./lib/startup_gate.ts";
|
|
38
46
|
|
|
39
47
|
// ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
|
|
40
48
|
//
|
|
@@ -74,15 +82,71 @@ import {
|
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
// Lock file path: %APPDATA%/viber/ (Windows) or ~/.config/viber/ (Linux/Mac).
|
|
77
|
-
// Namespaced by VIBER_BASE_URL
|
|
78
|
-
// (staging + dev) coexist
|
|
79
|
-
//
|
|
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.
|
|
80
98
|
const LOCK_DIR =
|
|
81
99
|
process.env.APPDATA
|
|
82
100
|
? join(process.env.APPDATA, "viber")
|
|
83
101
|
: join(process.env.HOME ?? "/tmp", ".config", "viber");
|
|
84
102
|
const LOCK_BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
|
|
85
|
-
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
|
+
})();
|
|
86
150
|
|
|
87
151
|
function isProcessAlive(pid: number): boolean {
|
|
88
152
|
try {
|
|
@@ -147,8 +211,17 @@ function releaseLock(): void {
|
|
|
147
211
|
// completes on slow boots) can cancel it idempotently via optional-chaining.
|
|
148
212
|
let scheduler: TokenRefreshScheduler | null = null;
|
|
149
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
|
+
|
|
150
218
|
// Best-effort cleanup — on Windows, signals may not fire (TerminateProcess)
|
|
151
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.
|
|
152
225
|
process.on("SIGINT", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
|
|
153
226
|
process.on("SIGTERM", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
|
|
154
227
|
|
|
@@ -166,6 +239,7 @@ process.on("SIGTERM", () => { scheduler?.cancel(); releaseLock(); process.exit(0
|
|
|
166
239
|
// `end` fires on real EOF (parent closes write end) and is sufficient for
|
|
167
240
|
// orphan detection. See plan #236 step-09 for the diagnosis.
|
|
168
241
|
process.stdin.on("end", () => {
|
|
242
|
+
shuttingDown = true;
|
|
169
243
|
process.stderr.write("[viber-channel] stdin closed (parent exited), shutting down\n");
|
|
170
244
|
scheduler?.cancel();
|
|
171
245
|
releaseLock();
|
|
@@ -174,20 +248,19 @@ process.stdin.on("end", () => {
|
|
|
174
248
|
|
|
175
249
|
// ---- Auth from .viber/auth.json ----
|
|
176
250
|
|
|
177
|
-
import {
|
|
251
|
+
import { authFilePath, loadAuth } from "./lib/auth.ts";
|
|
178
252
|
import { handleConversationTokenExpired } from "./lib/channel_errors.ts";
|
|
179
253
|
import { buildSseUrl } from "./lib/urls.ts";
|
|
180
254
|
|
|
181
|
-
|
|
255
|
+
const auth = loadAuth();
|
|
182
256
|
|
|
183
257
|
// ---- Fingerprint verification ----
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
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) {
|
|
189
262
|
process.stderr.write(
|
|
190
|
-
`[viber-channel] Folder fingerprint mismatch —
|
|
263
|
+
`[viber-channel] Folder fingerprint mismatch — \`${authFilePath()}\` was issued for a different machine/folder.\n` +
|
|
191
264
|
`Re-run the connect flow at https://viber.dgypx.dev/projects.\n`
|
|
192
265
|
);
|
|
193
266
|
process.exit(1);
|
|
@@ -197,11 +270,9 @@ if (computedFp !== auth.client_fingerprint) {
|
|
|
197
270
|
|
|
198
271
|
import {
|
|
199
272
|
defaultLabel,
|
|
200
|
-
mintConversation,
|
|
201
273
|
refreshConversationToken,
|
|
202
274
|
RefreshHttpError,
|
|
203
275
|
RefreshNetworkError,
|
|
204
|
-
ReverifyRequiredError,
|
|
205
276
|
} from "./lib/conversation.ts";
|
|
206
277
|
import { postMessage, ConversationTokenExpiredError, parseArtifact } from "./lib/messages.ts";
|
|
207
278
|
import { cfAccessHeaders } from "./lib/cfAccess.ts";
|
|
@@ -221,6 +292,7 @@ const mcp = new Server(
|
|
|
221
292
|
instructions: [
|
|
222
293
|
'Voice transcripts arrive as <channel source="viber-channel"> events carrying the user\'s microphone speech.',
|
|
223
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.",
|
|
224
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.",
|
|
225
297
|
"Available formats: `markdown` (default), `code`, `json`, `html`.",
|
|
226
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.",
|
|
@@ -337,8 +409,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
337
409
|
};
|
|
338
410
|
});
|
|
339
411
|
|
|
340
|
-
// Prevent duplicate instances (#166)
|
|
341
|
-
|
|
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
|
+
);
|
|
342
420
|
const blockingPid = acquireLock();
|
|
343
421
|
process.stderr.write(`[viber-channel] startup: lock acquired (blockingPid=${blockingPid})\n`);
|
|
344
422
|
|
|
@@ -362,33 +440,45 @@ if (blockingPid !== null) {
|
|
|
362
440
|
process.exit(1);
|
|
363
441
|
}
|
|
364
442
|
|
|
365
|
-
// ----
|
|
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) ----
|
|
366
462
|
|
|
367
463
|
let CONVERSATION_TOKEN: string;
|
|
368
464
|
let VOICE_BASE_URL: string;
|
|
369
465
|
let CONVERSATION_ID: string;
|
|
370
466
|
|
|
371
|
-
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`);
|
|
372
468
|
try {
|
|
373
|
-
const minted = await
|
|
469
|
+
const minted = await acquireConversation(
|
|
470
|
+
SESSION_FILE,
|
|
374
471
|
BASE_URL,
|
|
375
472
|
auth.project_id,
|
|
376
473
|
auth.project_token,
|
|
377
474
|
auth.client_fingerprint,
|
|
378
475
|
label,
|
|
379
476
|
auth.target_conversation_id ?? null,
|
|
477
|
+
REATTACH_WINDOW_SECONDS,
|
|
380
478
|
);
|
|
381
|
-
process.stderr.write(`[viber-channel] startup: mint OK, conv_id=${minted.conversation_id}\n`);
|
|
382
479
|
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
try {
|
|
386
|
-
auth = updateAuthToken(process.cwd(), auth, minted.new_project_token);
|
|
387
|
-
} catch (err) {
|
|
388
|
-
process.stderr.write(`[viber-channel] Warning: failed to update .viber/auth.json: ${String(err)}\n`);
|
|
389
|
-
// Don't exit — token is valid in memory; auth.json will be stale but channel keeps running
|
|
390
|
-
}
|
|
391
|
-
}
|
|
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.
|
|
392
482
|
|
|
393
483
|
CONVERSATION_TOKEN = minted.conversation_token;
|
|
394
484
|
VOICE_BASE_URL = minted.ws_url;
|
|
@@ -481,20 +571,29 @@ try {
|
|
|
481
571
|
);
|
|
482
572
|
scheduler.start(minted.expires_at);
|
|
483
573
|
} catch (err) {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
process.exit(2);
|
|
493
|
-
}
|
|
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.
|
|
494
582
|
process.stderr.write(`[viber-channel] ${String(err)}\n`);
|
|
495
583
|
process.stderr.write(
|
|
496
|
-
`[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`
|
|
497
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
|
+
});
|
|
498
597
|
process.exit(1);
|
|
499
598
|
}
|
|
500
599
|
|
|
@@ -539,6 +638,11 @@ async function pushTranscript(text: string, lang: string): Promise<void> {
|
|
|
539
638
|
});
|
|
540
639
|
}
|
|
541
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
|
+
|
|
542
646
|
/**
|
|
543
647
|
* Forward a saved conversation message from the event bus to Claude.
|
|
544
648
|
*
|
|
@@ -556,6 +660,12 @@ async function pushMessage(msg: {
|
|
|
556
660
|
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
557
661
|
}): Promise<void> {
|
|
558
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
|
+
}
|
|
559
669
|
// MCP notification handler (Claude Code v2.1.143) validates meta with Zod;
|
|
560
670
|
// null fields are rejected as invalid type. Omit message_id when missing.
|
|
561
671
|
const meta: Record<string, unknown> = { source: msg.source ?? "conversation" };
|
|
@@ -632,7 +742,8 @@ async function sseLoop(): Promise<void> {
|
|
|
632
742
|
break;
|
|
633
743
|
|
|
634
744
|
case "stop":
|
|
635
|
-
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);
|
|
636
747
|
scheduler?.cancel();
|
|
637
748
|
releaseLock();
|
|
638
749
|
process.exit(0);
|