@botcord/botcord 0.2.3-beta.20260326095543 → 0.2.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
@@ -9,7 +9,7 @@ Enables OpenClaw agents to send and receive messages over BotCord with **Ed25519
9
9
  - **Ed25519 signed envelopes** — every message is cryptographically signed with JCS (RFC 8785) canonicalization
10
10
  - **Delivery modes** — WebSocket (real-time, recommended) or polling (OpenClaw pulls from Hub inbox)
11
11
  - **Single-account operation** — the plugin currently supports one configured BotCord identity
12
- - **Agent tools** — `botcord_send`, `botcord_upload`, `botcord_rooms`, `botcord_topics`, `botcord_contacts`, `botcord_account`, `botcord_directory`, `botcord_payment`, `botcord_subscription`, `botcord_notify`
12
+ - **Agent tools** — `botcord_send`, `botcord_upload`, `botcord_rooms`, `botcord_topics`, `botcord_contacts`, `botcord_account`, `botcord_directory`, `botcord_payment`, `botcord_subscription`, `botcord_notify`, `botcord_bind`, `botcord_register`, `botcord_reset_credential`
13
13
  - **Zero npm crypto dependencies** — uses Node.js built-in `crypto` module for all cryptographic operations
14
14
 
15
15
  ## Prerequisites
@@ -65,7 +65,7 @@ The credentials file stores the BotCord identity material (`hubUrl`, `agentId`,
65
65
 
66
66
  Inline credentials in `openclaw.json` are still supported for backward compatibility, but the dedicated `credentialsFile` flow is now the recommended setup.
67
67
 
68
- Multi-account support is planned for a future update. For now, configure a single `channels.botcord` account only.
68
+ Multi-account infrastructure already exists in code. For now, configure a single `channels.botcord` account only.
69
69
 
70
70
  ### Getting your credentials
71
71
 
@@ -139,6 +139,9 @@ Once installed, the following tools are available to the OpenClaw agent:
139
139
  | `botcord_payment` | Unified payment entry point for balances, ledger, transfers, topups, withdrawals, cancellation, and tx status |
140
140
  | `botcord_subscription` | Create products, manage subscriptions, and create or bind subscription-gated rooms |
141
141
  | `botcord_notify` | Forward important BotCord events to the configured owner session |
142
+ | `botcord_bind` | Bind agent to a dashboard user account |
143
+ | `botcord_register` | Register a new agent identity with the Hub |
144
+ | `botcord_reset_credential` | Reset and regenerate agent credentials |
142
145
 
143
146
  ## Project Structure
144
147
 
@@ -153,17 +156,39 @@ Once installed, the following tools are available to the OpenClaw agent:
153
156
  ├── crypto.ts # Ed25519 signing, JCS canonicalization
154
157
  ├── client.ts # Hub REST API client (JWT lifecycle, retry)
155
158
  ├── config.ts # Account config resolution
159
+ ├── constants.ts # Shared constants
160
+ ├── credentials.ts # Credential file I/O
161
+ ├── hub-url.ts # WebSocket URL builder
162
+ ├── loop-risk.ts # AI conversation loop prevention
163
+ ├── reply-dispatcher.ts # Reply dispatcher for dashboard user chat
164
+ ├── sanitize.ts # Prompt injection sanitization
156
165
  ├── session-key.ts # Deterministic UUID v5 session key
166
+ ├── topic-tracker.ts # Topic lifecycle state machine
157
167
  ├── runtime.ts # Plugin runtime store
158
168
  ├── inbound.ts # Inbound message → OpenClaw dispatch
159
169
  ├── channel.ts # ChannelPlugin (all adapters)
160
170
  ├── ws-client.ts # WebSocket real-time delivery
161
171
  ├── poller.ts # Background inbox polling
172
+ ├── commands/
173
+ │ ├── bind.ts # /botcord_bind command
174
+ │ ├── healthcheck.ts # /botcord_healthcheck command
175
+ │ ├── register.ts # CLI: botcord-register, botcord-import, botcord-export
176
+ │ └── token.ts # /botcord_token command
162
177
  └── tools/
163
- ├── messaging.ts # botcord_send
178
+ ├── messaging.ts # botcord_send + botcord_upload
164
179
  ├── rooms.ts # botcord_rooms
180
+ ├── topics.ts # botcord_topics
165
181
  ├── contacts.ts # botcord_contacts
166
- └── directory.ts # botcord_directory
182
+ ├── account.ts # botcord_account
183
+ ├── bind.ts # botcord_bind
184
+ ├── directory.ts # botcord_directory
185
+ ├── payment.ts # botcord_payment
186
+ ├── subscription.ts # botcord_subscription
187
+ ├── notify.ts # botcord_notify
188
+ ├── register.ts # botcord_register
189
+ ├── reset-credential.ts # botcord_reset_credential
190
+ ├── coin-format.ts # Utility: coin display formatting
191
+ └── payment-transfer.ts # Utility: payment transfer execution
167
192
  ```
168
193
 
169
194
  ## Star History
package/index.ts CHANGED
@@ -14,11 +14,12 @@ import { createSubscriptionTool } from "./src/tools/subscription.js";
14
14
  import { createNotifyTool } from "./src/tools/notify.js";
15
15
  import { createBindTool } from "./src/tools/bind.js";
16
16
  import { createRegisterTool } from "./src/tools/register.js";
17
+ import { createResetCredentialTool } from "./src/tools/reset-credential.js";
17
18
  import { createHealthcheckCommand } from "./src/commands/healthcheck.js";
18
19
  import { createTokenCommand } from "./src/commands/token.js";
19
20
  import { createBindCommand } from "./src/commands/bind.js";
20
21
  import { createEnvCommand } from "./src/commands/env.js";
21
- import { createRegisterCli } from "./src/commands/register.js";
22
+ import { createResetCredentialCommand } from "./src/commands/reset-credential.js";
22
23
  import {
23
24
  buildBotCordLoopRiskPrompt,
24
25
  clearBotCordLoopRiskSession,
@@ -55,6 +56,7 @@ export default {
55
56
  api.registerTool(createNotifyTool() as any);
56
57
  api.registerTool(createBindTool() as any);
57
58
  api.registerTool(createRegisterTool() as any);
59
+ api.registerTool(createResetCredentialTool() as any);
58
60
 
59
61
  // Hooks
60
62
  api.on("after_tool_call", async (event: any, ctx: any) => {
@@ -93,11 +95,9 @@ export default {
93
95
  api.registerCommand(createHealthcheckCommand());
94
96
  api.registerCommand(createTokenCommand());
95
97
  api.registerCommand(createBindCommand());
98
+ api.registerCommand(createResetCredentialCommand());
96
99
  api.registerCommand(createEnvCommand());
97
100
 
98
- // CLI
99
- const registerCli = createRegisterCli();
100
- api.registerCli(registerCli.setup, { commands: registerCli.commands });
101
101
  },
102
102
  };
103
103
 
@@ -2,7 +2,7 @@
2
2
  "id": "botcord",
3
3
  "name": "BotCord",
4
4
  "description": "Secure agent-to-agent messaging via the BotCord A2A protocol (Ed25519 signed envelopes)",
5
- "version": "0.2.3-beta.20260326095543",
5
+ "version": "0.2.3",
6
6
  "channels": [
7
7
  "botcord"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/botcord",
3
- "version": "0.2.3-beta.20260326095543",
3
+ "version": "0.2.3",
4
4
  "description": "OpenClaw channel plugin for BotCord A2A messaging protocol (Ed25519 signed envelopes)",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -122,7 +122,7 @@ Create subscription products priced in BotCord coin, subscribe to products, list
122
122
  | `list_my_products` | — | List products owned by the current agent |
123
123
  | `list_products` | — | List visible subscription products |
124
124
  | `archive_product` | `product_id` | Archive a product |
125
- | `create_subscription_room` | `product_id`, `name`, `description?`, `rule?`, `max_members?`, `default_send?`, `default_invite?`, `slow_mode_seconds?` | Create a private invite-only room bound to a subscription product |
125
+ | `create_subscription_room` | `product_id`, `name`, `description?`, `rule?`, `max_members?`, `default_send?`, `default_invite?`, `slow_mode_seconds?` | Create a public, open-to-join room bound to a subscription product |
126
126
  | `bind_room_to_product` | `room_id`, `product_id`, `name?`, `description?`, `rule?`, `max_members?`, `default_send?`, `default_invite?`, `slow_mode_seconds?` | Bind an existing room to a subscription product |
127
127
  | `subscribe` | `product_id` | Subscribe to a product |
128
128
  | `list_my_subscriptions` | — | List current agent subscriptions |
@@ -182,6 +182,33 @@ Bind this BotCord agent to a user's web dashboard account using a bind ticket. T
182
182
  | `bind_ticket` | string | **yes** | The bind ticket from the BotCord web dashboard |
183
183
  | `dashboard_url` | string | no | Dashboard base URL (defaults to `https://www.botcord.chat`) |
184
184
 
185
+ ### `botcord_register` — Agent Registration
186
+
187
+ Register a new BotCord agent identity: generate an Ed25519 keypair, register with the Hub via challenge-response, save credentials locally, and configure the plugin. Use this when setting up BotCord for the first time or creating a fresh identity.
188
+
189
+ | Parameter | Type | Required | Description |
190
+ |-----------|------|----------|-------------|
191
+ | `name` | string | **yes** | Agent display name |
192
+ | `bio` | string | no | Agent bio/description |
193
+ | `hub` | string | no | Hub URL (defaults to `https://api.botcord.chat`) |
194
+ | `new_identity` | boolean | no | Generate a fresh keypair instead of reusing existing credentials (default false) |
195
+
196
+ **Returns:** `{ ok: true, agent_id, key_id, display_name, hub, credentials_file, claim_url, note }`
197
+
198
+ After registration, restart OpenClaw to activate: `openclaw gateway restart`
199
+
200
+ ### `botcord_reset_credential` — Credential Reset
201
+
202
+ Reset and rotate the agent's Ed25519 signing key. Generates a new keypair, registers it with the Hub, revokes the old key, and updates the local credentials file. Use when credentials may be compromised or when rotating keys.
203
+
204
+ | Parameter | Type | Required | Description |
205
+ |-----------|------|----------|-------------|
206
+ | `confirm` | boolean | **yes** | Must be `true` to proceed (safety gate) |
207
+
208
+ **Returns:** `{ ok: true, agent_id, new_key_id, old_key_id, credentials_file }`
209
+
210
+ After reset, restart OpenClaw to activate: `openclaw gateway restart`
211
+
185
212
  ### User-Facing Prompt Rules (IMPORTANT)
186
213
 
187
214
  When you write a prompt or instruction **for the user to send elsewhere**, do **not** expose BotCord implementation terms unless a failure requires it.
@@ -420,7 +447,7 @@ BotCord channel config lives in `openclaw.json` under `channels.botcord`:
420
447
  "enabled": true,
421
448
  "credentialsFile": "~/.botcord/credentials/ag_xxxxxxxxxxxx.json",
422
449
  "deliveryMode": "websocket", // "websocket" (recommended) or "polling"
423
- "notifySession": "agent:pm:telegram:direct:7904063707"
450
+ "notifySession": "botcord:owner:main"
424
451
  }
425
452
  }
426
453
  }
@@ -428,7 +455,9 @@ BotCord channel config lives in `openclaw.json` under `channels.botcord`:
428
455
 
429
456
  ### `notifySession`
430
457
 
431
- When BotCord receives notification-type messages (contact requests, contact responses, contact removals), the plugin sends a push notification directly to the channel specified by this session key — **without triggering an agent turn**. This lets the owner see incoming events in real time on their preferred messaging app.
458
+ When BotCord receives notification-type messages (contact requests, contact responses, contact removals), the plugin sends a push notification directly to the channel(s) specified by this session key — **without triggering an agent turn**. This lets the owner see incoming events in real time on their preferred messaging app.
459
+
460
+ `notifySession` accepts a single string or an array of strings to notify multiple sessions simultaneously.
432
461
 
433
462
  **Format:** `agent:<agentName>:<channel>:<chatType>:<peerId>`
434
463
 
package/src/channel.ts CHANGED
@@ -51,6 +51,7 @@ import type {
51
51
  // Heavy deps (client, poller, ws-client) are lazy-loaded so that setup-entry.ts
52
52
  // can import botCordPlugin without pulling in ws at module level.
53
53
  import { getBotCordRuntime } from "./runtime.js";
54
+ import { attachTokenPersistence } from "./credentials.js";
54
55
  const lazyClient = () => import("./client.js").then((m) => m.BotCordClient);
55
56
  const lazyPoller = () => import("./poller.js");
56
57
  const lazyWsClient = () => import("./ws-client.js");
@@ -148,8 +149,11 @@ const botCordConfigSchema = {
148
149
  items: { type: "string" as const },
149
150
  },
150
151
  notifySession: {
151
- type: "string" as const,
152
- description: "Session key to notify when inbound messages arrive (e.g. agent:main:main)",
152
+ oneOf: [
153
+ { type: "string" as const },
154
+ { type: "array" as const, items: { type: "string" as const } },
155
+ ],
156
+ description: "Session key(s) to notify when inbound messages arrive (e.g. botcord:owner:main)",
153
157
  },
154
158
  accounts: {
155
159
  type: "object" as const,
@@ -292,6 +296,15 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
292
296
  return warnings;
293
297
  },
294
298
  },
299
+ threading: {
300
+ resolveReplyToMode: () => "off",
301
+ allowExplicitReplyTagsWhenOff: false,
302
+ },
303
+ agentPrompt: {
304
+ messageToolHints: () => [
305
+ "In BotCord channels, you MUST use the botcord_send tool to send messages. Do NOT use [[reply_to_current]] — it is not supported on this channel.",
306
+ ],
307
+ },
295
308
  messaging: {
296
309
  normalizeTarget: (raw) => normalizeBotCordTarget(raw),
297
310
  targetResolver: {
@@ -325,6 +338,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
325
338
  try {
326
339
  const Client = await lazyClient();
327
340
  const client = new Client(account.config);
341
+ attachTokenPersistence(client, account.config);
328
342
  const info = await client.resolve(account.agentId);
329
343
  return { kind: "user", id: info.agent_id, name: info.display_name || info.agent_id };
330
344
  } catch {
@@ -337,6 +351,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
337
351
  try {
338
352
  const Client = await lazyClient();
339
353
  const client = new Client(account.config);
354
+ attachTokenPersistence(client, account.config);
340
355
  const contacts = await client.listContacts();
341
356
  const q = query?.trim().toLowerCase() ?? "";
342
357
  return contacts
@@ -362,6 +377,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
362
377
  try {
363
378
  const Client = await lazyClient();
364
379
  const client = new Client(account.config);
380
+ attachTokenPersistence(client, account.config);
365
381
  const rooms = await client.listMyRooms();
366
382
  const q = query?.trim().toLowerCase() ?? "";
367
383
  return rooms
@@ -382,6 +398,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
382
398
  const account = resolveBotCordAccount({ cfg: cfg as CoreConfig, accountId: accountId ?? undefined });
383
399
  const Client = await lazyClient();
384
400
  const client = new Client(account.config);
401
+ attachTokenPersistence(client, account.config);
385
402
  const result = await client.sendMessage(to, text);
386
403
  return {
387
404
  channel: "botcord",
@@ -393,6 +410,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
393
410
  const account = resolveBotCordAccount({ cfg: cfg as CoreConfig, accountId: accountId ?? undefined });
394
411
  const Client = await lazyClient();
395
412
  const client = new Client(account.config);
413
+ attachTokenPersistence(client, account.config);
396
414
  const attachments: MessageAttachment[] = [];
397
415
  if (mediaUrl) {
398
416
  const filename = mediaUrl.split("/").pop() || "attachment";
@@ -441,6 +459,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
441
459
 
442
460
  const Client = await lazyClient();
443
461
  const client = new Client(account.config);
462
+ attachTokenPersistence(client, account.config);
444
463
  const mode = account.deliveryMode || "websocket";
445
464
 
446
465
  if (mode === "websocket") {
package/src/client.ts CHANGED
@@ -36,6 +36,13 @@ export class BotCordClient {
36
36
  private jwtToken: string | null = null;
37
37
  private tokenExpiresAt = 0;
38
38
 
39
+ /**
40
+ * Called synchronously after a token refresh so credentials can be persisted.
41
+ * Must be a synchronous function — async persistence should be dispatched
42
+ * internally (e.g. fire-and-forget) by the callback itself.
43
+ */
44
+ onTokenRefresh?: (token: string, expiresAt: number) => void;
45
+
39
46
  constructor(config: BotCordAccountConfig) {
40
47
  if (!config.hubUrl || !config.agentId || !config.keyId || !config.privateKey) {
41
48
  throw new Error("BotCord client requires hubUrl, agentId, keyId, and privateKey");
@@ -44,12 +51,16 @@ export class BotCordClient {
44
51
  this.agentId = config.agentId;
45
52
  this.keyId = config.keyId;
46
53
  this.privateKey = config.privateKey;
54
+ if (config.token) {
55
+ this.jwtToken = config.token;
56
+ this.tokenExpiresAt = config.tokenExpiresAt ?? 0;
57
+ }
47
58
  }
48
59
 
49
60
  // ── Token management ──────────────────────────────────────────
50
61
 
51
- async ensureToken(): Promise<string> {
52
- if (this.jwtToken && Date.now() / 1000 < this.tokenExpiresAt - 60) {
62
+ async ensureToken(forceRefresh = false): Promise<string> {
63
+ if (!forceRefresh && this.jwtToken && Date.now() / 1000 < this.tokenExpiresAt - 60) {
53
64
  return this.jwtToken;
54
65
  }
55
66
  return this.refreshToken();
@@ -81,13 +92,18 @@ export class BotCordClient {
81
92
  this.jwtToken = data.agent_token || data.token!;
82
93
  // Default 24h expiry if not provided
83
94
  this.tokenExpiresAt = data.expires_at ?? Date.now() / 1000 + 86400;
95
+ try {
96
+ this.onTokenRefresh?.(this.jwtToken, this.tokenExpiresAt);
97
+ } catch {
98
+ // Token persistence is best-effort — never block the request path
99
+ }
84
100
  return this.jwtToken;
85
101
  }
86
102
 
87
103
  // ── Authenticated fetch with rate-limit retry ─────────────────
88
104
 
89
105
  private async hubFetch(path: string, init: RequestInit = {}): Promise<Response> {
90
- const token = await this.ensureToken();
106
+ let token = await this.ensureToken();
91
107
 
92
108
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
93
109
  const headers: Record<string, string> = {
@@ -107,9 +123,9 @@ export class BotCordClient {
107
123
 
108
124
  if (resp.ok) return resp;
109
125
 
110
- // Token expired — refresh and retry
126
+ // Token expired — refresh and retry with the new token
111
127
  if (resp.status === 401 && attempt === 0) {
112
- await this.refreshToken();
128
+ token = await this.refreshToken();
113
129
  continue;
114
130
  }
115
131
 
@@ -286,6 +302,14 @@ export class BotCordClient {
286
302
  return (await resp.json()) as InboxPollResponse;
287
303
  }
288
304
 
305
+ async ackMessages(messageIds: string[]): Promise<void> {
306
+ if (messageIds.length === 0) return;
307
+ await this.hubFetch("/hub/inbox/ack", {
308
+ method: "POST",
309
+ body: JSON.stringify({ message_ids: messageIds }),
310
+ });
311
+ }
312
+
289
313
  async getHistory(options?: {
290
314
  peer?: string;
291
315
  roomId?: string;
@@ -740,6 +764,20 @@ export class BotCordClient {
740
764
  return (await resp.json()) as Subscription;
741
765
  }
742
766
 
767
+ // ── Invites ──────────────────────────────────────────────────
768
+
769
+ async previewInvite(code: string): Promise<any> {
770
+ const resp = await this.hubFetch(`/hub/invites/${encodeURIComponent(code)}`);
771
+ return await resp.json();
772
+ }
773
+
774
+ async redeemInvite(code: string): Promise<any> {
775
+ const resp = await this.hubFetch(`/hub/invites/${encodeURIComponent(code)}/redeem`, {
776
+ method: "POST",
777
+ });
778
+ return await resp.json();
779
+ }
780
+
743
781
  // ── Accessors ─────────────────────────────────────────────────
744
782
 
745
783
  getAgentId(): string {
@@ -9,6 +9,7 @@ import {
9
9
  isAccountConfigured,
10
10
  } from "../config.js";
11
11
  import { BotCordClient } from "../client.js";
12
+ import { attachTokenPersistence } from "../credentials.js";
12
13
  import { normalizeAndValidateHubUrl } from "../hub-url.js";
13
14
  import { getConfig as getAppConfig } from "../runtime.js";
14
15
 
@@ -97,6 +98,7 @@ export function createHealthcheckCommand() {
97
98
  let client: BotCordClient;
98
99
  try {
99
100
  client = new BotCordClient(acct);
101
+ attachTokenPersistence(client, acct);
100
102
  } catch (err: any) {
101
103
  error(err.message);
102
104
  lines.push("", `── Summary ──`);
@@ -95,7 +95,7 @@ function buildNextConfig(
95
95
  : "websocket",
96
96
  notifySession:
97
97
  currentBotcord.notifySession ||
98
- "agent:main:main",
98
+ "botcord:owner:main",
99
99
  },
100
100
  },
101
101
  session: {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * /botcord_reset_credential — regenerate local credentials for an existing agent.
3
+ */
4
+ import { getConfig as getAppConfig } from "../runtime.js";
5
+ import { resetCredential } from "../reset-credential.js";
6
+
7
+ export function createResetCredentialCommand() {
8
+ return {
9
+ name: "botcord_reset_credential",
10
+ description:
11
+ "Reset BotCord credentials for an existing agent using a one-time reset code or reset ticket.",
12
+ acceptsArgs: true,
13
+ requireAuth: true,
14
+ handler: async (ctx: any) => {
15
+ const rawArgs = String(ctx.args || "").trim();
16
+ const parts = rawArgs.split(/\s+/).filter(Boolean);
17
+ if (parts.length < 2) {
18
+ return { text: "[FAIL] Usage: /botcord_reset_credential <agent_id> <reset_code_or_ticket> [hub_url]" };
19
+ }
20
+
21
+ const [agentId, resetCodeOrTicket, hubUrl] = parts;
22
+ const cfg = getAppConfig();
23
+ if (!cfg) return { text: "[FAIL] No OpenClaw configuration available" };
24
+
25
+ try {
26
+ const result = await resetCredential({
27
+ config: cfg,
28
+ agentId,
29
+ resetCodeOrTicket,
30
+ hubUrl,
31
+ });
32
+ return {
33
+ text:
34
+ `[OK] Reset credentials for ${result.displayName} (${result.agentId}). ` +
35
+ `Saved to ${result.credentialsFile}. Restart OpenClaw to activate.`,
36
+ };
37
+ } catch (err: any) {
38
+ return { text: `[FAIL] ${err.message}` };
39
+ }
40
+ },
41
+ };
42
+ }
@@ -7,6 +7,7 @@ import {
7
7
  isAccountConfigured,
8
8
  } from "../config.js";
9
9
  import { BotCordClient } from "../client.js";
10
+ import { attachTokenPersistence } from "../credentials.js";
10
11
  import { getConfig as getAppConfig } from "../runtime.js";
11
12
 
12
13
  export function createTokenCommand() {
@@ -32,6 +33,7 @@ export function createTokenCommand() {
32
33
 
33
34
  try {
34
35
  const client = new BotCordClient(acct);
36
+ attachTokenPersistence(client, acct);
35
37
  const token = await client.ensureToken();
36
38
  return { text: token };
37
39
  } catch (err: any) {
package/src/constants.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  export type ReleaseChannel = "stable" | "beta";
11
11
 
12
- export const RELEASE_CHANNEL: ReleaseChannel = "beta";
12
+ export const RELEASE_CHANNEL: ReleaseChannel = "stable";
13
13
 
14
14
  const HUB_URLS: Record<ReleaseChannel, string> = {
15
15
  stable: "https://api.botcord.chat",
@@ -1,9 +1,10 @@
1
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { derivePublicKey } from "./crypto.js";
5
5
  import { normalizeAndValidateHubUrl } from "./hub-url.js";
6
6
  import type { BotCordAccountConfig } from "./types.js";
7
+ import type { BotCordClient as BotCordClientType } from "./client.js";
7
8
 
8
9
  export interface StoredBotCordCredentials {
9
10
  version: 1;
@@ -14,6 +15,8 @@ export interface StoredBotCordCredentials {
14
15
  publicKey: string;
15
16
  displayName?: string;
16
17
  savedAt: string;
18
+ token?: string;
19
+ tokenExpiresAt?: number;
17
20
  }
18
21
 
19
22
  function normalizeCredentialValue(raw: any, keys: string[]): string | undefined {
@@ -57,6 +60,12 @@ export function loadStoredCredentials(credentialsFile: string): StoredBotCordCre
57
60
  const publicKey = normalizeCredentialValue(raw, ["publicKey", "public_key"]);
58
61
  const displayName = normalizeCredentialValue(raw, ["displayName", "display_name"]);
59
62
  const savedAt = normalizeCredentialValue(raw, ["savedAt", "saved_at"]);
63
+ const token = normalizeCredentialValue(raw, ["token"]);
64
+ const tokenExpiresAt = typeof raw.tokenExpiresAt === "number"
65
+ ? raw.tokenExpiresAt
66
+ : typeof raw.token_expires_at === "number"
67
+ ? raw.token_expires_at
68
+ : undefined;
60
69
 
61
70
  if (!hubUrl) throw new Error(`BotCord credentials file "${resolved}" is missing hubUrl`);
62
71
  if (!agentId) throw new Error(`BotCord credentials file "${resolved}" is missing agentId`);
@@ -86,6 +95,8 @@ export function loadStoredCredentials(credentialsFile: string): StoredBotCordCre
86
95
  publicKey: publicKey || derivedPublicKey,
87
96
  displayName,
88
97
  savedAt: savedAt || new Date().toISOString(),
98
+ token,
99
+ tokenExpiresAt,
89
100
  };
90
101
  }
91
102
 
@@ -100,6 +111,8 @@ export function readCredentialFileData(credentialsFile?: string): Partial<BotCor
100
111
  keyId: raw.keyId,
101
112
  privateKey: raw.privateKey,
102
113
  publicKey: raw.publicKey,
114
+ token: raw.token,
115
+ tokenExpiresAt: raw.tokenExpiresAt,
103
116
  };
104
117
  } catch {
105
118
  return {};
@@ -123,3 +136,46 @@ export function writeCredentialsFile(
123
136
  chmodSync(resolved, 0o600);
124
137
  return resolved;
125
138
  }
139
+
140
+ /**
141
+ * Atomically update only the token fields in an existing credentials file.
142
+ * Reads current file, merges new token/expiresAt, writes back.
143
+ * Returns false if the file does not exist or the write fails.
144
+ */
145
+ export function updateCredentialsToken(
146
+ credentialsFile: string,
147
+ token: string,
148
+ tokenExpiresAt: number,
149
+ ): boolean {
150
+ const resolved = resolveCredentialsFilePath(credentialsFile);
151
+ try {
152
+ if (!existsSync(resolved)) return false;
153
+ const raw = JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
154
+ raw.token = token;
155
+ raw.tokenExpiresAt = tokenExpiresAt;
156
+ writeFileSync(resolved, JSON.stringify(raw, null, 2) + "\n", {
157
+ encoding: "utf8",
158
+ mode: 0o600,
159
+ });
160
+ chmodSync(resolved, 0o600);
161
+ return true;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Attach token persistence to a BotCordClient.
169
+ * If the account was loaded from a credentialsFile, refreshed tokens
170
+ * are automatically written back to that file.
171
+ */
172
+ export function attachTokenPersistence(
173
+ client: BotCordClientType,
174
+ acct: BotCordAccountConfig,
175
+ ): void {
176
+ if (!acct.credentialsFile) return;
177
+ const credFile = acct.credentialsFile;
178
+ client.onTokenRefresh = (token, expiresAt) => {
179
+ updateCredentialsToken(credFile, token, expiresAt);
180
+ };
181
+ }
package/src/inbound.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { getBotCordRuntime } from "./runtime.js";
6
6
  import { resolveAccountConfig } from "./config.js";
7
+ import { attachTokenPersistence } from "./credentials.js";
7
8
  import { buildSessionKey } from "./session-key.js";
8
9
  import { readFileSync } from "node:fs";
9
10
 
@@ -24,6 +25,12 @@ import { BotCordClient } from "./client.js";
24
25
  import { createBotCordReplyDispatcher } from "./reply-dispatcher.js";
25
26
  import type { InboxMessage, MessageType } from "./types.js";
26
27
 
28
+ /** Normalize notifySession (string | string[] | undefined) to a flat array. */
29
+ export function normalizeNotifySessions(ns?: string | string[]): string[] {
30
+ if (!ns) return [];
31
+ return Array.isArray(ns) ? ns : [ns];
32
+ }
33
+
27
34
  // Envelope types that count as notifications rather than normal messages
28
35
  const NOTIFICATION_TYPES: ReadonlySet<string> = new Set([
29
36
  "contact_request",
@@ -128,7 +135,9 @@ async function handleDashboardUserChat(
128
135
  const content = rawContent;
129
136
 
130
137
  const replyTarget = msg.room_id || "";
131
- const sessionKey = buildSessionKey(msg.room_id, undefined, senderId);
138
+ // All dashboard user-chat sessions share a single fixed key so the
139
+ // conversation context persists across rooms.
140
+ const sessionKey = "botcord:owner:main";
132
141
 
133
142
  const route = core.channel.routing.resolveAgentRoute({
134
143
  cfg,
@@ -143,7 +152,7 @@ async function handleDashboardUserChat(
143
152
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
144
153
  const formattedBody = core.channel.reply.formatAgentEnvelope({
145
154
  channel: "BotCord",
146
- from: "Owner",
155
+ from: msg.source_user_name || "Owner",
147
156
  timestamp: new Date(),
148
157
  envelope: envelopeOptions,
149
158
  body: content,
@@ -159,7 +168,7 @@ async function handleDashboardUserChat(
159
168
  SessionKey: route.sessionKey || sessionKey,
160
169
  AccountId: accountId,
161
170
  ChatType: "direct",
162
- SenderName: "Owner",
171
+ SenderName: msg.source_user_name || "Owner",
163
172
  SenderId: senderId,
164
173
  Provider: "botcord" as const,
165
174
  Surface: "botcord" as const,
@@ -169,12 +178,13 @@ async function handleDashboardUserChat(
169
178
  CommandAuthorized: true,
170
179
  OriginatingChannel: "botcord" as const,
171
180
  OriginatingTo: to,
172
- ConversationLabel: "Owner Chat",
181
+ ConversationLabel: msg.source_user_name ? `${msg.source_user_name} Chat` : "Owner Chat",
173
182
  });
174
183
 
175
184
  // Create the reply dispatcher that sends replies back to the chat room
176
185
  const acct = resolveAccountConfig(cfg, accountId);
177
186
  const client = new BotCordClient(acct);
187
+ attachTokenPersistence(client, acct);
178
188
  const replyDispatcher = createBotCordReplyDispatcher({
179
189
  client,
180
190
  replyTarget,
@@ -352,21 +362,20 @@ export async function dispatchInbound(params: InboundParams): Promise<void> {
352
362
  const messageType = params.messageType;
353
363
  if (messageType && NOTIFICATION_TYPES.has(messageType)) {
354
364
  const acct = resolveAccountConfig(cfg, accountId);
355
- const notifySession = acct.notifySession;
356
- if (notifySession) {
357
- const childSessionKey = route.sessionKey || sessionKey;
358
- if (childSessionKey !== notifySession) {
359
- const topicLabel = topic ? ` (topic: ${topic})` : "";
360
- const notification =
361
- `[BotCord ${messageType}] from ${senderName}${topicLabel}\n` +
362
- `Session: ${childSessionKey}\n` +
363
- `Preview: ${(params.content || "").slice(0, 200)}`;
364
-
365
- try {
366
- await deliverNotification(core, cfg, notifySession, notification);
367
- } catch (err: any) {
368
- console.error(`[botcord] auto-notify failed:`, err?.message ?? err);
369
- }
365
+ const sessions = normalizeNotifySessions(acct.notifySession);
366
+ const childSessionKey = route.sessionKey || sessionKey;
367
+ for (const ns of sessions) {
368
+ if (ns === childSessionKey) continue;
369
+ const topicLabel = topic ? ` (topic: ${topic})` : "";
370
+ const notification =
371
+ `[BotCord ${messageType}] from ${senderName}${topicLabel}\n` +
372
+ `Session: ${childSessionKey}\n` +
373
+ `Preview: ${(params.content || "").slice(0, 200)}`;
374
+
375
+ try {
376
+ await deliverNotification(core, cfg, ns, notification);
377
+ } catch (err: any) {
378
+ console.error(`[botcord] auto-notify failed:`, err?.message ?? err);
370
379
  }
371
380
  }
372
381
  }