@profullstack/qryptchat 0.1.0 → 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.
@@ -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 = 100, before } = {}) {
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 out = [];
972
+ const raw = [];
898
973
  for (const m of messages) {
899
- let text;
974
+ let content = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : "";
900
975
  if (m.message_type === "file" || m.has_attachments) {
901
- text = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : "";
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
- out.push({
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 send(conversationId, text) {
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: "text" }
1016
+ body: { conversationId, encryptedContents, messageType, ...replyTo ? { replyToId: replyTo } : {} }
939
1017
  });
940
1018
  return { message, skipped: missing.length };
941
1019
  }
@@ -1049,10 +1127,32 @@ var TIMEOUT_MS = 5 * 60 * 1000;
1049
1127
  var PAGE = (title, body) => `<!doctype html><meta charset="utf-8"><title>${title}</title>
1050
1128
  <style>body{font:16px system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;background:#0b0d12;color:#e6e8ee}main{max-width:28rem;text-align:center}</style>
1051
1129
  <main><h1>${title}</h1><p>${body}</p></main>`;
1130
+ function isRemote({ platform = process.platform, env = process.env } = {}) {
1131
+ if (env.QC_NO_BROWSER)
1132
+ return true;
1133
+ return !!(env.SSH_CONNECTION || env.SSH_TTY) && !env.DISPLAY && !env.WAYLAND_DISPLAY && platform !== "darwin" && platform !== "win32";
1134
+ }
1135
+ function parsePasted(input, state) {
1136
+ const text = String(input || "").trim();
1137
+ if (!text)
1138
+ return null;
1139
+ if (/^https?:\/\//i.test(text)) {
1140
+ let url;
1141
+ try {
1142
+ url = new URL(text);
1143
+ } catch {
1144
+ return null;
1145
+ }
1146
+ const got = url.searchParams.get("state");
1147
+ if (got && got !== state)
1148
+ throw new Error("That link belongs to a different login. Run qc login again.");
1149
+ return url.searchParams.get("code");
1150
+ }
1151
+ return /^[A-Za-z0-9_-]{20,}$/.test(text) ? text : null;
1152
+ }
1052
1153
  function openBrowser(url, { platform = process.platform, env = process.env } = {}) {
1053
- if (env.QC_NO_BROWSER || (env.SSH_CONNECTION || env.SSH_TTY) && !env.DISPLAY && !env.WAYLAND_DISPLAY && platform === "linux") {
1154
+ if (isRemote({ platform, env }))
1054
1155
  return false;
1055
- }
1056
1156
  const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
1057
1157
  try {
1058
1158
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
@@ -1102,7 +1202,8 @@ async function login(options = {}) {
1102
1202
  const ephemeral = await ephemeralKeypair();
1103
1203
  let server;
1104
1204
  let redirectUri = "oob";
1105
- if (!options.oob) {
1205
+ const oob = options.oob || isRemote({ env });
1206
+ if (!oob) {
1106
1207
  server = createServer();
1107
1208
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
1108
1209
  redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
@@ -1124,20 +1225,44 @@ async function login(options = {}) {
1124
1225
  print(` Confirmation code: ${match}`);
1125
1226
  print(" Check that the browser shows the same code before you approve.");
1126
1227
  print("");
1127
- const opened = !options.oob && openBrowser(url.toString(), { env });
1228
+ const opened = !oob && openBrowser(url.toString(), { env });
1128
1229
  print(opened ? " Opened your browser. If it did not, open:" : " Open this link in a browser where you are signed in to qrypt.chat:");
1129
1230
  print(` ${url.toString()}`);
1130
1231
  print("");
1131
1232
  let code;
1233
+ let rl;
1132
1234
  try {
1133
1235
  if (server) {
1134
- code = await waitForCallback(server, state);
1236
+ const waits = [waitForCallback(server, state)];
1237
+ if (process.stdin.isTTY) {
1238
+ rl = createInterface({ input: process.stdin, output: process.stderr });
1239
+ waits.push((async () => {
1240
+ for (;; ) {
1241
+ let line;
1242
+ try {
1243
+ line = await rl.question(" Waiting for the browser… or paste the URL it ended up on: ");
1244
+ } catch {
1245
+ return new Promise(() => {});
1246
+ }
1247
+ const pasted = parsePasted(line, state);
1248
+ if (pasted)
1249
+ return pasted;
1250
+ print(" That is not a qc login code or callback URL.");
1251
+ }
1252
+ })());
1253
+ }
1254
+ code = await Promise.race(waits);
1135
1255
  } else {
1136
- const rl = createInterface({ input: process.stdin, output: process.stderr });
1137
- code = (await rl.question(" Paste the code from the browser: ")).trim();
1138
- rl.close();
1256
+ rl = createInterface({ input: process.stdin, output: process.stderr });
1257
+ for (;; ) {
1258
+ code = parsePasted(await rl.question(" Paste the code (or the URL) from the browser: "), state);
1259
+ if (code)
1260
+ break;
1261
+ print(" That is not a qc login code.");
1262
+ }
1139
1263
  }
1140
1264
  } finally {
1265
+ rl?.close();
1141
1266
  server?.close();
1142
1267
  }
1143
1268
  if (!code)
@@ -1174,7 +1299,8 @@ Usage:
1174
1299
  qc whoami who this terminal is signed in as
1175
1300
  qc chats list your chats
1176
1301
  qc read <chat> [-n 20] print the last messages of a chat
1177
- 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
1178
1304
  qc listen print new messages as they arrive (NDJSON with --json)
1179
1305
  qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
1180
1306
 
@@ -1192,7 +1318,7 @@ function parseArgs2(argv) {
1192
1318
  const [k, v] = a.slice(2).split("=", 2);
1193
1319
  if (v !== undefined)
1194
1320
  args.flags[k] = v;
1195
- else if (["url", "n", "limit"].includes(k) && argv[i + 1] !== undefined)
1321
+ else if (["url", "n", "limit", "reply"].includes(k) && argv[i + 1] !== undefined)
1196
1322
  args.flags[k] = argv[++i];
1197
1323
  else
1198
1324
  args.flags[k] = true;
@@ -1292,10 +1418,27 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1292
1418
  const last = messages.slice(-Math.max(1, Number(flags.n || flags.limit || 20)));
1293
1419
  if (flags.json)
1294
1420
  return json(last);
1295
- for (const m of last)
1296
- out(`[${stamp(m.at)}] ${m.mine ? "you" : m.sender}: ${m.text}`);
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
+ }
1297
1427
  return;
1298
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
+ }
1299
1442
  case "send": {
1300
1443
  if (!rest[0] || rest.length < 2)
1301
1444
  throw new Error('Usage: qc send <chat> <text> (text "-" reads stdin)');
@@ -1304,7 +1447,7 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1304
1447
  throw new Error("Nothing to send.");
1305
1448
  const c = await client(flags);
1306
1449
  const chat = findChat2(await c.conversations(), rest[0]);
1307
- const { message, skipped } = await c.send(chat.id, text);
1450
+ const { message, skipped } = await c.send(chat.id, text, { replyTo: flags.reply || undefined });
1308
1451
  if (flags.json)
1309
1452
  return json({ id: message?.id, conversation: chat.id, skipped });
1310
1453
  return out(`Sent to ${chat.title}${skipped ? ` (${skipped} participant(s) have no key yet)` : ""}.`);
package/dist/commands.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  parseArgs2,
4
4
  findChat2,
5
5
  main2
6
- } from "./chunk-9kkxje88.js";
6
+ } from "./chunk-c8qkdept.js";
7
7
  export {
8
8
  HELP2 as HELP,
9
9
  findChat2 as findChat,
package/dist/mcp.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  findChat2
3
- } from "./chunk-9kkxje88.js";
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 { chat: { id: chat.id, title: chat.title }, messages: messages.slice(-limit).map(({ sender, mine, text, at }) => ({ from: mine ? "me" : sender, text, at })) };
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.1.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": {