@botcord/botcord 0.3.4 → 0.3.6-beta.20260413082920
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 +6 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/skills/botcord/SKILL.md +78 -379
- package/skills/botcord-account/SKILL.md +172 -0
- package/skills/botcord-messaging/SKILL.md +188 -0
- package/skills/botcord-payment/SKILL.md +90 -0
- package/skills/botcord-social/SKILL.md +106 -0
- package/src/client.ts +101 -8
- package/src/commands/bind.ts +4 -3
- package/src/commands/uninstall.ts +129 -0
- package/src/constants.ts +1 -1
- package/src/credentials.ts +19 -146
- package/src/crypto.ts +1 -155
- package/src/hub-url.ts +1 -41
- package/src/inbound.ts +1 -10
- package/src/onboarding-hook.ts +106 -35
- package/src/session-key.ts +1 -59
- package/src/tools/account.ts +16 -32
- package/src/tools/api.ts +112 -0
- package/src/tools/bind.ts +11 -36
- package/src/tools/coin-format.ts +19 -0
- package/src/tools/contacts.ts +26 -37
- package/src/tools/directory.ts +9 -30
- package/src/tools/messaging.ts +25 -40
- package/src/tools/payment.ts +44 -43
- package/src/tools/register.ts +5 -4
- package/src/tools/reset-credential.ts +6 -5
- package/src/tools/room-context.ts +34 -35
- package/src/tools/rooms.ts +36 -42
- package/src/tools/subscription.ts +34 -43
- package/src/tools/tool-result.ts +10 -3
- package/src/tools/topics.ts +17 -31
- package/src/types.ts +3 -256
package/src/client.ts
CHANGED
|
@@ -24,11 +24,42 @@ import type {
|
|
|
24
24
|
WithdrawalResponse,
|
|
25
25
|
SubscriptionProduct,
|
|
26
26
|
Subscription,
|
|
27
|
+
PublicRoomsResponse,
|
|
27
28
|
} from "./types.js";
|
|
28
29
|
|
|
29
30
|
const MAX_RETRIES = 2;
|
|
30
31
|
const RETRY_BASE_MS = 1000;
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Typed error thrown by BotCordClient for non-ok HTTP responses.
|
|
35
|
+
* Carries the HTTP status and an optional error code parsed from the response body.
|
|
36
|
+
*/
|
|
37
|
+
export class HubApiError extends Error {
|
|
38
|
+
readonly status: number;
|
|
39
|
+
readonly code: string | undefined;
|
|
40
|
+
|
|
41
|
+
constructor(status: number, body: string, path: string) {
|
|
42
|
+
super(`BotCord ${path} failed: ${status} ${body}`);
|
|
43
|
+
this.name = "HubApiError";
|
|
44
|
+
this.status = status;
|
|
45
|
+
this.code = HubApiError.parseCode(body);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private static parseCode(body: string): string | undefined {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(body);
|
|
51
|
+
// Hub returns { "detail": "BLOCKED" } or { "code": "NOT_IN_CONTACTS" }
|
|
52
|
+
if (typeof parsed.code === "string") return parsed.code;
|
|
53
|
+
if (typeof parsed.detail === "string" && /^[A-Z_]+$/.test(parsed.detail)) return parsed.detail;
|
|
54
|
+
} catch {
|
|
55
|
+
// Not JSON — try to extract an all-caps code from the raw body
|
|
56
|
+
const match = body.match(/\b([A-Z][A-Z_]{2,})\b/);
|
|
57
|
+
if (match) return match[1];
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
32
63
|
export class BotCordClient {
|
|
33
64
|
private hubUrl: string;
|
|
34
65
|
private agentId: string;
|
|
@@ -177,13 +208,57 @@ export class BotCordClient {
|
|
|
177
208
|
}
|
|
178
209
|
|
|
179
210
|
const body = await resp.text().catch(() => "");
|
|
180
|
-
|
|
181
|
-
(err as any).status = resp.status;
|
|
182
|
-
throw err;
|
|
211
|
+
throw new HubApiError(resp.status, body, path);
|
|
183
212
|
}
|
|
184
213
|
throw new Error(`BotCord ${path} failed: exhausted retries`);
|
|
185
214
|
}
|
|
186
215
|
|
|
216
|
+
// ── Raw API access ──────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Execute an arbitrary authenticated request against the Hub API.
|
|
220
|
+
* Returns the parsed JSON response body.
|
|
221
|
+
*/
|
|
222
|
+
async request(
|
|
223
|
+
method: string,
|
|
224
|
+
path: string,
|
|
225
|
+
options?: { body?: unknown; query?: Record<string, string | string[]> },
|
|
226
|
+
): Promise<unknown> {
|
|
227
|
+
let fullPath = path;
|
|
228
|
+
if (options?.query) {
|
|
229
|
+
const params = new URLSearchParams();
|
|
230
|
+
for (const [key, val] of Object.entries(options.query)) {
|
|
231
|
+
if (Array.isArray(val)) {
|
|
232
|
+
for (const v of val) params.append(key, v);
|
|
233
|
+
} else {
|
|
234
|
+
params.append(key, val);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
238
|
+
fullPath = `${path}${sep}${params}`;
|
|
239
|
+
}
|
|
240
|
+
const init: RequestInit = { method };
|
|
241
|
+
if (options?.body !== undefined) {
|
|
242
|
+
init.body = JSON.stringify(options.body);
|
|
243
|
+
}
|
|
244
|
+
const resp = await this.hubFetch(fullPath, init);
|
|
245
|
+
const text = await resp.text();
|
|
246
|
+
// Guard against unexpectedly large responses (1MB cap for raw API use)
|
|
247
|
+
const MAX_RESPONSE_SIZE = 1024 * 1024;
|
|
248
|
+
if (text.length > MAX_RESPONSE_SIZE) {
|
|
249
|
+
throw new HubApiError(
|
|
250
|
+
resp.status,
|
|
251
|
+
`Response too large (${(text.length / 1024).toFixed(0)}KB > 1MB limit)`,
|
|
252
|
+
fullPath,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
return JSON.parse(text);
|
|
257
|
+
} catch {
|
|
258
|
+
return text;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
187
262
|
// ── File upload ──────────────────────────────────────────────
|
|
188
263
|
|
|
189
264
|
async uploadFile(
|
|
@@ -584,6 +659,20 @@ export class BotCordClient {
|
|
|
584
659
|
return (await resp.json()) as RoomInfo[];
|
|
585
660
|
}
|
|
586
661
|
|
|
662
|
+
async discoverPublicRooms(q?: string): Promise<PublicRoomsResponse> {
|
|
663
|
+
const params = new URLSearchParams();
|
|
664
|
+
if (q) params.set("q", q);
|
|
665
|
+
const qs = params.toString();
|
|
666
|
+
const resp = await fetch(`${this.hubUrl}/public/rooms${qs ? `?${qs}` : ""}`, {
|
|
667
|
+
signal: AbortSignal.timeout(30000),
|
|
668
|
+
});
|
|
669
|
+
if (!resp.ok) {
|
|
670
|
+
const body = await resp.text().catch(() => "");
|
|
671
|
+
throw new Error(`BotCord /public/rooms failed: ${resp.status} ${body}`);
|
|
672
|
+
}
|
|
673
|
+
return (await resp.json()) as PublicRoomsResponse;
|
|
674
|
+
}
|
|
675
|
+
|
|
587
676
|
async updateRoom(
|
|
588
677
|
roomId: string,
|
|
589
678
|
params: {
|
|
@@ -774,7 +863,7 @@ export class BotCordClient {
|
|
|
774
863
|
name: string;
|
|
775
864
|
description?: string;
|
|
776
865
|
amount_minor: string;
|
|
777
|
-
billing_interval: "week" | "month";
|
|
866
|
+
billing_interval: "week" | "month" | "once";
|
|
778
867
|
asset_code?: string;
|
|
779
868
|
}): Promise<SubscriptionProduct> {
|
|
780
869
|
const resp = await this.hubFetch("/subscriptions/products", {
|
|
@@ -866,7 +955,7 @@ export class BotCordClient {
|
|
|
866
955
|
|
|
867
956
|
async roomSearch(
|
|
868
957
|
roomId: string,
|
|
869
|
-
query: string,
|
|
958
|
+
query: string | string[],
|
|
870
959
|
opts?: {
|
|
871
960
|
limit?: number;
|
|
872
961
|
before?: string;
|
|
@@ -875,7 +964,9 @@ export class BotCordClient {
|
|
|
875
964
|
},
|
|
876
965
|
): Promise<any> {
|
|
877
966
|
const params = new URLSearchParams();
|
|
878
|
-
|
|
967
|
+
const queries = (Array.isArray(query) ? query : [query])
|
|
968
|
+
.map((q) => q.trim()).filter(Boolean);
|
|
969
|
+
for (const q of queries) params.append("q", q);
|
|
879
970
|
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
880
971
|
if (opts?.before) params.set("before", opts.before);
|
|
881
972
|
if (opts?.topicId) params.set("topic_id", opts.topicId);
|
|
@@ -893,7 +984,7 @@ export class BotCordClient {
|
|
|
893
984
|
}
|
|
894
985
|
|
|
895
986
|
async globalSearch(
|
|
896
|
-
query: string,
|
|
987
|
+
query: string | string[],
|
|
897
988
|
opts?: {
|
|
898
989
|
limit?: number;
|
|
899
990
|
roomId?: string;
|
|
@@ -903,7 +994,9 @@ export class BotCordClient {
|
|
|
903
994
|
},
|
|
904
995
|
): Promise<any> {
|
|
905
996
|
const params = new URLSearchParams();
|
|
906
|
-
|
|
997
|
+
const queries = (Array.isArray(query) ? query : [query])
|
|
998
|
+
.map((q) => q.trim()).filter(Boolean);
|
|
999
|
+
for (const q of queries) params.append("q", q);
|
|
907
1000
|
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
908
1001
|
if (opts?.roomId) params.set("room_id", opts.roomId);
|
|
909
1002
|
if (opts?.topicId) params.set("topic_id", opts.topicId);
|
package/src/commands/bind.ts
CHANGED
|
@@ -19,10 +19,11 @@ export function createBindCommand() {
|
|
|
19
19
|
return { text: "[FAIL] Usage: /botcord_bind <bind_code_or_bind_ticket>" };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const result = await executeBind(bindCredential)
|
|
22
|
+
const result = await executeBind(bindCredential) as Record<string, unknown>;
|
|
23
23
|
|
|
24
|
-
if (
|
|
25
|
-
|
|
24
|
+
if (!result.ok) {
|
|
25
|
+
const err = (result.error as { message?: string })?.message || "Unknown error";
|
|
26
|
+
return { text: `[FAIL] ${err}` };
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
const agentId = result.agent_id || "unknown";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: `openclaw botcord-uninstall`
|
|
3
|
+
*
|
|
4
|
+
* Safe uninstall that uses OpenClaw's plugin API instead of editing JSON directly.
|
|
5
|
+
* Prevents the common failure mode where AI agents corrupt openclaw.json.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function createUninstallCli() {
|
|
9
|
+
return {
|
|
10
|
+
setup: (ctx: any) => {
|
|
11
|
+
ctx.program
|
|
12
|
+
.command("botcord-uninstall")
|
|
13
|
+
.description("Safely uninstall the BotCord plugin")
|
|
14
|
+
.option("--purge", "Also delete credentials from ~/.botcord/", false)
|
|
15
|
+
.option("--keep-channel", "Keep channel config in openclaw.json", false)
|
|
16
|
+
.option("--profile <name>", "OpenClaw profile to target")
|
|
17
|
+
.option("--dev", "Target the dev profile")
|
|
18
|
+
.action(async (options: { purge?: boolean; keepChannel?: boolean; profile?: string; dev?: boolean }) => {
|
|
19
|
+
const { existsSync, rmSync, readdirSync } = await import("node:fs");
|
|
20
|
+
const { join } = await import("node:path");
|
|
21
|
+
const { spawnSync } = await import("node:child_process");
|
|
22
|
+
const { homedir } = await import("node:os");
|
|
23
|
+
|
|
24
|
+
const home = homedir();
|
|
25
|
+
const credDir = join(home, ".botcord", "credentials");
|
|
26
|
+
|
|
27
|
+
// Build profile args to forward to openclaw CLI (as array, never shell-interpolated)
|
|
28
|
+
const profileArgs: string[] = [];
|
|
29
|
+
if (options.dev) profileArgs.push("--dev");
|
|
30
|
+
else if (options.profile) {
|
|
31
|
+
// Validate profile name to prevent injection via spawn args
|
|
32
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(options.profile)) {
|
|
33
|
+
ctx.logger.error("Invalid profile name — only alphanumeric, dots, hyphens, and underscores are allowed");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
profileArgs.push("--profile", options.profile);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Helper: run openclaw CLI safely via spawnSync (no shell interpolation)
|
|
40
|
+
const oc = (cmdArgs: string[]) =>
|
|
41
|
+
spawnSync("openclaw", [...profileArgs, ...cmdArgs], { stdio: "pipe", encoding: "utf8" });
|
|
42
|
+
|
|
43
|
+
// Resolve extension dir from openclaw CLI if possible, else default
|
|
44
|
+
let extensionDir = join(home, ".openclaw", "extensions", "botcord");
|
|
45
|
+
try {
|
|
46
|
+
const result = oc(["config", "path"]);
|
|
47
|
+
const configFile = (result.stdout || "").trim();
|
|
48
|
+
if (configFile) {
|
|
49
|
+
const configDir = join(configFile, "..");
|
|
50
|
+
extensionDir = join(configDir, "extensions", "botcord");
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// fall back to default path
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Step 1: Disable plugin via OpenClaw CLI (safe — no JSON editing)
|
|
57
|
+
ctx.logger.info("Disabling BotCord plugin ...");
|
|
58
|
+
try {
|
|
59
|
+
const result = oc(["plugins", "disable", "botcord"]);
|
|
60
|
+
if (result.status === 0) {
|
|
61
|
+
ctx.logger.info(" Plugin disabled");
|
|
62
|
+
} else {
|
|
63
|
+
ctx.logger.warn(" Plugin was not enabled (or already disabled)");
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
ctx.logger.warn(" Plugin was not enabled (or already disabled)");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Step 2: Remove channel config via OpenClaw CLI if available
|
|
70
|
+
if (!options.keepChannel) {
|
|
71
|
+
ctx.logger.info("Removing channel configuration ...");
|
|
72
|
+
try {
|
|
73
|
+
const result = oc(["config", "unset", "channels.botcord"]);
|
|
74
|
+
if (result.status === 0) {
|
|
75
|
+
ctx.logger.info(" Channel config removed");
|
|
76
|
+
} else {
|
|
77
|
+
ctx.logger.warn(" Could not remove channel config via CLI — may need manual cleanup");
|
|
78
|
+
ctx.logger.warn(" If needed, remove 'channels.botcord' from openclaw.json");
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
ctx.logger.warn(" Could not remove channel config via CLI — may need manual cleanup");
|
|
82
|
+
ctx.logger.warn(" If needed, remove 'channels.botcord' from openclaw.json");
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
ctx.logger.info("Keeping channel configuration (--keep-channel)");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Step 3: Remove plugin files
|
|
89
|
+
if (existsSync(extensionDir)) {
|
|
90
|
+
ctx.logger.info(`Removing plugin files from ${extensionDir} ...`);
|
|
91
|
+
rmSync(extensionDir, { recursive: true, force: true });
|
|
92
|
+
ctx.logger.info(" Plugin files removed");
|
|
93
|
+
} else {
|
|
94
|
+
ctx.logger.info(" No plugin files found (already removed)");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Step 4: Optionally purge credentials
|
|
98
|
+
if (options.purge) {
|
|
99
|
+
if (existsSync(credDir)) {
|
|
100
|
+
const files = readdirSync(credDir).filter((f: string) => f.endsWith(".json"));
|
|
101
|
+
if (files.length > 0) {
|
|
102
|
+
ctx.logger.info(`Deleting ${files.length} credential file(s) from ${credDir} ...`);
|
|
103
|
+
for (const f of files) {
|
|
104
|
+
rmSync(join(credDir, f), { force: true });
|
|
105
|
+
ctx.logger.info(` Deleted ${f}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
ctx.logger.info(" No credentials directory found");
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
// Show what's preserved
|
|
113
|
+
if (existsSync(credDir)) {
|
|
114
|
+
const files = readdirSync(credDir).filter((f: string) => f.endsWith(".json"));
|
|
115
|
+
if (files.length > 0) {
|
|
116
|
+
ctx.logger.info(`Credentials preserved in ${credDir} (${files.length} file(s))`);
|
|
117
|
+
ctx.logger.info(" Use --purge to also delete credentials");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ctx.logger.info("");
|
|
123
|
+
ctx.logger.info("BotCord plugin uninstalled.");
|
|
124
|
+
ctx.logger.info("Restart OpenClaw to apply: openclaw gateway restart");
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
commands: ["botcord-uninstall"],
|
|
128
|
+
};
|
|
129
|
+
}
|
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 = "
|
|
12
|
+
export const RELEASE_CHANNEL: ReleaseChannel = "beta";
|
|
13
13
|
|
|
14
14
|
const HUB_URLS: Record<ReleaseChannel, string> = {
|
|
15
15
|
stable: "https://api.botcord.chat",
|
package/src/credentials.ts
CHANGED
|
@@ -1,108 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
type StoredBotCordCredentials,
|
|
4
|
+
updateCredentialsToken,
|
|
5
|
+
loadStoredCredentials,
|
|
6
|
+
writeCredentialsFile,
|
|
7
|
+
resolveCredentialsFilePath,
|
|
8
|
+
defaultCredentialsFile,
|
|
9
|
+
} from "@botcord/protocol-core";
|
|
6
10
|
import type { BotCordAccountConfig } from "./types.js";
|
|
7
11
|
import type { BotCordClient as BotCordClientType } from "./client.js";
|
|
8
12
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
token?: string;
|
|
19
|
-
tokenExpiresAt?: number;
|
|
20
|
-
onboardedAt?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function normalizeCredentialValue(raw: any, keys: string[]): string | undefined {
|
|
24
|
-
for (const key of keys) {
|
|
25
|
-
const value = raw?.[key];
|
|
26
|
-
if (typeof value === "string" && value.trim()) return value;
|
|
27
|
-
}
|
|
28
|
-
return undefined;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function resolveCredentialsFilePath(credentialsFile: string): string {
|
|
32
|
-
if (credentialsFile === "~") return os.homedir();
|
|
33
|
-
if (credentialsFile.startsWith("~/")) {
|
|
34
|
-
return path.join(os.homedir(), credentialsFile.slice(2));
|
|
35
|
-
}
|
|
36
|
-
return path.isAbsolute(credentialsFile)
|
|
37
|
-
? credentialsFile
|
|
38
|
-
: path.resolve(credentialsFile);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function defaultCredentialsFile(agentId: string): string {
|
|
42
|
-
return path.join(os.homedir(), ".botcord", "credentials", `${agentId}.json`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function readCredentialSource(credentialsFile: string): Record<string, unknown> {
|
|
46
|
-
const resolved = resolveCredentialsFilePath(credentialsFile);
|
|
47
|
-
try {
|
|
48
|
-
return JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
|
|
49
|
-
} catch (err: any) {
|
|
50
|
-
throw new Error(`Unable to read BotCord credentials file "${resolved}": ${err.message}`);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function loadStoredCredentials(credentialsFile: string): StoredBotCordCredentials {
|
|
55
|
-
const resolved = resolveCredentialsFilePath(credentialsFile);
|
|
56
|
-
const raw = readCredentialSource(resolved);
|
|
57
|
-
const hubUrl = normalizeCredentialValue(raw, ["hubUrl", "hub_url", "hub"]);
|
|
58
|
-
const agentId = normalizeCredentialValue(raw, ["agentId", "agent_id"]);
|
|
59
|
-
const keyId = normalizeCredentialValue(raw, ["keyId", "key_id"]);
|
|
60
|
-
const privateKey = normalizeCredentialValue(raw, ["privateKey", "private_key"]);
|
|
61
|
-
const publicKey = normalizeCredentialValue(raw, ["publicKey", "public_key"]);
|
|
62
|
-
const displayName = normalizeCredentialValue(raw, ["displayName", "display_name"]);
|
|
63
|
-
const savedAt = normalizeCredentialValue(raw, ["savedAt", "saved_at"]);
|
|
64
|
-
const token = normalizeCredentialValue(raw, ["token"]);
|
|
65
|
-
const tokenExpiresAt = typeof raw.tokenExpiresAt === "number"
|
|
66
|
-
? raw.tokenExpiresAt
|
|
67
|
-
: typeof raw.token_expires_at === "number"
|
|
68
|
-
? raw.token_expires_at
|
|
69
|
-
: undefined;
|
|
70
|
-
|
|
71
|
-
if (!hubUrl) throw new Error(`BotCord credentials file "${resolved}" is missing hubUrl`);
|
|
72
|
-
if (!agentId) throw new Error(`BotCord credentials file "${resolved}" is missing agentId`);
|
|
73
|
-
if (!keyId) throw new Error(`BotCord credentials file "${resolved}" is missing keyId`);
|
|
74
|
-
if (!privateKey) throw new Error(`BotCord credentials file "${resolved}" is missing privateKey`);
|
|
75
|
-
|
|
76
|
-
const derivedPublicKey = derivePublicKey(privateKey);
|
|
77
|
-
if (publicKey && publicKey !== derivedPublicKey) {
|
|
78
|
-
throw new Error(
|
|
79
|
-
`BotCord credentials file "${resolved}" has a publicKey that does not match privateKey`,
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
let normalizedHubUrl: string;
|
|
84
|
-
try {
|
|
85
|
-
normalizedHubUrl = normalizeAndValidateHubUrl(hubUrl);
|
|
86
|
-
} catch (err: any) {
|
|
87
|
-
throw new Error(`BotCord credentials file "${resolved}" has an invalid hubUrl: ${err.message}`);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const onboardedAt = normalizeCredentialValue(raw, ["onboardedAt", "onboarded_at"]);
|
|
13
|
+
// Re-export core functions so existing plugin imports don't break
|
|
14
|
+
export {
|
|
15
|
+
type StoredBotCordCredentials,
|
|
16
|
+
updateCredentialsToken,
|
|
17
|
+
loadStoredCredentials,
|
|
18
|
+
writeCredentialsFile,
|
|
19
|
+
resolveCredentialsFilePath,
|
|
20
|
+
defaultCredentialsFile,
|
|
21
|
+
} from "@botcord/protocol-core";
|
|
91
22
|
|
|
92
|
-
|
|
93
|
-
version: 1,
|
|
94
|
-
hubUrl: normalizedHubUrl,
|
|
95
|
-
agentId,
|
|
96
|
-
keyId,
|
|
97
|
-
privateKey,
|
|
98
|
-
publicKey: publicKey || derivedPublicKey,
|
|
99
|
-
displayName,
|
|
100
|
-
savedAt: savedAt || new Date().toISOString(),
|
|
101
|
-
token,
|
|
102
|
-
tokenExpiresAt,
|
|
103
|
-
onboardedAt,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
23
|
+
// Plugin-specific helpers below
|
|
106
24
|
|
|
107
25
|
export function readCredentialFileData(credentialsFile?: string): Partial<BotCordAccountConfig> {
|
|
108
26
|
if (!credentialsFile) return {};
|
|
@@ -123,51 +41,6 @@ export function readCredentialFileData(credentialsFile?: string): Partial<BotCor
|
|
|
123
41
|
}
|
|
124
42
|
}
|
|
125
43
|
|
|
126
|
-
export function writeCredentialsFile(
|
|
127
|
-
credentialsFile: string,
|
|
128
|
-
credentials: StoredBotCordCredentials,
|
|
129
|
-
): string {
|
|
130
|
-
const resolved = resolveCredentialsFilePath(credentialsFile);
|
|
131
|
-
const normalizedCredentials = {
|
|
132
|
-
...credentials,
|
|
133
|
-
hubUrl: normalizeAndValidateHubUrl(credentials.hubUrl),
|
|
134
|
-
};
|
|
135
|
-
mkdirSync(path.dirname(resolved), { recursive: true, mode: 0o700 });
|
|
136
|
-
writeFileSync(resolved, JSON.stringify(normalizedCredentials, null, 2) + "\n", {
|
|
137
|
-
encoding: "utf8",
|
|
138
|
-
mode: 0o600,
|
|
139
|
-
});
|
|
140
|
-
chmodSync(resolved, 0o600);
|
|
141
|
-
return resolved;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Atomically update only the token fields in an existing credentials file.
|
|
146
|
-
* Reads current file, merges new token/expiresAt, writes back.
|
|
147
|
-
* Returns false if the file does not exist or the write fails.
|
|
148
|
-
*/
|
|
149
|
-
export function updateCredentialsToken(
|
|
150
|
-
credentialsFile: string,
|
|
151
|
-
token: string,
|
|
152
|
-
tokenExpiresAt: number,
|
|
153
|
-
): boolean {
|
|
154
|
-
const resolved = resolveCredentialsFilePath(credentialsFile);
|
|
155
|
-
try {
|
|
156
|
-
if (!existsSync(resolved)) return false;
|
|
157
|
-
const raw = JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
|
|
158
|
-
raw.token = token;
|
|
159
|
-
raw.tokenExpiresAt = tokenExpiresAt;
|
|
160
|
-
writeFileSync(resolved, JSON.stringify(raw, null, 2) + "\n", {
|
|
161
|
-
encoding: "utf8",
|
|
162
|
-
mode: 0o600,
|
|
163
|
-
});
|
|
164
|
-
chmodSync(resolved, 0o600);
|
|
165
|
-
return true;
|
|
166
|
-
} catch {
|
|
167
|
-
return false;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
44
|
/**
|
|
172
45
|
* Check whether the agent has completed onboarding.
|
|
173
46
|
*/
|
package/src/crypto.ts
CHANGED
|
@@ -1,155 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Ed25519 signing for BotCord protocol.
|
|
3
|
-
* Zero npm dependencies — uses Node.js built-in crypto module.
|
|
4
|
-
* Ported from botcord-skill/skill/botcord-crypto.mjs.
|
|
5
|
-
*/
|
|
6
|
-
import {
|
|
7
|
-
createHash,
|
|
8
|
-
createPublicKey,
|
|
9
|
-
createPrivateKey,
|
|
10
|
-
generateKeyPairSync,
|
|
11
|
-
sign,
|
|
12
|
-
randomUUID,
|
|
13
|
-
} from "node:crypto";
|
|
14
|
-
import type { BotCordMessageEnvelope, BotCordSignature, MessageType } from "./types.js";
|
|
15
|
-
|
|
16
|
-
// ── JCS (RFC 8785) canonicalization ─────────────────────────────
|
|
17
|
-
export function jcsCanonicalize(value: unknown): string | undefined {
|
|
18
|
-
if (value === null || typeof value === "boolean") return JSON.stringify(value);
|
|
19
|
-
if (typeof value === "number") {
|
|
20
|
-
if (Object.is(value, -0)) return "0";
|
|
21
|
-
return JSON.stringify(value);
|
|
22
|
-
}
|
|
23
|
-
if (typeof value === "string") return JSON.stringify(value);
|
|
24
|
-
if (Array.isArray(value))
|
|
25
|
-
return "[" + value.map((v) => jcsCanonicalize(v)).join(",") + "]";
|
|
26
|
-
if (typeof value === "object") {
|
|
27
|
-
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
28
|
-
const parts: string[] = [];
|
|
29
|
-
for (const k of keys) {
|
|
30
|
-
const v = (value as Record<string, unknown>)[k];
|
|
31
|
-
if (v === undefined) continue;
|
|
32
|
-
parts.push(JSON.stringify(k) + ":" + jcsCanonicalize(v));
|
|
33
|
-
}
|
|
34
|
-
return "{" + parts.join(",") + "}";
|
|
35
|
-
}
|
|
36
|
-
return undefined;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// ── Build Node.js KeyObject from raw 32-byte seed ───────────────
|
|
40
|
-
function privateKeyFromSeed(seed: Buffer): ReturnType<typeof createPrivateKey> {
|
|
41
|
-
const prefix = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
42
|
-
return createPrivateKey({
|
|
43
|
-
key: Buffer.concat([prefix, seed]),
|
|
44
|
-
format: "der",
|
|
45
|
-
type: "pkcs8",
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ── Payload hash ────────────────────────────────────────────────
|
|
50
|
-
export function computePayloadHash(payload: Record<string, unknown>): string {
|
|
51
|
-
const canonical = jcsCanonicalize(payload)!;
|
|
52
|
-
const digest = createHash("sha256").update(canonical).digest("hex");
|
|
53
|
-
return `sha256:${digest}`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ── Sign challenge ──────────────────────────────────────────────
|
|
57
|
-
export function signChallenge(privateKeyB64: string, challengeB64: string): string {
|
|
58
|
-
const pk = privateKeyFromSeed(Buffer.from(privateKeyB64, "base64"));
|
|
59
|
-
const sig = sign(null, Buffer.from(challengeB64, "base64"), pk);
|
|
60
|
-
return sig.toString("base64");
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function derivePublicKey(privateKeyB64: string): string {
|
|
64
|
-
const privateKey = privateKeyFromSeed(Buffer.from(privateKeyB64, "base64"));
|
|
65
|
-
const publicKey = createPublicKey(privateKey);
|
|
66
|
-
const pubDer = publicKey.export({ type: "spki", format: "der" });
|
|
67
|
-
return Buffer.from(pubDer.subarray(-32)).toString("base64");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ── Build and sign a full message envelope ──────────────────────
|
|
71
|
-
export function buildSignedEnvelope(params: {
|
|
72
|
-
from: string;
|
|
73
|
-
to: string;
|
|
74
|
-
type: MessageType;
|
|
75
|
-
payload: Record<string, unknown>;
|
|
76
|
-
privateKey: string; // base64 Ed25519 seed
|
|
77
|
-
keyId: string;
|
|
78
|
-
replyTo?: string | null;
|
|
79
|
-
ttlSec?: number;
|
|
80
|
-
topic?: string | null;
|
|
81
|
-
goal?: string | null;
|
|
82
|
-
}): BotCordMessageEnvelope {
|
|
83
|
-
const {
|
|
84
|
-
from,
|
|
85
|
-
to,
|
|
86
|
-
type,
|
|
87
|
-
payload,
|
|
88
|
-
privateKey,
|
|
89
|
-
keyId,
|
|
90
|
-
replyTo = null,
|
|
91
|
-
ttlSec = 3600,
|
|
92
|
-
topic = null,
|
|
93
|
-
goal = null,
|
|
94
|
-
} = params;
|
|
95
|
-
|
|
96
|
-
const msgId = randomUUID();
|
|
97
|
-
const ts = Math.floor(Date.now() / 1000);
|
|
98
|
-
const payloadHash = computePayloadHash(payload);
|
|
99
|
-
|
|
100
|
-
// Build signing input (newline-joined fields)
|
|
101
|
-
const parts = [
|
|
102
|
-
"a2a/0.1",
|
|
103
|
-
msgId,
|
|
104
|
-
String(ts),
|
|
105
|
-
from,
|
|
106
|
-
to,
|
|
107
|
-
String(type),
|
|
108
|
-
replyTo || "",
|
|
109
|
-
String(ttlSec),
|
|
110
|
-
payloadHash,
|
|
111
|
-
];
|
|
112
|
-
|
|
113
|
-
const pk = privateKeyFromSeed(Buffer.from(privateKey, "base64"));
|
|
114
|
-
const sigValue = sign(null, Buffer.from(parts.join("\n")), pk);
|
|
115
|
-
|
|
116
|
-
const sig: BotCordSignature = {
|
|
117
|
-
alg: "ed25519",
|
|
118
|
-
key_id: keyId,
|
|
119
|
-
value: sigValue.toString("base64"),
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
return {
|
|
123
|
-
v: "a2a/0.1",
|
|
124
|
-
msg_id: msgId,
|
|
125
|
-
ts,
|
|
126
|
-
from,
|
|
127
|
-
to,
|
|
128
|
-
type,
|
|
129
|
-
reply_to: replyTo,
|
|
130
|
-
ttl_sec: ttlSec,
|
|
131
|
-
topic,
|
|
132
|
-
goal,
|
|
133
|
-
payload,
|
|
134
|
-
payload_hash: payloadHash,
|
|
135
|
-
sig,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ── Keygen ──────────────────────────────────────────────────────
|
|
140
|
-
export function generateKeypair(): {
|
|
141
|
-
privateKey: string;
|
|
142
|
-
publicKey: string;
|
|
143
|
-
pubkeyFormatted: string;
|
|
144
|
-
} {
|
|
145
|
-
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
146
|
-
const privDer = privateKey.export({ type: "pkcs8", format: "der" });
|
|
147
|
-
const privB64 = Buffer.from(privDer.subarray(-32)).toString("base64");
|
|
148
|
-
const pubDer = publicKey.export({ type: "spki", format: "der" });
|
|
149
|
-
const pubB64 = Buffer.from(pubDer.subarray(-32)).toString("base64");
|
|
150
|
-
return {
|
|
151
|
-
privateKey: privB64,
|
|
152
|
-
publicKey: pubB64,
|
|
153
|
-
pubkeyFormatted: `ed25519:${pubB64}`,
|
|
154
|
-
};
|
|
155
|
-
}
|
|
1
|
+
export * from "@botcord/protocol-core";
|