viber-channel 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/connect.ts +3 -2
- package/lib/lockfile.ts +25 -0
- package/lib/messages.ts +58 -1
- package/package.json +1 -1
- package/viber-channel.ts +110 -27
package/lib/connect.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { clientFingerprint } from "./fingerprint.js";
|
|
22
|
+
import { cfAccessHeaders } from "./cfAccess.js";
|
|
22
23
|
|
|
23
24
|
interface InitResponse {
|
|
24
25
|
confirm_url: string;
|
|
@@ -141,7 +142,7 @@ export async function runConnect(
|
|
|
141
142
|
|
|
142
143
|
const initResp = await fetch(`${baseUrl}/api/connect/${claimId}`, {
|
|
143
144
|
method: "POST",
|
|
144
|
-
headers: { "Content-Type": "application/json" },
|
|
145
|
+
headers: { "Content-Type": "application/json", ...cfAccessHeaders() },
|
|
145
146
|
body: JSON.stringify({ client_fingerprint: fingerprint }),
|
|
146
147
|
});
|
|
147
148
|
if (!initResp.ok) {
|
|
@@ -158,7 +159,7 @@ export async function runConnect(
|
|
|
158
159
|
const deadline = Date.now() + MAX_POLL_DURATION_MS;
|
|
159
160
|
while (Date.now() < deadline) {
|
|
160
161
|
await sleep(POLL_INTERVAL_MS);
|
|
161
|
-
const pollResp = await fetch(pollUrl);
|
|
162
|
+
const pollResp = await fetch(pollUrl, { headers: cfAccessHeaders() });
|
|
162
163
|
if (!pollResp.ok) {
|
|
163
164
|
const body = await pollResp.text().catch(() => "");
|
|
164
165
|
throw new Error(`Poll failed (HTTP ${pollResp.status}): ${body}`);
|
package/lib/lockfile.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lockfile.ts — derive the channel's lock file path from VIBER_BASE_URL.
|
|
3
|
+
*
|
|
4
|
+
* The lock prevents two instances of the channel from racing on stdin when
|
|
5
|
+
* Claude Code accidentally spawns duplicates. But two channels targeting
|
|
6
|
+
* *different* backends (e.g. staging + dev) are legitimately different
|
|
7
|
+
* processes — they shouldn't share a lock.
|
|
8
|
+
*
|
|
9
|
+
* Namespacing the lock by base URL lets them coexist.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Return the lock file path for a given base URL, under `lockDir`.
|
|
16
|
+
*
|
|
17
|
+
* The filename is `channel-{8-hex}.lock` where the hex is a stable SHA-256
|
|
18
|
+
* prefix of the base URL. Two callers with the same base URL get the same
|
|
19
|
+
* path (same process collision still detected); different base URLs get
|
|
20
|
+
* different paths (no false collision).
|
|
21
|
+
*/
|
|
22
|
+
export function lockFilePath(baseUrl: string, lockDir: string): string {
|
|
23
|
+
const suffix = createHash("sha256").update(baseUrl).digest("hex").slice(0, 8);
|
|
24
|
+
return join(lockDir, `channel-${suffix}.lock`);
|
|
25
|
+
}
|
package/lib/messages.ts
CHANGED
|
@@ -15,6 +15,62 @@ export class ConversationTokenExpiredError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export type ArtifactFormat = "markdown" | "code" | "json" | "html";
|
|
19
|
+
|
|
20
|
+
export interface Artifact {
|
|
21
|
+
content: string;
|
|
22
|
+
format?: ArtifactFormat;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ALLOWED_ARTIFACT_FORMATS: readonly ArtifactFormat[] = [
|
|
26
|
+
"markdown",
|
|
27
|
+
"code",
|
|
28
|
+
"json",
|
|
29
|
+
"html",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export type ParseArtifactResult =
|
|
33
|
+
| { ok: true; artifact: Artifact | undefined }
|
|
34
|
+
| { ok: false; error: string };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validate and normalize a raw `artifact` value coming from MCP tool input.
|
|
38
|
+
*
|
|
39
|
+
* - `undefined` / `null` → `{ ok: true, artifact: undefined }` (no artifact attached).
|
|
40
|
+
* - Anything else must be a plain object with a non-empty trimmed `content` string and,
|
|
41
|
+
* optionally, a `format` from the whitelist (`markdown` | `code` | `json` | `html`).
|
|
42
|
+
* An omitted `format` is normalized to `"markdown"`.
|
|
43
|
+
*
|
|
44
|
+
* Kept as a pure function so the CallTool handler in viber-channel.ts stays small
|
|
45
|
+
* and the validation rules are unit-testable on their own.
|
|
46
|
+
*/
|
|
47
|
+
export function parseArtifact(raw: unknown): ParseArtifactResult {
|
|
48
|
+
if (raw === undefined || raw === null) {
|
|
49
|
+
return { ok: true, artifact: undefined };
|
|
50
|
+
}
|
|
51
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
52
|
+
return { ok: false, error: "artifact must be an object" };
|
|
53
|
+
}
|
|
54
|
+
const rawArtifact = raw as Record<string, unknown>;
|
|
55
|
+
const content = rawArtifact.content;
|
|
56
|
+
if (typeof content !== "string" || content.trim().length === 0) {
|
|
57
|
+
return { ok: false, error: "artifact.content must be a non-empty string" };
|
|
58
|
+
}
|
|
59
|
+
if (rawArtifact.format === undefined) {
|
|
60
|
+
return { ok: true, artifact: { content, format: "markdown" } };
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
typeof rawArtifact.format !== "string" ||
|
|
64
|
+
!ALLOWED_ARTIFACT_FORMATS.includes(rawArtifact.format as ArtifactFormat)
|
|
65
|
+
) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: `artifact.format must be one of ${ALLOWED_ARTIFACT_FORMATS.join(", ")}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, artifact: { content, format: rawArtifact.format as ArtifactFormat } };
|
|
72
|
+
}
|
|
73
|
+
|
|
18
74
|
export interface MessagePostSuccess {
|
|
19
75
|
ok: true;
|
|
20
76
|
message_id: string;
|
|
@@ -40,6 +96,7 @@ export async function postMessage(
|
|
|
40
96
|
conversationId: string,
|
|
41
97
|
conversationToken: string,
|
|
42
98
|
text: string,
|
|
99
|
+
artifact?: Artifact,
|
|
43
100
|
): Promise<MessagePostResult> {
|
|
44
101
|
if (!text || text.trim().length === 0) {
|
|
45
102
|
return {
|
|
@@ -60,7 +117,7 @@ export async function postMessage(
|
|
|
60
117
|
"Content-Type": "application/json",
|
|
61
118
|
...cfAccessHeaders(),
|
|
62
119
|
},
|
|
63
|
-
body: JSON.stringify({ content: text }),
|
|
120
|
+
body: JSON.stringify(artifact ? { content: text, artifact } : { content: text }),
|
|
64
121
|
});
|
|
65
122
|
} catch (err) {
|
|
66
123
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viber-channel",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/viber-channel.ts
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* viber-channel MCP channel server (#217)
|
|
3
|
+
* viber-channel MCP channel server (#217, #232)
|
|
4
4
|
*
|
|
5
5
|
* Reads .viber/auth.json for authentication, then connects to
|
|
6
6
|
* /api/conversations/<id>/events SSE stream and pushes transcripts and
|
|
7
7
|
* messages as channel events.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Launch via Claude Code — two MCP server names are in use:
|
|
10
|
+
* - server:viber-channel — npm-published build (bunx viber-channel@latest),
|
|
11
|
+
* staging backend (viber.dgypx.dev), wired by
|
|
12
|
+
* external consumers via `claude mcp add`.
|
|
13
|
+
* - server:viber-dev-channel — local-source build, dev backend
|
|
14
|
+
* (viber-dev.dgypx.dev). Defined in this repo's
|
|
15
|
+
* committed .mcp.json (or override locally).
|
|
16
|
+
*
|
|
17
|
+
* claude --dangerously-load-development-channels server:<name>
|
|
18
|
+
*
|
|
19
|
+
* Two PowerShell launchers wrap the right flag:
|
|
20
|
+
* - viber.ps1 → server:viber-channel (staging)
|
|
21
|
+
* - viber-dev.ps1 → server:viber-dev-channel (this repo, dev)
|
|
22
|
+
*
|
|
23
|
+
* CLI subcommand: `bunx viber-channel connect <claim_url>` runs the one-shot
|
|
24
|
+
* claim flow that writes .viber/auth.json without requiring any Claude Code
|
|
25
|
+
* recipe in CLAUDE.md.
|
|
11
26
|
*/
|
|
12
27
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
13
28
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -15,6 +30,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprot
|
|
|
15
30
|
import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
16
31
|
import { join } from "node:path";
|
|
17
32
|
import { runConnect } from "./lib/connect.ts";
|
|
33
|
+
import { lockFilePath } from "./lib/lockfile.ts";
|
|
18
34
|
|
|
19
35
|
// ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
|
|
20
36
|
//
|
|
@@ -53,13 +69,16 @@ import { runConnect } from "./lib/connect.ts";
|
|
|
53
69
|
}
|
|
54
70
|
}
|
|
55
71
|
|
|
56
|
-
// Lock file path: %APPDATA%/viber/ (Windows) or ~/.config/viber/ (Linux/Mac)
|
|
72
|
+
// Lock file path: %APPDATA%/viber/ (Windows) or ~/.config/viber/ (Linux/Mac).
|
|
73
|
+
// Namespaced by VIBER_BASE_URL so channels targeting different backends
|
|
74
|
+
// (staging + dev) coexist on the same machine — only same-backend duplicates
|
|
75
|
+
// still collide, which is the intended guard.
|
|
57
76
|
const LOCK_DIR =
|
|
58
77
|
process.env.APPDATA
|
|
59
78
|
? join(process.env.APPDATA, "viber")
|
|
60
79
|
: join(process.env.HOME ?? "/tmp", ".config", "viber");
|
|
61
|
-
|
|
62
|
-
const LOCK_FILE =
|
|
80
|
+
const LOCK_BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
|
|
81
|
+
const LOCK_FILE = lockFilePath(LOCK_BASE_URL, LOCK_DIR);
|
|
63
82
|
|
|
64
83
|
function isProcessAlive(pid: number): boolean {
|
|
65
84
|
try {
|
|
@@ -130,16 +149,18 @@ process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
|
|
|
130
149
|
// the bun subprocess outlives Claude Code and leaks (the orphan that
|
|
131
150
|
// taunts us on every restart). The MCP transport uses stdin too, but
|
|
132
151
|
// it doesn't propagate close as a process exit, so we wire it ourselves.
|
|
152
|
+
//
|
|
153
|
+
// NOTE: we deliberately listen to `end` only, not `close`. On Windows/Bun,
|
|
154
|
+
// registering an MCP `StdioServerTransport` reader appears to trigger a
|
|
155
|
+
// spurious `close` event ~2 ms after the transport's read loop starts,
|
|
156
|
+
// which would kill the channel mid-mint before MCP traffic even begins.
|
|
157
|
+
// `end` fires on real EOF (parent closes write end) and is sufficient for
|
|
158
|
+
// orphan detection. See plan #236 step-09 for the diagnosis.
|
|
133
159
|
process.stdin.on("end", () => {
|
|
134
160
|
process.stderr.write("[viber-channel] stdin closed (parent exited), shutting down\n");
|
|
135
161
|
releaseLock();
|
|
136
162
|
process.exit(0);
|
|
137
163
|
});
|
|
138
|
-
process.stdin.on("close", () => {
|
|
139
|
-
process.stderr.write("[viber-channel] stdin close event, shutting down\n");
|
|
140
|
-
releaseLock();
|
|
141
|
-
process.exit(0);
|
|
142
|
-
});
|
|
143
164
|
|
|
144
165
|
// ---- Auth from .viber/auth.json ----
|
|
145
166
|
|
|
@@ -165,7 +186,7 @@ if (computedFp !== auth.client_fingerprint) {
|
|
|
165
186
|
// ---- Imports for mint ----
|
|
166
187
|
|
|
167
188
|
import { defaultLabel, mintConversation, ReverifyRequiredError } from "./lib/conversation.ts";
|
|
168
|
-
import { postMessage, ConversationTokenExpiredError } from "./lib/messages.ts";
|
|
189
|
+
import { postMessage, ConversationTokenExpiredError, parseArtifact } from "./lib/messages.ts";
|
|
169
190
|
import { cfAccessHeaders } from "./lib/cfAccess.ts";
|
|
170
191
|
|
|
171
192
|
const BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
|
|
@@ -181,10 +202,13 @@ const mcp = new Server(
|
|
|
181
202
|
tools: {},
|
|
182
203
|
},
|
|
183
204
|
instructions: [
|
|
184
|
-
'Voice transcripts arrive as <channel source="viber-channel"
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"
|
|
205
|
+
'Voice transcripts arrive as <channel source="viber-channel"> events carrying the user\'s microphone speech.',
|
|
206
|
+
"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.",
|
|
207
|
+
"Put technical content (code, lists, JSON, tables, command output, long file paths) in the optional `artifact` field with the matching `format`; it renders in a side viewer the user can read.",
|
|
208
|
+
"Available formats: `markdown` (default), `code`, `json`, `html`.",
|
|
209
|
+
"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.",
|
|
210
|
+
"Rule of thumb: if it would sound bad read aloud, it belongs in the artifact, not the text.",
|
|
211
|
+
"On exit intent (bye, au revoir, stop) call stop_conversation(), speak a brief farewell, and stop.",
|
|
188
212
|
].join(" "),
|
|
189
213
|
}
|
|
190
214
|
);
|
|
@@ -196,14 +220,37 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
196
220
|
{
|
|
197
221
|
name: "send_message",
|
|
198
222
|
description:
|
|
199
|
-
"Send a message into the Viber conversation this channel is bound to.
|
|
223
|
+
"Send a message into the Viber conversation this channel is bound to. " +
|
|
224
|
+
"`text` is the spoken/conversational reply (read aloud — keep it short and natural, no markdown). " +
|
|
225
|
+
"`artifact` is optional and carries technical content (markdown, code, JSON, or sanitized HTML) rendered in a side viewer. " +
|
|
226
|
+
"Use `html` only when structural HTML markdown can't express is required; scripts and event handlers are stripped before display.",
|
|
200
227
|
inputSchema: {
|
|
201
228
|
type: "object" as const,
|
|
202
229
|
properties: {
|
|
203
230
|
text: {
|
|
204
231
|
type: "string",
|
|
205
232
|
minLength: 1,
|
|
206
|
-
description: "
|
|
233
|
+
description: "Spoken/conversational reply. Plain sentences, no markdown symbols or lists.",
|
|
234
|
+
},
|
|
235
|
+
artifact: {
|
|
236
|
+
type: "object",
|
|
237
|
+
description: "Optional technical content rendered in the artifact viewer alongside the spoken text.",
|
|
238
|
+
properties: {
|
|
239
|
+
content: {
|
|
240
|
+
type: "string",
|
|
241
|
+
minLength: 1,
|
|
242
|
+
description: "Artifact body — markdown, code, JSON, or HTML.",
|
|
243
|
+
},
|
|
244
|
+
format: {
|
|
245
|
+
type: "string",
|
|
246
|
+
enum: ["markdown", "code", "json", "html"],
|
|
247
|
+
description:
|
|
248
|
+
"Render format for the artifact body. Defaults to markdown if omitted. " +
|
|
249
|
+
"Use `html` only when structural HTML is required (styled cards, badges, mixed layouts); " +
|
|
250
|
+
"scripts, event handlers, iframes, and inline styles are stripped server-side before rendering.",
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
required: ["content"],
|
|
207
254
|
},
|
|
208
255
|
},
|
|
209
256
|
required: ["text"],
|
|
@@ -232,9 +279,18 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
232
279
|
};
|
|
233
280
|
}
|
|
234
281
|
|
|
282
|
+
const parsed = parseArtifact(args.artifact);
|
|
283
|
+
if (!parsed.ok) {
|
|
284
|
+
return {
|
|
285
|
+
isError: true,
|
|
286
|
+
content: [{ type: "text" as const, text: `Invalid input: ${parsed.error}` }],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const artifact = parsed.artifact;
|
|
290
|
+
|
|
235
291
|
let result;
|
|
236
292
|
try {
|
|
237
|
-
result = await postMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, text);
|
|
293
|
+
result = await postMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, text, artifact);
|
|
238
294
|
} catch (err) {
|
|
239
295
|
if (err instanceof ConversationTokenExpiredError) {
|
|
240
296
|
// Notify Claude (and the user) that the channel token has expired
|
|
@@ -379,19 +435,36 @@ async function pushTranscript(text: string, lang: string): Promise<void> {
|
|
|
379
435
|
});
|
|
380
436
|
}
|
|
381
437
|
|
|
382
|
-
/**
|
|
383
|
-
|
|
384
|
-
|
|
438
|
+
/**
|
|
439
|
+
* Forward a saved conversation message from the event bus to Claude.
|
|
440
|
+
*
|
|
441
|
+
* Wire field name is `content` — the SSE payload mirrors the Worker's
|
|
442
|
+
* `MessageResponse` shape (viber-api/src/db/types.ts), republished verbatim by
|
|
443
|
+
* Python (`ws_server.internal_notify_message` → `publish_to_conversation`).
|
|
444
|
+
* The optional `artifact` field is accepted here and threaded through the meta
|
|
445
|
+
* so downstream consumers (step-03 React viewer) can opt-in without a second
|
|
446
|
+
* round-trip; the channel itself does no special rendering of it yet.
|
|
447
|
+
*/
|
|
448
|
+
async function pushMessage(msg: {
|
|
449
|
+
content: string;
|
|
450
|
+
id?: string | null;
|
|
451
|
+
source?: string;
|
|
452
|
+
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
453
|
+
}): Promise<void> {
|
|
454
|
+
process.stderr.write(`[viber-channel] pushMessage ENTER: "${msg.content.slice(0, 60)}"\n`);
|
|
385
455
|
// MCP notification handler (Claude Code v2.1.143) validates meta with Zod;
|
|
386
456
|
// null fields are rejected as invalid type. Omit message_id when missing.
|
|
387
|
-
const meta: Record<string,
|
|
457
|
+
const meta: Record<string, unknown> = { source: msg.source ?? "conversation" };
|
|
388
458
|
if (typeof msg.id === "string" && msg.id.length > 0) {
|
|
389
459
|
meta.message_id = msg.id;
|
|
390
460
|
}
|
|
461
|
+
if (msg.artifact && typeof msg.artifact === "object" && typeof msg.artifact.content === "string") {
|
|
462
|
+
meta.artifact = msg.artifact;
|
|
463
|
+
}
|
|
391
464
|
try {
|
|
392
465
|
await mcp.notification({
|
|
393
466
|
method: "notifications/claude/channel",
|
|
394
|
-
params: { content: msg.
|
|
467
|
+
params: { content: msg.content, meta },
|
|
395
468
|
});
|
|
396
469
|
process.stderr.write(`[viber-channel] pushMessage OK\n`);
|
|
397
470
|
} catch (err) {
|
|
@@ -477,11 +550,21 @@ async function sseLoop(): Promise<void> {
|
|
|
477
550
|
process.stderr.write(`[viber-channel] SSE event 'message' received, data_len=${data.length}\n`);
|
|
478
551
|
if (data) {
|
|
479
552
|
try {
|
|
480
|
-
|
|
481
|
-
|
|
553
|
+
// Wire shape mirrors viber-api `MessageResponse` (db/types.ts):
|
|
554
|
+
// primary text is `content`, not `text`. `artifact` is threaded
|
|
555
|
+
// through opaquely so step-03 (React viewer) can consume it.
|
|
556
|
+
const msg = JSON.parse(data) as {
|
|
557
|
+
content: string;
|
|
558
|
+
id?: string;
|
|
559
|
+
source?: string;
|
|
560
|
+
role?: string;
|
|
561
|
+
timestamp?: string;
|
|
562
|
+
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
563
|
+
};
|
|
564
|
+
if (msg.content) {
|
|
482
565
|
await pushMessage(msg);
|
|
483
566
|
} else {
|
|
484
|
-
process.stderr.write(`[viber-channel] 'message' event has empty
|
|
567
|
+
process.stderr.write(`[viber-channel] 'message' event has empty content\n`);
|
|
485
568
|
}
|
|
486
569
|
} catch (err) {
|
|
487
570
|
process.stderr.write(`[viber-channel] Failed to parse message data: ${String(err)}\n`);
|