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 +50 -0
- package/lib/auth.ts +71 -0
- package/lib/cfAccess.ts +21 -0
- package/lib/channel_errors.ts +23 -0
- package/lib/conversation.ts +86 -0
- package/lib/fingerprint.ts +47 -0
- package/lib/messages.ts +123 -0
- package/lib/urls.ts +12 -0
- package/package.json +39 -0
- package/viber-channel.ts +478 -0
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
|
+
}
|
package/lib/cfAccess.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/messages.ts
ADDED
|
@@ -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
|
+
}
|
package/viber-channel.ts
ADDED
|
@@ -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();
|