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 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}`);
@@ -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.0",
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
- * Start Claude with:
10
- * claude --dangerously-load-development-channels server:viber-channel
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
- // TODO: rename to channel.lock once all installations have transitioned off the old name
62
- const LOCK_FILE = join(LOCK_DIR, "watchdog.lock");
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
- "Each event contains transcriptions from the user's microphone.",
186
- "Process the transcript and respond with speak(). Do NOT launch any watchdog or call check_voice().",
187
- "On exit intent (bye, au revoir, stop) → call stop_conversation(), speak farewell, stop.",
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. The user sees it in the web UI in real-time. Use this to deliver Claude's responses to the user without typing.",
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: "Message text to send to the user's conversation",
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
- /** Forward a saved conversation message from the event bus to Claude. */
383
- async function pushMessage(msg: { text: string; id?: string | null; source?: string }): Promise<void> {
384
- process.stderr.write(`[viber-channel] pushMessage ENTER: "${msg.text.slice(0, 60)}"\n`);
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, string> = { source: msg.source ?? "conversation" };
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.text, meta },
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
- const msg = JSON.parse(data) as { text: string; id?: string; source?: string; role?: string; timestamp?: string };
481
- if (msg.text) {
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 text\n`);
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`);