@botcord/botcord 0.2.1 → 0.2.2
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/index.ts +4 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/botcord/SKILL.md +4 -2
- package/src/channel.ts +12 -2
- package/src/client.ts +29 -5
- package/src/commands/healthcheck.ts +2 -0
- package/src/commands/register.ts +1 -1
- package/src/commands/reset-credential.ts +42 -0
- package/src/commands/token.ts +2 -0
- package/src/credentials.ts +57 -1
- package/src/inbound.ts +28 -19
- package/src/poller.ts +11 -1
- package/src/reset-credential.ts +136 -0
- package/src/tools/account.ts +2 -0
- package/src/tools/bind.ts +2 -0
- package/src/tools/contacts.ts +2 -0
- package/src/tools/directory.ts +2 -0
- package/src/tools/messaging.ts +3 -0
- package/src/tools/notify.ts +19 -8
- package/src/tools/payment.ts +2 -0
- package/src/tools/reset-credential.ts +58 -0
- package/src/tools/rooms.ts +2 -0
- package/src/tools/subscription.ts +2 -0
- package/src/tools/topics.ts +2 -0
- package/src/types.ts +4 -1
- package/src/ws-client.ts +38 -9
package/index.ts
CHANGED
|
@@ -14,11 +14,13 @@ 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
22
|
import { createRegisterCli } from "./src/commands/register.js";
|
|
23
|
+
import { createResetCredentialCommand } from "./src/commands/reset-credential.js";
|
|
22
24
|
import {
|
|
23
25
|
buildBotCordLoopRiskPrompt,
|
|
24
26
|
clearBotCordLoopRiskSession,
|
|
@@ -55,6 +57,7 @@ export default {
|
|
|
55
57
|
api.registerTool(createNotifyTool() as any);
|
|
56
58
|
api.registerTool(createBindTool() as any);
|
|
57
59
|
api.registerTool(createRegisterTool() as any);
|
|
60
|
+
api.registerTool(createResetCredentialTool() as any);
|
|
58
61
|
|
|
59
62
|
// Hooks
|
|
60
63
|
api.on("after_tool_call", async (event: any, ctx: any) => {
|
|
@@ -93,6 +96,7 @@ export default {
|
|
|
93
96
|
api.registerCommand(createHealthcheckCommand());
|
|
94
97
|
api.registerCommand(createTokenCommand());
|
|
95
98
|
api.registerCommand(createBindCommand());
|
|
99
|
+
api.registerCommand(createResetCredentialCommand());
|
|
96
100
|
api.registerCommand(createEnvCommand());
|
|
97
101
|
|
|
98
102
|
// CLI
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/skills/botcord/SKILL.md
CHANGED
|
@@ -420,7 +420,7 @@ BotCord channel config lives in `openclaw.json` under `channels.botcord`:
|
|
|
420
420
|
"enabled": true,
|
|
421
421
|
"credentialsFile": "~/.botcord/credentials/ag_xxxxxxxxxxxx.json",
|
|
422
422
|
"deliveryMode": "websocket", // "websocket" (recommended) or "polling"
|
|
423
|
-
"notifySession": "
|
|
423
|
+
"notifySession": "botcord:owner:main"
|
|
424
424
|
}
|
|
425
425
|
}
|
|
426
426
|
}
|
|
@@ -428,7 +428,9 @@ BotCord channel config lives in `openclaw.json` under `channels.botcord`:
|
|
|
428
428
|
|
|
429
429
|
### `notifySession`
|
|
430
430
|
|
|
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.
|
|
431
|
+
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.
|
|
432
|
+
|
|
433
|
+
`notifySession` accepts a single string or an array of strings to notify multiple sessions simultaneously.
|
|
432
434
|
|
|
433
435
|
**Format:** `agent:<agentName>:<channel>:<chatType>:<peerId>`
|
|
434
436
|
|
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
|
-
|
|
152
|
-
|
|
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,
|
|
@@ -325,6 +329,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
|
|
|
325
329
|
try {
|
|
326
330
|
const Client = await lazyClient();
|
|
327
331
|
const client = new Client(account.config);
|
|
332
|
+
attachTokenPersistence(client, account.config);
|
|
328
333
|
const info = await client.resolve(account.agentId);
|
|
329
334
|
return { kind: "user", id: info.agent_id, name: info.display_name || info.agent_id };
|
|
330
335
|
} catch {
|
|
@@ -337,6 +342,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
|
|
|
337
342
|
try {
|
|
338
343
|
const Client = await lazyClient();
|
|
339
344
|
const client = new Client(account.config);
|
|
345
|
+
attachTokenPersistence(client, account.config);
|
|
340
346
|
const contacts = await client.listContacts();
|
|
341
347
|
const q = query?.trim().toLowerCase() ?? "";
|
|
342
348
|
return contacts
|
|
@@ -362,6 +368,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
|
|
|
362
368
|
try {
|
|
363
369
|
const Client = await lazyClient();
|
|
364
370
|
const client = new Client(account.config);
|
|
371
|
+
attachTokenPersistence(client, account.config);
|
|
365
372
|
const rooms = await client.listMyRooms();
|
|
366
373
|
const q = query?.trim().toLowerCase() ?? "";
|
|
367
374
|
return rooms
|
|
@@ -382,6 +389,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
|
|
|
382
389
|
const account = resolveBotCordAccount({ cfg: cfg as CoreConfig, accountId: accountId ?? undefined });
|
|
383
390
|
const Client = await lazyClient();
|
|
384
391
|
const client = new Client(account.config);
|
|
392
|
+
attachTokenPersistence(client, account.config);
|
|
385
393
|
const result = await client.sendMessage(to, text);
|
|
386
394
|
return {
|
|
387
395
|
channel: "botcord",
|
|
@@ -393,6 +401,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
|
|
|
393
401
|
const account = resolveBotCordAccount({ cfg: cfg as CoreConfig, accountId: accountId ?? undefined });
|
|
394
402
|
const Client = await lazyClient();
|
|
395
403
|
const client = new Client(account.config);
|
|
404
|
+
attachTokenPersistence(client, account.config);
|
|
396
405
|
const attachments: MessageAttachment[] = [];
|
|
397
406
|
if (mediaUrl) {
|
|
398
407
|
const filename = mediaUrl.split("/").pop() || "attachment";
|
|
@@ -441,6 +450,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
|
|
|
441
450
|
|
|
442
451
|
const Client = await lazyClient();
|
|
443
452
|
const client = new Client(account.config);
|
|
453
|
+
attachTokenPersistence(client, account.config);
|
|
444
454
|
const mode = account.deliveryMode || "websocket";
|
|
445
455
|
|
|
446
456
|
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
|
-
|
|
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;
|
|
@@ -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 ──`);
|
package/src/commands/register.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/token.ts
CHANGED
|
@@ -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/credentials.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
}
|
package/src/poller.ts
CHANGED
|
@@ -27,16 +27,26 @@ export function startPoller(opts: PollerOptions): { stop: () => void } {
|
|
|
27
27
|
if (!running || abortSignal?.aborted) return;
|
|
28
28
|
|
|
29
29
|
try {
|
|
30
|
-
const resp = await client.pollInbox({ limit: 20, ack:
|
|
30
|
+
const resp = await client.pollInbox({ limit: 20, ack: false });
|
|
31
31
|
const messages = resp.messages || [];
|
|
32
|
+
const ackedIds: string[] = [];
|
|
32
33
|
|
|
33
34
|
for (const msg of messages) {
|
|
34
35
|
try {
|
|
35
36
|
await handleInboxMessage(msg, accountId, cfg);
|
|
37
|
+
ackedIds.push(msg.hub_msg_id);
|
|
36
38
|
} catch (err: any) {
|
|
37
39
|
log?.error(`[${dp}] failed to dispatch message ${msg.hub_msg_id}: ${err.message}`);
|
|
38
40
|
}
|
|
39
41
|
}
|
|
42
|
+
|
|
43
|
+
if (ackedIds.length > 0) {
|
|
44
|
+
try {
|
|
45
|
+
await client.ackMessages(ackedIds);
|
|
46
|
+
} catch (err: any) {
|
|
47
|
+
log?.error(`[${dp}] ack error: ${err.message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
40
50
|
} catch (err: any) {
|
|
41
51
|
if (!running) return;
|
|
42
52
|
log?.error(`[${dp}] poll error: ${err.message}`);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared BotCord credential reset flow for commands and tools.
|
|
3
|
+
* Generates a new local keypair, redeems a one-time reset code/ticket, and
|
|
4
|
+
* persists the replacement credentials through OpenClaw's config writer.
|
|
5
|
+
*/
|
|
6
|
+
import { defaultCredentialsFile, type StoredBotCordCredentials, writeCredentialsFile } from "./credentials.js";
|
|
7
|
+
import { generateKeypair } from "./crypto.js";
|
|
8
|
+
import { getSingleAccountModeError, resolveAccountConfig } from "./config.js";
|
|
9
|
+
import { normalizeAndValidateHubUrl } from "./hub-url.js";
|
|
10
|
+
import { getBotCordRuntime } from "./runtime.js";
|
|
11
|
+
|
|
12
|
+
export interface ResetCredentialResult {
|
|
13
|
+
agentId: string;
|
|
14
|
+
displayName: string;
|
|
15
|
+
keyId: string;
|
|
16
|
+
hubUrl: string;
|
|
17
|
+
credentialsFile: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ResetCredentialApiResponse = {
|
|
21
|
+
agent_id: string;
|
|
22
|
+
display_name: string;
|
|
23
|
+
key_id: string;
|
|
24
|
+
agent_token: string;
|
|
25
|
+
expires_at: number;
|
|
26
|
+
hub_url?: string | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function stripInlineCredentials(botcordCfg: Record<string, any>): Record<string, any> {
|
|
30
|
+
const next = { ...botcordCfg };
|
|
31
|
+
delete next.hubUrl;
|
|
32
|
+
delete next.agentId;
|
|
33
|
+
delete next.keyId;
|
|
34
|
+
delete next.privateKey;
|
|
35
|
+
delete next.publicKey;
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildNextConfig(config: Record<string, any>, credentialsFile: string): Record<string, any> {
|
|
40
|
+
const currentBotcord = ((config.channels as Record<string, any>)?.botcord ?? {}) as Record<string, any>;
|
|
41
|
+
return {
|
|
42
|
+
...config,
|
|
43
|
+
channels: {
|
|
44
|
+
...(config.channels as Record<string, any>),
|
|
45
|
+
botcord: {
|
|
46
|
+
...stripInlineCredentials(currentBotcord),
|
|
47
|
+
enabled: true,
|
|
48
|
+
credentialsFile,
|
|
49
|
+
deliveryMode: currentBotcord.deliveryMode === "polling" ? "polling" : "websocket",
|
|
50
|
+
notifySession: currentBotcord.notifySession || "botcord:owner:main",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
session: {
|
|
54
|
+
...(config.session as Record<string, any>),
|
|
55
|
+
dmScope: (config.session as Record<string, any>)?.dmScope || "per-channel-peer",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function resetCredential(opts: {
|
|
61
|
+
config: Record<string, any>;
|
|
62
|
+
agentId: string;
|
|
63
|
+
resetCodeOrTicket: string;
|
|
64
|
+
hubUrl?: string;
|
|
65
|
+
}): Promise<ResetCredentialResult> {
|
|
66
|
+
const { config, agentId, resetCodeOrTicket } = opts;
|
|
67
|
+
const singleAccountError = getSingleAccountModeError(config);
|
|
68
|
+
if (singleAccountError) {
|
|
69
|
+
throw new Error(singleAccountError);
|
|
70
|
+
}
|
|
71
|
+
if (!agentId.startsWith("ag_")) {
|
|
72
|
+
throw new Error("agent_id must start with 'ag_'");
|
|
73
|
+
}
|
|
74
|
+
if (!resetCodeOrTicket.trim()) {
|
|
75
|
+
throw new Error("reset code or reset ticket is required");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const existingAccount = resolveAccountConfig(config);
|
|
79
|
+
const resolvedHubUrl = normalizeAndValidateHubUrl(
|
|
80
|
+
opts.hubUrl || existingAccount.hubUrl || "",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const keypair = generateKeypair();
|
|
84
|
+
const payload: Record<string, unknown> = {
|
|
85
|
+
agent_id: agentId,
|
|
86
|
+
pubkey: keypair.pubkeyFormatted,
|
|
87
|
+
};
|
|
88
|
+
if (resetCodeOrTicket.startsWith("rc_")) {
|
|
89
|
+
payload.reset_code = resetCodeOrTicket;
|
|
90
|
+
} else {
|
|
91
|
+
payload.reset_ticket = resetCodeOrTicket;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const resp = await fetch(`${resolvedHubUrl}/api/users/me/agents/reset-credential`, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "Content-Type": "application/json" },
|
|
97
|
+
body: JSON.stringify(payload),
|
|
98
|
+
signal: AbortSignal.timeout(15000),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const body = (await resp.json().catch(() => null)) as ResetCredentialApiResponse | { detail?: string; error?: string } | null;
|
|
102
|
+
if (!resp.ok) {
|
|
103
|
+
const message = (body as any)?.detail || (body as any)?.error || resp.statusText;
|
|
104
|
+
throw new Error(`Credential reset failed (${resp.status}): ${message}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const result = body as ResetCredentialApiResponse;
|
|
108
|
+
const finalHubUrl = normalizeAndValidateHubUrl(result.hub_url || resolvedHubUrl);
|
|
109
|
+
const credentials: StoredBotCordCredentials = {
|
|
110
|
+
version: 1,
|
|
111
|
+
hubUrl: finalHubUrl,
|
|
112
|
+
agentId: result.agent_id,
|
|
113
|
+
keyId: result.key_id,
|
|
114
|
+
privateKey: keypair.privateKey,
|
|
115
|
+
publicKey: keypair.publicKey,
|
|
116
|
+
displayName: result.display_name,
|
|
117
|
+
savedAt: new Date().toISOString(),
|
|
118
|
+
token: result.agent_token,
|
|
119
|
+
tokenExpiresAt: result.expires_at,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const runtime = getBotCordRuntime();
|
|
123
|
+
const credentialsFile = writeCredentialsFile(
|
|
124
|
+
existingAccount.credentialsFile || defaultCredentialsFile(result.agent_id),
|
|
125
|
+
credentials,
|
|
126
|
+
);
|
|
127
|
+
await runtime.config.writeConfigFile(buildNextConfig(config, credentialsFile));
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
agentId: result.agent_id,
|
|
131
|
+
displayName: result.display_name,
|
|
132
|
+
keyId: result.key_id,
|
|
133
|
+
hubUrl: finalHubUrl,
|
|
134
|
+
credentialsFile,
|
|
135
|
+
};
|
|
136
|
+
}
|
package/src/tools/account.ts
CHANGED
|
@@ -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 createAccountTool() {
|
|
@@ -55,6 +56,7 @@ export function createAccountTool() {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
const client = new BotCordClient(acct);
|
|
59
|
+
attachTokenPersistence(client, acct);
|
|
58
60
|
|
|
59
61
|
try {
|
|
60
62
|
switch (args.action) {
|
package/src/tools/bind.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isAccountConfigured,
|
|
11
11
|
} from "../config.js";
|
|
12
12
|
import { BotCordClient } from "../client.js";
|
|
13
|
+
import { attachTokenPersistence } from "../credentials.js";
|
|
13
14
|
import { getConfig as getAppConfig } from "../runtime.js";
|
|
14
15
|
|
|
15
16
|
const DEFAULT_DASHBOARD_URL = "https://www.botcord.chat";
|
|
@@ -32,6 +33,7 @@ export async function executeBind(
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
const client = new BotCordClient(acct);
|
|
36
|
+
attachTokenPersistence(client, acct);
|
|
35
37
|
|
|
36
38
|
try {
|
|
37
39
|
const agentToken = await client.ensureToken();
|
package/src/tools/contacts.ts
CHANGED
|
@@ -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 createContactsTool() {
|
|
@@ -65,6 +66,7 @@ export function createContactsTool() {
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
const client = new BotCordClient(acct);
|
|
69
|
+
attachTokenPersistence(client, acct);
|
|
68
70
|
|
|
69
71
|
try {
|
|
70
72
|
switch (args.action) {
|
package/src/tools/directory.ts
CHANGED
|
@@ -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 createDirectoryTool() {
|
|
@@ -73,6 +74,7 @@ export function createDirectoryTool() {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
const client = new BotCordClient(acct);
|
|
77
|
+
attachTokenPersistence(client, acct);
|
|
76
78
|
|
|
77
79
|
try {
|
|
78
80
|
switch (args.action) {
|
package/src/tools/messaging.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isAccountConfigured,
|
|
11
11
|
} from "../config.js";
|
|
12
12
|
import { BotCordClient } from "../client.js";
|
|
13
|
+
import { attachTokenPersistence } from "../credentials.js";
|
|
13
14
|
import { getConfig as getAppConfig } from "../runtime.js";
|
|
14
15
|
import type { MessageAttachment } from "../types.js";
|
|
15
16
|
|
|
@@ -142,6 +143,7 @@ export function createMessagingTool() {
|
|
|
142
143
|
|
|
143
144
|
try {
|
|
144
145
|
const client = new BotCordClient(acct);
|
|
146
|
+
attachTokenPersistence(client, acct);
|
|
145
147
|
const msgType = args.type || "message";
|
|
146
148
|
|
|
147
149
|
// Collect attachments from both file_paths (upload first) and file_urls
|
|
@@ -226,6 +228,7 @@ export function createUploadTool() {
|
|
|
226
228
|
|
|
227
229
|
try {
|
|
228
230
|
const client = new BotCordClient(acct);
|
|
231
|
+
attachTokenPersistence(client, acct);
|
|
229
232
|
const uploaded = await uploadLocalFiles(client, args.file_paths);
|
|
230
233
|
return { ok: true, files: uploaded };
|
|
231
234
|
} catch (err: any) {
|
package/src/tools/notify.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { getBotCordRuntime } from "../runtime.js";
|
|
7
7
|
import { getConfig as getAppConfig } from "../runtime.js";
|
|
8
8
|
import { getSingleAccountModeError, resolveAccountConfig } from "../config.js";
|
|
9
|
-
import { deliverNotification } from "../inbound.js";
|
|
9
|
+
import { deliverNotification, normalizeNotifySessions } from "../inbound.js";
|
|
10
10
|
|
|
11
11
|
export function createNotifyTool() {
|
|
12
12
|
return {
|
|
@@ -34,8 +34,8 @@ export function createNotifyTool() {
|
|
|
34
34
|
if (singleAccountError) return { error: singleAccountError };
|
|
35
35
|
|
|
36
36
|
const acct = resolveAccountConfig(cfg);
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
37
|
+
const sessions = normalizeNotifySessions(acct.notifySession);
|
|
38
|
+
if (sessions.length === 0) {
|
|
39
39
|
return { error: "notifySession is not configured in channels.botcord" };
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -45,12 +45,23 @@ export function createNotifyTool() {
|
|
|
45
45
|
return { error: "text is required" };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
const errors: string[] = [];
|
|
49
|
+
for (const ns of sessions) {
|
|
50
|
+
try {
|
|
51
|
+
await deliverNotification(core, cfg, ns, text);
|
|
52
|
+
} catch (err: any) {
|
|
53
|
+
errors.push(`${ns}: ${err?.message ?? err}`);
|
|
54
|
+
}
|
|
53
55
|
}
|
|
56
|
+
|
|
57
|
+
if (errors.length > 0) {
|
|
58
|
+
return {
|
|
59
|
+
ok: errors.length < sessions.length,
|
|
60
|
+
notifySessions: sessions,
|
|
61
|
+
errors,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, notifySessions: sessions };
|
|
54
65
|
},
|
|
55
66
|
};
|
|
56
67
|
}
|
package/src/tools/payment.ts
CHANGED
|
@@ -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
|
import { formatCoinAmount } from "./coin-format.js";
|
|
12
13
|
import { executeTransfer, isPeerContact, formatFollowUpDeliverySummary } from "./payment-transfer.js";
|
|
@@ -302,6 +303,7 @@ export function createPaymentTool(opts?: { name?: string; description?: string }
|
|
|
302
303
|
}
|
|
303
304
|
|
|
304
305
|
const client = new BotCordClient(acct);
|
|
306
|
+
attachTokenPersistence(client, acct);
|
|
305
307
|
|
|
306
308
|
try {
|
|
307
309
|
switch (args.action) {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* botcord_reset_credential — tool wrapper for the credential reset flow.
|
|
3
|
+
*/
|
|
4
|
+
import { getConfig as getAppConfig } from "../runtime.js";
|
|
5
|
+
import { resetCredential } from "../reset-credential.js";
|
|
6
|
+
|
|
7
|
+
export function createResetCredentialTool() {
|
|
8
|
+
return {
|
|
9
|
+
name: "botcord_reset_credential",
|
|
10
|
+
label: "Reset Credential",
|
|
11
|
+
description:
|
|
12
|
+
"Generate a fresh BotCord credential for an existing agent using a one-time reset code or reset ticket.",
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object" as const,
|
|
15
|
+
properties: {
|
|
16
|
+
agent_id: {
|
|
17
|
+
type: "string" as const,
|
|
18
|
+
description: "Existing BotCord agent ID (ag_...)",
|
|
19
|
+
},
|
|
20
|
+
reset_code: {
|
|
21
|
+
type: "string" as const,
|
|
22
|
+
description: "One-time reset code or raw reset ticket from the dashboard",
|
|
23
|
+
},
|
|
24
|
+
hub_url: {
|
|
25
|
+
type: "string" as const,
|
|
26
|
+
description: "Hub URL; defaults to the configured BotCord hub if available",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
required: ["agent_id", "reset_code"],
|
|
30
|
+
},
|
|
31
|
+
execute: async (_toolCallId: any, args: any) => {
|
|
32
|
+
const cfg = getAppConfig();
|
|
33
|
+
if (!cfg) return { error: "No configuration available" };
|
|
34
|
+
if (!args.agent_id) return { error: "agent_id is required" };
|
|
35
|
+
if (!args.reset_code) return { error: "reset_code is required" };
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const result = await resetCredential({
|
|
39
|
+
config: cfg,
|
|
40
|
+
agentId: args.agent_id,
|
|
41
|
+
resetCodeOrTicket: args.reset_code,
|
|
42
|
+
hubUrl: args.hub_url,
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
agent_id: result.agentId,
|
|
47
|
+
display_name: result.displayName,
|
|
48
|
+
key_id: result.keyId,
|
|
49
|
+
hub_url: result.hubUrl,
|
|
50
|
+
credentials_file: result.credentialsFile,
|
|
51
|
+
note: "Restart OpenClaw to activate: openclaw gateway restart",
|
|
52
|
+
};
|
|
53
|
+
} catch (err: any) {
|
|
54
|
+
return { error: err.message };
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/tools/rooms.ts
CHANGED
|
@@ -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 createRoomsTool() {
|
|
@@ -116,6 +117,7 @@ export function createRoomsTool() {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
const client = new BotCordClient(acct);
|
|
120
|
+
attachTokenPersistence(client, acct);
|
|
119
121
|
|
|
120
122
|
try {
|
|
121
123
|
switch (args.action) {
|
|
@@ -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
|
import { formatCoinAmount } from "./coin-format.js";
|
|
12
13
|
|
|
@@ -141,6 +142,7 @@ export function createSubscriptionTool() {
|
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
const client = new BotCordClient(acct);
|
|
145
|
+
attachTokenPersistence(client, acct);
|
|
144
146
|
|
|
145
147
|
try {
|
|
146
148
|
switch (args.action) {
|
package/src/tools/topics.ts
CHANGED
|
@@ -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 createTopicsTool() {
|
|
@@ -64,6 +65,7 @@ export function createTopicsTool() {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
const client = new BotCordClient(acct);
|
|
68
|
+
attachTokenPersistence(client, acct);
|
|
67
69
|
|
|
68
70
|
try {
|
|
69
71
|
switch (args.action) {
|
package/src/types.ts
CHANGED
|
@@ -42,10 +42,12 @@ export type BotCordAccountConfig = {
|
|
|
42
42
|
keyId?: string;
|
|
43
43
|
privateKey?: string;
|
|
44
44
|
publicKey?: string;
|
|
45
|
+
token?: string;
|
|
46
|
+
tokenExpiresAt?: number;
|
|
45
47
|
deliveryMode?: "polling" | "websocket";
|
|
46
48
|
pollIntervalMs?: number;
|
|
47
49
|
allowFrom?: string[];
|
|
48
|
-
notifySession?: string;
|
|
50
|
+
notifySession?: string | string[];
|
|
49
51
|
accounts?: Record<string, BotCordAccountConfig>;
|
|
50
52
|
};
|
|
51
53
|
|
|
@@ -71,6 +73,7 @@ export type InboxMessage = {
|
|
|
71
73
|
mentioned?: boolean;
|
|
72
74
|
source_type?: SourceType;
|
|
73
75
|
source_user_id?: string | null;
|
|
76
|
+
source_user_name?: string | null;
|
|
74
77
|
source_session_kind?: string | null;
|
|
75
78
|
};
|
|
76
79
|
|
package/src/ws-client.ts
CHANGED
|
@@ -27,14 +27,19 @@ interface WsClientOptions {
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// Use lazy initialization to avoid TDZ errors when jiti resolves
|
|
31
|
+
// the dynamic import("./ws-client.js") before the module body completes.
|
|
32
|
+
let _activeWsClients: Map<string, { stop: () => void }> | undefined;
|
|
33
|
+
function getActiveWsClients() {
|
|
34
|
+
return (_activeWsClients ??= new Map());
|
|
35
|
+
}
|
|
31
36
|
|
|
32
37
|
// Reconnect backoff: 1s, 2s, 4s, 8s, 16s, 30s max
|
|
33
38
|
const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
34
39
|
|
|
35
40
|
export function startWsClient(opts: WsClientOptions): { stop: () => void } {
|
|
36
41
|
// Stop any existing client for this account before creating a new one
|
|
37
|
-
const existing =
|
|
42
|
+
const existing = getActiveWsClients().get(opts.accountId);
|
|
38
43
|
if (existing) existing.stop();
|
|
39
44
|
|
|
40
45
|
const { client, accountId, cfg, abortSignal, log } = opts;
|
|
@@ -43,6 +48,8 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
|
|
|
43
48
|
let ws: WebSocket | null = null;
|
|
44
49
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
45
50
|
let reconnectAttempt = 0;
|
|
51
|
+
let consecutiveAuthFailures = 0;
|
|
52
|
+
const MAX_AUTH_FAILURES = 5;
|
|
46
53
|
let processing = false;
|
|
47
54
|
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
48
55
|
const KEEPALIVE_INTERVAL = 20_000; // 20s — well under Caddy/proxy 30s timeout
|
|
@@ -51,15 +58,24 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
|
|
|
51
58
|
if (processing) return;
|
|
52
59
|
processing = true;
|
|
53
60
|
try {
|
|
54
|
-
const resp = await client.pollInbox({ limit: 20, ack:
|
|
61
|
+
const resp = await client.pollInbox({ limit: 20, ack: false });
|
|
55
62
|
const messages = resp.messages || [];
|
|
63
|
+
const ackedIds: string[] = [];
|
|
56
64
|
for (const msg of messages) {
|
|
57
65
|
try {
|
|
58
66
|
await handleInboxMessage(msg, accountId, cfg);
|
|
67
|
+
ackedIds.push(msg.hub_msg_id);
|
|
59
68
|
} catch (err: any) {
|
|
60
69
|
log?.error(`[${dp}] ws dispatch error for ${msg.hub_msg_id}: ${err.message}`);
|
|
61
70
|
}
|
|
62
71
|
}
|
|
72
|
+
if (ackedIds.length > 0) {
|
|
73
|
+
try {
|
|
74
|
+
await client.ackMessages(ackedIds);
|
|
75
|
+
} catch (err: any) {
|
|
76
|
+
log?.error(`[${dp}] ws ack error: ${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
63
79
|
} catch (err: any) {
|
|
64
80
|
log?.error(`[${dp}] ws poll error: ${err.message}`);
|
|
65
81
|
} finally {
|
|
@@ -91,6 +107,7 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
|
|
|
91
107
|
case "auth_ok":
|
|
92
108
|
log?.info(`[${dp}] WebSocket authenticated as ${msg.agent_id}`);
|
|
93
109
|
reconnectAttempt = 0; // Reset backoff on successful auth
|
|
110
|
+
consecutiveAuthFailures = 0; // Reset auth failure counter
|
|
94
111
|
// Start client-side keepalive to survive proxies/Caddy timeouts
|
|
95
112
|
if (keepaliveTimer) clearInterval(keepaliveTimer);
|
|
96
113
|
keepaliveTimer = setInterval(() => {
|
|
@@ -98,6 +115,8 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
|
|
|
98
115
|
ws.send(JSON.stringify({ type: "ping" }));
|
|
99
116
|
}
|
|
100
117
|
}, KEEPALIVE_INTERVAL);
|
|
118
|
+
// Catch up on messages missed during disconnect
|
|
119
|
+
fetchAndDispatch();
|
|
101
120
|
break;
|
|
102
121
|
|
|
103
122
|
case "inbox_update":
|
|
@@ -122,15 +141,25 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
|
|
|
122
141
|
}
|
|
123
142
|
});
|
|
124
143
|
|
|
125
|
-
ws.on("close", (code: number, reason: Buffer) => {
|
|
144
|
+
ws.on("close", async (code: number, reason: Buffer) => {
|
|
126
145
|
const reasonStr = reason.toString();
|
|
127
146
|
log?.info(`[${dp}] WebSocket closed: code=${code} reason=${reasonStr}`);
|
|
128
147
|
ws = null;
|
|
129
148
|
if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; }
|
|
130
149
|
|
|
131
150
|
if (code === 4001) {
|
|
132
|
-
|
|
133
|
-
|
|
151
|
+
consecutiveAuthFailures++;
|
|
152
|
+
if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
|
|
153
|
+
log?.error(`[${dp}] WebSocket auth failed ${consecutiveAuthFailures} times consecutively, stopping reconnect`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
log?.warn(`[${dp}] WebSocket auth failed (${consecutiveAuthFailures}/${MAX_AUTH_FAILURES}), force-refreshing token before reconnect`);
|
|
157
|
+
// Await token refresh so the next connect() picks up the new token
|
|
158
|
+
try {
|
|
159
|
+
await client.ensureToken(true);
|
|
160
|
+
} catch (err: any) {
|
|
161
|
+
log?.error(`[${dp}] Token force-refresh failed: ${err.message}`);
|
|
162
|
+
}
|
|
134
163
|
}
|
|
135
164
|
|
|
136
165
|
scheduleReconnect();
|
|
@@ -167,14 +196,14 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
|
|
|
167
196
|
}
|
|
168
197
|
ws = null;
|
|
169
198
|
}
|
|
170
|
-
|
|
199
|
+
getActiveWsClients().delete(accountId);
|
|
171
200
|
}
|
|
172
201
|
|
|
173
202
|
// Start connection
|
|
174
203
|
connect();
|
|
175
204
|
|
|
176
205
|
const entry = { stop };
|
|
177
|
-
|
|
206
|
+
getActiveWsClients().set(accountId, entry);
|
|
178
207
|
|
|
179
208
|
abortSignal?.addEventListener("abort", stop, { once: true });
|
|
180
209
|
|
|
@@ -182,6 +211,6 @@ export function startWsClient(opts: WsClientOptions): { stop: () => void } {
|
|
|
182
211
|
}
|
|
183
212
|
|
|
184
213
|
export function stopWsClient(accountId: string): void {
|
|
185
|
-
const entry =
|
|
214
|
+
const entry = getActiveWsClients().get(accountId);
|
|
186
215
|
if (entry) entry.stop();
|
|
187
216
|
}
|