From 4a1897bac91f6dc6a40f3177a375d4d5ed3357a1 Mon Sep 17 00:00:00 2001 From: Stefano Date: Mon, 23 Feb 2026 21:56:45 +0000 Subject: [PATCH 1/6] Fix streaming, add setup and fix client script - Wire onPartialReply in dispatchReplyWithBufferedBlockDispatcher to enable preview streaming: tokens now arrive progressively instead of all at once - Pre-create placeholder message before dispatch (onPartialReply is fire-and-forget) - Replace all hand-rolled loadEnv() functions with dotenv library - Add .env.example with placeholder values (safe to commit) - Add setup-app.ts for provisioning a new Stream Chat app - Fix test-client.ts streaming display: track lastSeenText per message to print only delta characters instead of repeating cumulative text - Print message IDs in test-client.ts for easier thread/quote testing - Update CLAUDE.md with accurate inbound flow and key design decisions Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 16 +++++ .gitignore | 1 + CLAUDE.md | 21 ++++-- package-lock.json | 2 +- package.json | 1 + scripts/discover-channels.ts | 38 +++-------- scripts/generate-bot-token.ts | 30 ++------ scripts/setup-app.ts | 124 ++++++++++++++++++++++++++++++++++ scripts/test-client.ts | 54 ++++++--------- scripts/test-roundtrip.ts | 15 ++-- scripts/test-thread.ts | 15 ++-- src/channel.ts | 73 ++++++++++---------- 12 files changed, 250 insertions(+), 140 deletions(-) create mode 100644 .env.example create mode 100644 scripts/setup-app.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4525fb2 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Stream Chat app credentials +# Get these from https://dashboard.getstream.io +STREAM_API_KEY=your_stream_api_key + +# Bot and test client credentials +# Run `npx tsx scripts/setup-app.ts` to populate these automatically, +# or generate a token manually with: +# STREAM_API_SECRET=your_secret npx tsx scripts/generate-bot-token.ts +BOT_USER_ID=openclaw-bot +TEST_USER_ID=test-user +TEST_USER_TOKEN=your_test_user_jwt_token +TEST_CHANNEL_ID=ai-test-channel + +# Note: STREAM_API_SECRET is only needed when running setup scripts. +# Pass it on the command line rather than storing it here: +# STREAM_API_SECRET=your_secret npx tsx scripts/setup-app.ts diff --git a/.gitignore b/.gitignore index 77dbd76..66d6847 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ *.d.ts.map !scripts/*.ts .env +!.env.example diff --git a/CLAUDE.md b/CLAUDE.md index 3ed4d01..e79b6d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,14 +67,20 @@ message.new (WebSocket) → buildEnvelope (wraps text with thread/reply context tags) → finalizeInboundContext → recordInboundSession + → onRunStarted (pre-creates placeholder + THINKING indicator) → dispatchReplyWithBufferedBlockDispatcher - deliver(payload, info) called per block: + replyOptions.onPartialReply fires per streaming token (cumulative text): + delta = full.slice(lastPartialText.length) → onTextChunk (throttled partialUpdateMessage) + deliver(payload, info) called once per complete block: info.kind === "tool" → onRunProgress (EXTERNAL_SOURCES indicator) - text chunk → onRunStarted (first) + onTextChunk + payload.isError → onRunError (error text + ERROR indicator) + text block → no-op (already handled token-by-token above) after dispatcher returns: → onRunCompleted (final partialUpdateMessage + ai_indicator.clear) ``` +**Why pre-create the placeholder:** `onPartialReply` is called fire-and-forget (`void`) by OpenClaw, so it cannot safely do async work (like `channel.sendMessage`). The placeholder must exist before the first token arrives. + The `ai_generated: true` check is critical — without it the bot would trigger on its own empty placeholder message created by `onRunStarted`, causing an infinite loop. ### Event mapping @@ -84,11 +90,11 @@ How each signal from the OpenClaw pipeline translates into Stream Chat API calls | Trigger | Stream Chat action | Notes | |---|---|---| | Inbound message received | `channel.sendReaction(msgId, { type: "eyes" })` | Ack reaction, fire-and-forget | -| First `deliver` text block | `channel.sendMessage({ text: "", ai_generated: true })` | Creates the bot's placeholder message | -| First `deliver` text block | `channel.sendEvent({ type: "ai_indicator.update", ai_state: "AI_STATE_THINKING" })` | Immediately followed by GENERATING below | -| First text chunk processed | `channel.sendEvent({ type: "ai_indicator.update", ai_state: "AI_STATE_GENERATING" })` | Transitions from THINKING on the very first `onTextChunk` call | -| Text chunk — throttled flush | `client.partialUpdateMessage(msgId, { set: { text, generating: true } })` | Odd chunks 1,3,5,7; then every N (default 15). Chained via `lastUpdatePromise` to avoid out-of-order updates | -| `deliver` with `info.kind === "tool"` | `channel.sendEvent({ type: "ai_indicator.update", ai_state: "AI_STATE_EXTERNAL_SOURCES" })` | Only emitted once per run (de-duplicated by `indicatorState`); only if streaming has already started | +| Pre-dispatch (before agent runs) | `channel.sendMessage({ text: "", ai_generated: true })` | Creates the bot's placeholder message | +| Pre-dispatch (before agent runs) | `channel.sendEvent({ type: "ai_indicator.update", ai_state: "AI_STATE_THINKING" })` | Sent immediately with placeholder | +| `onPartialReply` first token | `channel.sendEvent({ type: "ai_indicator.update", ai_state: "AI_STATE_GENERATING" })` | Transitions from THINKING on the very first token | +| `onPartialReply` per token — throttled | `client.partialUpdateMessage(msgId, { set: { text, generating: true } })` | Delta-computed from cumulative text. Odd chunks 1,3,5,7; then every N (default 15). Chained via `lastUpdatePromise` to avoid out-of-order updates | +| `deliver` with `info.kind === "tool"` | `channel.sendEvent({ type: "ai_indicator.update", ai_state: "AI_STATE_EXTERNAL_SOURCES" })` | Only emitted once per run (de-duplicated by `indicatorState`) | | Dispatcher resolves (run complete) | `client.partialUpdateMessage(msgId, { set: { text, generating: false } })` | Final flush, waits for any in-flight partial updates first | | Dispatcher resolves (run complete) | `channel.sendEvent({ type: "ai_indicator.clear" })` | Clears the indicator bubble | | Dispatcher resolves (run complete) | `channel.deleteReaction(inboundMsgId, "eyes")` → `channel.sendReaction(inboundMsgId, { type: "white_check_mark" })` | Reaction swap on the original user message | @@ -145,3 +151,4 @@ Config supports a flat default account or named sub-accounts: - **Partial updates are chained via `lastUpdatePromise`.** Each `partialUpdateMessage` is `.then()`-chained onto the previous one to avoid out-of-order message text. - **`safeSendEvent` swallows errors.** Indicator events are best-effort; a failed `ai_indicator` update must not abort message delivery. Retries: 5 attempts, exponential backoff starting at 100 ms, only on 429/5xx. - **`seenThreads` is process-scoped.** The `Set` tracking "first message in thread" lives at module level, so it persists across gateway reloads until the process restarts. This is intentional — it avoids re-sending parent context for active threads after a config reload. +- **`onTextChunk` receives deltas despite the wire protocol using full text.** `onPartialReply` provides cumulative text; `channel.ts` extracts the delta before calling `onTextChunk`. Inside `StreamingHandler`, `onTextChunk` re-accumulates deltas into `accumulatedText` and passes that full string to `partialUpdateMessage`. The round-trip is: cumulative → delta → cumulative. The delta extraction exists because `StreamingHandler` was designed around the "streaming chunks" mental model — it owns the accumulation and the throttle counter, making that API feel natural. The redundancy is intentional for architectural clarity, not a bug. diff --git a/package-lock.json b/package-lock.json index 6e0f403..6b22c1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "dotenv": "^17.3.1", "typescript": "^5.3.0" }, "peerDependencies": { @@ -5605,7 +5606,6 @@ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 1ca5916..d1607ab 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "dotenv": "^17.3.1", "typescript": "^5.3.0" }, "peerDependencies": { diff --git a/scripts/discover-channels.ts b/scripts/discover-channels.ts index 6a9a80f..85af19a 100644 --- a/scripts/discover-channels.ts +++ b/scripts/discover-channels.ts @@ -3,38 +3,22 @@ * Discover channels that a user belongs to. * * Usage: - * STREAM_API_KEY=... USER_ID=steookk USER_TOKEN=... npx tsx scripts/discover-channels.ts + * npx tsx scripts/discover-channels.ts + * + * Requires a .env file at the project root (see .env.example). */ +import "dotenv/config"; import { StreamChat } from "stream-chat"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -function loadEnv(): void { - try { - const envPath = resolve(import.meta.dirname ?? ".", "../.env"); - const content = readFileSync(envPath, "utf-8"); - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eqIdx = trimmed.indexOf("="); - if (eqIdx === -1) continue; - const key = trimmed.slice(0, eqIdx).trim(); - const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, ""); - if (!process.env[key]) process.env[key] = value; - } - } catch { - // .env file not found - } -} +const apiKey = process.env.STREAM_API_KEY; +const userId = process.env.TEST_USER_ID; +const userToken = process.env.TEST_USER_TOKEN; -loadEnv(); - -const apiKey = process.env.STREAM_API_KEY || "b3haysfrr5yg"; -const userId = process.env.USER_ID || "steookk"; -const userToken = - process.env.USER_TOKEN || - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic3Rlb29rayJ9.9yO--MWVC9bYQAjdUR5vp_cKxiBXEzHrXXnPXesqakE"; +if (!apiKey || !userId || !userToken) { + console.error("Error: STREAM_API_KEY, TEST_USER_ID, and TEST_USER_TOKEN must be set in .env"); + process.exit(1); +} const client = new StreamChat(apiKey, { allowServerSideConnect: true }); diff --git a/scripts/generate-bot-token.ts b/scripts/generate-bot-token.ts index fb628e1..577345a 100644 --- a/scripts/generate-bot-token.ts +++ b/scripts/generate-bot-token.ts @@ -12,34 +12,12 @@ * channels.streamchat.botUserToken */ +import "dotenv/config"; import { StreamChat } from "stream-chat"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -// Try loading .env manually (no dotenv dependency needed) -function loadEnv(): void { - try { - const envPath = resolve(import.meta.dirname ?? ".", "../.env"); - const content = readFileSync(envPath, "utf-8"); - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eqIdx = trimmed.indexOf("="); - if (eqIdx === -1) continue; - const key = trimmed.slice(0, eqIdx).trim(); - const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, ""); - if (!process.env[key]) process.env[key] = value; - } - } catch { - // .env file not found, that's fine - } -} - -loadEnv(); - -const apiKey = process.env.STREAM_API_KEY || process.env.STREAM_CHAT_API_KEY; -const apiSecret = process.env.STREAM_API_SECRET || process.env.STREAM_CHAT_API_SECRET; -const botUserId = process.env.BOT_USER_ID || "chatgpt"; +const apiKey = process.env.STREAM_API_KEY; +const apiSecret = process.env.STREAM_API_SECRET; +const botUserId = process.env.BOT_USER_ID || "openclaw-bot"; if (!apiKey || !apiSecret) { console.error( diff --git a/scripts/setup-app.ts b/scripts/setup-app.ts new file mode 100644 index 0000000..ecfa637 --- /dev/null +++ b/scripts/setup-app.ts @@ -0,0 +1,124 @@ +#!/usr/bin/env npx tsx +/** + * One-shot setup script for a fresh Stream Chat app. + * + * What it does: + * 1. Creates/upserts a bot user and a test user via the server API + * 2. Generates permanent tokens for both + * 3. Creates a messaging channel with both as members + * 4. Updates ~/.openclaw/openclaw.json with the new bot credentials + * 5. Writes a .env file at the project root + * + * Usage: + * STREAM_API_SECRET=your_secret npx tsx scripts/setup-app.ts + * + * STREAM_API_KEY can be in .env or passed on the command line. + */ + +import "dotenv/config"; +import { StreamChat } from "stream-chat"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const SCRIPTS_DIR = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = join(SCRIPTS_DIR, ".."); +const OPENCLAW_CONFIG = join(homedir(), ".openclaw/openclaw.json"); + +// ── App credentials ────────────────────────────────────────────────────────── +const API_KEY = process.env.STREAM_API_KEY; +const API_SECRET = process.env.STREAM_API_SECRET; + +if (!API_KEY || !API_SECRET) { + console.error("Error: STREAM_API_KEY and STREAM_API_SECRET must be set"); + process.exit(1); +} + +// ── User IDs ───────────────────────────────────────────────────────────────── +const BOT_USER_ID = process.env.BOT_USER_ID || "openclaw-bot"; +const BOT_USER_NAME = "OpenClaw Bot"; +const TEST_USER_ID = process.env.TEST_USER_ID || "test-user"; +const TEST_USER_NAME = "Test User"; + +// ── Channel ─────────────────────────────────────────────────────────────────── +const CHANNEL_TYPE = "messaging"; +const CHANNEL_ID = "ai-test-channel"; +const CHANNEL_NAME = "AI Test Channel"; + +// ───────────────────────────────────────────────────────────────────────────── + +console.log("=== Stream Chat App Setup ===\n"); + +// Step 1: Server-side client (needs secret for admin operations) +console.log(`Connecting to app ${API_KEY} as server...`); +const server = new StreamChat(API_KEY, API_SECRET); + +// Step 2: Create/upsert users +console.log(`\nUpserting users...`); +await server.upsertUsers([ + { id: BOT_USER_ID, name: BOT_USER_NAME, role: "admin" }, + { id: TEST_USER_ID, name: TEST_USER_NAME }, +]); +console.log(` ✓ ${BOT_USER_ID} (bot)`); +console.log(` ✓ ${TEST_USER_ID} (client)`); + +// Step 3: Generate tokens +const botToken = server.createToken(BOT_USER_ID); +const clientToken = server.createToken(TEST_USER_ID); +console.log(`\nGenerated tokens:`); +console.log(` bot: ${botToken.slice(0, 40)}...`); +console.log(` client: ${clientToken.slice(0, 40)}...`); + +// Step 4: Create channel with both members +console.log(`\nCreating channel ${CHANNEL_TYPE}:${CHANNEL_ID}...`); +const channel = server.channel(CHANNEL_TYPE, CHANNEL_ID, { + name: CHANNEL_NAME, + members: [BOT_USER_ID, TEST_USER_ID], + created_by_id: TEST_USER_ID, +}); +await channel.create(); +console.log(` ✓ Channel created`); + +// Step 5: Update openclaw.json +console.log(`\nUpdating ${OPENCLAW_CONFIG}...`); +const rawConfig = readFileSync(OPENCLAW_CONFIG, "utf-8"); +const config = JSON.parse(rawConfig) as Record; +const channels = (config.channels ?? {}) as Record; +const existing = (channels.streamchat ?? {}) as Record; +channels.streamchat = { + ...existing, + apiKey: API_KEY, + botUserId: BOT_USER_ID, + botUserToken: botToken, +}; +config.channels = channels; +writeFileSync(OPENCLAW_CONFIG, JSON.stringify(config, null, 2) + "\n"); +console.log(` ✓ channels.streamchat updated`); + +// Step 6: Write .env at project root for all scripts +const envPath = join(PROJECT_ROOT, ".env"); +const envContent = [ + `STREAM_API_KEY=${API_KEY}`, + `TEST_USER_ID=${TEST_USER_ID}`, + `TEST_USER_TOKEN=${clientToken}`, + `TEST_CHANNEL_ID=${CHANNEL_ID}`, +].join("\n") + "\n"; +writeFileSync(envPath, envContent); +console.log(` ✓ Wrote .env`); + +// Done +console.log(` +✓ Setup complete! + + App key: ${API_KEY} + Bot user: ${BOT_USER_ID} + Client user: ${TEST_USER_ID} + Channel: ${CHANNEL_TYPE}:${CHANNEL_ID} + +Next steps: + openclaw gateway restart + npx tsx scripts/test-roundtrip.ts +`); + +process.exit(0); diff --git a/scripts/test-client.ts b/scripts/test-client.ts index a859f15..c4638c6 100644 --- a/scripts/test-client.ts +++ b/scripts/test-client.ts @@ -14,36 +14,18 @@ * npx tsx scripts/test-client.ts myChannel "Hello" # Send a message and watch */ +import "dotenv/config"; import { StreamChat } from "stream-chat"; import { createInterface } from "node:readline"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -function loadEnv(): void { - try { - const envPath = resolve(import.meta.dirname ?? ".", "../.env"); - const content = readFileSync(envPath, "utf-8"); - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eqIdx = trimmed.indexOf("="); - if (eqIdx === -1) continue; - const key = trimmed.slice(0, eqIdx).trim(); - const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, ""); - if (!process.env[key]) process.env[key] = value; - } - } catch { - // .env file not found - } -} -loadEnv(); +const apiKey = process.env.STREAM_API_KEY; +const userId = process.env.TEST_USER_ID; +const userToken = process.env.TEST_USER_TOKEN; -const apiKey = process.env.STREAM_API_KEY || "b3haysfrr5yg"; -const userId = process.env.TEST_USER_ID || "steookk"; -const userToken = - process.env.TEST_USER_TOKEN || - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic3Rlb29rayJ9.9yO--MWVC9bYQAjdUR5vp_cKxiBXEzHrXXnPXesqakE"; +if (!apiKey || !userId || !userToken) { + console.error("Error: STREAM_API_KEY, TEST_USER_ID, and TEST_USER_TOKEN must be set in .env"); + process.exit(1); +} const channelId = process.argv[2] || ""; const initialMessage = process.argv[3] || ""; @@ -97,6 +79,7 @@ client.on("message.new", (event) => { const aiGenerated = event.message.ai_generated ? " [AI]" : ""; const generating = (event.message as Record).generating ? " [generating...]" : ""; console.log(`\n[${from}]${aiGenerated}${generating}: ${text}`); + console.log(` id: ${event.message.id}`); }); // Listen for AI indicators @@ -112,21 +95,28 @@ for (const evType of ["ai_indicator.update", "ai_indicator.clear"]) { } // Listen for message updates (streaming) +const lastSeenText = new Map(); client.on("message.updated", (event) => { if (!event.message) return; + const msgId = event.message.id; const text = event.message.text || ""; const generating = (event.message as Record).generating; + const prev = lastSeenText.get(msgId) ?? ""; + const delta = text.slice(prev.length); if (generating) { - process.stdout.write(`\r [streaming] ${text.slice(-80)}`); + lastSeenText.set(msgId, text); + if (delta) process.stdout.write(delta); } else { - console.log(`\n [final] ${text.slice(0, 200)}`); + lastSeenText.delete(msgId); + if (delta) process.stdout.write(delta); + process.stdout.write(`\n id: ${msgId}\n`); } }); // Send initial message if provided if (initialMessage) { - console.log(`Sending: "${initialMessage}"`); - await channel.sendMessage({ text: initialMessage }); + const { message: sent } = await channel.sendMessage({ text: initialMessage }); + console.log(`Sent: "${initialMessage}" (id: ${sent.id})`); } // Interactive mode @@ -176,8 +166,8 @@ rl.on("line", async (line) => { return; } - await channel.sendMessage({ text: trimmed }); - console.log(` Sent: "${trimmed}"`); + const { message: sent } = await channel.sendMessage({ text: trimmed }); + console.log(` Sent: "${trimmed}" (id: ${sent.id})`); }); // Keep alive diff --git a/scripts/test-roundtrip.ts b/scripts/test-roundtrip.ts index 0ebf333..47b29e1 100644 --- a/scripts/test-roundtrip.ts +++ b/scripts/test-roundtrip.ts @@ -1,14 +1,21 @@ #!/usr/bin/env npx tsx /** * Automated round-trip test: sends a message and observes the streaming response. + * + * Requires a .env file at the project root (see .env.example). */ +import "dotenv/config"; import { StreamChat } from "stream-chat"; -const apiKey = "b3haysfrr5yg"; -const userId = "steookk"; -const userToken = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic3Rlb29rayJ9.9yO--MWVC9bYQAjdUR5vp_cKxiBXEzHrXXnPXesqakE"; +const apiKey = process.env.STREAM_API_KEY; +const userId = process.env.TEST_USER_ID; +const userToken = process.env.TEST_USER_TOKEN; + +if (!apiKey || !userId || !userToken) { + console.error("Error: STREAM_API_KEY, TEST_USER_ID, and TEST_USER_TOKEN must be set in .env"); + process.exit(1); +} const client = new StreamChat(apiKey, { allowServerSideConnect: true }); console.log(`Connecting as ${userId}...`); diff --git a/scripts/test-thread.ts b/scripts/test-thread.ts index a436d9b..48e56c2 100644 --- a/scripts/test-thread.ts +++ b/scripts/test-thread.ts @@ -1,14 +1,21 @@ #!/usr/bin/env npx tsx /** * Test thread support: sends a message, then sends a thread reply. + * + * Requires a .env file at the project root (see .env.example). */ +import "dotenv/config"; import { StreamChat } from "stream-chat"; -const apiKey = "b3haysfrr5yg"; -const userId = "steookk"; -const userToken = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic3Rlb29rayJ9.9yO--MWVC9bYQAjdUR5vp_cKxiBXEzHrXXnPXesqakE"; +const apiKey = process.env.STREAM_API_KEY; +const userId = process.env.TEST_USER_ID; +const userToken = process.env.TEST_USER_TOKEN; + +if (!apiKey || !userId || !userToken) { + console.error("Error: STREAM_API_KEY, TEST_USER_ID, and TEST_USER_TOKEN must be set in .env"); + process.exit(1); +} const client = new StreamChat(apiKey, { allowServerSideConnect: true }); console.log(`Connecting as ${userId}...`); diff --git a/src/channel.ts b/src/channel.ts index 49bf922..e469edd 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -268,16 +268,35 @@ async function handleStreamChatMessage(params: HandleMessageParams): Promise { + const full = payload.text ?? ""; + const delta = full.slice(lastPartialText.length); + lastPartialText = full; + if (delta) { + void streamingHandler.onTextChunk(runId, delta, account.streamingThrottle); + } + }, + }, dispatcherOptions: { responsePrefix: "", deliver: async ( @@ -285,47 +304,23 @@ async function handleStreamChatMessage(params: HandleMessageParams): Promise { try { - const channel = await chatRuntime.getOrQueryChannel( - channelType, - channelId, - ); - - // Handle tool progress events + // Tool progress: update indicator to EXTERNAL_SOURCES if (info.kind === "tool") { - if (streamStarted) { - await streamingHandler.onRunProgress(runId); - } + await streamingHandler.onRunProgress(runId); return; } - const textToSend = payload.text; - - // Handle error + // Error: finalize with error state if (payload.isError) { - if (streamStarted) { - await streamingHandler.onRunError( - runId, - textToSend || "Unknown error", - ); - errorDelivered = true; - } + await streamingHandler.onRunError( + runId, + payload.text || "Unknown error", + ); + errorDelivered = true; return; } - if (!textToSend) return; - - // Start streaming on first text chunk - if (!streamStarted) { - await streamingHandler.onRunStarted(runId, channel, runCtx); - streamStarted = true; - } - - // Stream text chunk - await streamingHandler.onTextChunk( - runId, - textToSend, - account.streamingThrottle, - ); + // Text blocks are handled token-by-token via onPartialReply above. } catch (err) { log?.error?.( `[StreamChat] Deliver failed: ${String(err)}`, @@ -337,7 +332,7 @@ async function handleStreamChatMessage(params: HandleMessageParams): Promise Date: Mon, 23 Feb 2026 22:07:37 +0000 Subject: [PATCH 2/6] Move .env and .env.example into scripts/ - All scripts now load dotenv from their own directory via config({ path: new URL(".env", import.meta.url).pathname }) - setup-app.ts writes scripts/.env instead of project root .env - Remove BOT_USER_ID from .env.example (not needed by scripts at runtime) - Update .gitignore to match new paths Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 ++-- .env.example => scripts/.env.example | 3 +-- scripts/discover-channels.ts | 3 ++- scripts/generate-bot-token.ts | 3 ++- scripts/setup-app.ts | 7 ++++--- scripts/test-client.ts | 3 ++- scripts/test-roundtrip.ts | 3 ++- scripts/test-thread.ts | 3 ++- 8 files changed, 17 insertions(+), 12 deletions(-) rename .env.example => scripts/.env.example (90%) diff --git a/.gitignore b/.gitignore index 66d6847..eae078b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,5 @@ dist/ *.js.map *.d.ts.map !scripts/*.ts -.env -!.env.example +scripts/.env +!scripts/.env.example diff --git a/.env.example b/scripts/.env.example similarity index 90% rename from .env.example rename to scripts/.env.example index 4525fb2..355388f 100644 --- a/.env.example +++ b/scripts/.env.example @@ -2,11 +2,10 @@ # Get these from https://dashboard.getstream.io STREAM_API_KEY=your_stream_api_key -# Bot and test client credentials +# Test client credentials # Run `npx tsx scripts/setup-app.ts` to populate these automatically, # or generate a token manually with: # STREAM_API_SECRET=your_secret npx tsx scripts/generate-bot-token.ts -BOT_USER_ID=openclaw-bot TEST_USER_ID=test-user TEST_USER_TOKEN=your_test_user_jwt_token TEST_CHANNEL_ID=ai-test-channel diff --git a/scripts/discover-channels.ts b/scripts/discover-channels.ts index 85af19a..0b06a97 100644 --- a/scripts/discover-channels.ts +++ b/scripts/discover-channels.ts @@ -8,7 +8,8 @@ * Requires a .env file at the project root (see .env.example). */ -import "dotenv/config"; +import { config } from "dotenv"; +config({ path: new URL(".env", import.meta.url).pathname }); import { StreamChat } from "stream-chat"; const apiKey = process.env.STREAM_API_KEY; diff --git a/scripts/generate-bot-token.ts b/scripts/generate-bot-token.ts index 577345a..a4a6658 100644 --- a/scripts/generate-bot-token.ts +++ b/scripts/generate-bot-token.ts @@ -12,7 +12,8 @@ * channels.streamchat.botUserToken */ -import "dotenv/config"; +import { config } from "dotenv"; +config({ path: new URL(".env", import.meta.url).pathname }); import { StreamChat } from "stream-chat"; const apiKey = process.env.STREAM_API_KEY; diff --git a/scripts/setup-app.ts b/scripts/setup-app.ts index ecfa637..69b9599 100644 --- a/scripts/setup-app.ts +++ b/scripts/setup-app.ts @@ -15,7 +15,8 @@ * STREAM_API_KEY can be in .env or passed on the command line. */ -import "dotenv/config"; +import { config } from "dotenv"; +config({ path: new URL(".env", import.meta.url).pathname }); import { StreamChat } from "stream-chat"; import { readFileSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; @@ -96,8 +97,8 @@ config.channels = channels; writeFileSync(OPENCLAW_CONFIG, JSON.stringify(config, null, 2) + "\n"); console.log(` ✓ channels.streamchat updated`); -// Step 6: Write .env at project root for all scripts -const envPath = join(PROJECT_ROOT, ".env"); +// Step 6: Write .env in scripts/ for all scripts +const envPath = join(SCRIPTS_DIR, ".env"); const envContent = [ `STREAM_API_KEY=${API_KEY}`, `TEST_USER_ID=${TEST_USER_ID}`, diff --git a/scripts/test-client.ts b/scripts/test-client.ts index c4638c6..0ce86e6 100644 --- a/scripts/test-client.ts +++ b/scripts/test-client.ts @@ -14,7 +14,8 @@ * npx tsx scripts/test-client.ts myChannel "Hello" # Send a message and watch */ -import "dotenv/config"; +import { config } from "dotenv"; +config({ path: new URL(".env", import.meta.url).pathname }); import { StreamChat } from "stream-chat"; import { createInterface } from "node:readline"; diff --git a/scripts/test-roundtrip.ts b/scripts/test-roundtrip.ts index 47b29e1..80dba0b 100644 --- a/scripts/test-roundtrip.ts +++ b/scripts/test-roundtrip.ts @@ -5,7 +5,8 @@ * Requires a .env file at the project root (see .env.example). */ -import "dotenv/config"; +import { config } from "dotenv"; +config({ path: new URL(".env", import.meta.url).pathname }); import { StreamChat } from "stream-chat"; const apiKey = process.env.STREAM_API_KEY; diff --git a/scripts/test-thread.ts b/scripts/test-thread.ts index 48e56c2..08a8336 100644 --- a/scripts/test-thread.ts +++ b/scripts/test-thread.ts @@ -5,7 +5,8 @@ * Requires a .env file at the project root (see .env.example). */ -import "dotenv/config"; +import { config } from "dotenv"; +config({ path: new URL(".env", import.meta.url).pathname }); import { StreamChat } from "stream-chat"; const apiKey = process.env.STREAM_API_KEY; From 47bff300e3d2bd197fb3a52a547e51ddaa05a8da Mon Sep 17 00:00:00 2001 From: Stefano Date: Mon, 23 Feb 2026 22:09:21 +0000 Subject: [PATCH 3/6] Clarify setup-app.ts JSDoc: exact files and fields written Co-Authored-By: Claude Sonnet 4.6 --- scripts/setup-app.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/setup-app.ts b/scripts/setup-app.ts index 69b9599..17d1950 100644 --- a/scripts/setup-app.ts +++ b/scripts/setup-app.ts @@ -6,8 +6,8 @@ * 1. Creates/upserts a bot user and a test user via the server API * 2. Generates permanent tokens for both * 3. Creates a messaging channel with both as members - * 4. Updates ~/.openclaw/openclaw.json with the new bot credentials - * 5. Writes a .env file at the project root + * 4. Updates ~/.openclaw/openclaw.json with apiKey, botUserId, botUserToken + * 5. Writes scripts/.env with STREAM_API_KEY, TEST_USER_ID, TEST_USER_TOKEN, TEST_CHANNEL_ID * * Usage: * STREAM_API_SECRET=your_secret npx tsx scripts/setup-app.ts From 75de51afd958e93f893f9dc9a19eec84cab2340b Mon Sep 17 00:00:00 2001 From: Stefano Date: Mon, 23 Feb 2026 22:13:08 +0000 Subject: [PATCH 4/6] Rename test-client.ts to chat-client.ts Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 2 +- README.md | 10 +++++----- scripts/{test-client.ts => chat-client.ts} | 0 3 files changed, 6 insertions(+), 6 deletions(-) rename scripts/{test-client.ts => chat-client.ts} (100%) diff --git a/CLAUDE.md b/CLAUDE.md index e79b6d4..44848df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ npm run type-check # tsc --noEmit # Run individual test scripts (no build step needed — tsx runs TS directly) npx tsx scripts/generate-bot-token.ts npx tsx scripts/discover-channels.ts -npx tsx scripts/test-client.ts +npx tsx scripts/chat-client.ts npx tsx scripts/test-roundtrip.ts npx tsx scripts/test-thread.ts diff --git a/README.md b/README.md index b3238a4..40d4090 100644 --- a/README.md +++ b/README.md @@ -99,19 +99,19 @@ Override the defaults with environment variables: STREAM_API_KEY=... USER_ID=myuser USER_TOKEN=... npx tsx scripts/discover-channels.ts ``` -### Interactive test client +### Interactive chat client Connects as a test user, watches a channel, and lets you send messages interactively while printing incoming bot responses and AI indicator events: ```bash # Auto-discover channels and use the first one -npx tsx scripts/test-client.ts +npx tsx scripts/chat-client.ts # Specify a channel -npx tsx scripts/test-client.ts myChannelId +npx tsx scripts/chat-client.ts myChannelId # Send a single message -npx tsx scripts/test-client.ts myChannelId "Hello bot" +npx tsx scripts/chat-client.ts myChannelId "Hello bot" ``` Commands inside the interactive client: @@ -125,7 +125,7 @@ Commands inside the interactive client: Override the test user with environment variables: ```bash -STREAM_API_KEY=... TEST_USER_ID=myuser TEST_USER_TOKEN=... npx tsx scripts/test-client.ts +STREAM_API_KEY=... TEST_USER_ID=myuser TEST_USER_TOKEN=... npx tsx scripts/chat-client.ts ``` ### Automated round-trip test diff --git a/scripts/test-client.ts b/scripts/chat-client.ts similarity index 100% rename from scripts/test-client.ts rename to scripts/chat-client.ts From 157a3a84a1305754dad5b76a8a67818bccaf0bc7 Mon Sep 17 00:00:00 2001 From: Stefano Date: Tue, 24 Feb 2026 11:28:11 +0000 Subject: [PATCH 5/6] Update README: setup scripts, env scope, and when to use each script Co-Authored-By: Claude Sonnet 4.6 --- README.md | 67 ++++++++++++++++++++++++------------------------------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 40d4090..56beea2 100644 --- a/README.md +++ b/README.md @@ -17,57 +17,54 @@ cd openclaw-channel-streamchat npm install ``` -### 2. Generate a bot token +### 2. Provision the app -The plugin connects to Stream Chat as a regular user (the bot). You need a JWT for that user, generated from your API secret. The secret is only used here — it is **not** stored in the plugin config. +You have two options depending on your situation: -Create a `.env` file in the plugin root: +**Option A — Fresh app (recommended for first-time setup)** -```env -STREAM_API_KEY=your_api_key -STREAM_API_SECRET=your_api_secret -BOT_USER_ID=chatgpt +Use `setup-app.ts` if you are starting from a new Stream Chat app. It creates the bot and test users, generates their tokens, creates a test channel, and writes both `~/.openclaw/openclaw.json` and `scripts/.env` automatically: + +```bash +STREAM_API_KEY=your_api_key STREAM_API_SECRET=your_api_secret npx tsx scripts/setup-app.ts ``` -Then run: +After this, skip to step 4. + +**Option B — Existing app (bot token only)** + +Use `generate-bot-token.ts` if the app and channel already exist and you only need to mint or rotate the bot JWT. It prints the token to stdout — copy it into `~/.openclaw/openclaw.json` manually: ```bash -npx tsx scripts/generate-bot-token.ts +STREAM_API_KEY=your_api_key STREAM_API_SECRET=your_api_secret npx tsx scripts/generate-bot-token.ts ``` -This prints the bot JWT. Copy it for the next step. +> **Note:** Pass the API secret inline as shown above. It is only needed by these two provisioning scripts and should not be stored in `scripts/.env`. -### 3. Configure OpenClaw +### 3. Configure OpenClaw (Option B only) -Add the channel config and plugin entry to your `~/.openclaw/openclaw.json`: +If you used Option B, add the channel config and plugin entry to `~/.openclaw/openclaw.json` manually: ```jsonc { - // Add the channel configuration "channels": { "streamchat": { "enabled": true, "apiKey": "your_api_key", - "botUserId": "chatgpt", - "botUserToken": "", + "botUserId": "openclaw-bot", + "botUserToken": "", // Optional: - "ackReaction": "eyes", // reaction added when message is received (default: "eyes") + "ackReaction": "eyes", // reaction added when message is received (default: "eyes") "doneReaction": "white_check_mark", // reaction swapped in when response is done (default: "white_check_mark") - "streamingThrottle": 15 // partial-update every Nth chunk (default: 15) + "streamingThrottle": 15 // partial-update every Nth chunk (default: 15) } }, - - // Register the plugin "plugins": { "load": { - "paths": [ - "/absolute/path/to/openclaw-channel-streamchat" - ] + "paths": ["/absolute/path/to/openclaw-channel-streamchat"] }, "entries": { - "streamchat": { - "enabled": true - } + "streamchat": { "enabled": true } } } } @@ -83,20 +80,20 @@ The plugin will connect to Stream Chat, watch all channels where the bot is a me ## Testing -All test scripts live in `scripts/` and can be run with `npx tsx`. They load credentials from `.env` or use environment variables. +All test scripts live in `scripts/` and load credentials from `scripts/.env` (populated by `setup-app.ts`). The plugin itself reads only from `~/.openclaw/openclaw.json` — `scripts/.env` is not used at runtime. -### Discover channels - -Lists all channels the test user belongs to: +See `scripts/.env.example` for the expected variables. You can also pass any variable inline to override the file: ```bash -npx tsx scripts/discover-channels.ts +STREAM_API_KEY=... TEST_USER_TOKEN=... npx tsx scripts/chat-client.ts ``` -Override the defaults with environment variables: +### Discover channels + +Lists all channels the test user belongs to: ```bash -STREAM_API_KEY=... USER_ID=myuser USER_TOKEN=... npx tsx scripts/discover-channels.ts +npx tsx scripts/discover-channels.ts ``` ### Interactive chat client @@ -122,12 +119,6 @@ Commands inside the interactive client: | `/quote ` | Send a quoted reply | | `/quit` | Disconnect and exit | -Override the test user with environment variables: - -```bash -STREAM_API_KEY=... TEST_USER_ID=myuser TEST_USER_TOKEN=... npx tsx scripts/chat-client.ts -``` - ### Automated round-trip test Sends a message and waits for the bot to respond, verifying the full streaming lifecycle (placeholder message, AI indicators, partial updates, final update): From f2a60f93a4a8a264d8e4cd233b7b606a8e161121 Mon Sep 17 00:00:00 2001 From: Stefano Uliari Date: Tue, 24 Feb 2026 16:18:49 +0100 Subject: [PATCH 6/6] fix wrong header set by claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 56beea2..e04314d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# @wunderchat/openclaw-channel-streamchat +# openclaw-channel-streamchat OpenClaw channel plugin for [Stream Chat](https://getstream.io/chat/). Connects as a bot user via WebSocket, normalizes inbound messages into OpenClaw envelope format, and delivers agent responses using Stream Chat's AI streaming pattern (`partialUpdateMessage` + `ai_indicator` events).