@keychat-io/keychat-openclaw 0.1.8
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/LICENSE +661 -0
- package/README.md +231 -0
- package/docs/setup-guide.md +124 -0
- package/index.ts +108 -0
- package/openclaw.plugin.json +38 -0
- package/package.json +33 -0
- package/scripts/install.sh +154 -0
- package/scripts/postinstall.mjs +97 -0
- package/scripts/publish.sh +25 -0
- package/src/bridge-client.ts +840 -0
- package/src/channel.ts +2607 -0
- package/src/config-schema.ts +50 -0
- package/src/ensure-binary.ts +65 -0
- package/src/keychain.ts +75 -0
- package/src/lightning.ts +128 -0
- package/src/media.ts +189 -0
- package/src/nwc.ts +360 -0
- package/src/paths.ts +38 -0
- package/src/qrcode-types.d.ts +4 -0
- package/src/qrcode.ts +9 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +125 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
const allowFromEntry = z.union([z.string(), z.number()]);
|
|
5
|
+
|
|
6
|
+
/** Per-account config fields (used both at top-level and inside accounts.*) */
|
|
7
|
+
const KeychatAccountSchema = z.object({
|
|
8
|
+
/** Account name */
|
|
9
|
+
name: z.string().optional(),
|
|
10
|
+
|
|
11
|
+
/** Whether this channel is enabled */
|
|
12
|
+
enabled: z.boolean().optional(),
|
|
13
|
+
|
|
14
|
+
/** Markdown formatting overrides */
|
|
15
|
+
markdown: MarkdownConfigSchema.optional(),
|
|
16
|
+
|
|
17
|
+
/** Mnemonic phrase for identity (auto-generated on first start) */
|
|
18
|
+
mnemonic: z.string().optional(),
|
|
19
|
+
|
|
20
|
+
/** Public key hex (derived, read-only) */
|
|
21
|
+
publicKey: z.string().optional(),
|
|
22
|
+
|
|
23
|
+
/** npub bech32 (derived, read-only) */
|
|
24
|
+
npub: z.string().optional(),
|
|
25
|
+
|
|
26
|
+
/** WebSocket relay URLs */
|
|
27
|
+
relays: z.array(z.string()).optional(),
|
|
28
|
+
|
|
29
|
+
/** DM access policy */
|
|
30
|
+
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
|
31
|
+
|
|
32
|
+
/** Allowed sender pubkeys (npub or hex format) */
|
|
33
|
+
allowFrom: z.array(allowFromEntry).optional(),
|
|
34
|
+
|
|
35
|
+
/** Lightning address for receiving payments (e.g. "user@walletofsatoshi.com") */
|
|
36
|
+
lightningAddress: z.string().optional(),
|
|
37
|
+
|
|
38
|
+
/** Nostr Wallet Connect URI (nostr+walletconnect://...) for Lightning wallet access */
|
|
39
|
+
nwcUri: z.string().optional(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** Top-level keychat config: single-account fields + optional multi-account map. */
|
|
43
|
+
export const KeychatConfigSchema = KeychatAccountSchema.extend({
|
|
44
|
+
/** Multiple named accounts, each with its own identity/config. */
|
|
45
|
+
accounts: z.record(z.string(), KeychatAccountSchema).optional(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export type KeychatConfig = z.infer<typeof KeychatConfigSchema>;
|
|
49
|
+
|
|
50
|
+
export const keychatChannelConfigSchema = buildChannelConfigSchema(KeychatConfigSchema);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-download the keychat-openclaw bridge binary if missing.
|
|
3
|
+
* Called before bridge startup. Downloads from GitHub Releases.
|
|
4
|
+
* Uses native fetch — no child_process dependency.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, chmodSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
const REPO = "keychat-io/keychat-openclaw";
|
|
11
|
+
|
|
12
|
+
const ARTIFACTS: Record<string, string> = {
|
|
13
|
+
"darwin-arm64": "keychat-openclaw-darwin-arm64",
|
|
14
|
+
"darwin-x64": "keychat-openclaw-darwin-x64",
|
|
15
|
+
"linux-x64": "keychat-openclaw-linux-x64",
|
|
16
|
+
"linux-arm64": "keychat-openclaw-linux-arm64",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function getBridgePath(): string {
|
|
20
|
+
return join(
|
|
21
|
+
import.meta.dirname ?? ".",
|
|
22
|
+
"..",
|
|
23
|
+
"bridge",
|
|
24
|
+
"target",
|
|
25
|
+
"release",
|
|
26
|
+
"keychat-openclaw",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function ensureBinary(): Promise<string> {
|
|
31
|
+
const binaryPath = getBridgePath();
|
|
32
|
+
if (existsSync(binaryPath)) return binaryPath;
|
|
33
|
+
|
|
34
|
+
const key = `${process.platform}-${process.arch}`;
|
|
35
|
+
const artifact = ARTIFACTS[key];
|
|
36
|
+
if (!artifact) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`No pre-compiled keychat-openclaw binary for ${key}. ` +
|
|
39
|
+
`Build from source: cd bridge && cargo build --release`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const url = `https://github.com/${REPO}/releases/latest/download/${artifact}`;
|
|
44
|
+
console.log(`[keychat] Downloading bridge binary (${artifact})...`);
|
|
45
|
+
|
|
46
|
+
const dir = join(binaryPath, "..");
|
|
47
|
+
mkdirSync(dir, { recursive: true });
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const response = await fetch(url, { redirect: "follow" });
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
53
|
+
}
|
|
54
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
55
|
+
writeFileSync(binaryPath, buffer);
|
|
56
|
+
chmodSync(binaryPath, 0o755);
|
|
57
|
+
console.log("[keychat] ✅ Bridge binary downloaded");
|
|
58
|
+
return binaryPath;
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Failed to download bridge binary: ${err.message}\n` +
|
|
62
|
+
`Build from source: cd bridge && cargo build --release`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/keychain.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure mnemonic storage using system keychain.
|
|
3
|
+
* Falls back gracefully if keychain is unavailable.
|
|
4
|
+
*
|
|
5
|
+
* macOS: Uses `security` CLI (Keychain Access)
|
|
6
|
+
* Linux: Uses `secret-tool` (libsecret / GNOME Keyring)
|
|
7
|
+
*
|
|
8
|
+
* Note: This file intentionally uses child_process for system keychain access.
|
|
9
|
+
* This triggers an OpenClaw scanner warning ("Shell command execution detected")
|
|
10
|
+
* which is expected — keychain storage is more secure than file-based alternatives.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
const SERVICE = "openclaw-keychat";
|
|
16
|
+
|
|
17
|
+
export async function storeMnemonic(accountId: string, mnemonic: string): Promise<boolean> {
|
|
18
|
+
const key = `mnemonic-${accountId}`;
|
|
19
|
+
try {
|
|
20
|
+
if (process.platform === "darwin") {
|
|
21
|
+
execFileSync("security", [
|
|
22
|
+
"add-generic-password", "-a", key, "-s", SERVICE, "-w", mnemonic, "-U",
|
|
23
|
+
], { stdio: "pipe" });
|
|
24
|
+
return true;
|
|
25
|
+
} else if (process.platform === "linux") {
|
|
26
|
+
execFileSync("secret-tool", [
|
|
27
|
+
"store", "--label", SERVICE, "service", SERVICE, "account", key,
|
|
28
|
+
], { stdio: "pipe", input: mnemonic });
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Keychain not available
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function retrieveMnemonic(accountId: string): Promise<string | null> {
|
|
38
|
+
const key = `mnemonic-${accountId}`;
|
|
39
|
+
try {
|
|
40
|
+
if (process.platform === "darwin") {
|
|
41
|
+
const result = execFileSync("security", [
|
|
42
|
+
"find-generic-password", "-a", key, "-s", SERVICE, "-w",
|
|
43
|
+
], { stdio: "pipe" });
|
|
44
|
+
return result.toString().trim();
|
|
45
|
+
} else if (process.platform === "linux") {
|
|
46
|
+
const result = execFileSync("secret-tool", [
|
|
47
|
+
"lookup", "service", SERVICE, "account", key,
|
|
48
|
+
], { stdio: "pipe" });
|
|
49
|
+
return result.toString().trim();
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Not found or keychain unavailable
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function deleteMnemonic(accountId: string): Promise<boolean> {
|
|
58
|
+
const key = `mnemonic-${accountId}`;
|
|
59
|
+
try {
|
|
60
|
+
if (process.platform === "darwin") {
|
|
61
|
+
execFileSync("security", [
|
|
62
|
+
"delete-generic-password", "-a", key, "-s", SERVICE,
|
|
63
|
+
], { stdio: "pipe" });
|
|
64
|
+
return true;
|
|
65
|
+
} else if (process.platform === "linux") {
|
|
66
|
+
execFileSync("secret-tool", [
|
|
67
|
+
"clear", "service", SERVICE, "account", key,
|
|
68
|
+
], { stdio: "pipe" });
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Not found
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
package/src/lightning.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightning wallet — receive-only via Lightning Address (LNURL-pay).
|
|
3
|
+
*
|
|
4
|
+
* The agent can:
|
|
5
|
+
* - Generate invoices from its configured Lightning address
|
|
6
|
+
* - Tell users its Lightning address for payments
|
|
7
|
+
* - Forward outbound payment requests (invoices) to the owner
|
|
8
|
+
*
|
|
9
|
+
* The agent CANNOT send payments directly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface LnurlPayMetadata {
|
|
13
|
+
callback: string;
|
|
14
|
+
minSendable: number; // millisatoshis
|
|
15
|
+
maxSendable: number; // millisatoshis
|
|
16
|
+
metadata: string;
|
|
17
|
+
tag: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LnurlInvoice {
|
|
21
|
+
pr: string; // BOLT11 payment request
|
|
22
|
+
routes: unknown[];
|
|
23
|
+
verify?: string; // verification URL
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse a Lightning address into a LNURL-pay endpoint URL.
|
|
28
|
+
* e.g. "user@domain.com" → "https://domain.com/.well-known/lnurlp/user"
|
|
29
|
+
*/
|
|
30
|
+
export function lightningAddressToLnurlp(address: string): string | null {
|
|
31
|
+
const parts = address.trim().split("@");
|
|
32
|
+
if (parts.length !== 2) return null;
|
|
33
|
+
const [user, domain] = parts;
|
|
34
|
+
if (!user || !domain) return null;
|
|
35
|
+
return `https://${domain}/.well-known/lnurlp/${user}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetch LNURL-pay metadata for a Lightning address.
|
|
40
|
+
*/
|
|
41
|
+
export async function fetchLnurlPayMetadata(
|
|
42
|
+
lightningAddress: string,
|
|
43
|
+
): Promise<LnurlPayMetadata | null> {
|
|
44
|
+
const url = lightningAddressToLnurlp(lightningAddress);
|
|
45
|
+
if (!url) return null;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(url, {
|
|
49
|
+
headers: { Accept: "application/json" },
|
|
50
|
+
signal: AbortSignal.timeout(10_000),
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) return null;
|
|
53
|
+
const data = (await res.json()) as LnurlPayMetadata;
|
|
54
|
+
if (data.tag !== "payRequest") return null;
|
|
55
|
+
return data;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error(`[keychat/lightning] Failed to fetch LNURL-pay metadata: ${err}`);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Request an invoice from the LNURL-pay callback.
|
|
64
|
+
* @param amountSats Amount in satoshis
|
|
65
|
+
* @param comment Optional comment/memo
|
|
66
|
+
*/
|
|
67
|
+
export async function requestInvoice(
|
|
68
|
+
lightningAddress: string,
|
|
69
|
+
amountSats: number,
|
|
70
|
+
comment?: string,
|
|
71
|
+
): Promise<LnurlInvoice | null> {
|
|
72
|
+
const meta = await fetchLnurlPayMetadata(lightningAddress);
|
|
73
|
+
if (!meta) return null;
|
|
74
|
+
|
|
75
|
+
const amountMsats = amountSats * 1000;
|
|
76
|
+
if (amountMsats < meta.minSendable || amountMsats > meta.maxSendable) {
|
|
77
|
+
console.error(
|
|
78
|
+
`[keychat/lightning] Amount ${amountSats} sats outside range: ${meta.minSendable / 1000}-${meta.maxSendable / 1000} sats`,
|
|
79
|
+
);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let callbackUrl = `${meta.callback}${meta.callback.includes("?") ? "&" : "?"}amount=${amountMsats}`;
|
|
84
|
+
if (comment) {
|
|
85
|
+
callbackUrl += `&comment=${encodeURIComponent(comment)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(callbackUrl, {
|
|
90
|
+
headers: { Accept: "application/json" },
|
|
91
|
+
signal: AbortSignal.timeout(10_000),
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) return null;
|
|
94
|
+
const data = (await res.json()) as LnurlInvoice;
|
|
95
|
+
if (!data.pr) return null;
|
|
96
|
+
return data;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error(`[keychat/lightning] Failed to request invoice: ${err}`);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Verify if an invoice has been paid (if verify URL is available).
|
|
105
|
+
*/
|
|
106
|
+
export async function verifyPayment(verifyUrl: string): Promise<boolean> {
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(verifyUrl, {
|
|
109
|
+
headers: { Accept: "application/json" },
|
|
110
|
+
signal: AbortSignal.timeout(10_000),
|
|
111
|
+
});
|
|
112
|
+
if (!res.ok) return false;
|
|
113
|
+
const data = (await res.json()) as { settled: boolean };
|
|
114
|
+
return data.settled === true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Format satoshi amounts for display.
|
|
122
|
+
*/
|
|
123
|
+
export function formatSats(sats: number): string {
|
|
124
|
+
if (sats >= 100_000_000) return `${(sats / 100_000_000).toFixed(8)} BTC`;
|
|
125
|
+
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(2)}M sats`;
|
|
126
|
+
if (sats >= 1_000) return `${(sats / 1_000).toFixed(1)}k sats`;
|
|
127
|
+
return `${sats} sats`;
|
|
128
|
+
}
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createDecipheriv, createCipheriv, randomBytes, createHash } from "node:crypto";
|
|
2
|
+
import { writeFile, mkdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { join, extname, basename } from "node:path";
|
|
4
|
+
import { MEDIA_DIR } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
export interface KeychatMediaInfo {
|
|
7
|
+
url: string;
|
|
8
|
+
kctype: string;
|
|
9
|
+
suffix: string;
|
|
10
|
+
key: string;
|
|
11
|
+
iv: string;
|
|
12
|
+
size: number;
|
|
13
|
+
hash?: string;
|
|
14
|
+
sourceName?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MediaUploadResult {
|
|
18
|
+
/** The full Keychat media URL with encryption params */
|
|
19
|
+
mediaUrl: string;
|
|
20
|
+
/** The kctype (image, video, file) */
|
|
21
|
+
kctype: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Default Blossom media server (same as Keychat app default) */
|
|
25
|
+
const DEFAULT_MEDIA_SERVER = "https://relay.keychat.io";
|
|
26
|
+
|
|
27
|
+
/** Determine kctype from file extension or mime type */
|
|
28
|
+
function resolveKctype(filePath: string, mimeType?: string): string {
|
|
29
|
+
const ext = extname(filePath).toLowerCase();
|
|
30
|
+
const imageExts = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".svg"];
|
|
31
|
+
const videoExts = [".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv", ".wmv", ".m4v"];
|
|
32
|
+
|
|
33
|
+
if (mimeType?.startsWith("image/") || imageExts.includes(ext)) return "image";
|
|
34
|
+
if (mimeType?.startsWith("video/") || videoExts.includes(ext)) return "video";
|
|
35
|
+
return "file";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Encrypt a file with AES-256-CTR (matches Keychat app's encryptFile).
|
|
40
|
+
* Returns the encrypted bytes + key/iv/hash/suffix/sourceName.
|
|
41
|
+
*/
|
|
42
|
+
async function encryptFile(filePath: string): Promise<{
|
|
43
|
+
encrypted: Buffer;
|
|
44
|
+
key: string;
|
|
45
|
+
iv: string;
|
|
46
|
+
hash: string;
|
|
47
|
+
suffix: string;
|
|
48
|
+
sourceName: string;
|
|
49
|
+
}> {
|
|
50
|
+
const fileBytes = await readFile(filePath);
|
|
51
|
+
const key = randomBytes(32);
|
|
52
|
+
const iv = randomBytes(16);
|
|
53
|
+
|
|
54
|
+
const cipher = createCipheriv("aes-256-ctr", key, iv);
|
|
55
|
+
const encrypted = Buffer.concat([cipher.update(fileBytes), cipher.final()]);
|
|
56
|
+
|
|
57
|
+
const sha256 = createHash("sha256").update(encrypted).digest("base64");
|
|
58
|
+
|
|
59
|
+
const fileName = basename(filePath);
|
|
60
|
+
const suffix = extname(filePath).replace(".", "");
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
encrypted,
|
|
64
|
+
key: key.toString("base64"),
|
|
65
|
+
iv: iv.toString("base64"),
|
|
66
|
+
hash: sha256,
|
|
67
|
+
suffix,
|
|
68
|
+
sourceName: fileName,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Upload encrypted bytes to a Blossom server.
|
|
74
|
+
* Uses Nostr auth (kind:24242 event signed by agent's key).
|
|
75
|
+
*/
|
|
76
|
+
async function uploadToBlossom(
|
|
77
|
+
encrypted: Buffer,
|
|
78
|
+
hash: string,
|
|
79
|
+
signEvent: (content: string, tags: string[][]) => Promise<string>,
|
|
80
|
+
server?: string,
|
|
81
|
+
): Promise<string> {
|
|
82
|
+
const baseUrl = server || DEFAULT_MEDIA_SERVER;
|
|
83
|
+
|
|
84
|
+
// Sign authorization event (kind:24242)
|
|
85
|
+
const expiration = Math.floor(Date.now() / 1000) + 86400 * 30; // 30 days
|
|
86
|
+
const eventJson = await signEvent(hash, [
|
|
87
|
+
["t", "upload"],
|
|
88
|
+
["x", hash],
|
|
89
|
+
["expiration", expiration.toString()],
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const authHeader = `Nostr ${Buffer.from(eventJson).toString("base64")}`;
|
|
93
|
+
|
|
94
|
+
const response = await fetch(`${baseUrl}/upload`, {
|
|
95
|
+
method: "PUT",
|
|
96
|
+
headers: {
|
|
97
|
+
"Content-Type": "application/octet-stream",
|
|
98
|
+
Authorization: authHeader,
|
|
99
|
+
},
|
|
100
|
+
body: new Uint8Array(encrypted),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
const body = await response.text().catch(() => "");
|
|
105
|
+
throw new Error(`Blossom upload failed (${response.status}): ${body}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const data = (await response.json()) as { url?: string; size?: number };
|
|
109
|
+
if (!data.url) throw new Error("Blossom upload response missing url");
|
|
110
|
+
return data.url;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Encrypt and upload a local file, returning a Keychat media URL.
|
|
115
|
+
*
|
|
116
|
+
* @param filePath - Local file path to upload
|
|
117
|
+
* @param signEvent - Function to sign a Nostr kind:24242 event for Blossom auth
|
|
118
|
+
* @param server - Optional Blossom server URL (defaults to relay.keychat.io)
|
|
119
|
+
* @param mimeType - Optional MIME type hint
|
|
120
|
+
*/
|
|
121
|
+
export async function encryptAndUpload(
|
|
122
|
+
filePath: string,
|
|
123
|
+
signEvent: (content: string, tags: string[][]) => Promise<string>,
|
|
124
|
+
server?: string,
|
|
125
|
+
mimeType?: string,
|
|
126
|
+
): Promise<MediaUploadResult> {
|
|
127
|
+
const { encrypted, key, iv, hash, suffix, sourceName } = await encryptFile(filePath);
|
|
128
|
+
const url = await uploadToBlossom(encrypted, hash, signEvent, server);
|
|
129
|
+
const kctype = resolveKctype(filePath, mimeType);
|
|
130
|
+
|
|
131
|
+
// Construct the Keychat media URL (same format as app)
|
|
132
|
+
const parsedUrl = new URL(url);
|
|
133
|
+
const mediaUrl = new URL(parsedUrl.origin + parsedUrl.pathname);
|
|
134
|
+
mediaUrl.searchParams.set("kctype", kctype);
|
|
135
|
+
mediaUrl.searchParams.set("suffix", suffix);
|
|
136
|
+
mediaUrl.searchParams.set("key", key);
|
|
137
|
+
mediaUrl.searchParams.set("iv", iv);
|
|
138
|
+
mediaUrl.searchParams.set("size", encrypted.length.toString());
|
|
139
|
+
mediaUrl.searchParams.set("hash", hash);
|
|
140
|
+
mediaUrl.searchParams.set("sourceName", sourceName);
|
|
141
|
+
|
|
142
|
+
return { mediaUrl: mediaUrl.toString(), kctype };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Parse a Keychat encrypted media URL. Returns null if not a media message. */
|
|
146
|
+
export function parseMediaUrl(content: string): KeychatMediaInfo | null {
|
|
147
|
+
const trimmed = content.trim();
|
|
148
|
+
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) return null;
|
|
149
|
+
|
|
150
|
+
let uri: URL;
|
|
151
|
+
try { uri = new URL(trimmed); } catch { return null; }
|
|
152
|
+
|
|
153
|
+
const kctype = uri.searchParams.get("kctype");
|
|
154
|
+
if (!kctype) return null;
|
|
155
|
+
|
|
156
|
+
const key = uri.searchParams.get("key");
|
|
157
|
+
const iv = uri.searchParams.get("iv");
|
|
158
|
+
if (!key || !iv) return null;
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
url: uri.origin + uri.pathname,
|
|
162
|
+
kctype,
|
|
163
|
+
suffix: uri.searchParams.get("suffix") || kctype,
|
|
164
|
+
key,
|
|
165
|
+
iv,
|
|
166
|
+
size: parseInt(uri.searchParams.get("size") || "0", 10),
|
|
167
|
+
hash: uri.searchParams.get("hash") || undefined,
|
|
168
|
+
sourceName: uri.searchParams.get("sourceName") || undefined,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Download and decrypt a Keychat media file. Returns local file path. */
|
|
173
|
+
export async function downloadAndDecrypt(media: KeychatMediaInfo): Promise<string> {
|
|
174
|
+
const response = await fetch(media.url);
|
|
175
|
+
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
|
176
|
+
const encrypted = Buffer.from(await response.arrayBuffer());
|
|
177
|
+
|
|
178
|
+
const keyBuf = Buffer.from(media.key, "base64");
|
|
179
|
+
const ivBuf = Buffer.from(media.iv, "base64");
|
|
180
|
+
const decipher = createDecipheriv("aes-256-ctr", keyBuf, ivBuf);
|
|
181
|
+
const decrypted = Buffer.concat([decipher.update(encrypted)]);
|
|
182
|
+
|
|
183
|
+
await mkdir(MEDIA_DIR, { recursive: true });
|
|
184
|
+
const filename = media.sourceName || `${Date.now()}.${media.suffix}`;
|
|
185
|
+
const filepath = join(MEDIA_DIR, filename);
|
|
186
|
+
await writeFile(filepath, decrypted);
|
|
187
|
+
|
|
188
|
+
return filepath;
|
|
189
|
+
}
|