@profullstack/qryptchat 0.1.1 → 0.3.0
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 +6 -1
- package/dist/{chunk-12bbzs4r.js → chunk-4r99spcc.js} +134 -36
- package/dist/chunk-vfahhydf.js +190 -0
- package/dist/commands.js +2 -1
- package/dist/mcp.js +38 -4
- package/dist/tui.js +9 -1
- package/package.json +1 -1
- package/dist/chunk-fb6zrz6m.js +0 -40
package/README.md
CHANGED
|
@@ -73,7 +73,12 @@ claude mcp add qryptchat -- qc mcp
|
|
|
73
73
|
|
|
74
74
|
Over SSH, `qc login --oob` shows a code in the browser for you to paste instead of redirecting back.
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
Your keys and tokens are **never stored in plaintext**. `~/.config/qc/session.json` (or `$QC_HOME`) keeps only your username and the server URL in the clear. Everything else is sealed with ChaCha20-Poly1305 under a key from:
|
|
77
|
+
|
|
78
|
+
- your OS keychain (macOS Keychain, or libsecret's `secret-tool` on a Linux desktop), so you're never asked; or
|
|
79
|
+
- a passphrase, stretched with scrypt. qc asks for it once per run; scripts and `qc mcp` read it from `QC_PASSPHRASE`.
|
|
80
|
+
|
|
81
|
+
Sessions refresh themselves and are re-sealed each time. `qc logout` deletes them. Point at another server with `--url` or `QC_URL`.
|
|
77
82
|
|
|
78
83
|
## Licence
|
|
79
84
|
|
|
@@ -2,9 +2,10 @@ import {
|
|
|
2
2
|
sessionPath,
|
|
3
3
|
baseUrl,
|
|
4
4
|
loadSession,
|
|
5
|
-
|
|
5
|
+
unlockSession,
|
|
6
|
+
storeSession,
|
|
6
7
|
clearSession
|
|
7
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-vfahhydf.js";
|
|
8
9
|
|
|
9
10
|
// src/cli/crypto.js
|
|
10
11
|
import { MlKem1024 as MlKem10242 } from "mlkem";
|
|
@@ -822,6 +823,81 @@ async function openKeyBlob(blob, ephemeral) {
|
|
|
822
823
|
return { keys1024: keys.keys1024, keys768: keys.keys768 };
|
|
823
824
|
}
|
|
824
825
|
|
|
826
|
+
// src/lib/chat/reactions.js
|
|
827
|
+
var REACTION_TYPE = "reaction";
|
|
828
|
+
var MAX_EMOJI_LENGTH = 32;
|
|
829
|
+
function reactionEnvelope(targetId, emoji, remove = false) {
|
|
830
|
+
return JSON.stringify({ v: 1, type: REACTION_TYPE, target: String(targetId), emoji: String(emoji), remove: !!remove });
|
|
831
|
+
}
|
|
832
|
+
function parseReaction(content) {
|
|
833
|
+
try {
|
|
834
|
+
const d = JSON.parse(content);
|
|
835
|
+
if (d?.type !== REACTION_TYPE || typeof d.target !== "string" || typeof d.emoji !== "string")
|
|
836
|
+
return null;
|
|
837
|
+
if (!d.emoji || d.emoji.length > MAX_EMOJI_LENGTH)
|
|
838
|
+
return null;
|
|
839
|
+
return { target: d.target, emoji: d.emoji, remove: !!d.remove };
|
|
840
|
+
} catch {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
var senderName = (m) => m?.sender?.display_name || m?.sender?.username || "Someone";
|
|
845
|
+
function snippet(m, max = 90) {
|
|
846
|
+
if (!m)
|
|
847
|
+
return "";
|
|
848
|
+
if (m.message_type === "file" || m.has_attachments)
|
|
849
|
+
return "\uD83D\uDCCE Attachment";
|
|
850
|
+
const text = String(m.content ?? "").replace(/\s+/g, " ").trim();
|
|
851
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
852
|
+
}
|
|
853
|
+
function foldMessages(messages, myUserId) {
|
|
854
|
+
const visible = [];
|
|
855
|
+
const byId = new Map;
|
|
856
|
+
const picks = new Map;
|
|
857
|
+
for (const m of messages) {
|
|
858
|
+
if (m.message_type === REACTION_TYPE) {
|
|
859
|
+
const r = parseReaction(m.content);
|
|
860
|
+
if (!r)
|
|
861
|
+
continue;
|
|
862
|
+
const forTarget = picks.get(r.target) ?? new Map;
|
|
863
|
+
const current = forTarget.get(m.sender_id);
|
|
864
|
+
if (r.remove) {
|
|
865
|
+
if (current && current.emoji === r.emoji)
|
|
866
|
+
forTarget.delete(m.sender_id);
|
|
867
|
+
} else {
|
|
868
|
+
forTarget.set(m.sender_id, { emoji: r.emoji, name: senderName(m), at: m.created_at });
|
|
869
|
+
}
|
|
870
|
+
picks.set(r.target, forTarget);
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
visible.push(m);
|
|
874
|
+
byId.set(m.id, m);
|
|
875
|
+
}
|
|
876
|
+
return visible.map((m) => {
|
|
877
|
+
const out = { ...m, reactions: [] };
|
|
878
|
+
const forTarget = picks.get(m.id);
|
|
879
|
+
if (forTarget?.size) {
|
|
880
|
+
const groups = new Map;
|
|
881
|
+
for (const [senderId, p] of forTarget) {
|
|
882
|
+
const g = groups.get(p.emoji) ?? { emoji: p.emoji, count: 0, mine: false, names: [], first: p.at };
|
|
883
|
+
g.count += 1;
|
|
884
|
+
g.names.push(senderId === myUserId ? "You" : p.name);
|
|
885
|
+
if (senderId === myUserId)
|
|
886
|
+
g.mine = true;
|
|
887
|
+
if (p.at < g.first)
|
|
888
|
+
g.first = p.at;
|
|
889
|
+
groups.set(p.emoji, g);
|
|
890
|
+
}
|
|
891
|
+
out.reactions = [...groups.values()].sort((a, b) => b.count - a.count || String(a.first).localeCompare(String(b.first)));
|
|
892
|
+
}
|
|
893
|
+
if (m.reply_to_id) {
|
|
894
|
+
const target = byId.get(m.reply_to_id);
|
|
895
|
+
out.replyTo = target ? { id: target.id, name: target.sender_id === myUserId ? "You" : senderName(target), snippet: snippet(target), missing: false } : { id: m.reply_to_id, name: "", snippet: "Original message not loaded", missing: true };
|
|
896
|
+
}
|
|
897
|
+
return out;
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
825
901
|
// src/cli/api.js
|
|
826
902
|
class QcError extends Error {
|
|
827
903
|
constructor(message, status) {
|
|
@@ -835,7 +911,7 @@ class QcClient {
|
|
|
835
911
|
constructor(session, options = {}) {
|
|
836
912
|
this.session = session;
|
|
837
913
|
this.base = baseUrl(session, options.env);
|
|
838
|
-
this.save = options.save ?? ((
|
|
914
|
+
this.save = options.save ?? (() => {});
|
|
839
915
|
this.fetch = options.fetch ?? globalThis.fetch;
|
|
840
916
|
this.ring = keyring(session.keys);
|
|
841
917
|
this.refreshing = null;
|
|
@@ -889,34 +965,37 @@ class QcClient {
|
|
|
889
965
|
const { conversations = [] } = await this.request("/api/conversations/load", { method: "POST", body: {} });
|
|
890
966
|
return conversations.map((c) => ({ ...c, title: conversationTitle(c, this.me) }));
|
|
891
967
|
}
|
|
892
|
-
async messages(conversationId, { limit =
|
|
968
|
+
async messages(conversationId, { limit = 200, before } = {}) {
|
|
893
969
|
const { messages = [], hasMore = false } = await this.request("/api/messages/load", {
|
|
894
970
|
method: "POST",
|
|
895
971
|
body: { conversationId, limit, ...before ? { before } : {} }
|
|
896
972
|
});
|
|
897
|
-
const
|
|
973
|
+
const raw = [];
|
|
898
974
|
for (const m of messages) {
|
|
899
|
-
let
|
|
975
|
+
let content = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : "";
|
|
900
976
|
if (m.message_type === "file" || m.has_attachments) {
|
|
901
|
-
|
|
902
|
-
text = `\uD83D\uDCCE ${text && text !== "[File attachment]" ? `${text} ` : ""}(attachment: open qrypt.chat to download)`;
|
|
903
|
-
} else {
|
|
904
|
-
text = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : "";
|
|
977
|
+
content = `\uD83D\uDCCE ${content && content !== "[File attachment]" ? `${content} ` : ""}(attachment: open qrypt.chat to download)`;
|
|
905
978
|
}
|
|
906
|
-
|
|
907
|
-
id: m.id,
|
|
908
|
-
conversationId: m.conversation_id,
|
|
909
|
-
senderId: m.sender_id,
|
|
910
|
-
sender: m.sender?.display_name || m.sender?.username || "unknown",
|
|
911
|
-
username: m.sender?.username || "",
|
|
912
|
-
mine: m.sender_id === this.me?.id,
|
|
913
|
-
text,
|
|
914
|
-
at: m.created_at
|
|
915
|
-
});
|
|
979
|
+
raw.push({ ...m, content });
|
|
916
980
|
}
|
|
981
|
+
const out = foldMessages(raw, this.me?.id).map((m) => ({
|
|
982
|
+
id: m.id,
|
|
983
|
+
conversationId: m.conversation_id,
|
|
984
|
+
senderId: m.sender_id,
|
|
985
|
+
sender: m.sender?.display_name || m.sender?.username || "unknown",
|
|
986
|
+
username: m.sender?.username || "",
|
|
987
|
+
mine: m.sender_id === this.me?.id,
|
|
988
|
+
text: m.content,
|
|
989
|
+
at: m.created_at,
|
|
990
|
+
reactions: m.reactions.map(({ emoji, count, mine, names }) => ({ emoji, count, mine, names })),
|
|
991
|
+
...m.replyTo ? { replyTo: m.replyTo } : {}
|
|
992
|
+
}));
|
|
917
993
|
return { messages: out, hasMore };
|
|
918
994
|
}
|
|
919
|
-
async
|
|
995
|
+
async react(conversationId, messageId, emoji, remove = false) {
|
|
996
|
+
return this.send(conversationId, reactionEnvelope(messageId, emoji, remove), { messageType: REACTION_TYPE });
|
|
997
|
+
}
|
|
998
|
+
async send(conversationId, text, { replyTo, messageType = "text" } = {}) {
|
|
920
999
|
const { participants = [] } = await this.request(`/api/chat/conversations/${encodeURIComponent(conversationId)}/participants`);
|
|
921
1000
|
const userIds = participants.map((p) => p.user_id);
|
|
922
1001
|
if (!userIds.length)
|
|
@@ -935,7 +1014,7 @@ class QcClient {
|
|
|
935
1014
|
throw new QcError("No participant has a public key yet");
|
|
936
1015
|
const { message } = await this.request("/api/messages/send", {
|
|
937
1016
|
method: "POST",
|
|
938
|
-
body: { conversationId, encryptedContents, messageType:
|
|
1017
|
+
body: { conversationId, encryptedContents, messageType, ...replyTo ? { replyToId: replyTo } : {} }
|
|
939
1018
|
});
|
|
940
1019
|
return { message, skipped: missing.length };
|
|
941
1020
|
}
|
|
@@ -1207,8 +1286,8 @@ async function login(options = {}) {
|
|
|
1207
1286
|
keys,
|
|
1208
1287
|
created_at: new Date().toISOString()
|
|
1209
1288
|
};
|
|
1210
|
-
|
|
1211
|
-
return session;
|
|
1289
|
+
const save = await storeSession(session, env, options.vault);
|
|
1290
|
+
return { session, save };
|
|
1212
1291
|
}
|
|
1213
1292
|
|
|
1214
1293
|
// src/cli/commands.js
|
|
@@ -1221,12 +1300,14 @@ Usage:
|
|
|
1221
1300
|
qc whoami who this terminal is signed in as
|
|
1222
1301
|
qc chats list your chats
|
|
1223
1302
|
qc read <chat> [-n 20] print the last messages of a chat
|
|
1224
|
-
qc send <chat> <text> send a message (text "-" reads stdin)
|
|
1303
|
+
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply)
|
|
1304
|
+
qc react <chat> <message-id> <emoji> [--remove] react to a message
|
|
1225
1305
|
qc listen print new messages as they arrive (NDJSON with --json)
|
|
1226
1306
|
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
|
|
1227
1307
|
|
|
1228
1308
|
<chat> is a chat id or part of its name. Options: --json, --url <server> (or QC_URL).
|
|
1229
|
-
Keys and
|
|
1309
|
+
Keys and tokens are sealed (ChaCha20-Poly1305) in ${"$"}QC_HOME or ~/.config/qc; the key is in your
|
|
1310
|
+
OS keychain, or comes from a passphrase (QC_PASSPHRASE for scripts and qc mcp).`;
|
|
1230
1311
|
function parseArgs2(argv) {
|
|
1231
1312
|
const args = { _: [], flags: {} };
|
|
1232
1313
|
for (let i = 0;i < argv.length; i++) {
|
|
@@ -1239,7 +1320,7 @@ function parseArgs2(argv) {
|
|
|
1239
1320
|
const [k, v] = a.slice(2).split("=", 2);
|
|
1240
1321
|
if (v !== undefined)
|
|
1241
1322
|
args.flags[k] = v;
|
|
1242
|
-
else if (["url", "n", "limit"].includes(k) && argv[i + 1] !== undefined)
|
|
1323
|
+
else if (["url", "n", "limit", "reply"].includes(k) && argv[i + 1] !== undefined)
|
|
1243
1324
|
args.flags[k] = argv[++i];
|
|
1244
1325
|
else
|
|
1245
1326
|
args.flags[k] = true;
|
|
@@ -1273,13 +1354,13 @@ function findChat2(chats, query) {
|
|
|
1273
1354
|
async function client(flags, { interactive = false } = {}) {
|
|
1274
1355
|
if (flags.url)
|
|
1275
1356
|
process.env.QC_URL = flags.url;
|
|
1276
|
-
let
|
|
1277
|
-
if (!
|
|
1357
|
+
let opened = await unlockSession();
|
|
1358
|
+
if (!opened) {
|
|
1278
1359
|
if (!interactive)
|
|
1279
1360
|
throw new Error("Not signed in. Run qc login.");
|
|
1280
|
-
|
|
1361
|
+
opened = await login({ oob: !!flags.oob });
|
|
1281
1362
|
}
|
|
1282
|
-
return new QcClient(session);
|
|
1363
|
+
return new QcClient(opened.session, { save: opened.save });
|
|
1283
1364
|
}
|
|
1284
1365
|
var stamp = (iso) => new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
|
|
1285
1366
|
async function readStdin() {
|
|
@@ -1309,8 +1390,8 @@ async function main2(argv, { version = "0.0.0" } = {}) {
|
|
|
1309
1390
|
return runTui(c, { initialChat: rest[0] });
|
|
1310
1391
|
}
|
|
1311
1392
|
case "login": {
|
|
1312
|
-
const session = await login({ oob: !!flags.oob });
|
|
1313
|
-
return out(`Signed in as @${session.user?.username ?? "unknown"} on ${session.base}. Keys
|
|
1393
|
+
const { session } = await login({ oob: !!flags.oob });
|
|
1394
|
+
return out(`Signed in as @${session.user?.username ?? "unknown"} on ${session.base}. Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`);
|
|
1314
1395
|
}
|
|
1315
1396
|
case "logout":
|
|
1316
1397
|
clearSession();
|
|
@@ -1339,10 +1420,27 @@ async function main2(argv, { version = "0.0.0" } = {}) {
|
|
|
1339
1420
|
const last = messages.slice(-Math.max(1, Number(flags.n || flags.limit || 20)));
|
|
1340
1421
|
if (flags.json)
|
|
1341
1422
|
return json(last);
|
|
1342
|
-
for (const m of last)
|
|
1343
|
-
|
|
1423
|
+
for (const m of last) {
|
|
1424
|
+
if (m.replyTo)
|
|
1425
|
+
out(` ┃ ${m.replyTo.name ? `${m.replyTo.name}: ` : ""}${m.replyTo.snippet}`);
|
|
1426
|
+
const reactions = m.reactions?.length ? ` [${m.reactions.map((r) => `${r.emoji}${r.count > 1 ? r.count : ""}`).join(" ")}]` : "";
|
|
1427
|
+
out(`[${stamp(m.at)}] ${m.mine ? "you" : m.sender}: ${m.text}${reactions} (${m.id.slice(0, 8)})`);
|
|
1428
|
+
}
|
|
1344
1429
|
return;
|
|
1345
1430
|
}
|
|
1431
|
+
case "react": {
|
|
1432
|
+
const [chatQuery, messageId, emoji] = rest;
|
|
1433
|
+
if (!chatQuery || !messageId || !emoji)
|
|
1434
|
+
throw new Error("Usage: qc react <chat> <message-id> <emoji> [--remove]");
|
|
1435
|
+
const c = await client(flags);
|
|
1436
|
+
const chat = findChat2(await c.conversations(), chatQuery);
|
|
1437
|
+
const { messages } = await c.messages(chat.id);
|
|
1438
|
+
const target = messages.filter((m) => m.id === messageId || m.id.startsWith(messageId));
|
|
1439
|
+
if (target.length !== 1)
|
|
1440
|
+
throw new Error(target.length ? `"${messageId}" matches ${target.length} messages` : `No message "${messageId}" in ${chat.title}`);
|
|
1441
|
+
await c.react(chat.id, target[0].id, emoji, !!flags.remove);
|
|
1442
|
+
return out(`${flags.remove ? "Removed" : "Reacted"} ${emoji} on ${chat.title}.`);
|
|
1443
|
+
}
|
|
1346
1444
|
case "send": {
|
|
1347
1445
|
if (!rest[0] || rest.length < 2)
|
|
1348
1446
|
throw new Error('Usage: qc send <chat> <text> (text "-" reads stdin)');
|
|
@@ -1351,7 +1449,7 @@ async function main2(argv, { version = "0.0.0" } = {}) {
|
|
|
1351
1449
|
throw new Error("Nothing to send.");
|
|
1352
1450
|
const c = await client(flags);
|
|
1353
1451
|
const chat = findChat2(await c.conversations(), rest[0]);
|
|
1354
|
-
const { message, skipped } = await c.send(chat.id, text);
|
|
1452
|
+
const { message, skipped } = await c.send(chat.id, text, { replyTo: flags.reply || undefined });
|
|
1355
1453
|
if (flags.json)
|
|
1356
1454
|
return json({ id: message?.id, conversation: chat.id, skipped });
|
|
1357
1455
|
return out(`Sent to ${chat.title}${skipped ? ` (${skipped} participant(s) have no key yet)` : ""}.`);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// src/cli/config.js
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// src/cli/vault.js
|
|
7
|
+
import { randomBytes, scryptSync } from "node:crypto";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
10
|
+
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
|
|
11
|
+
var SERVICE = "qrypt-qc";
|
|
12
|
+
var SCRYPT = { N: 1 << 16, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
|
|
13
|
+
var SECRET_FIELDS = ["access_token", "refresh_token", "expires_at", "expires_in", "token_type", "keys"];
|
|
14
|
+
var b64 = (u8) => Buffer.from(u8).toString("base64");
|
|
15
|
+
var unb64 = (s) => Uint8Array.from(Buffer.from(s, "base64"));
|
|
16
|
+
function deriveKey(passphrase, salt) {
|
|
17
|
+
return new Uint8Array(scryptSync(String(passphrase).normalize("NFKC"), Buffer.from(salt), 32, SCRYPT));
|
|
18
|
+
}
|
|
19
|
+
function seal(session, key, source) {
|
|
20
|
+
const secret = {};
|
|
21
|
+
const open = {};
|
|
22
|
+
for (const [k, v] of Object.entries(session))
|
|
23
|
+
(SECRET_FIELDS.includes(k) ? secret : open)[k] = v;
|
|
24
|
+
const nonce = Uint8Array.from(randomBytes(12));
|
|
25
|
+
const ct = chacha20poly1305(Uint8Array.from(key), nonce).encrypt(Uint8Array.from(Buffer.from(JSON.stringify(secret), "utf8")));
|
|
26
|
+
return { v: 2, ...open, sealed: { alg: "chacha20-poly1305", source: source.kind, ...source.salt ? { salt: source.salt } : {}, nonce: b64(nonce), ct: b64(ct) } };
|
|
27
|
+
}
|
|
28
|
+
function unseal(file, key) {
|
|
29
|
+
const { nonce, ct } = file.sealed;
|
|
30
|
+
let plain;
|
|
31
|
+
try {
|
|
32
|
+
plain = chacha20poly1305(Uint8Array.from(key), unb64(nonce)).decrypt(unb64(ct));
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error("Wrong passphrase, or the session was tampered with.");
|
|
35
|
+
}
|
|
36
|
+
const { v, sealed, ...open } = file;
|
|
37
|
+
return { ...open, ...JSON.parse(Buffer.from(plain).toString("utf8")) };
|
|
38
|
+
}
|
|
39
|
+
function run(cmd, args, input) {
|
|
40
|
+
try {
|
|
41
|
+
const r = spawnSync(cmd, args, { input, encoding: "utf8", timeout: 1e4 });
|
|
42
|
+
return r.status === 0 ? { ok: true, out: (r.stdout || "").trim() } : { ok: false };
|
|
43
|
+
} catch {
|
|
44
|
+
return { ok: false };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function keychain({ platform = process.platform, env = process.env } = {}) {
|
|
48
|
+
if (env.QC_NO_KEYCHAIN)
|
|
49
|
+
return null;
|
|
50
|
+
if (platform === "darwin") {
|
|
51
|
+
return {
|
|
52
|
+
get: (account) => {
|
|
53
|
+
const r = run("security", ["find-generic-password", "-s", SERVICE, "-a", account, "-w"]);
|
|
54
|
+
return r.ok && r.out ? r.out : null;
|
|
55
|
+
},
|
|
56
|
+
set: (account, secret) => run("security", ["add-generic-password", "-U", "-s", SERVICE, "-a", account, "-w", secret]).ok
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (platform === "linux" && (env.DBUS_SESSION_BUS_ADDRESS || env.DISPLAY || env.WAYLAND_DISPLAY) && run("secret-tool", ["--version"]).ok !== false) {
|
|
60
|
+
const probe = spawnSync("which", ["secret-tool"], { encoding: "utf8" });
|
|
61
|
+
if (probe.status !== 0)
|
|
62
|
+
return null;
|
|
63
|
+
return {
|
|
64
|
+
get: (account) => {
|
|
65
|
+
const r = run("secret-tool", ["lookup", "service", SERVICE, "account", account]);
|
|
66
|
+
return r.ok && r.out ? r.out : null;
|
|
67
|
+
},
|
|
68
|
+
set: (account, secret) => run("secret-tool", ["store", "--label", "qc (qrypt.chat) session key", "service", SERVICE, "account", account], secret).ok
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function askHidden(question) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
if (!process.stdin.isTTY) {
|
|
76
|
+
reject(new Error("qc needs a passphrase to unlock your keys: set QC_PASSPHRASE, or run it on a terminal."));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
80
|
+
rl._writeToOutput = (s) => {
|
|
81
|
+
if (s.includes(question))
|
|
82
|
+
process.stderr.write(s);
|
|
83
|
+
};
|
|
84
|
+
rl.question(question, (answer) => {
|
|
85
|
+
rl.close();
|
|
86
|
+
process.stderr.write(`
|
|
87
|
+
`);
|
|
88
|
+
resolve(answer);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async function dataKey(account, { existing, env = process.env, ask = askHidden, chain = keychain({ env }) } = {}) {
|
|
93
|
+
if (existing) {
|
|
94
|
+
if (existing.source === "keychain") {
|
|
95
|
+
const k = chain?.get(account);
|
|
96
|
+
if (!k)
|
|
97
|
+
throw new Error("Your session key is not in the keychain any more. Run qc login again.");
|
|
98
|
+
return { key: unb64(k), source: { kind: "keychain" } };
|
|
99
|
+
}
|
|
100
|
+
const pass = env.QC_PASSPHRASE ?? await ask(" Passphrase for your qc keys: ");
|
|
101
|
+
return { key: deriveKey(pass, existing.salt), source: { kind: "passphrase", salt: existing.salt } };
|
|
102
|
+
}
|
|
103
|
+
if (env.QC_PASSPHRASE) {
|
|
104
|
+
const salt = b64(randomBytes(16));
|
|
105
|
+
return { key: deriveKey(env.QC_PASSPHRASE, salt), source: { kind: "passphrase", salt } };
|
|
106
|
+
}
|
|
107
|
+
if (chain) {
|
|
108
|
+
const raw = randomBytes(32);
|
|
109
|
+
if (chain.set(account, b64(raw)))
|
|
110
|
+
return { key: new Uint8Array(raw), source: { kind: "keychain" } };
|
|
111
|
+
}
|
|
112
|
+
process.stderr.write(`
|
|
113
|
+
qc keeps your keys encrypted. Choose a passphrase (you will need it each time qc starts).
|
|
114
|
+
`);
|
|
115
|
+
for (;; ) {
|
|
116
|
+
const a = await ask(" New passphrase: ");
|
|
117
|
+
if (a.length < 8) {
|
|
118
|
+
process.stderr.write(` At least 8 characters, please.
|
|
119
|
+
`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const b = await ask(" Again: ");
|
|
123
|
+
if (a !== b) {
|
|
124
|
+
process.stderr.write(` Those did not match.
|
|
125
|
+
`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const salt = b64(randomBytes(16));
|
|
129
|
+
return { key: deriveKey(a, salt), source: { kind: "passphrase", salt } };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/cli/config.js
|
|
134
|
+
var DEFAULT_URL = "https://qrypt.chat";
|
|
135
|
+
function configDir(env = process.env) {
|
|
136
|
+
if (env.QC_HOME)
|
|
137
|
+
return env.QC_HOME;
|
|
138
|
+
return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "qc");
|
|
139
|
+
}
|
|
140
|
+
var sessionPath = (env = process.env) => join(configDir(env), "session.json");
|
|
141
|
+
function baseUrl(session, env = process.env) {
|
|
142
|
+
return (env.QC_URL || session?.base || DEFAULT_URL).replace(/\/+$/, "");
|
|
143
|
+
}
|
|
144
|
+
function loadSession(env = process.env) {
|
|
145
|
+
try {
|
|
146
|
+
const file = JSON.parse(readFileSync(sessionPath(env), "utf8"));
|
|
147
|
+
if (file?.v === 2 && file.sealed && file.user)
|
|
148
|
+
return file;
|
|
149
|
+
return file && file.access_token && file.keys ? file : null;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function writeFile(obj, env) {
|
|
155
|
+
const dir = configDir(env);
|
|
156
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
157
|
+
try {
|
|
158
|
+
chmodSync(dir, 448);
|
|
159
|
+
} catch {}
|
|
160
|
+
const file = sessionPath(env);
|
|
161
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
162
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 384 });
|
|
163
|
+
renameSync(tmp, file);
|
|
164
|
+
}
|
|
165
|
+
var accountOf = (session) => String(session.user?.id || session.user?.username || "default");
|
|
166
|
+
async function unlockSession(env = process.env, opts = {}) {
|
|
167
|
+
const file = loadSession(env);
|
|
168
|
+
if (!file)
|
|
169
|
+
return null;
|
|
170
|
+
const existing = file.v === 2 ? file.sealed : undefined;
|
|
171
|
+
const { key, source } = await dataKey(accountOf(file), { existing, env, ...opts });
|
|
172
|
+
const session = file.v === 2 ? unseal(file, key) : file;
|
|
173
|
+
const save = (s) => writeFile(seal(s, key, source), env);
|
|
174
|
+
if (file.v !== 2)
|
|
175
|
+
save(session);
|
|
176
|
+
return { session, save };
|
|
177
|
+
}
|
|
178
|
+
async function storeSession(session, env = process.env, opts = {}) {
|
|
179
|
+
const { key, source } = await dataKey(accountOf(session), { env, ...opts });
|
|
180
|
+
const save = (s) => writeFile(seal(s, key, source), env);
|
|
181
|
+
save(session);
|
|
182
|
+
return save;
|
|
183
|
+
}
|
|
184
|
+
function clearSession(env = process.env) {
|
|
185
|
+
const file = sessionPath(env);
|
|
186
|
+
if (existsSync(file))
|
|
187
|
+
rmSync(file);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export { configDir, sessionPath, baseUrl, loadSession, unlockSession, storeSession, clearSession };
|
package/dist/commands.js
CHANGED
package/dist/mcp.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import"./chunk-vfahhydf.js";
|
|
1
2
|
import {
|
|
2
3
|
findChat2
|
|
3
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-4r99spcc.js";
|
|
4
5
|
|
|
5
6
|
// src/cli/mcp.js
|
|
6
7
|
import { createInterface } from "node:readline";
|
|
@@ -30,11 +31,27 @@ var TOOLS = [
|
|
|
30
31
|
type: "object",
|
|
31
32
|
properties: {
|
|
32
33
|
chat: { type: "string", description: "Conversation id or part of its name" },
|
|
33
|
-
text: { type: "string", description: "The message" }
|
|
34
|
+
text: { type: "string", description: "The message" },
|
|
35
|
+
reply_to: { type: "string", description: "Optional: id of the message this replies to (from read_chat)" }
|
|
34
36
|
},
|
|
35
37
|
required: ["chat", "text"],
|
|
36
38
|
additionalProperties: false
|
|
37
39
|
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "react_to_message",
|
|
43
|
+
description: "React to a message with an emoji (Signal-style: one reaction per person; a new one replaces yours). End-to-end encrypted.",
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
chat: { type: "string", description: "Conversation id or part of its name" },
|
|
48
|
+
message_id: { type: "string", description: "The message id, from read_chat" },
|
|
49
|
+
emoji: { type: "string", description: "The emoji, e.g. ❤️" },
|
|
50
|
+
remove: { type: "boolean", description: "Withdraw this reaction instead" }
|
|
51
|
+
},
|
|
52
|
+
required: ["chat", "message_id", "emoji"],
|
|
53
|
+
additionalProperties: false
|
|
54
|
+
}
|
|
38
55
|
}
|
|
39
56
|
];
|
|
40
57
|
async function callTool(client, name, args = {}) {
|
|
@@ -46,15 +63,32 @@ async function callTool(client, name, args = {}) {
|
|
|
46
63
|
const chat = findChat2(await client.conversations(), args.chat);
|
|
47
64
|
const { messages } = await client.messages(chat.id, { limit: 100 });
|
|
48
65
|
const limit = Math.min(100, Math.max(1, Number(args.limit) || 20));
|
|
49
|
-
return {
|
|
66
|
+
return {
|
|
67
|
+
chat: { id: chat.id, title: chat.title },
|
|
68
|
+
messages: messages.slice(-limit).map(({ id, sender, mine, text, at, reactions, replyTo }) => ({
|
|
69
|
+
id,
|
|
70
|
+
from: mine ? "me" : sender,
|
|
71
|
+
text,
|
|
72
|
+
at,
|
|
73
|
+
...reactions?.length ? { reactions: reactions.map(({ emoji, count, mine: m }) => ({ emoji, count, mine: m })) } : {},
|
|
74
|
+
...replyTo ? { reply_to: { id: replyTo.id, from: replyTo.name, text: replyTo.snippet } } : {}
|
|
75
|
+
}))
|
|
76
|
+
};
|
|
50
77
|
}
|
|
51
78
|
if (name === "send_message") {
|
|
52
79
|
if (!args.text || typeof args.text !== "string")
|
|
53
80
|
throw new Error("text is required");
|
|
54
81
|
const chat = findChat2(await client.conversations(), args.chat);
|
|
55
|
-
const { message, skipped } = await client.send(chat.id, args.text);
|
|
82
|
+
const { message, skipped } = await client.send(chat.id, args.text, { replyTo: typeof args.reply_to === "string" ? args.reply_to : undefined });
|
|
56
83
|
return { sent: true, id: message?.id, chat: chat.title, skipped };
|
|
57
84
|
}
|
|
85
|
+
if (name === "react_to_message") {
|
|
86
|
+
if (!args.emoji || typeof args.emoji !== "string")
|
|
87
|
+
throw new Error("emoji is required");
|
|
88
|
+
const chat = findChat2(await client.conversations(), args.chat);
|
|
89
|
+
await client.react(chat.id, String(args.message_id), args.emoji, !!args.remove);
|
|
90
|
+
return { reacted: !args.remove, emoji: args.emoji, chat: chat.title };
|
|
91
|
+
}
|
|
58
92
|
throw new Error(`Unknown tool ${name}`);
|
|
59
93
|
}
|
|
60
94
|
async function handle(client, msg, { version }) {
|
package/dist/tui.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
configDir
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-vfahhydf.js";
|
|
4
4
|
|
|
5
5
|
// src/cli/tui.js
|
|
6
6
|
import { createApp, editText, emojify, insertText, stringWidth, truncate, widgets, wrap } from "@profullstack/hqtui";
|
|
@@ -61,11 +61,19 @@ function transcriptLines(messages, width, theme) {
|
|
|
61
61
|
]
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
|
+
if (m.replyTo) {
|
|
65
|
+
const who = m.replyTo.name ? `${m.replyTo.name}: ` : "";
|
|
66
|
+
lines.push({ text: truncate(` ┃ ${who}${m.replyTo.snippet}`, width), fg: theme.muted });
|
|
67
|
+
}
|
|
64
68
|
for (const part of String(m.text ?? "").split(`
|
|
65
69
|
`)) {
|
|
66
70
|
for (const line of wrap(part, Math.max(4, width - 2)))
|
|
67
71
|
lines.push({ text: ` ${line}`, fg: theme.foreground });
|
|
68
72
|
}
|
|
73
|
+
if (m.reactions?.length) {
|
|
74
|
+
const summary = m.reactions.map((r) => `${r.emoji}${r.count > 1 ? ` ${r.count}` : ""}`).join(" ");
|
|
75
|
+
lines.push({ text: truncate(` ${summary}`, width), fg: m.reactions.some((r) => r.mine) ? theme.accent : theme.muted });
|
|
76
|
+
}
|
|
69
77
|
last = m;
|
|
70
78
|
}
|
|
71
79
|
return lines;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/qryptchat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/dist/chunk-fb6zrz6m.js
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
// src/cli/config.js
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
var DEFAULT_URL = "https://qrypt.chat";
|
|
6
|
-
function configDir(env = process.env) {
|
|
7
|
-
if (env.QC_HOME)
|
|
8
|
-
return env.QC_HOME;
|
|
9
|
-
return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "qc");
|
|
10
|
-
}
|
|
11
|
-
var sessionPath = (env = process.env) => join(configDir(env), "session.json");
|
|
12
|
-
function baseUrl(session, env = process.env) {
|
|
13
|
-
return (env.QC_URL || session?.base || DEFAULT_URL).replace(/\/+$/, "");
|
|
14
|
-
}
|
|
15
|
-
function loadSession(env = process.env) {
|
|
16
|
-
try {
|
|
17
|
-
const session = JSON.parse(readFileSync(sessionPath(env), "utf8"));
|
|
18
|
-
return session && session.access_token && session.keys ? session : null;
|
|
19
|
-
} catch {
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
function saveSession(session, env = process.env) {
|
|
24
|
-
const dir = configDir(env);
|
|
25
|
-
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
26
|
-
try {
|
|
27
|
-
chmodSync(dir, 448);
|
|
28
|
-
} catch {}
|
|
29
|
-
const file = sessionPath(env);
|
|
30
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
31
|
-
writeFileSync(tmp, JSON.stringify(session, null, 2), { mode: 384 });
|
|
32
|
-
renameSync(tmp, file);
|
|
33
|
-
}
|
|
34
|
-
function clearSession(env = process.env) {
|
|
35
|
-
const file = sessionPath(env);
|
|
36
|
-
if (existsSync(file))
|
|
37
|
-
rmSync(file);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export { configDir, sessionPath, baseUrl, loadSession, saveSession, clearSession };
|