viber-channel 0.4.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 +190 -0
- package/lib/lockfile.ts +25 -0
- package/lib/messages.ts +58 -1
- package/package.json +1 -1
- package/viber-channel.ts +149 -28
package/lib/connect.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connect.ts — one-shot claim flow that turns a Viber connection URL into
|
|
3
|
+
* `.viber/auth.json` so the channel can be started.
|
|
4
|
+
*
|
|
5
|
+
* Invoked from the CLI as:
|
|
6
|
+
* bunx viber-channel connect https://viber.dgypx.dev/connect/<claim_id>
|
|
7
|
+
*
|
|
8
|
+
* Pure logic: takes a URL + a target folder, talks to the Worker, writes the
|
|
9
|
+
* three files (`.viber/auth.json`, `.viber/readme.md`, `.gitignore` entry).
|
|
10
|
+
* No MCP, no SSE.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
appendFileSync,
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { clientFingerprint } from "./fingerprint.js";
|
|
22
|
+
import { cfAccessHeaders } from "./cfAccess.js";
|
|
23
|
+
|
|
24
|
+
interface InitResponse {
|
|
25
|
+
confirm_url: string;
|
|
26
|
+
wait_token: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ConsumedResponse {
|
|
30
|
+
status: "consumed";
|
|
31
|
+
project_id: number;
|
|
32
|
+
project_name: string;
|
|
33
|
+
user_email: string;
|
|
34
|
+
project_token: string;
|
|
35
|
+
issued_at: string;
|
|
36
|
+
rotates_after?: string;
|
|
37
|
+
target_conversation_id?: string | null;
|
|
38
|
+
readme_md: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface PendingResponse {
|
|
42
|
+
status: "pending";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface TerminalResponse {
|
|
46
|
+
status: "rejected" | "expired";
|
|
47
|
+
reason?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type PollResponse = ConsumedResponse | PendingResponse | TerminalResponse;
|
|
51
|
+
|
|
52
|
+
const POLL_INTERVAL_MS = 2000;
|
|
53
|
+
const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
|
|
54
|
+
|
|
55
|
+
function parseClaimUrl(input: string): { baseUrl: string; claimId: string } {
|
|
56
|
+
let url: URL;
|
|
57
|
+
try {
|
|
58
|
+
url = new URL(input);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new Error(`Invalid URL: ${input}`);
|
|
61
|
+
}
|
|
62
|
+
const match = url.pathname.match(/^\/connect\/([a-f0-9-]+)\/?$/i);
|
|
63
|
+
if (!match) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Not a Viber claim URL. Expected format: https://<host>/connect/<claim_id>`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return { baseUrl: `${url.protocol}//${url.host}`, claimId: match[1] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sleep(ms: number): Promise<void> {
|
|
72
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeAuthJson(
|
|
76
|
+
cwd: string,
|
|
77
|
+
data: ConsumedResponse,
|
|
78
|
+
fingerprint: string,
|
|
79
|
+
): void {
|
|
80
|
+
const viberDir = join(cwd, ".viber");
|
|
81
|
+
mkdirSync(viberDir, { recursive: true });
|
|
82
|
+
|
|
83
|
+
const auth: Record<string, unknown> = {
|
|
84
|
+
schema_version: 1,
|
|
85
|
+
project_id: data.project_id,
|
|
86
|
+
project_name: data.project_name,
|
|
87
|
+
user_email: data.user_email,
|
|
88
|
+
project_token: data.project_token,
|
|
89
|
+
client_fingerprint: fingerprint,
|
|
90
|
+
issued_at: data.issued_at,
|
|
91
|
+
rotates_after: data.rotates_after,
|
|
92
|
+
};
|
|
93
|
+
// Only emit target_conversation_id when the server explicitly returned a
|
|
94
|
+
// non-null value (attach-to-project flow). Absent / null → omit to keep
|
|
95
|
+
// the file minimal for the common "fresh standalone conversation" case.
|
|
96
|
+
if (
|
|
97
|
+
data.target_conversation_id !== undefined &&
|
|
98
|
+
data.target_conversation_id !== null
|
|
99
|
+
) {
|
|
100
|
+
auth.target_conversation_id = data.target_conversation_id;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
writeFileSync(
|
|
104
|
+
join(viberDir, "auth.json"),
|
|
105
|
+
JSON.stringify(auth, null, 2),
|
|
106
|
+
"utf-8",
|
|
107
|
+
);
|
|
108
|
+
writeFileSync(join(viberDir, "readme.md"), data.readme_md, "utf-8");
|
|
109
|
+
|
|
110
|
+
// Append `.viber/` to .gitignore if not already present.
|
|
111
|
+
const gitignorePath = join(cwd, ".gitignore");
|
|
112
|
+
let current = "";
|
|
113
|
+
if (existsSync(gitignorePath)) {
|
|
114
|
+
current = readFileSync(gitignorePath, "utf-8");
|
|
115
|
+
}
|
|
116
|
+
const alreadyIgnored = current
|
|
117
|
+
.split(/\r?\n/)
|
|
118
|
+
.some((line) => {
|
|
119
|
+
const trimmed = line.trim();
|
|
120
|
+
return trimmed === ".viber/" || trimmed === ".viber";
|
|
121
|
+
});
|
|
122
|
+
if (!alreadyIgnored) {
|
|
123
|
+
const prefix = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
124
|
+
appendFileSync(gitignorePath, `${prefix}.viber/\n`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Run the claim flow. Writes .viber/auth.json + .viber/readme.md + .gitignore
|
|
130
|
+
* entry on success. Exits the process on any terminal outcome.
|
|
131
|
+
*/
|
|
132
|
+
export async function runConnect(
|
|
133
|
+
claimUrl: string,
|
|
134
|
+
cwd: string = process.cwd(),
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
const { baseUrl, claimId } = parseClaimUrl(claimUrl);
|
|
137
|
+
|
|
138
|
+
const fingerprint = clientFingerprint(cwd);
|
|
139
|
+
process.stderr.write(
|
|
140
|
+
`[viber-channel] fingerprint=${fingerprint.slice(0, 8)}…\n`,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const initResp = await fetch(`${baseUrl}/api/connect/${claimId}`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { "Content-Type": "application/json", ...cfAccessHeaders() },
|
|
146
|
+
body: JSON.stringify({ client_fingerprint: fingerprint }),
|
|
147
|
+
});
|
|
148
|
+
if (!initResp.ok) {
|
|
149
|
+
const body = await initResp.text().catch(() => "");
|
|
150
|
+
throw new Error(`Claim init failed (HTTP ${initResp.status}): ${body}`);
|
|
151
|
+
}
|
|
152
|
+
const init = (await initResp.json()) as InitResponse;
|
|
153
|
+
process.stdout.write(
|
|
154
|
+
`\nConfirm this connection in your browser:\n ${init.confirm_url}\n\n`,
|
|
155
|
+
);
|
|
156
|
+
process.stdout.write(`Polling for confirmation (up to 10 minutes)…\n`);
|
|
157
|
+
|
|
158
|
+
const pollUrl = `${baseUrl}/api/connect/${claimId}?wait_token=${encodeURIComponent(init.wait_token)}`;
|
|
159
|
+
const deadline = Date.now() + MAX_POLL_DURATION_MS;
|
|
160
|
+
while (Date.now() < deadline) {
|
|
161
|
+
await sleep(POLL_INTERVAL_MS);
|
|
162
|
+
const pollResp = await fetch(pollUrl, { headers: cfAccessHeaders() });
|
|
163
|
+
if (!pollResp.ok) {
|
|
164
|
+
const body = await pollResp.text().catch(() => "");
|
|
165
|
+
throw new Error(`Poll failed (HTTP ${pollResp.status}): ${body}`);
|
|
166
|
+
}
|
|
167
|
+
const data = (await pollResp.json()) as PollResponse;
|
|
168
|
+
if (data.status === "consumed") {
|
|
169
|
+
writeAuthJson(cwd, data, fingerprint);
|
|
170
|
+
process.stdout.write(
|
|
171
|
+
`\n✓ Connected as ${data.user_email} to project '${data.project_name}'.\n` +
|
|
172
|
+
` Wrote ${join(cwd, ".viber", "auth.json")}\n\n` +
|
|
173
|
+
`Next steps:\n` +
|
|
174
|
+
` 1. Register the channel MCP server (one-time per machine):\n` +
|
|
175
|
+
` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +
|
|
176
|
+
` 2. Restart Claude Code in this directory.\n\n`,
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (data.status === "rejected" || data.status === "expired") {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Connection ${data.status}${data.reason ? `: ${data.reason}` : ""}. Get a fresh URL from ${baseUrl}/projects.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
// "pending" — keep polling
|
|
186
|
+
}
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Confirmation timed out after ${MAX_POLL_DURATION_MS / 60_000} minutes. Get a fresh URL from ${baseUrl}/projects.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
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.
|
|
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,27 +1,84 @@
|
|
|
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";
|
|
14
29
|
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
15
30
|
import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
16
31
|
import { join } from "node:path";
|
|
32
|
+
import { runConnect } from "./lib/connect.ts";
|
|
33
|
+
import { lockFilePath } from "./lib/lockfile.ts";
|
|
34
|
+
|
|
35
|
+
// ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
|
|
36
|
+
//
|
|
37
|
+
// `bunx viber-channel connect <claim_url>` runs the one-shot claim flow and
|
|
38
|
+
// exits — it does not start the MCP server. Any other invocation (no args, or
|
|
39
|
+
// the unknown subcommand) falls through to the MCP server below.
|
|
40
|
+
{
|
|
41
|
+
const sub = process.argv[2];
|
|
42
|
+
if (sub === "--help" || sub === "-h") {
|
|
43
|
+
process.stdout.write(
|
|
44
|
+
`viber-channel — voice + text MCP channel for Claude Code\n\n` +
|
|
45
|
+
`USAGE\n` +
|
|
46
|
+
` bunx viber-channel connect <claim_url> one-shot auth flow; writes .viber/auth.json\n` +
|
|
47
|
+
` bunx viber-channel start the MCP server (used by Claude Code)\n\n` +
|
|
48
|
+
`Get <claim_url> from https://viber.dgypx.dev/projects → Create connection.\n`,
|
|
49
|
+
);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
if (sub === "connect") {
|
|
53
|
+
const claimUrl = process.argv[3];
|
|
54
|
+
if (!claimUrl) {
|
|
55
|
+
process.stderr.write(
|
|
56
|
+
`[viber-channel] Missing claim URL.\n` +
|
|
57
|
+
`Usage: bunx viber-channel connect <claim_url>\n` +
|
|
58
|
+
`Get the URL from https://viber.dgypx.dev/projects → Create connection.\n`,
|
|
59
|
+
);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await runConnect(claimUrl);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
process.stderr.write(`[viber-channel] ${err instanceof Error ? err.message : String(err)}\n`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
17
71
|
|
|
18
|
-
// 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.
|
|
19
76
|
const LOCK_DIR =
|
|
20
77
|
process.env.APPDATA
|
|
21
78
|
? join(process.env.APPDATA, "viber")
|
|
22
79
|
: join(process.env.HOME ?? "/tmp", ".config", "viber");
|
|
23
|
-
|
|
24
|
-
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);
|
|
25
82
|
|
|
26
83
|
function isProcessAlive(pid: number): boolean {
|
|
27
84
|
try {
|
|
@@ -92,16 +149,18 @@ process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
|
|
|
92
149
|
// the bun subprocess outlives Claude Code and leaks (the orphan that
|
|
93
150
|
// taunts us on every restart). The MCP transport uses stdin too, but
|
|
94
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.
|
|
95
159
|
process.stdin.on("end", () => {
|
|
96
160
|
process.stderr.write("[viber-channel] stdin closed (parent exited), shutting down\n");
|
|
97
161
|
releaseLock();
|
|
98
162
|
process.exit(0);
|
|
99
163
|
});
|
|
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
164
|
|
|
106
165
|
// ---- Auth from .viber/auth.json ----
|
|
107
166
|
|
|
@@ -127,7 +186,7 @@ if (computedFp !== auth.client_fingerprint) {
|
|
|
127
186
|
// ---- Imports for mint ----
|
|
128
187
|
|
|
129
188
|
import { defaultLabel, mintConversation, ReverifyRequiredError } from "./lib/conversation.ts";
|
|
130
|
-
import { postMessage, ConversationTokenExpiredError } from "./lib/messages.ts";
|
|
189
|
+
import { postMessage, ConversationTokenExpiredError, parseArtifact } from "./lib/messages.ts";
|
|
131
190
|
import { cfAccessHeaders } from "./lib/cfAccess.ts";
|
|
132
191
|
|
|
133
192
|
const BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
|
|
@@ -136,17 +195,20 @@ const label = process.env.VIBER_CONVERSATION_LABEL ?? defaultLabel(process.cwd()
|
|
|
136
195
|
// ---- MCP server ----
|
|
137
196
|
|
|
138
197
|
const mcp = new Server(
|
|
139
|
-
{ name: "viber-channel", version: "0.
|
|
198
|
+
{ name: "viber-channel", version: "0.5.0" },
|
|
140
199
|
{
|
|
141
200
|
capabilities: {
|
|
142
201
|
experimental: { "claude/channel": {} },
|
|
143
202
|
tools: {},
|
|
144
203
|
},
|
|
145
204
|
instructions: [
|
|
146
|
-
'Voice transcripts arrive as <channel source="viber-channel"
|
|
147
|
-
"
|
|
148
|
-
"
|
|
149
|
-
"
|
|
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.",
|
|
150
212
|
].join(" "),
|
|
151
213
|
}
|
|
152
214
|
);
|
|
@@ -158,14 +220,37 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
158
220
|
{
|
|
159
221
|
name: "send_message",
|
|
160
222
|
description:
|
|
161
|
-
"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.",
|
|
162
227
|
inputSchema: {
|
|
163
228
|
type: "object" as const,
|
|
164
229
|
properties: {
|
|
165
230
|
text: {
|
|
166
231
|
type: "string",
|
|
167
232
|
minLength: 1,
|
|
168
|
-
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"],
|
|
169
254
|
},
|
|
170
255
|
},
|
|
171
256
|
required: ["text"],
|
|
@@ -194,9 +279,18 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
194
279
|
};
|
|
195
280
|
}
|
|
196
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
|
+
|
|
197
291
|
let result;
|
|
198
292
|
try {
|
|
199
|
-
result = await postMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, text);
|
|
293
|
+
result = await postMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, text, artifact);
|
|
200
294
|
} catch (err) {
|
|
201
295
|
if (err instanceof ConversationTokenExpiredError) {
|
|
202
296
|
// Notify Claude (and the user) that the channel token has expired
|
|
@@ -341,19 +435,36 @@ async function pushTranscript(text: string, lang: string): Promise<void> {
|
|
|
341
435
|
});
|
|
342
436
|
}
|
|
343
437
|
|
|
344
|
-
/**
|
|
345
|
-
|
|
346
|
-
|
|
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`);
|
|
347
455
|
// MCP notification handler (Claude Code v2.1.143) validates meta with Zod;
|
|
348
456
|
// null fields are rejected as invalid type. Omit message_id when missing.
|
|
349
|
-
const meta: Record<string,
|
|
457
|
+
const meta: Record<string, unknown> = { source: msg.source ?? "conversation" };
|
|
350
458
|
if (typeof msg.id === "string" && msg.id.length > 0) {
|
|
351
459
|
meta.message_id = msg.id;
|
|
352
460
|
}
|
|
461
|
+
if (msg.artifact && typeof msg.artifact === "object" && typeof msg.artifact.content === "string") {
|
|
462
|
+
meta.artifact = msg.artifact;
|
|
463
|
+
}
|
|
353
464
|
try {
|
|
354
465
|
await mcp.notification({
|
|
355
466
|
method: "notifications/claude/channel",
|
|
356
|
-
params: { content: msg.
|
|
467
|
+
params: { content: msg.content, meta },
|
|
357
468
|
});
|
|
358
469
|
process.stderr.write(`[viber-channel] pushMessage OK\n`);
|
|
359
470
|
} catch (err) {
|
|
@@ -439,11 +550,21 @@ async function sseLoop(): Promise<void> {
|
|
|
439
550
|
process.stderr.write(`[viber-channel] SSE event 'message' received, data_len=${data.length}\n`);
|
|
440
551
|
if (data) {
|
|
441
552
|
try {
|
|
442
|
-
|
|
443
|
-
|
|
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) {
|
|
444
565
|
await pushMessage(msg);
|
|
445
566
|
} else {
|
|
446
|
-
process.stderr.write(`[viber-channel] 'message' event has empty
|
|
567
|
+
process.stderr.write(`[viber-channel] 'message' event has empty content\n`);
|
|
447
568
|
}
|
|
448
569
|
} catch (err) {
|
|
449
570
|
process.stderr.write(`[viber-channel] Failed to parse message data: ${String(err)}\n`);
|