@profullstack/qryptchat 0.1.1 → 0.2.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/dist/{chunk-12bbzs4r.js → chunk-c8qkdept.js} +120 -24
- package/dist/commands.js +1 -1
- package/dist/mcp.js +37 -4
- package/dist/tui.js +8 -0
- package/package.json +1 -1
|
@@ -822,6 +822,81 @@ async function openKeyBlob(blob, ephemeral) {
|
|
|
822
822
|
return { keys1024: keys.keys1024, keys768: keys.keys768 };
|
|
823
823
|
}
|
|
824
824
|
|
|
825
|
+
// src/lib/chat/reactions.js
|
|
826
|
+
var REACTION_TYPE = "reaction";
|
|
827
|
+
var MAX_EMOJI_LENGTH = 32;
|
|
828
|
+
function reactionEnvelope(targetId, emoji, remove = false) {
|
|
829
|
+
return JSON.stringify({ v: 1, type: REACTION_TYPE, target: String(targetId), emoji: String(emoji), remove: !!remove });
|
|
830
|
+
}
|
|
831
|
+
function parseReaction(content) {
|
|
832
|
+
try {
|
|
833
|
+
const d = JSON.parse(content);
|
|
834
|
+
if (d?.type !== REACTION_TYPE || typeof d.target !== "string" || typeof d.emoji !== "string")
|
|
835
|
+
return null;
|
|
836
|
+
if (!d.emoji || d.emoji.length > MAX_EMOJI_LENGTH)
|
|
837
|
+
return null;
|
|
838
|
+
return { target: d.target, emoji: d.emoji, remove: !!d.remove };
|
|
839
|
+
} catch {
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
var senderName = (m) => m?.sender?.display_name || m?.sender?.username || "Someone";
|
|
844
|
+
function snippet(m, max = 90) {
|
|
845
|
+
if (!m)
|
|
846
|
+
return "";
|
|
847
|
+
if (m.message_type === "file" || m.has_attachments)
|
|
848
|
+
return "\uD83D\uDCCE Attachment";
|
|
849
|
+
const text = String(m.content ?? "").replace(/\s+/g, " ").trim();
|
|
850
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
851
|
+
}
|
|
852
|
+
function foldMessages(messages, myUserId) {
|
|
853
|
+
const visible = [];
|
|
854
|
+
const byId = new Map;
|
|
855
|
+
const picks = new Map;
|
|
856
|
+
for (const m of messages) {
|
|
857
|
+
if (m.message_type === REACTION_TYPE) {
|
|
858
|
+
const r = parseReaction(m.content);
|
|
859
|
+
if (!r)
|
|
860
|
+
continue;
|
|
861
|
+
const forTarget = picks.get(r.target) ?? new Map;
|
|
862
|
+
const current = forTarget.get(m.sender_id);
|
|
863
|
+
if (r.remove) {
|
|
864
|
+
if (current && current.emoji === r.emoji)
|
|
865
|
+
forTarget.delete(m.sender_id);
|
|
866
|
+
} else {
|
|
867
|
+
forTarget.set(m.sender_id, { emoji: r.emoji, name: senderName(m), at: m.created_at });
|
|
868
|
+
}
|
|
869
|
+
picks.set(r.target, forTarget);
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
visible.push(m);
|
|
873
|
+
byId.set(m.id, m);
|
|
874
|
+
}
|
|
875
|
+
return visible.map((m) => {
|
|
876
|
+
const out = { ...m, reactions: [] };
|
|
877
|
+
const forTarget = picks.get(m.id);
|
|
878
|
+
if (forTarget?.size) {
|
|
879
|
+
const groups = new Map;
|
|
880
|
+
for (const [senderId, p] of forTarget) {
|
|
881
|
+
const g = groups.get(p.emoji) ?? { emoji: p.emoji, count: 0, mine: false, names: [], first: p.at };
|
|
882
|
+
g.count += 1;
|
|
883
|
+
g.names.push(senderId === myUserId ? "You" : p.name);
|
|
884
|
+
if (senderId === myUserId)
|
|
885
|
+
g.mine = true;
|
|
886
|
+
if (p.at < g.first)
|
|
887
|
+
g.first = p.at;
|
|
888
|
+
groups.set(p.emoji, g);
|
|
889
|
+
}
|
|
890
|
+
out.reactions = [...groups.values()].sort((a, b) => b.count - a.count || String(a.first).localeCompare(String(b.first)));
|
|
891
|
+
}
|
|
892
|
+
if (m.reply_to_id) {
|
|
893
|
+
const target = byId.get(m.reply_to_id);
|
|
894
|
+
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 };
|
|
895
|
+
}
|
|
896
|
+
return out;
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
825
900
|
// src/cli/api.js
|
|
826
901
|
class QcError extends Error {
|
|
827
902
|
constructor(message, status) {
|
|
@@ -889,34 +964,37 @@ class QcClient {
|
|
|
889
964
|
const { conversations = [] } = await this.request("/api/conversations/load", { method: "POST", body: {} });
|
|
890
965
|
return conversations.map((c) => ({ ...c, title: conversationTitle(c, this.me) }));
|
|
891
966
|
}
|
|
892
|
-
async messages(conversationId, { limit =
|
|
967
|
+
async messages(conversationId, { limit = 200, before } = {}) {
|
|
893
968
|
const { messages = [], hasMore = false } = await this.request("/api/messages/load", {
|
|
894
969
|
method: "POST",
|
|
895
970
|
body: { conversationId, limit, ...before ? { before } : {} }
|
|
896
971
|
});
|
|
897
|
-
const
|
|
972
|
+
const raw = [];
|
|
898
973
|
for (const m of messages) {
|
|
899
|
-
let
|
|
974
|
+
let content = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : "";
|
|
900
975
|
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) : "";
|
|
976
|
+
content = `\uD83D\uDCCE ${content && content !== "[File attachment]" ? `${content} ` : ""}(attachment: open qrypt.chat to download)`;
|
|
905
977
|
}
|
|
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
|
-
});
|
|
978
|
+
raw.push({ ...m, content });
|
|
916
979
|
}
|
|
980
|
+
const out = foldMessages(raw, this.me?.id).map((m) => ({
|
|
981
|
+
id: m.id,
|
|
982
|
+
conversationId: m.conversation_id,
|
|
983
|
+
senderId: m.sender_id,
|
|
984
|
+
sender: m.sender?.display_name || m.sender?.username || "unknown",
|
|
985
|
+
username: m.sender?.username || "",
|
|
986
|
+
mine: m.sender_id === this.me?.id,
|
|
987
|
+
text: m.content,
|
|
988
|
+
at: m.created_at,
|
|
989
|
+
reactions: m.reactions.map(({ emoji, count, mine, names }) => ({ emoji, count, mine, names })),
|
|
990
|
+
...m.replyTo ? { replyTo: m.replyTo } : {}
|
|
991
|
+
}));
|
|
917
992
|
return { messages: out, hasMore };
|
|
918
993
|
}
|
|
919
|
-
async
|
|
994
|
+
async react(conversationId, messageId, emoji, remove = false) {
|
|
995
|
+
return this.send(conversationId, reactionEnvelope(messageId, emoji, remove), { messageType: REACTION_TYPE });
|
|
996
|
+
}
|
|
997
|
+
async send(conversationId, text, { replyTo, messageType = "text" } = {}) {
|
|
920
998
|
const { participants = [] } = await this.request(`/api/chat/conversations/${encodeURIComponent(conversationId)}/participants`);
|
|
921
999
|
const userIds = participants.map((p) => p.user_id);
|
|
922
1000
|
if (!userIds.length)
|
|
@@ -935,7 +1013,7 @@ class QcClient {
|
|
|
935
1013
|
throw new QcError("No participant has a public key yet");
|
|
936
1014
|
const { message } = await this.request("/api/messages/send", {
|
|
937
1015
|
method: "POST",
|
|
938
|
-
body: { conversationId, encryptedContents, messageType:
|
|
1016
|
+
body: { conversationId, encryptedContents, messageType, ...replyTo ? { replyToId: replyTo } : {} }
|
|
939
1017
|
});
|
|
940
1018
|
return { message, skipped: missing.length };
|
|
941
1019
|
}
|
|
@@ -1221,7 +1299,8 @@ Usage:
|
|
|
1221
1299
|
qc whoami who this terminal is signed in as
|
|
1222
1300
|
qc chats list your chats
|
|
1223
1301
|
qc read <chat> [-n 20] print the last messages of a chat
|
|
1224
|
-
qc send <chat> <text> send a message (text "-" reads stdin)
|
|
1302
|
+
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply)
|
|
1303
|
+
qc react <chat> <message-id> <emoji> [--remove] react to a message
|
|
1225
1304
|
qc listen print new messages as they arrive (NDJSON with --json)
|
|
1226
1305
|
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
|
|
1227
1306
|
|
|
@@ -1239,7 +1318,7 @@ function parseArgs2(argv) {
|
|
|
1239
1318
|
const [k, v] = a.slice(2).split("=", 2);
|
|
1240
1319
|
if (v !== undefined)
|
|
1241
1320
|
args.flags[k] = v;
|
|
1242
|
-
else if (["url", "n", "limit"].includes(k) && argv[i + 1] !== undefined)
|
|
1321
|
+
else if (["url", "n", "limit", "reply"].includes(k) && argv[i + 1] !== undefined)
|
|
1243
1322
|
args.flags[k] = argv[++i];
|
|
1244
1323
|
else
|
|
1245
1324
|
args.flags[k] = true;
|
|
@@ -1339,10 +1418,27 @@ async function main2(argv, { version = "0.0.0" } = {}) {
|
|
|
1339
1418
|
const last = messages.slice(-Math.max(1, Number(flags.n || flags.limit || 20)));
|
|
1340
1419
|
if (flags.json)
|
|
1341
1420
|
return json(last);
|
|
1342
|
-
for (const m of last)
|
|
1343
|
-
|
|
1421
|
+
for (const m of last) {
|
|
1422
|
+
if (m.replyTo)
|
|
1423
|
+
out(` ┃ ${m.replyTo.name ? `${m.replyTo.name}: ` : ""}${m.replyTo.snippet}`);
|
|
1424
|
+
const reactions = m.reactions?.length ? ` [${m.reactions.map((r) => `${r.emoji}${r.count > 1 ? r.count : ""}`).join(" ")}]` : "";
|
|
1425
|
+
out(`[${stamp(m.at)}] ${m.mine ? "you" : m.sender}: ${m.text}${reactions} (${m.id.slice(0, 8)})`);
|
|
1426
|
+
}
|
|
1344
1427
|
return;
|
|
1345
1428
|
}
|
|
1429
|
+
case "react": {
|
|
1430
|
+
const [chatQuery, messageId, emoji] = rest;
|
|
1431
|
+
if (!chatQuery || !messageId || !emoji)
|
|
1432
|
+
throw new Error("Usage: qc react <chat> <message-id> <emoji> [--remove]");
|
|
1433
|
+
const c = await client(flags);
|
|
1434
|
+
const chat = findChat2(await c.conversations(), chatQuery);
|
|
1435
|
+
const { messages } = await c.messages(chat.id);
|
|
1436
|
+
const target = messages.filter((m) => m.id === messageId || m.id.startsWith(messageId));
|
|
1437
|
+
if (target.length !== 1)
|
|
1438
|
+
throw new Error(target.length ? `"${messageId}" matches ${target.length} messages` : `No message "${messageId}" in ${chat.title}`);
|
|
1439
|
+
await c.react(chat.id, target[0].id, emoji, !!flags.remove);
|
|
1440
|
+
return out(`${flags.remove ? "Removed" : "Reacted"} ${emoji} on ${chat.title}.`);
|
|
1441
|
+
}
|
|
1346
1442
|
case "send": {
|
|
1347
1443
|
if (!rest[0] || rest.length < 2)
|
|
1348
1444
|
throw new Error('Usage: qc send <chat> <text> (text "-" reads stdin)');
|
|
@@ -1351,7 +1447,7 @@ async function main2(argv, { version = "0.0.0" } = {}) {
|
|
|
1351
1447
|
throw new Error("Nothing to send.");
|
|
1352
1448
|
const c = await client(flags);
|
|
1353
1449
|
const chat = findChat2(await c.conversations(), rest[0]);
|
|
1354
|
-
const { message, skipped } = await c.send(chat.id, text);
|
|
1450
|
+
const { message, skipped } = await c.send(chat.id, text, { replyTo: flags.reply || undefined });
|
|
1355
1451
|
if (flags.json)
|
|
1356
1452
|
return json({ id: message?.id, conversation: chat.id, skipped });
|
|
1357
1453
|
return out(`Sent to ${chat.title}${skipped ? ` (${skipped} participant(s) have no key yet)` : ""}.`);
|
package/dist/commands.js
CHANGED
package/dist/mcp.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
findChat2
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-c8qkdept.js";
|
|
4
4
|
|
|
5
5
|
// src/cli/mcp.js
|
|
6
6
|
import { createInterface } from "node:readline";
|
|
@@ -30,11 +30,27 @@ var TOOLS = [
|
|
|
30
30
|
type: "object",
|
|
31
31
|
properties: {
|
|
32
32
|
chat: { type: "string", description: "Conversation id or part of its name" },
|
|
33
|
-
text: { type: "string", description: "The message" }
|
|
33
|
+
text: { type: "string", description: "The message" },
|
|
34
|
+
reply_to: { type: "string", description: "Optional: id of the message this replies to (from read_chat)" }
|
|
34
35
|
},
|
|
35
36
|
required: ["chat", "text"],
|
|
36
37
|
additionalProperties: false
|
|
37
38
|
}
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "react_to_message",
|
|
42
|
+
description: "React to a message with an emoji (Signal-style: one reaction per person; a new one replaces yours). End-to-end encrypted.",
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
chat: { type: "string", description: "Conversation id or part of its name" },
|
|
47
|
+
message_id: { type: "string", description: "The message id, from read_chat" },
|
|
48
|
+
emoji: { type: "string", description: "The emoji, e.g. ❤️" },
|
|
49
|
+
remove: { type: "boolean", description: "Withdraw this reaction instead" }
|
|
50
|
+
},
|
|
51
|
+
required: ["chat", "message_id", "emoji"],
|
|
52
|
+
additionalProperties: false
|
|
53
|
+
}
|
|
38
54
|
}
|
|
39
55
|
];
|
|
40
56
|
async function callTool(client, name, args = {}) {
|
|
@@ -46,15 +62,32 @@ async function callTool(client, name, args = {}) {
|
|
|
46
62
|
const chat = findChat2(await client.conversations(), args.chat);
|
|
47
63
|
const { messages } = await client.messages(chat.id, { limit: 100 });
|
|
48
64
|
const limit = Math.min(100, Math.max(1, Number(args.limit) || 20));
|
|
49
|
-
return {
|
|
65
|
+
return {
|
|
66
|
+
chat: { id: chat.id, title: chat.title },
|
|
67
|
+
messages: messages.slice(-limit).map(({ id, sender, mine, text, at, reactions, replyTo }) => ({
|
|
68
|
+
id,
|
|
69
|
+
from: mine ? "me" : sender,
|
|
70
|
+
text,
|
|
71
|
+
at,
|
|
72
|
+
...reactions?.length ? { reactions: reactions.map(({ emoji, count, mine: m }) => ({ emoji, count, mine: m })) } : {},
|
|
73
|
+
...replyTo ? { reply_to: { id: replyTo.id, from: replyTo.name, text: replyTo.snippet } } : {}
|
|
74
|
+
}))
|
|
75
|
+
};
|
|
50
76
|
}
|
|
51
77
|
if (name === "send_message") {
|
|
52
78
|
if (!args.text || typeof args.text !== "string")
|
|
53
79
|
throw new Error("text is required");
|
|
54
80
|
const chat = findChat2(await client.conversations(), args.chat);
|
|
55
|
-
const { message, skipped } = await client.send(chat.id, args.text);
|
|
81
|
+
const { message, skipped } = await client.send(chat.id, args.text, { replyTo: typeof args.reply_to === "string" ? args.reply_to : undefined });
|
|
56
82
|
return { sent: true, id: message?.id, chat: chat.title, skipped };
|
|
57
83
|
}
|
|
84
|
+
if (name === "react_to_message") {
|
|
85
|
+
if (!args.emoji || typeof args.emoji !== "string")
|
|
86
|
+
throw new Error("emoji is required");
|
|
87
|
+
const chat = findChat2(await client.conversations(), args.chat);
|
|
88
|
+
await client.react(chat.id, String(args.message_id), args.emoji, !!args.remove);
|
|
89
|
+
return { reacted: !args.remove, emoji: args.emoji, chat: chat.title };
|
|
90
|
+
}
|
|
58
91
|
throw new Error(`Unknown tool ${name}`);
|
|
59
92
|
}
|
|
60
93
|
async function handle(client, msg, { version }) {
|
package/dist/tui.js
CHANGED
|
@@ -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.2.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": {
|