Skip to content

Commit f20019a

Browse files
committed
first working version
1 parent 9a55d61 commit f20019a

22 files changed

Lines changed: 12620 additions & 0 deletions

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
node_modules/
2+
dist/
3+
*.js
4+
*.d.ts
5+
*.js.map
6+
*.d.ts.map
7+
!scripts/*.ts
8+
.env

CLAUDE.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Commands
6+
7+
```bash
8+
# Type-check the plugin (no emit)
9+
npm run type-check # tsc --noEmit
10+
11+
# Run individual test scripts (no build step needed — tsx runs TS directly)
12+
npx tsx scripts/generate-bot-token.ts
13+
npx tsx scripts/discover-channels.ts
14+
npx tsx scripts/test-client.ts
15+
npx tsx scripts/test-roundtrip.ts
16+
npx tsx scripts/test-thread.ts
17+
18+
# Restart the gateway after code changes
19+
openclaw gateway restart
20+
```
21+
22+
There is no build step for the plugin itself. OpenClaw loads it directly from TypeScript source via `tsx`. The `scripts/` directory is excluded from `tsconfig.json`; scripts are run standalone with `npx tsx`.
23+
24+
The `openclaw/plugin-sdk` import alias resolves to `../openclaw/dist/plugin-sdk/plugin-sdk/index.d.ts`. If that file is missing, rebuild it:
25+
26+
```bash
27+
cd ../openclaw && tsc -p tsconfig.plugin-sdk.dts.json
28+
```
29+
30+
## Architecture
31+
32+
### Plugin registration
33+
34+
`index.ts` is the plugin entry point. It exports a default object conforming to `OpenClawPluginDefinition`: the `register(api)` method stores the framework runtime singleton (`setStreamChatRuntime`) and calls `api.registerChannel({ plugin: streamchatPlugin })`.
35+
36+
The OpenClaw framework discovers the plugin via `plugins.load.paths` in `~/.openclaw/openclaw.json`, looks for `openclaw.plugin.json` in the directory, then loads the extension listed in `package.json#openclaw.extensions`.
37+
38+
**Config wiring gotchas:**
39+
- The `plugins.entries` key must equal the manifest `id` field (`"streamchat"`), not the package name (`@wunderchat/openclaw-channel-streamchat`) or directory name. Using the wrong key causes a `plugin not found` validation error at startup.
40+
- `PluginEntryConfig` only accepts `{ enabled, config }`. Any other field (e.g. `source`) will be rejected with `Unrecognized key`.
41+
- Config validation runs before plugin loading, so `plugins.load.paths` and `plugins.entries` must both be correct before the gateway will start.
42+
- There will always be a harmless cosmetic warning: `plugin id mismatch (manifest uses "streamchat", entry hints "openclaw-channel-streamchat")`. This is because the directory name differs from the manifest id and can be ignored.
43+
44+
### Source module map
45+
46+
| File | Responsibility |
47+
|---|---|
48+
| `src/channel.ts` | Main plugin export (`streamchatPlugin`). Contains `handleStreamChatMessage` (inbound dispatch) and the `ChannelPlugin` adapter implementations: `config`, `outbound`, `gateway`, `status`. |
49+
| `src/stream-chat-runtime.ts` | `StreamChatClientRuntime` — wraps the `stream-chat` SDK. Connects as bot user (`allowServerSideConnect: true` is required for Node.js server contexts), queries + watches channels on startup, auto-watches channels added later via `notification.added_to_channel`. |
50+
| `src/streaming.ts` | `StreamingHandler` — manages the AI streaming lifecycle per run: creates placeholder message → sends `ai_indicator` events → calls `partialUpdateMessage` on throttled chunks → finalizes on completion. |
51+
| `src/run-context.ts` | `RunContextMap` — binds an OpenClaw `runId` (UUID generated per inbound message) to delivery routing state: `channelId`, `threadParentId`, `responseMessageId`. TTL of 5 min. |
52+
| `src/envelope.ts` | `buildEnvelope` — wraps the raw message text in `[Thread]` / `[Replying to]` XML-like tags so the LLM receives thread and quote context in the single-session model. |
53+
| `src/types.ts` | `StreamChatChannelConfig`, `ResolvedAccount`, `RunContext`, `EnvelopeResult` interfaces, plus config helper functions (`getStreamChatConfig`, `resolveStreamChatAccount`). |
54+
| `src/config-schema.ts` | Zod schema for `channels.streamchat.*` config. Uses `z.lazy()` for the recursive `accounts` sub-map (multi-account support). |
55+
| `src/runtime.ts` | Module-level singleton accessor (`getStreamChatRuntime` / `setStreamChatRuntime`) for the `PluginRuntime` injected by OpenClaw at registration time. |
56+
| `src/stream-chat.d.ts` | Module augmentation adding `generating?: boolean` and `ai_generated?: boolean` to `CustomMessageData`. |
57+
| `src/utils.ts` | `truncate` and `safeAsync` helpers. |
58+
59+
### Inbound flow
60+
61+
```
62+
message.new (WebSocket)
63+
→ handleStreamChatMessage
64+
→ skip if event.user.id === botUserId (own messages)
65+
→ skip if message.ai_generated === true (own placeholder/streamed messages)
66+
→ resolveAgentRoute (peer kind: "channel", id: channelId)
67+
→ buildEnvelope (wraps text with thread/reply context tags)
68+
→ finalizeInboundContext
69+
→ recordInboundSession
70+
→ dispatchReplyWithBufferedBlockDispatcher
71+
deliver(payload, info) called per block:
72+
info.kind === "tool" → onRunProgress (EXTERNAL_SOURCES indicator)
73+
text chunk → onRunStarted (first) + onTextChunk
74+
after dispatcher returns:
75+
→ onRunCompleted (final partialUpdateMessage + ai_indicator.clear)
76+
```
77+
78+
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.
79+
80+
### Event mapping
81+
82+
How each signal from the OpenClaw pipeline translates into Stream Chat API calls or channel events:
83+
84+
| Trigger | Stream Chat action | Notes |
85+
|---|---|---|
86+
| Inbound message received | `channel.sendReaction(msgId, { type: "eyes" })` | Ack reaction, fire-and-forget |
87+
| First `deliver` text block | `channel.sendMessage({ text: "", ai_generated: true })` | Creates the bot's placeholder message |
88+
| First `deliver` text block | `channel.sendEvent({ type: "ai_indicator.update", ai_state: "AI_STATE_THINKING" })` | Immediately followed by GENERATING below |
89+
| First text chunk processed | `channel.sendEvent({ type: "ai_indicator.update", ai_state: "AI_STATE_GENERATING" })` | Transitions from THINKING on the very first `onTextChunk` call |
90+
| 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 |
91+
| `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 |
92+
| Dispatcher resolves (run complete) | `client.partialUpdateMessage(msgId, { set: { text, generating: false } })` | Final flush, waits for any in-flight partial updates first |
93+
| Dispatcher resolves (run complete) | `channel.sendEvent({ type: "ai_indicator.clear" })` | Clears the indicator bubble |
94+
| Dispatcher resolves (run complete) | `channel.deleteReaction(inboundMsgId, "eyes")``channel.sendReaction(inboundMsgId, { type: "white_check_mark" })` | Reaction swap on the original user message |
95+
| `deliver` with `payload.isError` | `client.partialUpdateMessage(msgId, { set: { text: "…\n\nError: …", generating: false } })` | Appends error to any partial text already accumulated |
96+
| `deliver` with `payload.isError` | `channel.sendEvent({ type: "ai_indicator.update", ai_state: "AI_STATE_ERROR" })` | Leaves the error indicator visible (no `ai_indicator.clear`) |
97+
| `ai_indicator.stop` from client | `client.partialUpdateMessage(msgId, { set: { generating: false } })` | Clears the generating flag without touching the accumulated text |
98+
| `ai_indicator.stop` from client | `channel.sendEvent({ type: "ai_indicator.clear" })` | |
99+
100+
The `ai_indicator` events are sent via `safeSendEvent`, which retries up to 5 times on 429/5xx with exponential backoff (100 ms base, doubles each attempt) and swallows the error rather than aborting delivery if all retries fail.
101+
102+
### Outbound streaming lifecycle
103+
104+
Each agent run that produces text goes through these steps in `StreamingHandler`:
105+
106+
1. `onRunStarted``channel.sendMessage({ text: "", ai_generated: true })``ai_indicator.update(AI_STATE_THINKING)`
107+
2. `onTextChunk` — accumulates text, switches indicator to `AI_STATE_GENERATING` on first chunk, calls `client.partialUpdateMessage({ set: { text, generating: true } })` throttled (early burst: odd chunks < 8; then every Nth chunk, default N=15)
108+
3. `onRunCompleted` — waits for in-flight partial updates, sends final `partialUpdateMessage({ generating: false })`, sends `ai_indicator.clear`
109+
110+
Force-stop (`ai_indicator.stop` from client) calls `onForceStop`, which clears `generating` without overwriting the accumulated text.
111+
112+
### Session model
113+
114+
Each Stream Chat channel maps to exactly one OpenClaw session:
115+
116+
```
117+
agent:<agentId>:streamchat:channel:<channelId>
118+
```
119+
120+
This is achieved by passing `peer: { kind: "channel", id: channelId }` to `resolveAgentRoute`. The "channel" peer kind bypasses the `dmScope` logic and always builds per-channel keys. Do not use `peer.kind: "direct"` — with the framework default of `dmScope: "main"`, all direct-peer messages collapse into a single shared session (`agent:main:main`), so all channels would share one conversation context.
121+
122+
All messages in a channel — main feed and threads — go to the same session. Thread context is injected into the prompt via `buildEnvelope` wrappers, not via separate sessions. This preserves cross-thread LLM context.
123+
124+
### Multi-account support
125+
126+
Config supports a flat default account or named sub-accounts:
127+
128+
```jsonc
129+
"channels": {
130+
"streamchat": {
131+
"apiKey": "...", // default account
132+
"accounts": {
133+
"workspace-b": { "apiKey": "..." } // named account
134+
}
135+
}
136+
}
137+
```
138+
139+
`resolveStreamChatAccount(cfg, accountId)` merges the named account config over the base config. Each account gets its own `StreamChatClientRuntime`, `RunContextMap`, and `StreamingHandler` instance (created in `gateway.startAccount`).
140+
141+
## Key design decisions
142+
143+
- **Bot token in config, secret is not.** The API secret is only used in `scripts/generate-bot-token.ts` to mint a JWT. Only the resulting token is stored in `openclaw.json`.
144+
- **`deliver` callback vs. completion signal.** `dispatchReplyWithBufferedBlockDispatcher` signals completion by resolving its promise, not by passing an `isComplete` flag. The `info.kind` parameter (`"tool" | "block" | "final"`) distinguishes delivery type. `onRunCompleted` is called after the dispatcher awaits. The `ReplyPayload` type has `text` and `isError` as the only relevant fields — there is no `markdown`, `isComplete`, or `toolName` field, despite what seems intuitive.
145+
- **Partial updates are chained via `lastUpdatePromise`.** Each `partialUpdateMessage` is `.then()`-chained onto the previous one to avoid out-of-order message text.
146+
- **`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.
147+
- **`seenThreads` is process-scoped.** The `Set<string>` 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.

README.md

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# @wunderchat/openclaw-channel-streamchat
2+
3+
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).
4+
5+
## Prerequisites
6+
7+
- OpenClaw `>= 2026.2.13`
8+
- A Stream Chat application (API key + secret from the [Stream Dashboard](https://dashboard.getstream.io/))
9+
- Node.js `>= 20`
10+
11+
## Setup
12+
13+
### 1. Install dependencies
14+
15+
```bash
16+
cd openclaw-channel-streamchat
17+
npm install
18+
```
19+
20+
### 2. Generate a bot token
21+
22+
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.
23+
24+
Create a `.env` file in the plugin root:
25+
26+
```env
27+
STREAM_API_KEY=your_api_key
28+
STREAM_API_SECRET=your_api_secret
29+
BOT_USER_ID=chatgpt
30+
```
31+
32+
Then run:
33+
34+
```bash
35+
npx tsx scripts/generate-bot-token.ts
36+
```
37+
38+
This prints the bot JWT. Copy it for the next step.
39+
40+
### 3. Configure OpenClaw
41+
42+
Add the channel config and plugin entry to your `~/.openclaw/openclaw.json`:
43+
44+
```jsonc
45+
{
46+
// Add the channel configuration
47+
"channels": {
48+
"streamchat": {
49+
"enabled": true,
50+
"apiKey": "your_api_key",
51+
"botUserId": "chatgpt",
52+
"botUserToken": "<token from step 2>",
53+
// Optional:
54+
"ackReaction": "eyes", // reaction added when message is received (default: "eyes")
55+
"doneReaction": "white_check_mark", // reaction swapped in when response is done (default: "white_check_mark")
56+
"streamingThrottle": 15 // partial-update every Nth chunk (default: 15)
57+
}
58+
},
59+
60+
// Register the plugin
61+
"plugins": {
62+
"load": {
63+
"paths": [
64+
"/absolute/path/to/openclaw-channel-streamchat"
65+
]
66+
},
67+
"entries": {
68+
"streamchat": {
69+
"enabled": true
70+
}
71+
}
72+
}
73+
}
74+
```
75+
76+
### 4. Restart the gateway
77+
78+
```bash
79+
openclaw gateway restart
80+
```
81+
82+
The plugin will connect to Stream Chat, watch all channels where the bot is a member, and start processing messages.
83+
84+
## Testing
85+
86+
All test scripts live in `scripts/` and can be run with `npx tsx`. They load credentials from `.env` or use environment variables.
87+
88+
### Discover channels
89+
90+
Lists all channels the test user belongs to:
91+
92+
```bash
93+
npx tsx scripts/discover-channels.ts
94+
```
95+
96+
Override the defaults with environment variables:
97+
98+
```bash
99+
STREAM_API_KEY=... USER_ID=myuser USER_TOKEN=... npx tsx scripts/discover-channels.ts
100+
```
101+
102+
### Interactive test client
103+
104+
Connects as a test user, watches a channel, and lets you send messages interactively while printing incoming bot responses and AI indicator events:
105+
106+
```bash
107+
# Auto-discover channels and use the first one
108+
npx tsx scripts/test-client.ts
109+
110+
# Specify a channel
111+
npx tsx scripts/test-client.ts myChannelId
112+
113+
# Send a single message
114+
npx tsx scripts/test-client.ts myChannelId "Hello bot"
115+
```
116+
117+
Commands inside the interactive client:
118+
119+
| Command | Description |
120+
|---------|-------------|
121+
| `/thread <parentId> <text>` | Send a thread reply |
122+
| `/quote <messageId> <text>` | Send a quoted reply |
123+
| `/quit` | Disconnect and exit |
124+
125+
Override the test user with environment variables:
126+
127+
```bash
128+
STREAM_API_KEY=... TEST_USER_ID=myuser TEST_USER_TOKEN=... npx tsx scripts/test-client.ts
129+
```
130+
131+
### Automated round-trip test
132+
133+
Sends a message and waits for the bot to respond, verifying the full streaming lifecycle (placeholder message, AI indicators, partial updates, final update):
134+
135+
```bash
136+
npx tsx scripts/test-roundtrip.ts
137+
```
138+
139+
Expected output:
140+
141+
```
142+
[NEW MSG][chatgpt] [AI]: (no text) # empty placeholder
143+
[AI INDICATOR] AI_STATE_THINKING # thinking indicator
144+
[AI INDICATOR] AI_STATE_GENERATING # generating indicator
145+
[STREAMING] 2 + 2 = 4. # partial update
146+
[FINAL] 2 + 2 = 4. # final update (generating: false)
147+
[AI INDICATOR] cleared # indicator cleared
148+
149+
✓ Round-trip test PASSED — got bot response.
150+
```
151+
152+
### Thread test
153+
154+
Sends a parent message, waits for the bot's response, then sends a thread reply and verifies the bot responds inside the thread:
155+
156+
```bash
157+
npx tsx scripts/test-thread.ts
158+
```
159+
160+
## How it works
161+
162+
**Inbound flow:**
163+
1. Bot receives `message.new` event via WebSocket
164+
2. Plugin filters out bot's own messages and AI-generated messages
165+
3. Builds an envelope with thread/reply context wrappers (`[Thread]`, `[Replying]`)
166+
4. Dispatches to the OpenClaw agent pipeline
167+
168+
**Outbound flow (streaming):**
169+
1. Creates an empty placeholder message with `ai_generated: true`
170+
2. Sends `ai_indicator.update` with `AI_STATE_THINKING`
171+
3. On first text chunk, switches to `AI_STATE_GENERATING`
172+
4. Progressively updates the message via `partialUpdateMessage` with `generating: true`
173+
5. On completion, sends final update with `generating: false` and clears the indicator
174+
175+
**Thread handling:**
176+
- Thread replies include `parent_id` so the bot's response routes to the correct thread
177+
- First message in a thread includes the parent message text for context
178+
- Quoted replies are wrapped in `[Replying to ...]` envelopes

index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3+
import { streamchatPlugin } from "./src/channel.js";
4+
import { setStreamChatRuntime } from "./src/runtime.js";
5+
6+
const plugin = {
7+
id: "streamchat",
8+
name: "Stream Chat",
9+
description: "Stream Chat messaging channel for OpenClaw",
10+
configSchema: emptyPluginConfigSchema(),
11+
register(api: OpenClawPluginApi): void {
12+
setStreamChatRuntime(api.runtime);
13+
api.registerChannel({ plugin: streamchatPlugin });
14+
},
15+
};
16+
17+
export default plugin;

openclaw.plugin.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"id": "streamchat",
3+
"channels": ["streamchat"],
4+
"configSchema": {
5+
"type": "object",
6+
"additionalProperties": true,
7+
"properties": {}
8+
}
9+
}

0 commit comments

Comments
 (0)