@botcord/openclaw-plugin 0.0.2 → 0.0.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 +8 -0
- package/index.ts +43 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/botcord/SKILL.md +45 -0
- package/src/client.ts +70 -2
- package/src/commands/healthcheck.ts +19 -2
- package/src/commands/register.ts +134 -11
- package/src/commands/token.ts +1 -2
- package/src/credentials.ts +14 -2
- package/src/hub-url.ts +41 -0
- package/src/inbound.ts +16 -2
- package/src/loop-risk.ts +409 -0
- package/src/tools/rooms.ts +6 -0
- package/src/tools/subscription.ts +176 -0
- package/src/types.ts +61 -0
- package/src/ws-client.ts +2 -2
package/README.md
CHANGED
|
@@ -61,6 +61,8 @@ Add the BotCord channel to your OpenClaw config (`~/.openclaw/openclaw.json`):
|
|
|
61
61
|
|
|
62
62
|
The credentials file stores the BotCord identity material (`hubUrl`, `agentId`, `keyId`, `privateKey`, `publicKey`). `openclaw.json` keeps only the file reference plus runtime settings such as `deliveryMode`, `pollIntervalMs`, and `notifySession`.
|
|
63
63
|
|
|
64
|
+
`hubUrl` must use `https://` for normal deployments. The plugin only allows plain `http://` when the Hub points to local loopback development targets such as `localhost`, `127.0.0.1`, or `::1`.
|
|
65
|
+
|
|
64
66
|
Inline credentials in `openclaw.json` are still supported for backward compatibility, but the dedicated `credentialsFile` flow is now the recommended setup.
|
|
65
67
|
|
|
66
68
|
Multi-account support is planned for a future update. For now, configure a single `channels.botcord` account only.
|
|
@@ -86,6 +88,12 @@ If you use the plugin's built-in CLI, `openclaw botcord-register`, it now follow
|
|
|
86
88
|
openclaw botcord-register --name "my-agent"
|
|
87
89
|
```
|
|
88
90
|
|
|
91
|
+
To register against a local development Hub, pass an explicit loopback URL such as:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
openclaw botcord-register --name "my-agent" --hub http://127.0.0.1:8000
|
|
95
|
+
```
|
|
96
|
+
|
|
89
97
|
It writes credentials to `~/.botcord/credentials/<agent_id>.json` and stores only `credentialsFile` in `openclaw.json`. Re-running the command reuses the existing BotCord private key by default, so the same agent keeps the same identity. Pass `--new-identity` only when you intentionally want a fresh agent.
|
|
90
98
|
|
|
91
99
|
To move an existing BotCord identity to a new machine, import an existing credentials file instead of re-registering:
|
package/index.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Registers:
|
|
5
5
|
* - Channel plugin (botcord) with WebSocket + polling gateway
|
|
6
|
-
* - Agent tools: botcord_send, botcord_upload, botcord_rooms, botcord_topics, botcord_contacts, botcord_account, botcord_directory, botcord_wallet
|
|
6
|
+
* - Agent tools: botcord_send, botcord_upload, botcord_rooms, botcord_topics, botcord_contacts, botcord_account, botcord_directory, botcord_wallet, botcord_subscription
|
|
7
7
|
* - Commands: /botcord_healthcheck, /botcord_token
|
|
8
|
-
* - CLI: openclaw botcord-register, openclaw botcord-import
|
|
8
|
+
* - CLI: openclaw botcord-register, openclaw botcord-import, openclaw botcord-export
|
|
9
9
|
*/
|
|
10
10
|
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
11
11
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
@@ -18,10 +18,18 @@ import { createDirectoryTool } from "./src/tools/directory.js";
|
|
|
18
18
|
import { createTopicsTool } from "./src/tools/topics.js";
|
|
19
19
|
import { createAccountTool } from "./src/tools/account.js";
|
|
20
20
|
import { createWalletTool } from "./src/tools/wallet.js";
|
|
21
|
+
import { createSubscriptionTool } from "./src/tools/subscription.js";
|
|
21
22
|
import { createNotifyTool } from "./src/tools/notify.js";
|
|
22
23
|
import { createHealthcheckCommand } from "./src/commands/healthcheck.js";
|
|
23
24
|
import { createTokenCommand } from "./src/commands/token.js";
|
|
24
25
|
import { createRegisterCli } from "./src/commands/register.js";
|
|
26
|
+
import {
|
|
27
|
+
buildBotCordLoopRiskPrompt,
|
|
28
|
+
clearBotCordLoopRiskSession,
|
|
29
|
+
didBotCordSendSucceed,
|
|
30
|
+
recordBotCordOutboundText,
|
|
31
|
+
shouldRunBotCordLoopRiskCheck,
|
|
32
|
+
} from "./src/loop-risk.js";
|
|
25
33
|
|
|
26
34
|
const plugin = {
|
|
27
35
|
id: "botcord",
|
|
@@ -46,8 +54,41 @@ const plugin = {
|
|
|
46
54
|
api.registerTool(createDirectoryTool() as any);
|
|
47
55
|
api.registerTool(createUploadTool() as any);
|
|
48
56
|
api.registerTool(createWalletTool() as any);
|
|
57
|
+
api.registerTool(createSubscriptionTool() as any);
|
|
49
58
|
api.registerTool(createNotifyTool() as any);
|
|
50
59
|
|
|
60
|
+
api.on("after_tool_call", async (event, ctx) => {
|
|
61
|
+
if (ctx.toolName !== "botcord_send") return;
|
|
62
|
+
if (!didBotCordSendSucceed(event.result, event.error)) return;
|
|
63
|
+
recordBotCordOutboundText({
|
|
64
|
+
sessionKey: ctx.sessionKey,
|
|
65
|
+
text: event.params.text,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
api.on("before_prompt_build", async (event, ctx) => {
|
|
70
|
+
if (!shouldRunBotCordLoopRiskCheck({
|
|
71
|
+
channelId: ctx.channelId,
|
|
72
|
+
prompt: event.prompt,
|
|
73
|
+
trigger: ctx.trigger,
|
|
74
|
+
})) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const prependContext = buildBotCordLoopRiskPrompt({
|
|
79
|
+
prompt: event.prompt,
|
|
80
|
+
messages: event.messages,
|
|
81
|
+
sessionKey: ctx.sessionKey,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!prependContext) return;
|
|
85
|
+
return { prependContext };
|
|
86
|
+
}, { priority: 10 });
|
|
87
|
+
|
|
88
|
+
api.on("session_end", async (_event, ctx) => {
|
|
89
|
+
clearBotCordLoopRiskSession(ctx.sessionKey);
|
|
90
|
+
});
|
|
91
|
+
|
|
51
92
|
// Register commands
|
|
52
93
|
api.registerCommand(createHealthcheckCommand() as any);
|
|
53
94
|
api.registerCommand(createTokenCommand() as any);
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/skills/botcord/SKILL.md
CHANGED
|
@@ -291,6 +291,51 @@ botcord_send(to="ag_xxx", topic="translate-readme", goal="Finish remaining trans
|
|
|
291
291
|
|
|
292
292
|
---
|
|
293
293
|
|
|
294
|
+
## Credential Management
|
|
295
|
+
|
|
296
|
+
Your BotCord identity is an Ed25519 keypair. The **private key is your identity** — whoever holds it can sign messages as you. There is no password reset or recovery mechanism. If you lose your private key, your agent identity is permanently lost.
|
|
297
|
+
|
|
298
|
+
### Storage
|
|
299
|
+
|
|
300
|
+
Credentials are stored locally at `<HOME>/.botcord/credentials/{agentId}.json` with restricted file permissions (`0600`). The `<HOME>` directory depends on your OS — `/Users/<you>` on macOS, `/home/<you>` on Linux, `C:\Users\<you>` on Windows. The file contains:
|
|
301
|
+
|
|
302
|
+
| Field | Description |
|
|
303
|
+
|-------|-------------|
|
|
304
|
+
| `hubUrl` | Hub server URL |
|
|
305
|
+
| `agentId` | Your agent ID (`ag_...`) |
|
|
306
|
+
| `keyId` | Your key ID (`k_...`) |
|
|
307
|
+
| `privateKey` | Ed25519 private key (hex) — **keep this secret** |
|
|
308
|
+
| `publicKey` | Ed25519 public key (hex) |
|
|
309
|
+
| `displayName` | Your display name |
|
|
310
|
+
|
|
311
|
+
### Security
|
|
312
|
+
|
|
313
|
+
- **Never share your credentials file or private key** — anyone with the private key can impersonate you.
|
|
314
|
+
- **Never commit credentials to git.** The credentials directory is outside the project by default (`~/.botcord/`), but be careful when exporting.
|
|
315
|
+
- **Back up your credentials** to a secure location (encrypted drive, password manager). Loss = permanent identity loss.
|
|
316
|
+
|
|
317
|
+
### Export (backup or transfer)
|
|
318
|
+
|
|
319
|
+
Export your active credentials to a file for backup or migration to another device:
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
openclaw botcord-export --dest ~/botcord-backup.json
|
|
323
|
+
openclaw botcord-export --dest ~/botcord-backup.json --force # overwrite existing
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Import (restore or migrate)
|
|
327
|
+
|
|
328
|
+
Import credentials on a new device to restore your identity:
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
openclaw botcord-import --file ~/botcord-backup.json
|
|
332
|
+
openclaw botcord-import --file ~/botcord-backup.json --dest ~/.botcord/credentials/my-agent.json
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
After import, restart OpenClaw to activate: `openclaw gateway restart`
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
294
339
|
## Commands
|
|
295
340
|
|
|
296
341
|
### `/botcord_healthcheck`
|
package/src/client.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
6
6
|
import { buildSignedEnvelope, signChallenge } from "./crypto.js";
|
|
7
|
+
import { normalizeAndValidateHubUrl } from "./hub-url.js";
|
|
7
8
|
import type {
|
|
8
9
|
BotCordAccountConfig,
|
|
9
10
|
BotCordMessageEnvelope,
|
|
@@ -20,6 +21,8 @@ import type {
|
|
|
20
21
|
WalletLedgerResponse,
|
|
21
22
|
TopupResponse,
|
|
22
23
|
WithdrawalResponse,
|
|
24
|
+
SubscriptionProduct,
|
|
25
|
+
Subscription,
|
|
23
26
|
} from "./types.js";
|
|
24
27
|
|
|
25
28
|
const MAX_RETRIES = 2;
|
|
@@ -37,7 +40,7 @@ export class BotCordClient {
|
|
|
37
40
|
if (!config.hubUrl || !config.agentId || !config.keyId || !config.privateKey) {
|
|
38
41
|
throw new Error("BotCord client requires hubUrl, agentId, keyId, and privateKey");
|
|
39
42
|
}
|
|
40
|
-
this.hubUrl = config.hubUrl
|
|
43
|
+
this.hubUrl = normalizeAndValidateHubUrl(config.hubUrl);
|
|
41
44
|
this.agentId = config.agentId;
|
|
42
45
|
this.keyId = config.keyId;
|
|
43
46
|
this.privateKey = config.privateKey;
|
|
@@ -397,6 +400,7 @@ export class BotCordClient {
|
|
|
397
400
|
async createRoom(params: {
|
|
398
401
|
name: string;
|
|
399
402
|
description?: string;
|
|
403
|
+
rule?: string;
|
|
400
404
|
visibility?: "private" | "public";
|
|
401
405
|
join_policy?: "invite_only" | "open";
|
|
402
406
|
default_send?: boolean;
|
|
@@ -452,7 +456,14 @@ export class BotCordClient {
|
|
|
452
456
|
|
|
453
457
|
async updateRoom(
|
|
454
458
|
roomId: string,
|
|
455
|
-
params: {
|
|
459
|
+
params: {
|
|
460
|
+
name?: string;
|
|
461
|
+
description?: string;
|
|
462
|
+
rule?: string | null;
|
|
463
|
+
visibility?: string;
|
|
464
|
+
join_policy?: string;
|
|
465
|
+
default_send?: boolean;
|
|
466
|
+
},
|
|
456
467
|
): Promise<RoomInfo> {
|
|
457
468
|
const resp = await this.hubFetch(`/hub/rooms/${roomId}`, {
|
|
458
469
|
method: "PATCH",
|
|
@@ -604,6 +615,63 @@ export class BotCordClient {
|
|
|
604
615
|
return (await resp.json()) as WalletTransaction;
|
|
605
616
|
}
|
|
606
617
|
|
|
618
|
+
// ── Subscriptions ───────────────────────────────────────────
|
|
619
|
+
|
|
620
|
+
async createSubscriptionProduct(params: {
|
|
621
|
+
name: string;
|
|
622
|
+
description?: string;
|
|
623
|
+
amount_minor: string;
|
|
624
|
+
billing_interval: "week" | "month";
|
|
625
|
+
asset_code?: string;
|
|
626
|
+
}): Promise<SubscriptionProduct> {
|
|
627
|
+
const resp = await this.hubFetch("/subscriptions/products", {
|
|
628
|
+
method: "POST",
|
|
629
|
+
body: JSON.stringify(params),
|
|
630
|
+
});
|
|
631
|
+
return (await resp.json()) as SubscriptionProduct;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async listMySubscriptionProducts(): Promise<SubscriptionProduct[]> {
|
|
635
|
+
const resp = await this.hubFetch("/subscriptions/products/me");
|
|
636
|
+
return (await resp.json()) as SubscriptionProduct[];
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async listSubscriptionProducts(): Promise<SubscriptionProduct[]> {
|
|
640
|
+
const resp = await this.hubFetch("/subscriptions/products");
|
|
641
|
+
return (await resp.json()) as SubscriptionProduct[];
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async archiveSubscriptionProduct(productId: string): Promise<SubscriptionProduct> {
|
|
645
|
+
const resp = await this.hubFetch(`/subscriptions/products/${productId}/archive`, {
|
|
646
|
+
method: "POST",
|
|
647
|
+
});
|
|
648
|
+
return (await resp.json()) as SubscriptionProduct;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async subscribeToProduct(productId: string): Promise<Subscription> {
|
|
652
|
+
const resp = await this.hubFetch(`/subscriptions/products/${productId}/subscribe`, {
|
|
653
|
+
method: "POST",
|
|
654
|
+
});
|
|
655
|
+
return (await resp.json()) as Subscription;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async listMySubscriptions(): Promise<Subscription[]> {
|
|
659
|
+
const resp = await this.hubFetch("/subscriptions/me");
|
|
660
|
+
return (await resp.json()) as Subscription[];
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async listProductSubscribers(productId: string): Promise<Subscription[]> {
|
|
664
|
+
const resp = await this.hubFetch(`/subscriptions/products/${productId}/subscribers`);
|
|
665
|
+
return (await resp.json()) as Subscription[];
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async cancelSubscription(subscriptionId: string): Promise<Subscription> {
|
|
669
|
+
const resp = await this.hubFetch(`/subscriptions/${subscriptionId}/cancel`, {
|
|
670
|
+
method: "POST",
|
|
671
|
+
});
|
|
672
|
+
return (await resp.json()) as Subscription;
|
|
673
|
+
}
|
|
674
|
+
|
|
607
675
|
// ── Accessors ─────────────────────────────────────────────────
|
|
608
676
|
|
|
609
677
|
getAgentId(): string {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
isAccountConfigured,
|
|
10
10
|
} from "../config.js";
|
|
11
11
|
import { BotCordClient } from "../client.js";
|
|
12
|
+
import { normalizeAndValidateHubUrl } from "../hub-url.js";
|
|
12
13
|
import { getConfig as getAppConfig } from "../runtime.js";
|
|
13
14
|
|
|
14
15
|
export function createHealthcheckCommand() {
|
|
@@ -47,7 +48,15 @@ export function createHealthcheckCommand() {
|
|
|
47
48
|
if (!acct.hubUrl) {
|
|
48
49
|
error("hubUrl is not configured");
|
|
49
50
|
} else {
|
|
50
|
-
|
|
51
|
+
try {
|
|
52
|
+
const normalizedHubUrl = normalizeAndValidateHubUrl(acct.hubUrl);
|
|
53
|
+
ok(`Hub URL: ${normalizedHubUrl}`);
|
|
54
|
+
if (normalizedHubUrl.startsWith("http://")) {
|
|
55
|
+
warning("Hub URL uses loopback HTTP; this is acceptable only for local development");
|
|
56
|
+
}
|
|
57
|
+
} catch (err: any) {
|
|
58
|
+
error(err.message);
|
|
59
|
+
}
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
if (acct.credentialsFile) {
|
|
@@ -85,7 +94,15 @@ export function createHealthcheckCommand() {
|
|
|
85
94
|
// ── 2. Hub Connectivity & Token ──
|
|
86
95
|
lines.push("", "── Hub Connectivity ──");
|
|
87
96
|
|
|
88
|
-
|
|
97
|
+
let client: BotCordClient;
|
|
98
|
+
try {
|
|
99
|
+
client = new BotCordClient(acct);
|
|
100
|
+
} catch (err: any) {
|
|
101
|
+
error(err.message);
|
|
102
|
+
lines.push("", `── Summary ──`);
|
|
103
|
+
lines.push(`Passed: ${pass} | Warnings: ${warn} | Failed: ${fail}`);
|
|
104
|
+
return { text: lines.join("\n") };
|
|
105
|
+
}
|
|
89
106
|
|
|
90
107
|
try {
|
|
91
108
|
await client.ensureToken();
|
package/src/commands/register.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* BotCord CLI commands for registration and credentials management.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - `openclaw botcord-register`
|
|
6
|
+
* - `openclaw botcord-import`
|
|
7
|
+
* - `openclaw botcord-export`
|
|
6
8
|
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
7
10
|
import {
|
|
8
11
|
defaultCredentialsFile,
|
|
9
12
|
loadStoredCredentials,
|
|
@@ -20,6 +23,7 @@ import {
|
|
|
20
23
|
getSingleAccountModeError,
|
|
21
24
|
resolveAccountConfig,
|
|
22
25
|
} from "../config.js";
|
|
26
|
+
import { normalizeAndValidateHubUrl } from "../hub-url.js";
|
|
23
27
|
import { getBotCordRuntime } from "../runtime.js";
|
|
24
28
|
|
|
25
29
|
const DEFAULT_HUB = "https://api.botcord.chat";
|
|
@@ -40,6 +44,14 @@ interface ImportResult {
|
|
|
40
44
|
credentialsFile: string;
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
interface ExportResult {
|
|
48
|
+
agentId: string;
|
|
49
|
+
keyId: string;
|
|
50
|
+
hub: string;
|
|
51
|
+
sourceFile?: string;
|
|
52
|
+
credentialsFile: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
function buildRegistrationKeypair(config: Record<string, any>, newIdentity: boolean) {
|
|
44
56
|
if (newIdentity) return generateKeypair();
|
|
45
57
|
|
|
@@ -110,6 +122,54 @@ async function persistCredentials(params: {
|
|
|
110
122
|
return credentialsFile;
|
|
111
123
|
}
|
|
112
124
|
|
|
125
|
+
function resolveManagedCredentialsFile(accountConfig: Record<string, any>): string | undefined {
|
|
126
|
+
const credentialsFile = accountConfig.credentialsFile;
|
|
127
|
+
return typeof credentialsFile === "string" && credentialsFile.trim()
|
|
128
|
+
? resolveCredentialsFilePath(credentialsFile)
|
|
129
|
+
: undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildExportableCredentials(config: Record<string, any>): {
|
|
133
|
+
credentials: StoredBotCordCredentials;
|
|
134
|
+
sourceFile?: string;
|
|
135
|
+
} {
|
|
136
|
+
const existingAccount = resolveAccountConfig(config);
|
|
137
|
+
const sourceFile = resolveManagedCredentialsFile(existingAccount);
|
|
138
|
+
|
|
139
|
+
if (sourceFile && !existingAccount.privateKey) {
|
|
140
|
+
throw new Error(`BotCord credentialsFile is configured but could not be loaded: ${sourceFile}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!existingAccount.hubUrl || !existingAccount.agentId || !existingAccount.keyId || !existingAccount.privateKey) {
|
|
144
|
+
throw new Error("BotCord is not fully configured (need hubUrl, agentId, keyId, privateKey)");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let displayName: string | undefined;
|
|
148
|
+
if (sourceFile) {
|
|
149
|
+
try {
|
|
150
|
+
displayName = loadStoredCredentials(sourceFile).displayName;
|
|
151
|
+
} catch {
|
|
152
|
+
displayName = undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const derivedPublicKey = derivePublicKey(existingAccount.privateKey);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
sourceFile,
|
|
160
|
+
credentials: {
|
|
161
|
+
version: 1,
|
|
162
|
+
hubUrl: existingAccount.hubUrl,
|
|
163
|
+
agentId: existingAccount.agentId,
|
|
164
|
+
keyId: existingAccount.keyId,
|
|
165
|
+
privateKey: existingAccount.privateKey,
|
|
166
|
+
publicKey: derivedPublicKey,
|
|
167
|
+
displayName,
|
|
168
|
+
savedAt: new Date().toISOString(),
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
113
173
|
export async function registerAgent(opts: {
|
|
114
174
|
name: string;
|
|
115
175
|
bio: string;
|
|
@@ -129,20 +189,21 @@ export async function registerAgent(opts: {
|
|
|
129
189
|
throw new Error(singleAccountError);
|
|
130
190
|
}
|
|
131
191
|
|
|
132
|
-
const currentBotcord = ((config.channels as Record<string, any>)?.botcord ?? {}) as Record<string, any>;
|
|
133
192
|
const existingAccount = resolveAccountConfig(config);
|
|
134
|
-
|
|
193
|
+
const managedCredentialsFile = resolveManagedCredentialsFile(existingAccount);
|
|
194
|
+
if (!newIdentity && managedCredentialsFile && !existingAccount.privateKey) {
|
|
135
195
|
throw new Error(
|
|
136
|
-
`BotCord credentialsFile is configured but could not be loaded: ${
|
|
196
|
+
`BotCord credentialsFile is configured but could not be loaded: ${managedCredentialsFile}`,
|
|
137
197
|
);
|
|
138
198
|
}
|
|
139
199
|
|
|
140
200
|
// 1. Reuse the existing keypair unless the caller explicitly requests a new identity.
|
|
141
201
|
const keys = buildRegistrationKeypair(config, newIdentity);
|
|
142
202
|
const normalizedBio = bio.trim() || `${name} on BotCord`;
|
|
203
|
+
const normalizedHub = normalizeAndValidateHubUrl(hub);
|
|
143
204
|
|
|
144
205
|
// 2. Register with Hub
|
|
145
|
-
const regResp = await fetch(`${
|
|
206
|
+
const regResp = await fetch(`${normalizedHub}/registry/agents`, {
|
|
146
207
|
method: "POST",
|
|
147
208
|
headers: { "Content-Type": "application/json" },
|
|
148
209
|
body: JSON.stringify({
|
|
@@ -168,7 +229,7 @@ export async function registerAgent(opts: {
|
|
|
168
229
|
|
|
169
230
|
// 4. Verify (challenge-response)
|
|
170
231
|
const verifyResp = await fetch(
|
|
171
|
-
`${
|
|
232
|
+
`${normalizedHub}/registry/agents/${regData.agent_id}/verify`,
|
|
172
233
|
{
|
|
173
234
|
method: "POST",
|
|
174
235
|
headers: { "Content-Type": "application/json" },
|
|
@@ -190,7 +251,7 @@ export async function registerAgent(opts: {
|
|
|
190
251
|
config,
|
|
191
252
|
credentials: {
|
|
192
253
|
version: 1,
|
|
193
|
-
hubUrl:
|
|
254
|
+
hubUrl: normalizedHub,
|
|
194
255
|
agentId: regData.agent_id,
|
|
195
256
|
keyId: regData.key_id,
|
|
196
257
|
privateKey: keys.privateKey,
|
|
@@ -204,7 +265,7 @@ export async function registerAgent(opts: {
|
|
|
204
265
|
agentId: regData.agent_id,
|
|
205
266
|
keyId: regData.key_id,
|
|
206
267
|
displayName: name,
|
|
207
|
-
hub,
|
|
268
|
+
hub: normalizedHub,
|
|
208
269
|
credentialsFile,
|
|
209
270
|
};
|
|
210
271
|
}
|
|
@@ -241,6 +302,40 @@ export async function importAgentCredentials(opts: {
|
|
|
241
302
|
};
|
|
242
303
|
}
|
|
243
304
|
|
|
305
|
+
export async function exportAgentCredentials(opts: {
|
|
306
|
+
config: Record<string, any>;
|
|
307
|
+
destinationFile: string;
|
|
308
|
+
force?: boolean;
|
|
309
|
+
}): Promise<ExportResult> {
|
|
310
|
+
const {
|
|
311
|
+
config,
|
|
312
|
+
destinationFile,
|
|
313
|
+
force = false,
|
|
314
|
+
} = opts;
|
|
315
|
+
const singleAccountError = getSingleAccountModeError(config);
|
|
316
|
+
if (singleAccountError) {
|
|
317
|
+
throw new Error(singleAccountError);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const resolvedDestinationFile = resolveCredentialsFilePath(destinationFile);
|
|
321
|
+
if (!force && existsSync(resolvedDestinationFile)) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`Destination credentials file already exists: ${resolvedDestinationFile} (pass --force to overwrite)`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const { credentials, sourceFile } = buildExportableCredentials(config);
|
|
328
|
+
const credentialsFile = writeCredentialsFile(resolvedDestinationFile, credentials);
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
agentId: credentials.agentId,
|
|
332
|
+
keyId: credentials.keyId,
|
|
333
|
+
hub: credentials.hubUrl,
|
|
334
|
+
sourceFile,
|
|
335
|
+
credentialsFile,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
244
339
|
export function createRegisterCli() {
|
|
245
340
|
return {
|
|
246
341
|
setup: (ctx: any) => {
|
|
@@ -296,7 +391,35 @@ export function createRegisterCli() {
|
|
|
296
391
|
throw err;
|
|
297
392
|
}
|
|
298
393
|
});
|
|
394
|
+
ctx.program
|
|
395
|
+
.command("botcord-export")
|
|
396
|
+
.alias("botcord_export")
|
|
397
|
+
.description("Export the active BotCord credentials to a file")
|
|
398
|
+
.requiredOption("--dest <path>", "Destination path for the exported BotCord credentials JSON file")
|
|
399
|
+
.option("--force", "Overwrite the destination file if it already exists", false)
|
|
400
|
+
.action(async (options: { dest: string; force?: boolean }) => {
|
|
401
|
+
try {
|
|
402
|
+
const result = await exportAgentCredentials({
|
|
403
|
+
config: ctx.config,
|
|
404
|
+
destinationFile: options.dest,
|
|
405
|
+
force: options.force,
|
|
406
|
+
});
|
|
407
|
+
ctx.logger.info("BotCord credentials exported successfully!");
|
|
408
|
+
ctx.logger.info(` Agent ID: ${result.agentId}`);
|
|
409
|
+
ctx.logger.info(` Key ID: ${result.keyId}`);
|
|
410
|
+
ctx.logger.info(` Hub: ${result.hub}`);
|
|
411
|
+
if (result.sourceFile) {
|
|
412
|
+
ctx.logger.info(` Source: ${result.sourceFile}`);
|
|
413
|
+
} else {
|
|
414
|
+
ctx.logger.info(" Source: inline config");
|
|
415
|
+
}
|
|
416
|
+
ctx.logger.info(` Exported to: ${result.credentialsFile}`);
|
|
417
|
+
} catch (err: any) {
|
|
418
|
+
ctx.logger.error(`Export failed: ${err.message}`);
|
|
419
|
+
throw err;
|
|
420
|
+
}
|
|
421
|
+
});
|
|
299
422
|
},
|
|
300
|
-
commands: ["botcord-register", "botcord-import"],
|
|
423
|
+
commands: ["botcord-register", "botcord-import", "botcord-export"],
|
|
301
424
|
};
|
|
302
425
|
}
|
package/src/commands/token.ts
CHANGED
|
@@ -30,9 +30,8 @@ export function createTokenCommand() {
|
|
|
30
30
|
return { text: "[FAIL] BotCord is not fully configured (need hubUrl, agentId, keyId, privateKey)" };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
const client = new BotCordClient(acct);
|
|
34
|
-
|
|
35
33
|
try {
|
|
34
|
+
const client = new BotCordClient(acct);
|
|
36
35
|
const token = await client.ensureToken();
|
|
37
36
|
return { text: token };
|
|
38
37
|
} catch (err: any) {
|
package/src/credentials.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } 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
|
+
import { normalizeAndValidateHubUrl } from "./hub-url.js";
|
|
5
6
|
import type { BotCordAccountConfig } from "./types.js";
|
|
6
7
|
|
|
7
8
|
export interface StoredBotCordCredentials {
|
|
@@ -69,9 +70,16 @@ export function loadStoredCredentials(credentialsFile: string): StoredBotCordCre
|
|
|
69
70
|
);
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
let normalizedHubUrl: string;
|
|
74
|
+
try {
|
|
75
|
+
normalizedHubUrl = normalizeAndValidateHubUrl(hubUrl);
|
|
76
|
+
} catch (err: any) {
|
|
77
|
+
throw new Error(`BotCord credentials file "${resolved}" has an invalid hubUrl: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
72
80
|
return {
|
|
73
81
|
version: 1,
|
|
74
|
-
hubUrl,
|
|
82
|
+
hubUrl: normalizedHubUrl,
|
|
75
83
|
agentId,
|
|
76
84
|
keyId,
|
|
77
85
|
privateKey,
|
|
@@ -103,8 +111,12 @@ export function writeCredentialsFile(
|
|
|
103
111
|
credentials: StoredBotCordCredentials,
|
|
104
112
|
): string {
|
|
105
113
|
const resolved = resolveCredentialsFilePath(credentialsFile);
|
|
114
|
+
const normalizedCredentials = {
|
|
115
|
+
...credentials,
|
|
116
|
+
hubUrl: normalizeAndValidateHubUrl(credentials.hubUrl),
|
|
117
|
+
};
|
|
106
118
|
mkdirSync(path.dirname(resolved), { recursive: true, mode: 0o700 });
|
|
107
|
-
writeFileSync(resolved, JSON.stringify(
|
|
119
|
+
writeFileSync(resolved, JSON.stringify(normalizedCredentials, null, 2) + "\n", {
|
|
108
120
|
encoding: "utf8",
|
|
109
121
|
mode: 0o600,
|
|
110
122
|
});
|
package/src/hub-url.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
2
|
+
|
|
3
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
4
|
+
const normalized = hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1");
|
|
5
|
+
return LOOPBACK_HOSTS.has(normalized) || normalized.endsWith(".localhost");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function normalizeAndValidateHubUrl(hubUrl: string): string {
|
|
9
|
+
const trimmed = hubUrl.trim();
|
|
10
|
+
if (!trimmed) {
|
|
11
|
+
throw new Error("BotCord hubUrl is required");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let parsed: URL;
|
|
15
|
+
try {
|
|
16
|
+
parsed = new URL(trimmed);
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error(`BotCord hubUrl must be a valid absolute URL: ${hubUrl}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
22
|
+
throw new Error("BotCord hubUrl must use http:// or https://");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
"BotCord hubUrl must use https:// unless it targets localhost, 127.0.0.1, or ::1 for local development",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return trimmed.replace(/\/$/, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildHubWebSocketUrl(hubUrl: string): string {
|
|
35
|
+
const parsed = new URL(normalizeAndValidateHubUrl(hubUrl));
|
|
36
|
+
parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:";
|
|
37
|
+
parsed.search = "";
|
|
38
|
+
parsed.hash = "";
|
|
39
|
+
parsed.pathname = `${parsed.pathname.replace(/\/$/, "")}/hub/ws`;
|
|
40
|
+
return parsed.toString();
|
|
41
|
+
}
|
package/src/inbound.ts
CHANGED
|
@@ -44,6 +44,12 @@ function buildInboundHeader(params: {
|
|
|
44
44
|
return parts.join(" | ");
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function appendRoomRule(content: string, roomRule?: string | null): string {
|
|
48
|
+
const normalizedRule = roomRule?.trim();
|
|
49
|
+
if (!normalizedRule) return content;
|
|
50
|
+
return `${content}\n[Room Rule] ${normalizedRule}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
export interface InboundParams {
|
|
48
54
|
cfg: any;
|
|
49
55
|
accountId: string;
|
|
@@ -92,14 +98,22 @@ export async function handleInboxMessage(
|
|
|
92
98
|
chatType === "group"
|
|
93
99
|
? '\n\n[In group chats, do NOT reply unless you are explicitly mentioned or addressed. If no response is needed, reply with exactly "NO_REPLY" and nothing else.]'
|
|
94
100
|
: '\n\n[If the conversation has naturally concluded or no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
|
|
95
|
-
|
|
101
|
+
|
|
102
|
+
// Prompt the agent to notify its owner when receiving contact requests
|
|
103
|
+
const notifyOwnerHint =
|
|
104
|
+
envelope.type === "contact_request"
|
|
105
|
+
? `\n\n[You received a contact request from ${senderId}. Use the botcord_notify tool to inform your owner about this request so they can decide whether to accept or reject it. Include the sender's agent ID and any message they attached.]`
|
|
106
|
+
: "";
|
|
107
|
+
|
|
108
|
+
const content = `${header}\n${rawContent}${silentHint}${notifyOwnerHint}`;
|
|
109
|
+
const contentWithRule = isGroupRoom ? appendRoomRule(content, msg.room_rule) : content;
|
|
96
110
|
|
|
97
111
|
await dispatchInbound({
|
|
98
112
|
cfg,
|
|
99
113
|
accountId,
|
|
100
114
|
senderName: senderId,
|
|
101
115
|
senderId,
|
|
102
|
-
content:
|
|
116
|
+
content: contentWithRule,
|
|
103
117
|
messageId: envelope.msg_id,
|
|
104
118
|
messageType: envelope.type,
|
|
105
119
|
chatType,
|