@botcord/botcord 0.3.6 → 0.3.7
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 +31 -10
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/skills/botcord/SKILL.md +83 -381
- package/skills/botcord/SKILL_PROACTIVE.md +116 -0
- package/skills/botcord/SKILL_SCENARIOS.md +263 -0
- package/skills/botcord/onboarding_instruction.md +45 -0
- package/skills/botcord-account/SKILL.md +195 -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 +100 -3
- package/src/commands/bind.ts +4 -3
- package/src/commands/healthcheck.ts +2 -11
- package/src/commands/uninstall.ts +129 -0
- package/src/credentials.ts +26 -168
- package/src/crypto.ts +1 -155
- package/src/dynamic-context.ts +33 -16
- package/src/hub-url.ts +1 -41
- package/src/memory.ts +50 -0
- 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 +10 -30
- package/src/tools/contacts.ts +26 -37
- package/src/tools/directory.ts +8 -29
- package/src/tools/messaging.ts +25 -40
- package/src/tools/payment.ts +27 -37
- package/src/tools/register.ts +5 -4
- package/src/tools/reset-credential.ts +6 -5
- package/src/tools/room-context.ts +10 -31
- package/src/tools/rooms.ts +35 -41
- package/src/tools/subscription.ts +27 -38
- package/src/tools/tool-result.ts +10 -3
- package/src/tools/topics.ts +17 -31
- package/src/types.ts +3 -283
- package/src/onboarding-hook.ts +0 -139
|
@@ -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/credentials.ts
CHANGED
|
@@ -1,108 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { existsSync, readFileSync } 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;
|
|
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";
|
|
70
22
|
|
|
71
|
-
|
|
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"]);
|
|
91
|
-
|
|
92
|
-
return {
|
|
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,55 +41,15 @@ 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
44
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
45
|
+
* Check whether the agent completed onboarding under the legacy system
|
|
46
|
+
* (credentials file contains onboardedAt). Used as a migration bridge
|
|
47
|
+
* in readOrSeedWorkingMemory() to avoid re-triggering onboarding for
|
|
48
|
+
* agents that already went through the old flow.
|
|
49
|
+
*
|
|
50
|
+
* Read-only — this function never writes to the credentials file.
|
|
148
51
|
*/
|
|
149
|
-
export function
|
|
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
|
-
/**
|
|
172
|
-
* Check whether the agent has completed onboarding.
|
|
173
|
-
*/
|
|
174
|
-
export function isOnboarded(credentialsFile: string): boolean {
|
|
52
|
+
export function isLegacyOnboarded(credentialsFile: string): boolean {
|
|
175
53
|
const resolved = resolveCredentialsFilePath(credentialsFile);
|
|
176
54
|
try {
|
|
177
55
|
if (!existsSync(resolved)) return false;
|
|
@@ -182,26 +60,6 @@ export function isOnboarded(credentialsFile: string): boolean {
|
|
|
182
60
|
}
|
|
183
61
|
}
|
|
184
62
|
|
|
185
|
-
/**
|
|
186
|
-
* Mark the agent as onboarded by writing onboardedAt timestamp.
|
|
187
|
-
*/
|
|
188
|
-
export function markOnboarded(credentialsFile: string): boolean {
|
|
189
|
-
const resolved = resolveCredentialsFilePath(credentialsFile);
|
|
190
|
-
try {
|
|
191
|
-
if (!existsSync(resolved)) return false;
|
|
192
|
-
const raw = JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
|
|
193
|
-
raw.onboardedAt = new Date().toISOString();
|
|
194
|
-
writeFileSync(resolved, JSON.stringify(raw, null, 2) + "\n", {
|
|
195
|
-
encoding: "utf8",
|
|
196
|
-
mode: 0o600,
|
|
197
|
-
});
|
|
198
|
-
chmodSync(resolved, 0o600);
|
|
199
|
-
return true;
|
|
200
|
-
} catch {
|
|
201
|
-
return false;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
63
|
/**
|
|
206
64
|
* Attach token persistence to a BotCordClient.
|
|
207
65
|
* If the account was loaded from a credentialsFile, refreshed tokens
|
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";
|
package/src/dynamic-context.ts
CHANGED
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
* out of the box with zero config.
|
|
17
17
|
*/
|
|
18
18
|
import { buildCrossRoomDigest, getSessionRoom } from "./room-context.js";
|
|
19
|
-
import { readWorkingMemory } from "./memory.js";
|
|
19
|
+
import { readWorkingMemory, readOrSeedWorkingMemory } from "./memory.js";
|
|
20
20
|
import { buildWorkingMemoryPrompt } from "./memory-protocol.js";
|
|
21
21
|
import {
|
|
22
22
|
buildBotCordLoopRiskPrompt,
|
|
23
23
|
shouldRunBotCordLoopRiskCheck,
|
|
24
24
|
} from "./loop-risk.js";
|
|
25
|
+
import type { BotCordClient as BotCordClientType } from "./client.js";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Build the dynamic context for a BotCord session.
|
|
@@ -35,32 +36,48 @@ export async function buildDynamicContext(params: {
|
|
|
35
36
|
prompt?: string;
|
|
36
37
|
messages?: unknown[];
|
|
37
38
|
trigger?: string;
|
|
39
|
+
client?: BotCordClientType;
|
|
40
|
+
credentialsFile?: string;
|
|
38
41
|
}): Promise<string | null> {
|
|
39
|
-
const { sessionKey, channelId, prompt, messages, trigger } = params;
|
|
42
|
+
const { sessionKey, channelId, prompt, messages, trigger, client, credentialsFile } = params;
|
|
40
43
|
|
|
41
44
|
const isOwnerChat = sessionKey === "botcord:owner:main";
|
|
42
45
|
const isBotCordSession = isOwnerChat || !!getSessionRoom(sessionKey);
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// 1. Cross-room activity digest
|
|
49
|
-
const digest = await buildCrossRoomDigest(sessionKey);
|
|
50
|
-
if (digest) parts.push(digest);
|
|
51
|
-
|
|
52
|
-
// 2. Working memory
|
|
47
|
+
// Read working memory early — needed both for injection and for the
|
|
48
|
+
// onboarding gate decision below.
|
|
49
|
+
let wm: Awaited<ReturnType<typeof readOrSeedWorkingMemory>> = null;
|
|
53
50
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
wm = client
|
|
52
|
+
? await readOrSeedWorkingMemory({ client, credentialsFile })
|
|
53
|
+
: readWorkingMemory();
|
|
57
54
|
} catch (err: unknown) {
|
|
58
55
|
const msg = err instanceof Error ? err.message : String(err);
|
|
59
56
|
console.warn("[botcord] dynamic-context: failed to read working memory:", msg);
|
|
60
57
|
}
|
|
61
58
|
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
const onboardingPending = !!wm?.sections?.onboarding;
|
|
60
|
+
|
|
61
|
+
// Gate: inject context for BotCord sessions unconditionally, but also
|
|
62
|
+
// for ANY session while onboarding is still pending — so the agent can
|
|
63
|
+
// start/continue onboarding from Telegram, Discord, webchat, etc.
|
|
64
|
+
if (!isBotCordSession && !onboardingPending) return null;
|
|
65
|
+
|
|
66
|
+
const parts: string[] = [];
|
|
67
|
+
|
|
68
|
+
// 1. Cross-room activity digest (BotCord sessions only — not relevant
|
|
69
|
+
// for non-BotCord sessions during onboarding)
|
|
70
|
+
if (isBotCordSession) {
|
|
71
|
+
const digest = await buildCrossRoomDigest(sessionKey);
|
|
72
|
+
if (digest) parts.push(digest);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. Working memory
|
|
76
|
+
const memoryPrompt = buildWorkingMemoryPrompt({ workingMemory: wm });
|
|
77
|
+
parts.push(memoryPrompt);
|
|
78
|
+
|
|
79
|
+
// 3. Loop-risk guard (BotCord sessions only)
|
|
80
|
+
if (isBotCordSession && prompt && shouldRunBotCordLoopRiskCheck({
|
|
64
81
|
channelId: channelId ?? "botcord",
|
|
65
82
|
prompt,
|
|
66
83
|
trigger,
|
package/src/hub-url.ts
CHANGED
|
@@ -1,41 +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
|
-
}
|
|
1
|
+
export * from "@botcord/protocol-core";
|
package/src/memory.ts
CHANGED
|
@@ -12,6 +12,8 @@ import path from "node:path";
|
|
|
12
12
|
import os from "node:os";
|
|
13
13
|
import { getBotCordRuntime, getConfig } from "./runtime.js";
|
|
14
14
|
import { resolveAccountConfig } from "./config.js";
|
|
15
|
+
import { isLegacyOnboarded } from "./credentials.js";
|
|
16
|
+
import type { BotCordClient as BotCordClientType } from "./client.js";
|
|
15
17
|
|
|
16
18
|
// ── Types ──────────────────────────────────────────────────────────
|
|
17
19
|
|
|
@@ -197,6 +199,54 @@ export function writeWorkingMemory(
|
|
|
197
199
|
writeJsonFileAtomic(workingMemoryPath(memDir), data);
|
|
198
200
|
}
|
|
199
201
|
|
|
202
|
+
// ── Seed (lazy init) ──────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Read working memory with lazy seed from Hub API.
|
|
206
|
+
*
|
|
207
|
+
* If local memory file does not exist:
|
|
208
|
+
* 1. Check the legacy onboardedAt flag → skip seed if set (migration bridge).
|
|
209
|
+
* 2. Fetch default memory from GET /hub/memory/default.
|
|
210
|
+
* 3. Write the seed to local file and return it.
|
|
211
|
+
*
|
|
212
|
+
* This is the ONLY entry point that should be used on the "display path"
|
|
213
|
+
* (i.e., injecting memory into the agent prompt). Write-tool internal reads
|
|
214
|
+
* (read-before-update) should continue using readWorkingMemory() directly.
|
|
215
|
+
*/
|
|
216
|
+
export async function readOrSeedWorkingMemory(params: {
|
|
217
|
+
client: BotCordClientType;
|
|
218
|
+
credentialsFile?: string;
|
|
219
|
+
memDir?: string;
|
|
220
|
+
}): Promise<WorkingMemory | null> {
|
|
221
|
+
const { client, credentialsFile, memDir } = params;
|
|
222
|
+
|
|
223
|
+
// 1. Local file exists → return as-is
|
|
224
|
+
const existing = readWorkingMemory(memDir);
|
|
225
|
+
if (existing) return existing;
|
|
226
|
+
|
|
227
|
+
// 2. Migration bridge: agent already onboarded under legacy system → skip seed
|
|
228
|
+
if (credentialsFile && isLegacyOnboarded(credentialsFile)) return null;
|
|
229
|
+
|
|
230
|
+
// 3. Fetch seed from Hub API
|
|
231
|
+
try {
|
|
232
|
+
const seed = await client.getDefaultMemory();
|
|
233
|
+
if (seed && typeof seed === "object" && seed.version === 2) {
|
|
234
|
+
const wm: WorkingMemory = {
|
|
235
|
+
version: 2,
|
|
236
|
+
goal: typeof seed.goal === "string" ? seed.goal : undefined,
|
|
237
|
+
sections: seed.sections && typeof seed.sections === "object" ? seed.sections as Record<string, string> : {},
|
|
238
|
+
updatedAt: new Date().toISOString(),
|
|
239
|
+
};
|
|
240
|
+
writeWorkingMemory(wm, memDir);
|
|
241
|
+
return wm;
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
// Offline / network error → no onboarding guidance, but don't block
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
200
250
|
// ── Room State ─────────────────────────────────────────────────────
|
|
201
251
|
|
|
202
252
|
function roomStatePath(roomId: string, memDir?: string): string {
|