viber-channel 0.4.0

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/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # viber-channel
2
+
3
+ Voice + text MCP channel that connects a Claude Code session to the Viber UI at https://viber.dgypx.dev.
4
+
5
+ When attached, transcripts spoken in the Viber browser tab are pushed into the Claude Code session as channel notifications, and Claude's `send_message` tool delivers text back to the UI (with edge-tts playback).
6
+
7
+ ## Prerequisites
8
+
9
+ - A Viber project + token. Open https://viber.dgypx.dev/projects, sign in with Google, create a project, click "Create connection", and paste the resulting `connect to viber: <url>` line into your Claude Code session — Claude writes `.viber/auth.json` for you.
10
+ - [bun](https://bun.sh) on `PATH` (used to run the TypeScript channel).
11
+
12
+ ## Wiring
13
+
14
+ Add this to your project's `.mcp.json`:
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "viber-channel": {
20
+ "command": "bunx",
21
+ "args": ["viber-channel@latest"],
22
+ "env": {
23
+ "VIBER_BASE_URL": "https://viber.dgypx.dev"
24
+ }
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ Or register globally for the current user:
31
+
32
+ ```bash
33
+ claude mcp add viber-channel --scope user \
34
+ --env VIBER_BASE_URL=https://viber.dgypx.dev \
35
+ -- bunx viber-channel@latest
36
+ ```
37
+
38
+ Restart Claude Code in the project directory. The channel acquires a single-instance lock under `%APPDATA%/viber/` (or `~/.config/viber/` on Linux/macOS), mints a conversation against the Worker, and subscribes to the conversation-scoped SSE stream.
39
+
40
+ ## Configuration
41
+
42
+ | Env var | Default | Purpose |
43
+ |---|---|---|
44
+ | `VIBER_BASE_URL` | `https://viber.dgypx.dev` | Worker base URL (staging today; switch later if a separate prod hostname appears). |
45
+ | `VIBER_CHANNEL_LABEL` | folder basename | Human-readable name for the conversation in the UI. |
46
+ | `VIBER_CF_CLIENT_ID` / `VIBER_CF_CLIENT_SECRET` | unset | CF Access service-token headers (only needed when targeting a CF Access-protected hostname without a public bypass policy). |
47
+
48
+ ## Source
49
+
50
+ This package is built from [`viber-channel/`](https://github.com/dgx80/viber/tree/master/viber-channel) in the main Viber repo.
package/lib/auth.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export interface AuthJson {
5
+ schema_version: number;
6
+ project_id: number;
7
+ project_name: string;
8
+ user_email: string;
9
+ project_token: string;
10
+ client_fingerprint: string;
11
+ issued_at: string;
12
+ rotates_after?: string;
13
+ /**
14
+ * Optional — when present, the CLI must pass this id to the conversation
15
+ * mint endpoint so the worker UPDATES the existing standalone conversation
16
+ * in place rather than INSERTING a new one (step-05 / D4 case 3).
17
+ *
18
+ * The field is one-shot: once the upgrade succeeds the worker flips the
19
+ * conversation's mode to 'channel-attached' and subsequent mints would
20
+ * return `target_not_standalone`. Future writers may strip this field after
21
+ * the first successful mint, but doing so is not required for correctness.
22
+ */
23
+ target_conversation_id?: string | null;
24
+ }
25
+
26
+ /**
27
+ * Atomically update the project_token in .viber/auth.json.
28
+ * Writes to a tmp file first, then renames (atomic on POSIX; also atomic on Windows for same-volume).
29
+ */
30
+ export function updateAuthToken(folderPath: string, currentAuth: AuthJson, newToken: string): AuthJson {
31
+ const path = join(folderPath, ".viber", "auth.json");
32
+ const tmp = `${path}.tmp`;
33
+ const updated: AuthJson = {
34
+ ...currentAuth,
35
+ project_token: newToken,
36
+ issued_at: new Date().toISOString(),
37
+ };
38
+ writeFileSync(tmp, JSON.stringify(updated, null, 2), "utf-8");
39
+ renameSync(tmp, path);
40
+ return updated;
41
+ }
42
+
43
+ export function loadAuth(cwd: string = process.cwd()): AuthJson {
44
+ const path = join(cwd, ".viber", "auth.json");
45
+ let raw: string;
46
+ try {
47
+ raw = readFileSync(path, "utf-8");
48
+ } catch {
49
+ process.stderr.write(
50
+ `[viber-channel] No \`.viber/auth.json\` found in this folder.\n` +
51
+ `Run the connect flow first: visit https://viber.dgypx.dev/projects → Create connection.\n`
52
+ );
53
+ process.exit(1);
54
+ }
55
+ let auth: AuthJson;
56
+ try {
57
+ auth = JSON.parse(raw) as AuthJson;
58
+ } catch (err) {
59
+ process.stderr.write(`[viber-channel] Malformed .viber/auth.json: ${String(err)}\n`);
60
+ process.exit(1);
61
+ }
62
+ if (auth.schema_version !== 1) {
63
+ process.stderr.write(`[viber-channel] Unsupported auth schema version: ${auth.schema_version}\n`);
64
+ process.exit(1);
65
+ }
66
+ if (!auth.project_token) {
67
+ process.stderr.write(`[viber-channel] Empty project_token in .viber/auth.json\n`);
68
+ process.exit(1);
69
+ }
70
+ return auth;
71
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * cfAccess — optional Cloudflare Access service-token headers.
3
+ *
4
+ * Some viber hostnames (notably the dev tunnel `viber-dev.dgypx.dev`) are
5
+ * behind CF Access. When `VIBER_CF_CLIENT_ID` and `VIBER_CF_CLIENT_SECRET`
6
+ * env vars are set, every API call from the channel includes them so the
7
+ * request reaches the Worker instead of being 302'd to a login page.
8
+ *
9
+ * Returns an empty object when the env vars aren't set — production setups
10
+ * that have a CF Access bypass policy on the API paths don't need these.
11
+ */
12
+
13
+ export function cfAccessHeaders(): Record<string, string> {
14
+ const id = process.env.VIBER_CF_CLIENT_ID;
15
+ const secret = process.env.VIBER_CF_CLIENT_SECRET;
16
+ if (!id || !secret) return {};
17
+ return {
18
+ "CF-Access-Client-Id": id,
19
+ "CF-Access-Client-Secret": secret,
20
+ };
21
+ }
@@ -0,0 +1,23 @@
1
+ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+
3
+ /**
4
+ * Called when the server returns HTTP 401 during SSE or polling.
5
+ *
6
+ * The CONVERSATION_TOKEN minted at startup has a 1h TTL. After expiry, the
7
+ * server starts responding with 401. We notify the user and exit with code 3
8
+ * (distinct from reverify exit code 2) so the process manager / user can
9
+ * distinguish the cause.
10
+ *
11
+ * We do NOT attempt to re-mint inline — the project_token is tied to the
12
+ * original auth flow. The user must restart the channel to get a fresh token.
13
+ */
14
+ export async function handleConversationTokenExpired(mcp: Server): Promise<never> {
15
+ await mcp.notification({
16
+ method: "notifications/claude/channel",
17
+ params: {
18
+ content: "⚠️ Voice connection expired. Restart this channel to continue.",
19
+ meta: { source: "system", type: "conversation_token_expired" },
20
+ },
21
+ });
22
+ process.exit(3);
23
+ }
@@ -0,0 +1,86 @@
1
+ import { basename } from "node:path";
2
+ import { cfAccessHeaders } from "./cfAccess.js";
3
+
4
+ export interface ConversationMintResponse {
5
+ conversation_id: string;
6
+ conversation_token: string;
7
+ ws_url: string;
8
+ expires_at: number;
9
+ new_project_token?: string;
10
+ new_project_token_expires_at?: number | null;
11
+ }
12
+
13
+ export class ReverifyRequiredError extends Error {
14
+ url: string;
15
+ reason: string;
16
+ constructor(url: string, reason: string) {
17
+ super(`Re-verification required: ${reason}`);
18
+ this.name = "ReverifyRequiredError";
19
+ this.url = url;
20
+ this.reason = reason;
21
+ }
22
+ }
23
+
24
+ export async function mintConversation(
25
+ baseUrl: string,
26
+ projectId: number,
27
+ projectToken: string,
28
+ fingerprint: string,
29
+ label: string,
30
+ /**
31
+ * Optional — when set, the worker upgrades the existing standalone
32
+ * conversation with this id rather than minting a new one (step-05).
33
+ * Originates from `.viber/auth.json` (`target_conversation_id`), which
34
+ * Claude Code populates from the connect-flow poll response when the claim
35
+ * was created via attach-to-project.
36
+ */
37
+ targetConversationId?: string | null,
38
+ ): Promise<ConversationMintResponse> {
39
+ const requestBody: { conversation_label: string; target_conversation_id?: string } = {
40
+ conversation_label: label,
41
+ };
42
+ // Explicit null check (not truthy) — guards against future widening of the
43
+ // type accidentally letting empty strings flow through to the worker, which
44
+ // would be parsed as null on the receiving end and silently fall through to
45
+ // the new-conv path.
46
+ if (targetConversationId != null) {
47
+ requestBody.target_conversation_id = targetConversationId;
48
+ }
49
+
50
+ const resp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`, {
51
+ method: "POST",
52
+ headers: {
53
+ Authorization: `Bearer ${projectToken}`,
54
+ "X-Client-Fingerprint": fingerprint,
55
+ "Content-Type": "application/json",
56
+ ...cfAccessHeaders(),
57
+ },
58
+ body: JSON.stringify(requestBody),
59
+ });
60
+ if (!resp.ok) {
61
+ if (resp.status === 403) {
62
+ let body: { type?: string; reverify_url?: string; reason?: string };
63
+ try {
64
+ body = (await resp.json()) as typeof body;
65
+ } catch {
66
+ body = {};
67
+ }
68
+ if (body.type === "reverify_required" && typeof body.reverify_url === "string") {
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)}`);
72
+ }
73
+ const detail = await resp.text();
74
+ throw new Error(`Failed to mint conversation token (HTTP ${resp.status}): ${detail}`);
75
+ }
76
+ return (await resp.json()) as ConversationMintResponse;
77
+ }
78
+
79
+ export function defaultLabel(folderPath: string): string {
80
+ const folder = basename(folderPath);
81
+ // Local time, not UTC — the user sees this label in the UI; UTC was confusing.
82
+ const d = new Date();
83
+ const pad = (n: number) => String(n).padStart(2, "0");
84
+ const ts = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
85
+ return `${folder} • ${ts}`;
86
+ }
@@ -0,0 +1,47 @@
1
+ import { execSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { realpathSync } from "node:fs";
4
+
5
+ // Path normalization: on Windows, Node's realpathSync returns "C:\\Users\\..."
6
+ // while the Claude Code bootstrap shell uses `pwd -P` under Git Bash, which returns
7
+ // "/c/Users/...". These paths must hash identically — otherwise the fingerprint
8
+ // computed at bootstrap time and at channel start time would never match.
9
+ export function normalizePath(absPath: string): string {
10
+ if (process.platform !== "win32") return absPath;
11
+ // C:\Users\jeanp\... → /c/Users/jeanp/...
12
+ let p = absPath.replace(/\\/g, "/");
13
+ const driveMatch = p.match(/^([A-Za-z]):\/(.*)$/);
14
+ if (driveMatch) {
15
+ const drive = driveMatch[1].toLowerCase();
16
+ const rest = driveMatch[2];
17
+ p = `/${drive}/${rest}`;
18
+ }
19
+ return p;
20
+ }
21
+
22
+ export function getMachineId(): string {
23
+ if (process.platform === "linux") {
24
+ const id = execSync("cat /etc/machine-id 2>/dev/null || cat /var/lib/dbus/machine-id").toString().trim();
25
+ if (!id) throw new Error("Could not read machine-id on Linux");
26
+ return id;
27
+ }
28
+ if (process.platform === "darwin") {
29
+ const out = execSync("ioreg -rd1 -c IOPlatformExpertDevice").toString();
30
+ const id = out.match(/IOPlatformUUID.*=.*"([^"]+)"/)?.[1];
31
+ if (!id) throw new Error("Could not read IOPlatformUUID on macOS");
32
+ return id;
33
+ }
34
+ if (process.platform === "win32") {
35
+ const out = execSync('reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid').toString();
36
+ const id = out.match(/MachineGuid\s+REG_SZ\s+(\S+)/)?.[1]?.trim();
37
+ if (!id) throw new Error("Could not read MachineGuid from Windows registry");
38
+ return id;
39
+ }
40
+ throw new Error(`Unsupported platform: ${process.platform}`);
41
+ }
42
+
43
+ export function clientFingerprint(folderPath: string): string {
44
+ const machineId = getMachineId();
45
+ const absPath = normalizePath(realpathSync(folderPath) as string);
46
+ return createHash("sha256").update(`${machineId}:${absPath}`).digest("hex").slice(0, 32);
47
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * messages.ts — HTTP client for POST /api/conversations/:id/messages
3
+ *
4
+ * Pure function: no state, no MCP, no side effects.
5
+ * The MCP CallTool handler in viber-channel.ts calls postMessage() and maps
6
+ * the result to MCP tool call results.
7
+ */
8
+
9
+ import { cfAccessHeaders } from "./cfAccess.js";
10
+
11
+ export class ConversationTokenExpiredError extends Error {
12
+ constructor() {
13
+ super("conversation token expired (HTTP 401)");
14
+ this.name = "ConversationTokenExpiredError";
15
+ }
16
+ }
17
+
18
+ export interface MessagePostSuccess {
19
+ ok: true;
20
+ message_id: string;
21
+ }
22
+
23
+ export interface MessagePostError {
24
+ ok: false;
25
+ status: number;
26
+ detail: string;
27
+ retriable: boolean;
28
+ }
29
+
30
+ export type MessagePostResult = MessagePostSuccess | MessagePostError;
31
+
32
+ /**
33
+ * POST /api/conversations/:conversationId/messages
34
+ *
35
+ * @throws ConversationTokenExpiredError on HTTP 401
36
+ * @returns MessagePostResult — success with message_id, or error with detail
37
+ */
38
+ export async function postMessage(
39
+ baseUrl: string,
40
+ conversationId: string,
41
+ conversationToken: string,
42
+ text: string,
43
+ ): Promise<MessagePostResult> {
44
+ if (!text || text.trim().length === 0) {
45
+ return {
46
+ ok: false,
47
+ status: 0,
48
+ detail: "text must be a non-empty string",
49
+ retriable: false,
50
+ };
51
+ }
52
+
53
+ const url = `${baseUrl}/api/conversations/${conversationId}/messages`;
54
+ let resp: Response;
55
+ try {
56
+ resp = await fetch(url, {
57
+ method: "POST",
58
+ headers: {
59
+ Authorization: `Bearer ${conversationToken}`,
60
+ "Content-Type": "application/json",
61
+ ...cfAccessHeaders(),
62
+ },
63
+ body: JSON.stringify({ content: text }),
64
+ });
65
+ } catch (err) {
66
+ return {
67
+ ok: false,
68
+ status: 0,
69
+ detail: `Network error: ${String(err)}`,
70
+ retriable: true,
71
+ };
72
+ }
73
+
74
+ if (resp.status === 401) {
75
+ throw new ConversationTokenExpiredError();
76
+ }
77
+
78
+ if (resp.status >= 500) {
79
+ let body = "";
80
+ try {
81
+ body = await resp.text();
82
+ } catch {
83
+ // ignore
84
+ }
85
+ return {
86
+ ok: false,
87
+ status: resp.status,
88
+ detail: `Server error (HTTP ${resp.status}): ${body || resp.statusText}. Retry the call.`,
89
+ retriable: true,
90
+ };
91
+ }
92
+
93
+ if (!resp.ok) {
94
+ let detail = `HTTP ${resp.status}`;
95
+ try {
96
+ const json = (await resp.json()) as { detail?: string };
97
+ if (typeof json.detail === "string") detail = `HTTP ${resp.status}: ${json.detail}`;
98
+ } catch {
99
+ try {
100
+ const body = await resp.text();
101
+ if (body) detail = `HTTP ${resp.status}: ${body}`;
102
+ } catch {
103
+ // ignore
104
+ }
105
+ }
106
+ return { ok: false, status: resp.status, detail, retriable: false };
107
+ }
108
+
109
+ // 200 or 201 — parse the saved message
110
+ let message: { id: string };
111
+ try {
112
+ message = (await resp.json()) as { id: string };
113
+ } catch (err) {
114
+ return {
115
+ ok: false,
116
+ status: resp.status,
117
+ detail: `Failed to parse response JSON: ${String(err)}`,
118
+ retriable: false,
119
+ };
120
+ }
121
+
122
+ return { ok: true, message_id: message.id };
123
+ }
package/lib/urls.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * URL construction helpers for viber-channel.
3
+ * Kept in a side-effect-free module so tests can import them directly.
4
+ */
5
+
6
+ /**
7
+ * Build the conversation-scoped SSE URL.
8
+ * Introduced in #221 step-05 to replace the legacy /api/voice-events endpoint.
9
+ */
10
+ export function buildSseUrl(voiceBaseUrl: string, conversationId: string): string {
11
+ return `${voiceBaseUrl}/api/conversations/${conversationId}/events`;
12
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "viber-channel",
3
+ "version": "0.4.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "viber-channel": "./viber-channel.ts"
8
+ },
9
+ "files": [
10
+ "viber-channel.ts",
11
+ "lib/",
12
+ "README.md"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/dgx80/viber.git",
17
+ "directory": "viber-channel"
18
+ },
19
+ "homepage": "https://viber.dgypx.dev",
20
+ "bugs": {
21
+ "url": "https://github.com/dgx80/viber/issues"
22
+ },
23
+ "keywords": [
24
+ "viber",
25
+ "claude-code",
26
+ "mcp",
27
+ "channel",
28
+ "voice",
29
+ "transcription"
30
+ ],
31
+ "license": "MIT",
32
+ "scripts": {
33
+ "start": "bun run viber-channel.ts",
34
+ "test": "bun test"
35
+ },
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "^1.0.0"
38
+ }
39
+ }
@@ -0,0 +1,478 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * viber-channel MCP channel server (#217)
4
+ *
5
+ * Reads .viber/auth.json for authentication, then connects to
6
+ * /api/conversations/<id>/events SSE stream and pushes transcripts and
7
+ * messages as channel events.
8
+ *
9
+ * Start Claude with:
10
+ * claude --dangerously-load-development-channels server:viber-channel
11
+ */
12
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
13
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
+ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
15
+ import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ // Lock file path: %APPDATA%/viber/ (Windows) or ~/.config/viber/ (Linux/Mac)
19
+ const LOCK_DIR =
20
+ process.env.APPDATA
21
+ ? join(process.env.APPDATA, "viber")
22
+ : join(process.env.HOME ?? "/tmp", ".config", "viber");
23
+ // TODO: rename to channel.lock once all installations have transitioned off the old name
24
+ const LOCK_FILE = join(LOCK_DIR, "watchdog.lock");
25
+
26
+ function isProcessAlive(pid: number): boolean {
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ } catch (err: any) {
31
+ if (err?.code === "EPERM") return true; // Process exists, no permission to signal
32
+ return false; // ESRCH or anything Bun-specific — treat as dead
33
+ }
34
+ }
35
+
36
+ /** Try to acquire lock. Returns null on success, or the blocking PID on conflict. */
37
+ function acquireLock(): number | null {
38
+ mkdirSync(LOCK_DIR, { recursive: true });
39
+ const ourPid = String(process.pid);
40
+
41
+ for (let attempt = 0; attempt < 2; attempt++) {
42
+ try {
43
+ writeFileSync(LOCK_FILE, ourPid + "\n", { flag: "wx" });
44
+ return null; // Lock acquired
45
+ } catch (err: any) {
46
+ if (err?.code !== "EEXIST") throw err;
47
+
48
+ // Lock file exists — check if the holder is still alive
49
+ let existingPid: number;
50
+ try {
51
+ existingPid = parseInt(readFileSync(LOCK_FILE, "utf-8").trim(), 10);
52
+ } catch {
53
+ // Corrupt or unreadable — treat as stale
54
+ releaseLock();
55
+ continue;
56
+ }
57
+
58
+ if (isNaN(existingPid) || !isProcessAlive(existingPid)) {
59
+ // Stale lock — remove and retry
60
+ releaseLock();
61
+ continue;
62
+ }
63
+
64
+ // Another live channel is running
65
+ process.stderr.write(
66
+ `[viber-channel] Another instance already running (PID ${existingPid}), exiting.\n`
67
+ );
68
+ return existingPid;
69
+ }
70
+ }
71
+
72
+ process.stderr.write("[viber-channel] Failed to acquire lock after retries, exiting.\n");
73
+ return -1; // Failed to acquire
74
+ }
75
+
76
+ function releaseLock(): void {
77
+ try {
78
+ unlinkSync(LOCK_FILE);
79
+ } catch {
80
+ // Best-effort — file may already be gone
81
+ }
82
+ }
83
+
84
+ // Best-effort cleanup — on Windows, signals may not fire (TerminateProcess)
85
+ process.on("exit", releaseLock);
86
+ process.on("SIGINT", () => { releaseLock(); process.exit(0); });
87
+ process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
88
+
89
+ // stdin EOF — Claude Code closes its end of the pipe when the session
90
+ // terminates (or the parent process is killed via TerminateProcess on
91
+ // Windows, which the kernel propagates as a pipe close). Without this,
92
+ // the bun subprocess outlives Claude Code and leaks (the orphan that
93
+ // taunts us on every restart). The MCP transport uses stdin too, but
94
+ // it doesn't propagate close as a process exit, so we wire it ourselves.
95
+ process.stdin.on("end", () => {
96
+ process.stderr.write("[viber-channel] stdin closed (parent exited), shutting down\n");
97
+ releaseLock();
98
+ process.exit(0);
99
+ });
100
+ process.stdin.on("close", () => {
101
+ process.stderr.write("[viber-channel] stdin close event, shutting down\n");
102
+ releaseLock();
103
+ process.exit(0);
104
+ });
105
+
106
+ // ---- Auth from .viber/auth.json ----
107
+
108
+ import { loadAuth, updateAuthToken } from "./lib/auth.ts";
109
+ import { handleConversationTokenExpired } from "./lib/channel_errors.ts";
110
+ import { buildSseUrl } from "./lib/urls.ts";
111
+
112
+ let auth = loadAuth();
113
+
114
+ // ---- Fingerprint verification ----
115
+
116
+ import { clientFingerprint } from "./lib/fingerprint.ts";
117
+
118
+ const computedFp = clientFingerprint(process.cwd());
119
+ if (computedFp !== auth.client_fingerprint) {
120
+ process.stderr.write(
121
+ `[viber-channel] Folder fingerprint mismatch — \`.viber/auth.json\` was issued for a different machine/folder.\n` +
122
+ `Re-run the connect flow at https://viber.dgypx.dev/projects.\n`
123
+ );
124
+ process.exit(1);
125
+ }
126
+
127
+ // ---- Imports for mint ----
128
+
129
+ import { defaultLabel, mintConversation, ReverifyRequiredError } from "./lib/conversation.ts";
130
+ import { postMessage, ConversationTokenExpiredError } from "./lib/messages.ts";
131
+ import { cfAccessHeaders } from "./lib/cfAccess.ts";
132
+
133
+ const BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
134
+ const label = process.env.VIBER_CONVERSATION_LABEL ?? defaultLabel(process.cwd());
135
+
136
+ // ---- MCP server ----
137
+
138
+ const mcp = new Server(
139
+ { name: "viber-channel", version: "0.3.0" },
140
+ {
141
+ capabilities: {
142
+ experimental: { "claude/channel": {} },
143
+ tools: {},
144
+ },
145
+ instructions: [
146
+ 'Voice transcripts arrive as <channel source="viber-channel">.',
147
+ "Each event contains transcriptions from the user's microphone.",
148
+ "Process the transcript and respond with speak(). Do NOT launch any watchdog or call check_voice().",
149
+ "On exit intent (bye, au revoir, stop) → call stop_conversation(), speak farewell, stop.",
150
+ ].join(" "),
151
+ }
152
+ );
153
+
154
+ // ---- Tool registration (ListTools + CallTool) ----
155
+
156
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
157
+ tools: [
158
+ {
159
+ name: "send_message",
160
+ description:
161
+ "Send a message into the Viber conversation this channel is bound to. The user sees it in the web UI in real-time. Use this to deliver Claude's responses to the user without typing.",
162
+ inputSchema: {
163
+ type: "object" as const,
164
+ properties: {
165
+ text: {
166
+ type: "string",
167
+ minLength: 1,
168
+ description: "Message text to send to the user's conversation",
169
+ },
170
+ },
171
+ required: ["text"],
172
+ },
173
+ },
174
+ ],
175
+ }));
176
+
177
+ // NOTE: CONVERSATION_TOKEN, CONVERSATION_ID, BASE_URL are populated after
178
+ // mintConversation() runs (below). The handler closure captures the variables
179
+ // by reference — by the time Claude Code calls the tool, they are populated.
180
+ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
181
+ if (request.params.name !== "send_message") {
182
+ return {
183
+ isError: true,
184
+ content: [{ type: "text" as const, text: `Unknown tool: ${request.params.name}` }],
185
+ };
186
+ }
187
+
188
+ const args = (request.params.arguments ?? {}) as Record<string, unknown>;
189
+ const text = args.text;
190
+ if (typeof text !== "string" || text.trim().length === 0) {
191
+ return {
192
+ isError: true,
193
+ content: [{ type: "text" as const, text: "Invalid input: text must be a non-empty string" }],
194
+ };
195
+ }
196
+
197
+ let result;
198
+ try {
199
+ result = await postMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, text);
200
+ } catch (err) {
201
+ if (err instanceof ConversationTokenExpiredError) {
202
+ // Notify Claude (and the user) that the channel token has expired
203
+ await handleConversationTokenExpired(mcp);
204
+ // handleConversationTokenExpired calls process.exit(3) — unreachable, but
205
+ // TypeScript needs a return to satisfy the type checker.
206
+ return {
207
+ isError: true,
208
+ content: [{ type: "text" as const, text: "conversation token expired" }],
209
+ };
210
+ }
211
+ return {
212
+ isError: true,
213
+ content: [{ type: "text" as const, text: `Unexpected error: ${String(err)}` }],
214
+ };
215
+ }
216
+
217
+ if (!result.ok) {
218
+ return {
219
+ isError: true,
220
+ content: [{ type: "text" as const, text: result.detail }],
221
+ };
222
+ }
223
+
224
+ return {
225
+ content: [{ type: "text" as const, text: `Message sent: ${result.message_id}` }],
226
+ };
227
+ });
228
+
229
+ // Prevent duplicate instances (#166)
230
+ process.stderr.write(`[viber-channel] startup: pid=${process.pid}, acquiring lock\n`);
231
+ const blockingPid = acquireLock();
232
+ process.stderr.write(`[viber-channel] startup: lock acquired (blockingPid=${blockingPid})\n`);
233
+
234
+ // Connect to Claude Code over stdio FIRST so notifications can be sent
235
+ process.stderr.write(`[viber-channel] startup: calling mcp.connect()\n`);
236
+ await mcp.connect(new StdioServerTransport());
237
+ process.stderr.write(`[viber-channel] startup: mcp.connect() returned\n`);
238
+
239
+ // If another instance is running, notify Claude and exit
240
+ if (blockingPid !== null) {
241
+ const msg = blockingPid > 0
242
+ ? `⚠️ Another viber-channel is already running (PID ${blockingPid}). This channel is inactive — voice transcriptions are handled by the other instance.`
243
+ : `⚠️ Failed to acquire channel lock file. This channel is inactive.`;
244
+ await mcp.notification({
245
+ method: "notifications/claude/channel",
246
+ params: {
247
+ content: msg,
248
+ meta: { source: "system" },
249
+ },
250
+ });
251
+ process.exit(1);
252
+ }
253
+
254
+ // ---- Mint conversation token (after mcp.connect so reverify notifications can be sent) ----
255
+
256
+ let CONVERSATION_TOKEN: string;
257
+ let VOICE_BASE_URL: string;
258
+ let CONVERSATION_ID: string;
259
+
260
+ process.stderr.write(`[viber-channel] startup: calling mintConversation (project=${auth.project_id}, target=${auth.target_conversation_id ?? "null"})\n`);
261
+ try {
262
+ const minted = await mintConversation(
263
+ BASE_URL,
264
+ auth.project_id,
265
+ auth.project_token,
266
+ auth.client_fingerprint,
267
+ label,
268
+ auth.target_conversation_id ?? null,
269
+ );
270
+ process.stderr.write(`[viber-channel] startup: mint OK, conv_id=${minted.conversation_id}\n`);
271
+
272
+ // Apply token rotation if the server issued a new project_token
273
+ if (minted.new_project_token) {
274
+ try {
275
+ auth = updateAuthToken(process.cwd(), auth, minted.new_project_token);
276
+ } catch (err) {
277
+ process.stderr.write(`[viber-channel] Warning: failed to update .viber/auth.json: ${String(err)}\n`);
278
+ // Don't exit — token is valid in memory; auth.json will be stale but channel keeps running
279
+ }
280
+ }
281
+
282
+ CONVERSATION_TOKEN = minted.conversation_token;
283
+ VOICE_BASE_URL = minted.ws_url;
284
+ CONVERSATION_ID = minted.conversation_id;
285
+ } catch (err) {
286
+ if (err instanceof ReverifyRequiredError) {
287
+ await mcp.notification({
288
+ method: "notifications/claude/channel",
289
+ params: {
290
+ content: `⚠️ Re-verification required (reason: ${err.reason}). Visit ${err.url} to re-authenticate, then restart this channel.`,
291
+ meta: { source: "system", type: "reverify_required", url: err.url },
292
+ },
293
+ });
294
+ process.exit(2);
295
+ }
296
+ process.stderr.write(`[viber-channel] ${String(err)}\n`);
297
+ process.stderr.write(
298
+ `[viber-channel] If your project_token is no longer valid, run the connect flow again at https://viber.dgypx.dev/projects.\n`
299
+ );
300
+ process.exit(1);
301
+ }
302
+
303
+ // ---- Channel ready banner ----
304
+
305
+ process.stderr.write(
306
+ `[viber-channel] Channel ready: 1 tool (send_message), SSE on /api/conversations/${CONVERSATION_ID}/events\n`
307
+ );
308
+
309
+ // ---- URLs and auth headers ----
310
+
311
+ /** Primary SSE endpoint: conversation-scoped event bus (step-03 / #221). */
312
+ const SSE_URL = buildSseUrl(VOICE_BASE_URL, CONVERSATION_ID);
313
+
314
+ // Voice endpoints (/api/conversations/<id>/events) authenticate via the
315
+ // per-conversation token minted by the Worker at startup (#220 step 05).
316
+ // Python validates the token through ConversationTokenValidator → Worker,
317
+ // not against a static shared secret. The previous VIBER_CHANNEL_SECRET path
318
+ // has been removed everywhere. The legacy /api/check_voice long-poll fallback
319
+ // was retired in #221 step-09 once the conversation-scoped SSE proved reliable.
320
+
321
+ function getHeaders(): Record<string, string> {
322
+ return {
323
+ Authorization: `Bearer ${CONVERSATION_TOKEN}`,
324
+ "X-Client-Fingerprint": auth.client_fingerprint,
325
+ ...cfAccessHeaders(),
326
+ };
327
+ }
328
+
329
+ function sleep(ms: number): Promise<void> {
330
+ return new Promise((r) => setTimeout(r, ms));
331
+ }
332
+
333
+ async function pushTranscript(text: string, lang: string): Promise<void> {
334
+ process.stderr.write(`[viber-channel] Speech detected: "${text}"\n`);
335
+ await mcp.notification({
336
+ method: "notifications/claude/channel",
337
+ params: {
338
+ content: text,
339
+ meta: { lang, source: "microphone" },
340
+ },
341
+ });
342
+ }
343
+
344
+ /** Forward a saved conversation message from the event bus to Claude. */
345
+ async function pushMessage(msg: { text: string; id?: string | null; source?: string }): Promise<void> {
346
+ process.stderr.write(`[viber-channel] pushMessage ENTER: "${msg.text.slice(0, 60)}"\n`);
347
+ // MCP notification handler (Claude Code v2.1.143) validates meta with Zod;
348
+ // null fields are rejected as invalid type. Omit message_id when missing.
349
+ const meta: Record<string, string> = { source: msg.source ?? "conversation" };
350
+ if (typeof msg.id === "string" && msg.id.length > 0) {
351
+ meta.message_id = msg.id;
352
+ }
353
+ try {
354
+ await mcp.notification({
355
+ method: "notifications/claude/channel",
356
+ params: { content: msg.text, meta },
357
+ });
358
+ process.stderr.write(`[viber-channel] pushMessage OK\n`);
359
+ } catch (err) {
360
+ process.stderr.write(`[viber-channel] pushMessage FAILED: ${String(err)}\n`);
361
+ throw err;
362
+ }
363
+ }
364
+
365
+ /** SSE stream — preferred method (#170) */
366
+ async function sseLoop(): Promise<void> {
367
+ const headers = getHeaders();
368
+ headers["Accept"] = "text/event-stream";
369
+
370
+ process.stderr.write(`[viber-channel] Connecting to SSE: ${SSE_URL}\n`);
371
+
372
+ const resp = await fetch(SSE_URL, { headers });
373
+
374
+ if (resp.status === 401) {
375
+ await handleConversationTokenExpired(mcp);
376
+ }
377
+
378
+ if (!resp.ok || !resp.body) {
379
+ throw new Error(`SSE connection failed: HTTP ${resp.status}`);
380
+ }
381
+
382
+ process.stderr.write(`[viber-channel] SSE connected\n`);
383
+
384
+ const reader = resp.body.getReader();
385
+ const decoder = new TextDecoder();
386
+ let buffer = "";
387
+
388
+ while (true) {
389
+ const { done, value } = await reader.read();
390
+ if (done) break;
391
+
392
+ buffer += decoder.decode(value, { stream: true });
393
+
394
+ // Parse SSE events from buffer
395
+ while (true) {
396
+ const eventEnd = buffer.indexOf("\n\n");
397
+ if (eventEnd === -1) break;
398
+
399
+ const eventBlock = buffer.slice(0, eventEnd);
400
+ buffer = buffer.slice(eventEnd + 2);
401
+
402
+ // Skip comments (keepalive)
403
+ if (eventBlock.startsWith(":")) continue;
404
+
405
+ // Parse event type and data
406
+ let eventType = "";
407
+ let data = "";
408
+ for (const line of eventBlock.split("\n")) {
409
+ if (line.startsWith("event: ")) eventType = line.slice(7);
410
+ else if (line.startsWith("data: ")) data = line.slice(6);
411
+ }
412
+
413
+ switch (eventType) {
414
+ case "connected":
415
+ // Keepalive marker sent when the stream opens — no action needed.
416
+ process.stderr.write(`[viber-channel] SSE stream ready (connected event)\n`);
417
+ break;
418
+
419
+ case "stop":
420
+ process.stderr.write(`[viber-channel] Received stop signal, exiting.\n`);
421
+ releaseLock();
422
+ process.exit(0);
423
+ break;
424
+
425
+ case "transcription":
426
+ if (data) {
427
+ try {
428
+ const item = JSON.parse(data) as { text: string; language?: string };
429
+ if (item.text) {
430
+ await pushTranscript(item.text, item.language ?? "unknown");
431
+ }
432
+ } catch (err) {
433
+ process.stderr.write(`[viber-channel] Failed to parse transcription data: ${String(err)}\n`);
434
+ }
435
+ }
436
+ break;
437
+
438
+ case "message":
439
+ process.stderr.write(`[viber-channel] SSE event 'message' received, data_len=${data.length}\n`);
440
+ if (data) {
441
+ try {
442
+ const msg = JSON.parse(data) as { text: string; id?: string; source?: string; role?: string; timestamp?: string };
443
+ if (msg.text) {
444
+ await pushMessage(msg);
445
+ } else {
446
+ process.stderr.write(`[viber-channel] 'message' event has empty text\n`);
447
+ }
448
+ } catch (err) {
449
+ process.stderr.write(`[viber-channel] Failed to parse message data: ${String(err)}\n`);
450
+ }
451
+ }
452
+ break;
453
+
454
+ default:
455
+ if (eventType) {
456
+ process.stderr.write(`[viber-channel] Unknown SSE event type: "${eventType}", ignoring.\n`);
457
+ }
458
+ break;
459
+ }
460
+ }
461
+ }
462
+ }
463
+
464
+ /** Main loop: keep the SSE stream connected, reconnect on transient errors. */
465
+ async function mainLoop(): Promise<void> {
466
+ while (true) {
467
+ try {
468
+ await sseLoop();
469
+ // If SSE ends cleanly, reconnect immediately
470
+ process.stderr.write(`[viber-channel] SSE stream ended, reconnecting...\n`);
471
+ } catch (err) {
472
+ process.stderr.write(`[viber-channel] SSE failed: ${String(err)}, retrying in 2s...\n`);
473
+ await sleep(2000);
474
+ }
475
+ }
476
+ }
477
+
478
+ mainLoop();