botschat 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -8,6 +8,40 @@ A self-hosted chat interface for [OpenClaw](https://github.com/openclaw/openclaw
8
8
 
9
9
  BotsChat gives you a modern, Slack-like web UI to interact with your OpenClaw agents — organize conversations into **Channels**, schedule **Background Tasks**, and monitor **Job** executions. Everything runs on your own infrastructure; your API keys and data never leave your machine.
10
10
 
11
+ ## Key Features
12
+
13
+ ### Structured Conversation Management
14
+
15
+ BotsChat organizes your conversations through a **Channel → Session → Thread** three-layer hierarchy, keeping complex agent interactions clean and navigable:
16
+
17
+ - **Channel** — one workspace per agent (e.g. "#General", "#BotsChat"), listed in the left sidebar.
18
+ - **Session** — multiple session tabs within each channel, so you can run parallel conversations without losing context.
19
+ - **Thread** — branch off from any message to start a focused sub-conversation in the right panel, without cluttering the main chat.
20
+
21
+ You can also **switch models on the fly** from the top-right selector, and trigger common **Skills** (like `/model`, `/help`, `/skills`) directly from the command bar at the bottom of the chat.
22
+
23
+ ![Conversation Structure — Channel, Session, and Thread](docs/thread.png)
24
+
25
+ ### Interactive Agent UI (A2UI)
26
+
27
+ Instead of plain text walls, BotsChat renders agent responses as **interactive UI elements** — clickable buttons, radio groups, and selection cards. When an agent asks "What kind of project do you want to create?", you see styled option buttons you can click, not just text to read and retype. This makes multi-step workflows feel like a guided wizard rather than a raw chat.
28
+
29
+ ![Interactive UI — Agent responses rendered as buttons and selection cards](docs/a2ui.png)
30
+
31
+ ### Background Task Automation
32
+
33
+ Schedule **cron-style background tasks** that run your agents on autopilot. Each task has its own prompt, schedule, model selection, and full execution history. You can view detailed job logs, re-run tasks on demand, and enable/disable them with a single toggle.
34
+
35
+ ![Background Task — Schedule, prompt, and execution history](docs/cron.png)
36
+
37
+ ### Built-in Debug Log
38
+
39
+ A collapsible **Debug Log** panel at the bottom of the UI gives you real-time visibility into what's happening under the hood — WebSocket events, cron task loading, agent scan results, and more. Filter by log level (ALL, WS, WST, API, INF, WRN, ERR) to quickly diagnose issues without leaving the chat interface.
40
+
41
+ ![Debug Log — Real-time logs with level filtering](docs/debug.png)
42
+
43
+ ---
44
+
11
45
  ## Architecture
12
46
 
13
47
  ![BotsChat Architecture](docs/architecture.png)
@@ -51,9 +85,17 @@ npm install
51
85
 
52
86
  ### Step 2: Deploy BotsChat Server
53
87
 
54
- Choose one of the two options below:
88
+ Choose one of the three options below:
89
+
90
+ #### Option A: Use Hosted Console (Recommended)
91
+
92
+ The easiest way to get started — no deployment needed. We run a hosted BotsChat instance at **[console.botschat.app](https://console.botschat.app)**. Just sign up, generate a pairing token, and connect your OpenClaw instance directly.
93
+
94
+ Your API keys and data still stay on your machine — the hosted console only relays chat messages via WebSocket, exactly the same as a self-hosted deployment.
95
+
96
+ > Skip to [Step 3](#step-3-install-the-openclaw-plugin) after signing up.
55
97
 
56
- #### Option A: Run Locally
98
+ #### Option B: Run Locally
57
99
 
58
100
  Wrangler uses [Miniflare](https://miniflare.dev) under the hood, so D1, R2, and Durable Objects all run locally — **no Cloudflare account needed**.
59
101
 
@@ -82,7 +124,7 @@ Other dev commands:
82
124
  ./scripts/dev.sh logs # Tail remote gateway logs
83
125
  ```
84
126
 
85
- #### Option B: Deploy to Cloudflare
127
+ #### Option C: Deploy to Cloudflare
86
128
 
87
129
  For remote access (e.g. chatting with your agents from your phone), deploy to Cloudflare Workers. The free tier is more than enough for personal use.
88
130
 
@@ -122,6 +164,7 @@ Open the BotsChat web UI, register an account, and generate a **pairing token**
122
164
  **3. Configure the connection**
123
165
 
124
166
  ```bash
167
+ # For hosted console, use https://console.botschat.app
125
168
  # For local deployment, use http://localhost:8787 or your LAN IP
126
169
  # For Cloudflare deployment, use your Workers URL
127
170
  openclaw config set channels.botschat.cloudUrl <BOTSCHAT_URL>
@@ -0,0 +1,10 @@
1
+ -- Support Firebase OAuth (Google, GitHub, etc.) users who don't have a password.
2
+
3
+ -- auth_provider: 'email' (default), 'google', 'github'
4
+ ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'email';
5
+
6
+ -- Firebase UID — unique across all Firebase auth providers
7
+ ALTER TABLE users ADD COLUMN firebase_uid TEXT;
8
+
9
+ -- Index for quick lookup by firebase_uid
10
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_firebase_uid ON users(firebase_uid) WHERE firebase_uid IS NOT NULL;
@@ -0,0 +1,10 @@
1
+ -- Pairing token security enhancements: soft-delete (revoke), audit fields.
2
+
3
+ -- Soft-delete: revoked tokens are rejected but kept for audit trail
4
+ ALTER TABLE pairing_tokens ADD COLUMN revoked_at INTEGER;
5
+
6
+ -- Last connecting IP (for audit)
7
+ ALTER TABLE pairing_tokens ADD COLUMN last_ip TEXT;
8
+
9
+ -- Connection count (for anomaly detection)
10
+ ALTER TABLE pairing_tokens ADD COLUMN connection_count INTEGER NOT NULL DEFAULT 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botschat",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A self-hosted chat interface for OpenClaw AI agents",
5
5
  "workspaces": [
6
6
  "packages/*"
@@ -36,11 +36,13 @@ export class ConnectionDO implements DurableObject {
36
36
  // Route: /gateway/:accountId — OpenClaw plugin connects here
37
37
  if (url.pathname.startsWith("/gateway/")) {
38
38
  // Extract and store userId from the gateway path
39
- const userId = url.pathname.split("/gateway/")[1];
39
+ const userId = url.pathname.split("/gateway/")[1]?.split("?")[0];
40
40
  if (userId) {
41
41
  await this.state.storage.put("userId", userId);
42
42
  }
43
- return this.handleOpenClawConnect(request);
43
+ // Check if the API worker already verified the token against D1
44
+ const preVerified = url.searchParams.get("verified") === "1";
45
+ return this.handleOpenClawConnect(request, preVerified);
44
46
  }
45
47
 
46
48
  // Route: /client/:sessionId — Browser client connects here
@@ -121,7 +123,7 @@ export class ConnectionDO implements DurableObject {
121
123
 
122
124
  // ---- Connection handlers ----
123
125
 
124
- private handleOpenClawConnect(request: Request): Response {
126
+ private handleOpenClawConnect(request: Request, preVerified = false): Response {
125
127
  if (request.headers.get("Upgrade") !== "websocket") {
126
128
  return new Response("Expected WebSocket upgrade", { status: 426 });
127
129
  }
@@ -132,8 +134,10 @@ export class ConnectionDO implements DurableObject {
132
134
  // Accept with Hibernation API, tag as "openclaw"
133
135
  this.state.acceptWebSocket(server, ["openclaw"]);
134
136
 
135
- // Store initial state: not yet authenticated
136
- server.serializeAttachment({ authenticated: false, tag: "openclaw" });
137
+ // If the API worker already verified the token against D1, mark as
138
+ // pre-verified. The plugin still sends an auth message, which we'll
139
+ // fast-track through without re-validating the token.
140
+ server.serializeAttachment({ authenticated: false, tag: "openclaw", preVerified });
137
141
 
138
142
  return new Response(null, { status: 101, webSocket: client });
139
143
  }
@@ -162,12 +166,14 @@ export class ConnectionDO implements DurableObject {
162
166
  ws: WebSocket,
163
167
  msg: Record<string, unknown>,
164
168
  ): Promise<void> {
165
- const attachment = ws.deserializeAttachment() as { authenticated: boolean; tag: string } | null;
169
+ const attachment = ws.deserializeAttachment() as { authenticated: boolean; tag: string; preVerified?: boolean } | null;
166
170
 
167
171
  // Handle auth handshake
168
172
  if (msg.type === "auth") {
169
173
  const token = msg.token as string;
170
- const isValid = await this.validatePairingToken(token);
174
+ // If the API worker already validated this token against D1, skip
175
+ // the DO-level check. Otherwise fall back to local validation.
176
+ const isValid = attachment?.preVerified || await this.validatePairingToken(token);
171
177
 
172
178
  if (isValid) {
173
179
  ws.serializeAttachment({ ...attachment, authenticated: true });
@@ -5,4 +5,7 @@ export type Env = {
5
5
  CONNECTION_DO: DurableObjectNamespace;
6
6
  ENVIRONMENT: string;
7
7
  JWT_SECRET?: string;
8
+ FIREBASE_PROJECT_ID?: string;
9
+ /** Canonical public URL override — if set, always use this as cloudUrl. */
10
+ PUBLIC_URL?: string;
8
11
  };
@@ -11,6 +11,7 @@ import { models } from "./routes/models.js";
11
11
  import { pairing } from "./routes/pairing.js";
12
12
  import { sessions } from "./routes/sessions.js";
13
13
  import { upload } from "./routes/upload.js";
14
+ import { setup } from "./routes/setup.js";
14
15
 
15
16
  // Re-export the Durable Object class so wrangler can find it
16
17
  export { ConnectionDO } from "./do/connection-do.js";
@@ -25,6 +26,7 @@ app.get("/api/health", (c) => c.json({ status: "ok", version: "0.1.0" }));
25
26
 
26
27
  // ---- Public routes (no auth) ----
27
28
  app.route("/api/auth", auth);
29
+ app.route("/api/setup", setup);
28
30
 
29
31
  // ---- Protected routes (require Bearer token) ----
30
32
  const protectedApp = new Hono<{ Bindings: Env; Variables: { userId: string } }>();
@@ -232,9 +234,9 @@ app.all("/api/gateway/:connId", async (c) => {
232
234
  return c.json({ error: "Token required for gateway connection" }, 401);
233
235
  }
234
236
 
235
- // Look up user by pairing token
237
+ // Look up user by pairing token (exclude revoked tokens)
236
238
  const row = await c.env.DB.prepare(
237
- "SELECT user_id FROM pairing_tokens WHERE token = ?",
239
+ "SELECT user_id FROM pairing_tokens WHERE token = ? AND revoked_at IS NULL",
238
240
  )
239
241
  .bind(token)
240
242
  .first<{ user_id: string }>();
@@ -244,18 +246,24 @@ app.all("/api/gateway/:connId", async (c) => {
244
246
  }
245
247
  userId = row.user_id;
246
248
 
247
- // Update last_connected_at
249
+ // Update audit fields: last_connected_at, last_ip, connection_count
250
+ const clientIp = c.req.header("CF-Connecting-IP") ?? c.req.header("X-Forwarded-For") ?? "unknown";
248
251
  await c.env.DB.prepare(
249
- "UPDATE pairing_tokens SET last_connected_at = unixepoch() WHERE token = ?",
252
+ `UPDATE pairing_tokens
253
+ SET last_connected_at = unixepoch(), last_ip = ?, connection_count = connection_count + 1
254
+ WHERE token = ?`,
250
255
  )
251
- .bind(token)
256
+ .bind(clientIp, token)
252
257
  .run();
253
258
  }
254
259
 
255
260
  const doId = c.env.CONNECTION_DO.idFromName(userId);
256
261
  const stub = c.env.CONNECTION_DO.get(doId);
257
262
  const url = new URL(c.req.url);
263
+ // Pass verified userId to DO — the API worker already validated the token
264
+ // against D1 above, so DO can trust this.
258
265
  url.pathname = `/gateway/${userId}`;
266
+ url.searchParams.set("verified", "1");
259
267
  return stub.fetch(new Request(url.toString(), c.req.raw));
260
268
  });
261
269
 
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Env } from "../env.js";
3
3
  import { createToken, hashPassword } from "../utils/auth.js";
4
+ import { verifyFirebaseIdToken } from "../utils/firebase.js";
4
5
  import { generateId } from "../utils/id.js";
5
6
 
6
7
  const auth = new Hono<{ Bindings: Env }>();
@@ -73,6 +74,105 @@ auth.post("/login", async (c) => {
73
74
  });
74
75
  });
75
76
 
77
+ /**
78
+ * POST /api/auth/firebase — sign in (or register) with a Firebase ID token.
79
+ * Works for all Firebase-backed providers (Google, GitHub, etc.).
80
+ */
81
+ async function handleFirebaseAuth(c: {
82
+ req: { json: <T>() => Promise<T> };
83
+ env: Env;
84
+ json: (data: unknown, status?: number) => Response;
85
+ }) {
86
+ const { idToken } = await c.req.json<{ idToken: string }>();
87
+
88
+ if (!idToken?.trim()) {
89
+ return c.json({ error: "idToken is required" }, 400);
90
+ }
91
+
92
+ const projectId = c.env.FIREBASE_PROJECT_ID;
93
+ if (!projectId) {
94
+ return c.json({ error: "Firebase sign-in is not configured" }, 500);
95
+ }
96
+
97
+ // 1. Verify the Firebase ID token
98
+ let firebaseUser;
99
+ try {
100
+ firebaseUser = await verifyFirebaseIdToken(idToken, projectId);
101
+ } catch (err) {
102
+ const msg = err instanceof Error ? err.message : "Token verification failed";
103
+ return c.json({ error: msg }, 401);
104
+ }
105
+
106
+ const email = firebaseUser.email?.toLowerCase();
107
+ if (!email) {
108
+ return c.json({ error: "Account has no email address" }, 400);
109
+ }
110
+
111
+ const firebaseUid = firebaseUser.sub;
112
+ // Determine provider from Firebase token (google.com, github.com, etc.)
113
+ const signInProvider = firebaseUser.firebase?.sign_in_provider ?? "unknown";
114
+ const authProvider = signInProvider.includes("google")
115
+ ? "google"
116
+ : signInProvider.includes("github")
117
+ ? "github"
118
+ : signInProvider;
119
+ const displayName = firebaseUser.name ?? email.split("@")[0];
120
+
121
+ // 2. Look up existing user by firebase_uid first, then by email
122
+ let row = await c.env.DB.prepare(
123
+ "SELECT id, email, display_name, auth_provider FROM users WHERE firebase_uid = ?",
124
+ )
125
+ .bind(firebaseUid)
126
+ .first<{ id: string; email: string; display_name: string | null; auth_provider: string }>();
127
+
128
+ if (!row) {
129
+ // Check if there's an existing email-based account — link it
130
+ row = await c.env.DB.prepare(
131
+ "SELECT id, email, display_name, auth_provider FROM users WHERE email = ?",
132
+ )
133
+ .bind(email)
134
+ .first<{ id: string; email: string; display_name: string | null; auth_provider: string }>();
135
+
136
+ if (row) {
137
+ // Link Firebase UID to existing account
138
+ await c.env.DB.prepare(
139
+ "UPDATE users SET firebase_uid = ?, auth_provider = ?, updated_at = unixepoch() WHERE id = ?",
140
+ )
141
+ .bind(firebaseUid, authProvider, row.id)
142
+ .run();
143
+ }
144
+ }
145
+
146
+ if (!row) {
147
+ // 3. Create a new user (OAuth-only, no password)
148
+ const id = generateId("u_");
149
+ await c.env.DB.prepare(
150
+ `INSERT INTO users (id, email, password_hash, display_name, auth_provider, firebase_uid)
151
+ VALUES (?, ?, '', ?, ?, ?)`,
152
+ )
153
+ .bind(id, email, displayName, authProvider, firebaseUid)
154
+ .run();
155
+
156
+ row = { id, email, display_name: displayName, auth_provider: authProvider };
157
+ }
158
+
159
+ // 4. Issue our own JWT
160
+ const secret = c.env.JWT_SECRET ?? "botschat-dev-secret";
161
+ const token = await createToken(row.id, secret);
162
+
163
+ return c.json({
164
+ id: row.id,
165
+ email: row.email,
166
+ displayName: row.display_name,
167
+ token,
168
+ });
169
+ }
170
+
171
+ // Register the unified Firebase auth handler and provider-specific aliases
172
+ auth.post("/firebase", (c) => handleFirebaseAuth(c));
173
+ auth.post("/google", (c) => handleFirebaseAuth(c));
174
+ auth.post("/github", (c) => handleFirebaseAuth(c));
175
+
76
176
  /** GET /api/auth/me — returns current user info */
77
177
  auth.get("/me", async (c) => {
78
178
  // This route requires auth middleware to be applied upstream
@@ -4,12 +4,15 @@ import { generateId, generatePairingToken } from "../utils/id.js";
4
4
 
5
5
  const pairing = new Hono<{ Bindings: Env; Variables: { userId: string } }>();
6
6
 
7
- /** GET /api/pairing-tokens — list pairing tokens for the current user */
7
+ /** GET /api/pairing-tokens — list active (non-revoked) pairing tokens for the current user */
8
8
  pairing.get("/", async (c) => {
9
9
  const userId = c.get("userId");
10
10
 
11
11
  const { results } = await c.env.DB.prepare(
12
- "SELECT id, token, label, last_connected_at, created_at FROM pairing_tokens WHERE user_id = ? ORDER BY created_at DESC",
12
+ `SELECT id, token, label, last_connected_at, last_ip, connection_count, created_at
13
+ FROM pairing_tokens
14
+ WHERE user_id = ? AND revoked_at IS NULL
15
+ ORDER BY created_at DESC`,
13
16
  )
14
17
  .bind(userId)
15
18
  .all<{
@@ -17,17 +20,20 @@ pairing.get("/", async (c) => {
17
20
  token: string;
18
21
  label: string | null;
19
22
  last_connected_at: number | null;
23
+ last_ip: string | null;
24
+ connection_count: number;
20
25
  created_at: number;
21
26
  }>();
22
27
 
23
28
  return c.json({
24
29
  tokens: (results ?? []).map((r) => ({
25
30
  id: r.id,
26
- // Show only last 8 chars for security
27
31
  token: r.token,
28
32
  tokenPreview: `bc_pat_...${r.token.slice(-8)}`,
29
33
  label: r.label,
30
34
  lastConnectedAt: r.last_connected_at,
35
+ lastIp: r.last_ip,
36
+ connectionCount: r.connection_count,
31
37
  createdAt: r.created_at,
32
38
  })),
33
39
  });
@@ -59,13 +65,14 @@ pairing.post("/", async (c) => {
59
65
  );
60
66
  });
61
67
 
62
- /** DELETE /api/pairing-tokens/:id — revoke a pairing token */
68
+ /** DELETE /api/pairing-tokens/:id — soft-revoke a pairing token */
63
69
  pairing.delete("/:id", async (c) => {
64
70
  const userId = c.get("userId");
65
71
  const tokenId = c.req.param("id");
66
72
 
73
+ // Soft-delete: set revoked_at instead of removing the row
67
74
  await c.env.DB.prepare(
68
- "DELETE FROM pairing_tokens WHERE id = ? AND user_id = ?",
75
+ "UPDATE pairing_tokens SET revoked_at = unixepoch() WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
69
76
  )
70
77
  .bind(tokenId, userId)
71
78
  .run();
@@ -0,0 +1,199 @@
1
+ import { Hono } from "hono";
2
+ import type { Env } from "../env.js";
3
+ import { createToken, hashPassword } from "../utils/auth.js";
4
+ import { verifyFirebaseIdToken } from "../utils/firebase.js";
5
+ import { generateId, generatePairingToken } from "../utils/id.js";
6
+ import { resolveCloudUrlWithHints } from "../utils/resolve-url.js";
7
+
8
+ const setup = new Hono<{ Bindings: Env }>();
9
+
10
+ /**
11
+ * POST /api/setup/init — One-shot CLI onboarding endpoint.
12
+ *
13
+ * Accepts email+password OR a Firebase idToken.
14
+ * Returns everything the CLI needs to configure the OpenClaw plugin:
15
+ * - userId, JWT token, pairing token, cloud URL, ready-to-run commands.
16
+ *
17
+ * Idempotent: if the user already has a pairing token, a new one is created
18
+ * for this setup session (old ones remain valid).
19
+ */
20
+ setup.post("/init", async (c) => {
21
+ const body = await c.req.json<{
22
+ email?: string;
23
+ password?: string;
24
+ idToken?: string;
25
+ }>();
26
+
27
+ let userId: string;
28
+ let email: string;
29
+ let displayName: string | null = null;
30
+
31
+ if (body.idToken) {
32
+ // ---- Firebase auth path ----
33
+ const projectId = c.env.FIREBASE_PROJECT_ID;
34
+ if (!projectId) {
35
+ return c.json({ error: "Firebase sign-in is not configured" }, 500);
36
+ }
37
+
38
+ let firebaseUser;
39
+ try {
40
+ firebaseUser = await verifyFirebaseIdToken(body.idToken, projectId);
41
+ } catch (err) {
42
+ const msg = err instanceof Error ? err.message : "Token verification failed";
43
+ return c.json({ error: msg }, 401);
44
+ }
45
+
46
+ email = firebaseUser.email?.toLowerCase() ?? "";
47
+ if (!email) return c.json({ error: "Account has no email" }, 400);
48
+
49
+ displayName = firebaseUser.name ?? null;
50
+ const firebaseUid = firebaseUser.sub;
51
+
52
+ // Find or create user
53
+ let row = await c.env.DB.prepare(
54
+ "SELECT id, display_name FROM users WHERE firebase_uid = ?",
55
+ ).bind(firebaseUid).first<{ id: string; display_name: string | null }>();
56
+
57
+ if (!row) {
58
+ row = await c.env.DB.prepare(
59
+ "SELECT id, display_name FROM users WHERE email = ?",
60
+ ).bind(email).first<{ id: string; display_name: string | null }>();
61
+
62
+ if (row) {
63
+ await c.env.DB.prepare(
64
+ "UPDATE users SET firebase_uid = ?, updated_at = unixepoch() WHERE id = ?",
65
+ ).bind(firebaseUid, row.id).run();
66
+ }
67
+ }
68
+
69
+ if (!row) {
70
+ const id = generateId("u_");
71
+ const signInProvider = firebaseUser.firebase?.sign_in_provider ?? "unknown";
72
+ const authProvider = signInProvider.includes("google") ? "google"
73
+ : signInProvider.includes("github") ? "github" : signInProvider;
74
+ await c.env.DB.prepare(
75
+ `INSERT INTO users (id, email, password_hash, display_name, auth_provider, firebase_uid)
76
+ VALUES (?, ?, '', ?, ?, ?)`,
77
+ ).bind(id, email, displayName ?? email.split("@")[0], authProvider, firebaseUid).run();
78
+ row = { id, display_name: displayName };
79
+ }
80
+
81
+ userId = row.id;
82
+ displayName = row.display_name;
83
+ } else {
84
+ // ---- Email + password path ----
85
+ if (!body.email?.trim() || !body.password?.trim()) {
86
+ return c.json({ error: "email+password or idToken is required" }, 400);
87
+ }
88
+
89
+ email = body.email.trim().toLowerCase();
90
+ const passwordHash = await hashPassword(body.password);
91
+
92
+ const row = await c.env.DB.prepare(
93
+ "SELECT id, display_name FROM users WHERE email = ? AND password_hash = ?",
94
+ ).bind(email, passwordHash).first<{ id: string; display_name: string | null }>();
95
+
96
+ if (!row) {
97
+ return c.json({ error: "Invalid email or password" }, 401);
98
+ }
99
+
100
+ userId = row.id;
101
+ displayName = row.display_name;
102
+ }
103
+
104
+ // ---- Create a fresh pairing token for this setup ----
105
+ const ptId = generateId("pt_");
106
+ const pairingToken = generatePairingToken();
107
+ await c.env.DB.prepare(
108
+ "INSERT INTO pairing_tokens (id, user_id, token, label) VALUES (?, ?, ?, ?)",
109
+ ).bind(ptId, userId, pairingToken, "CLI setup").run();
110
+
111
+ // ---- Ensure a default channel exists ----
112
+ let channel = await c.env.DB.prepare(
113
+ "SELECT id, name FROM channels WHERE user_id = ? LIMIT 1",
114
+ ).bind(userId).first<{ id: string; name: string }>();
115
+
116
+ if (!channel) {
117
+ const chId = generateId("ch_");
118
+ await c.env.DB.prepare(
119
+ "INSERT INTO channels (id, user_id, name, description) VALUES (?, ?, ?, ?)",
120
+ ).bind(chId, userId, "My Agent", "Default channel").run();
121
+ channel = { id: chId, name: "My Agent" };
122
+ }
123
+
124
+ // ---- Issue JWT ----
125
+ const secret = c.env.JWT_SECRET ?? "botschat-dev-secret";
126
+ const token = await createToken(userId, secret);
127
+
128
+ // ---- Resolve the best cloud URL for the plugin to connect back ----
129
+ const { cloudUrl, isLoopback, hint } = resolveCloudUrlWithHints(c.req.raw, c.env);
130
+
131
+ return c.json({
132
+ userId,
133
+ email,
134
+ displayName,
135
+ token,
136
+ pairingToken,
137
+ cloudUrl,
138
+ ...(isLoopback ? { cloudUrlWarning: hint } : {}),
139
+ channel: { id: channel.id, name: channel.name },
140
+ setupCommands: [
141
+ "openclaw plugins install @botschat/openclaw-plugin",
142
+ `openclaw config set channels.botschat.cloudUrl ${cloudUrl}`,
143
+ `openclaw config set channels.botschat.pairingToken ${pairingToken}`,
144
+ "openclaw config set channels.botschat.enabled true",
145
+ "openclaw gateway restart",
146
+ ],
147
+ });
148
+ });
149
+
150
+ /**
151
+ * GET /api/setup/cloud-url — Returns the recommended cloudUrl for the plugin.
152
+ *
153
+ * Used by the web onboarding page to display the correct URL in commands.
154
+ * No auth required (the URL is not secret).
155
+ */
156
+ setup.get("/cloud-url", async (c) => {
157
+ const { cloudUrl, isLoopback, hint } = resolveCloudUrlWithHints(c.req.raw, c.env);
158
+ return c.json({ cloudUrl, isLoopback, ...(hint ? { hint } : {}) });
159
+ });
160
+
161
+ /**
162
+ * GET /api/setup/status — Check if the user's OpenClaw is connected.
163
+ *
164
+ * Used by CLI to verify the setup was successful.
165
+ * Requires Bearer token auth.
166
+ */
167
+ setup.get("/status", async (c) => {
168
+ const authHeader = c.req.header("Authorization");
169
+ if (!authHeader?.startsWith("Bearer ")) {
170
+ return c.json({ error: "Missing Authorization header" }, 401);
171
+ }
172
+
173
+ const { verifyToken } = await import("../utils/auth.js");
174
+ const jwtSecret = c.env.JWT_SECRET ?? "botschat-dev-secret";
175
+ const payload = await verifyToken(authHeader.slice(7), jwtSecret);
176
+ if (!payload) {
177
+ return c.json({ error: "Invalid or expired token" }, 401);
178
+ }
179
+
180
+ const userId = payload.sub;
181
+
182
+ // Query the DO for connection status
183
+ const doId = c.env.CONNECTION_DO.idFromName(userId);
184
+ const stub = c.env.CONNECTION_DO.get(doId);
185
+ const resp = await stub.fetch(new Request("https://internal/status"));
186
+
187
+ if (!resp.ok) {
188
+ return c.json({ connected: false });
189
+ }
190
+
191
+ const status = await resp.json() as { openclawConnected?: boolean };
192
+
193
+ return c.json({
194
+ connected: !!status.openclawConnected,
195
+ userId,
196
+ });
197
+ });
198
+
199
+ export { setup };