Skip to content

Commit ddb0377

Browse files
ralyodioclaude
andauthored
feat(qc): message anyone new from the TUI, CLI and MCP (0.17.0, qc 0.13.0) (#344)
Anthony: "qrypt.chat tui has no way to message a new user." qc could only talk in chats that already existed. Both server routes a new chat needs were cookie-only, so even a hand-rolled call from qc got 401: - /api/users/search and POST /api/chat/conversations now authenticate with authenticateRequest (Bearer or cookie), like the rest of the API. - A new chat is announced (announceJoined): every member's open devices put it in their list at once and join its live room. qc: - TUI: /msg @name [first message] (also /dm, /query, /new) finds the person, opens or reuses your direct chat with them and sends the text. `n` in the chat list types "/msg @" for you; the empty screen says how. - CLI: qc dm <@user> [text] (qc msg is the same; "-" reads stdin). - MCP: start_direct_chat { user, text? }. - QcClient.searchUsers / startDirect; pickUser takes an exact @username, else the only match, else lists who it could be. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 51467dc commit ddb0377

11 files changed

Lines changed: 210 additions & 27 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qryptchat-web",
33
"private": true,
4-
"version": "0.16.1",
4+
"version": "0.17.0",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",

‎packages/qryptchat/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ The mouse works too: click a chat, scroll the transcript, click an emoji. Unread
4949
```sh
5050
qc chats # id and name of every chat
5151
qc read alice -n 20 # the last 20 messages, decrypted
52+
qc dm @alice "hey, it's me" # start (or reopen) a direct chat with anyone, by @username
5253
qc send alice "on my way" # encrypted to every participant
5354
qc send ops "@ri deploy is done" # an @partial matching one person is sent as @riotcoder
5455
qc mentions ops @al # who @al completes to: @alice, @alina

‎packages/qryptchat/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/qryptchat",
3-
"version": "0.12.0",
3+
"version": "0.13.0",
44
"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.",
55
"type": "module",
66
"bin": {

‎src/app/api/chat/conversations/route.js‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { NextResponse } from 'next/server';
22
import { createSupabaseServerClient } from '@/lib/supabase.js';
3+
import { authenticateRequest } from '@/lib/api/middleware/auth.js';
4+
import { sseManager } from '@/lib/api/sse-manager.js';
5+
import { announceJoined } from '@/lib/chat/membership-events.js';
36

47

58
export async function GET(request, { params } = {}) {
@@ -84,12 +87,11 @@ export async function GET(request, { params } = {}) {
8487

8588
export async function POST(request, { params } = {}) {
8689
try {
87-
const supabase = await createSupabaseServerClient();
8890
const { type, name, participant_ids, group_id } = await request.json();
89-
90-
// Get user from session
91-
const { data: { user }, error: userError } = await supabase.auth.getUser();
92-
if (userError || !user) {
91+
92+
// Bearer (qc, MCP, passkey and CoinPay sessions) or the cookie session.
93+
const { user, supabase } = await authenticateRequest(request);
94+
if (!user) {
9395
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
9496
}
9597

@@ -225,6 +227,9 @@ export async function POST(request, { params } = {}) {
225227
return NextResponse.json({ error: 'Failed to add participants' }, { status: 500 });
226228
}
227229

230+
// Every member's open devices get the new chat in their list now.
231+
announceJoined(sseManager, conversationId, participants.map((p) => p.user_id));
232+
228233
return NextResponse.json({
229234
conversation_id: conversationId,
230235
message: 'Conversation created successfully'

‎src/app/api/users/search/route.js‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextResponse } from 'next/server';
2-
import { createSupabaseServerClient } from '@/lib/supabase.js';
2+
import { authenticateRequest } from '@/lib/api/middleware/auth.js';
33
import { getServiceRoleClient } from '@/lib/supabase/service-role.js';
44

55
// Directory search has to reach across every user, which is the one thing the narrowed
@@ -16,13 +16,12 @@ const MIN_PHONE_QUERY_LENGTH = 7;
1616

1717
export async function GET(request, { params } = {}) {
1818
try {
19-
const supabase = await createSupabaseServerClient();
2019
const url = new URL(request.url);
2120
const query = url.searchParams.get('q');
22-
23-
// Get user from session
24-
const { data: { user }, error: userError } = await supabase.auth.getUser();
25-
if (userError || !user) {
21+
22+
// Bearer (qc, MCP, passkey and CoinPay sessions) or the cookie session.
23+
const { user } = await authenticateRequest(request);
24+
if (!user) {
2625
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
2726
}
2827

‎src/cli/api.js‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,30 @@ export class QcClient {
195195
return { messages: out, hasMore };
196196
}
197197

198+
/** People on qrypt.chat matching a username, name or unique id (best first, never you). */
199+
async searchUsers(query) {
200+
const q = String(query ?? '').trim().replace(/^@/, '');
201+
if (!q) return [];
202+
const { users = [] } = await this.request(`/api/users/search?q=${encodeURIComponent(q)}`);
203+
return users;
204+
}
205+
206+
/**
207+
* Open a direct chat with one person, given their @username (or a name that
208+
* matches only them). An existing chat between the two of you is reused.
209+
* Returns the chat as conversations() lists it.
210+
*/
211+
async startDirect(who) {
212+
const user = pickUser(await this.searchUsers(who), who);
213+
const { conversation_id: id } = await this.request('/api/chat/conversations', {
214+
method: 'POST',
215+
body: { type: 'direct', participant_ids: [user.id] },
216+
});
217+
if (!id) throw new QcError(`Could not start a chat with @${user.username}`);
218+
const chat = (await this.conversations()).find((c) => c.id === id);
219+
return chat ?? { id, title: user.display_name || user.username, participants: [] };
220+
}
221+
198222
/** Join a group from its invite link or code. Returns { conversation_id, joined }. */
199223
async joinGroup(code) {
200224
return this.request('/api/conversations/join', { method: 'POST', body: { code } });
@@ -330,6 +354,20 @@ function sleep(ms, signal) {
330354
});
331355
}
332356

357+
/**
358+
* The one person `query` means among search results: an exact @username (or
359+
* unique id) first, else the only result. Anything else says who it could be.
360+
*/
361+
export function pickUser(users, query) {
362+
const q = String(query ?? '').trim().replace(/^@/, '').toLowerCase();
363+
const exact = users.filter((u) => u.username?.toLowerCase() === q || u.unique_identifier?.toLowerCase() === q);
364+
if (exact.length === 1) return exact[0];
365+
if (users.length === 1) return users[0];
366+
if (!users.length) throw new QcError(`Nobody on qrypt.chat matches "${query}"`, 404);
367+
const names = users.slice(0, 8).map((u) => `@${u.username}${u.display_name && u.display_name !== u.username ? ` (${u.display_name})` : ''}`);
368+
throw new QcError(`"${query}" matches ${users.length} people: ${names.join(', ')}${users.length > 8 ? ', …' : ''}. Use their @username.`, 409);
369+
}
370+
333371
/** A conversation's name, or the other participants' names for a direct chat. */
334372
export function conversationTitle(c, me) {
335373
if (c.name) return c.name;

‎src/cli/commands.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ Usage:
4848
qc read <chat> [-n 20] print the last messages of a chat
4949
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply);
5050
an @partial that matches one person becomes their @username
51+
qc dm <@user> [text] start (or reopen) a direct chat with anyone on qrypt.chat and send
52+
text if given ("-" reads stdin); qc msg is the same. In the client: /msg @user
5153
qc mentions <chat> [@partial] who @ completes to in a chat, best match first (one per line)
5254
qc react <chat> <message-id> <emoji> [--remove] react to a message
5355
qc listen print new messages as they arrive (NDJSON with --json; --notify alerts with it)
54-
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, list_mentions)
56+
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, start_direct_chat, list_mentions)
5557
qc join <link> join a group chat from its invite link (https://qrypt.chat/join/...)
5658
qc invite <chat> print a group's invite link (group admins) [--reset makes a new one, --off turns it off]
5759
@@ -367,6 +369,17 @@ export async function main(argv, { version = '0.0.0' } = {}) {
367369
for (const a of m.ambiguous) out(warn(`@${a.query} could be ${a.matches.join(', ')}: sent as typed.`));
368370
return out(`${mark('send')}Sent to ${chat.title}.`);
369371
}
372+
case 'dm':
373+
case 'msg': {
374+
if (!rest[0]) throw new Error(`Usage: qc ${cmd} <@user> [text] (a username, or a name only one person has)`);
375+
const text = rest[1] === '-' && rest.length === 2 ? (await readStdin()).trim() : rest.slice(1).join(' ');
376+
const c = await client(flags);
377+
const chat = await c.startDirect(rest[0]);
378+
const sent = text ? await c.send(chat.id, text) : null;
379+
if (flags.json) return json({ conversation: chat.id, title: chat.title, ...(sent ? { id: sent.message?.id, skipped: sent.skipped } : {}) });
380+
if (sent?.skipped) out(warn(`${chat.title} has no key yet and will not see it until they sign in.`));
381+
return out(`${mark('send')}${sent ? `Sent to ${chat.title}` : `Chat with ${chat.title} is open (qc send "${chat.title}" <text>)`}.`);
382+
}
370383
case 'listen': {
371384
const c = await client(flags);
372385
const chats = await c.conversations();

‎src/cli/mcp.js‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ const TOOLS = [
3939
additionalProperties: false,
4040
},
4141
},
42+
{
43+
name: 'start_direct_chat',
44+
description: 'Start (or reopen) an end-to-end encrypted direct chat with anyone on qrypt.chat, by @username or a name only one person has, and optionally send the first message. Returns the chat id for read_chat/send_message.',
45+
inputSchema: {
46+
type: 'object',
47+
properties: {
48+
user: { type: 'string', description: 'Their @username (or a name that matches only them)' },
49+
text: { type: 'string', description: 'Optional: the first message' },
50+
},
51+
required: ['user'],
52+
additionalProperties: false,
53+
},
54+
},
4255
{
4356
name: 'list_mentions',
4457
description: 'Who an @mention can complete to in a conversation, best match first (like typing @ali then Tab). Without partial: everyone you can mention.',
@@ -97,6 +110,12 @@ export async function callTool(client, name, args = {}) {
97110
const { message, skipped } = await client.send(chat.id, m.text, { replyTo: typeof args.reply_to === 'string' ? args.reply_to : undefined });
98111
return { sent: true, id: message?.id, chat: chat.title, skipped, ...(m.mentions.length ? { mentions: m.mentions } : {}), ...(m.ambiguous.length ? { ambiguous: m.ambiguous } : {}) };
99112
}
113+
if (name === 'start_direct_chat') {
114+
if (!args.user || typeof args.user !== 'string') throw new Error('user is required');
115+
const chat = await client.startDirect(args.user);
116+
const sent = typeof args.text === 'string' && args.text.trim() ? await client.send(chat.id, args.text.trim()) : null;
117+
return { chat: chat.title, id: chat.id, ...(sent ? { sent: true, message_id: sent.message?.id, skipped: sent.skipped } : {}) };
118+
}
100119
if (name === 'list_mentions') {
101120
const chat = findChat(await client.conversations(), args.chat);
102121
const matches = mentionMatches(chat, client.me, typeof args.partial === 'string' ? args.partial : '');

‎src/cli/tui.js‎

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
* └─────────────┘└──────────────────────────────────────────────┘
1313
* ENTER send CTRL+E emoji TAB chats PGUP scroll ● live
1414
*
15-
* Composer commands: /voice posts a fresh end-to-end encrypted PairUX room,
15+
* Composer commands: /msg @name [text] starts (or reopens) a direct chat with
16+
* anyone on qrypt.chat, IRC-style (/dm, /query and /new too; `n` in the list
17+
* types it for you), /voice posts a fresh end-to-end encrypted PairUX room,
1618
* /archive and /unarchive file the open chat away, /archived flips the list
1719
* between your chats and the archived ones (also Ctrl+O; `a` in the list).
1820
* /names (who is here) and /list (your chats) answer IRC-style in the
@@ -95,7 +97,16 @@ export function chatsWithNews(prev, next) {
9597
}
9698

9799
/** Composer slash commands the TUI handles itself; anything else is sent as typed. */
98-
export const SLASH_COMMANDS = ['voice', 'archive', 'unarchive', 'archived', 'names', 'list'];
100+
export const SLASH_COMMANDS = ['voice', 'archive', 'unarchive', 'archived', 'names', 'list', 'msg', 'dm', 'query', 'new'];
101+
102+
/** The slash commands that start a direct chat: /msg @name [first message]. */
103+
export const NEW_CHAT_COMMANDS = ['msg', 'dm', 'query', 'new'];
104+
105+
/** "/msg @alice hi there" args as { who: '@alice', text: 'hi there' }. */
106+
export function parseNewChat(args) {
107+
const m = /^(\S+)(?:\s+([\s\S]*))?$/.exec(String(args ?? '').trim());
108+
return m ? { who: m[1], text: (m[2] ?? '').trim() } : null;
109+
}
99110

100111
const pad2 = (n) => String(n).padStart(2, '0');
101112
const timeOf = (iso) => {
@@ -156,7 +167,8 @@ function drawTranscript(surface, state) {
156167
const theme = surface.theme;
157168
const messages = state.messages[state.activeId] || [];
158169
if (!state.activeId) {
159-
surface.text(1, 1, state.loading ? 'Loading your chats…' : 'Pick a chat on the left.', { fg: theme.muted });
170+
const hint = state.loading ? 'Loading your chats…' : state.conversations.length ? 'Pick a chat on the left, or type /msg @name to start one.' : 'No chats yet. Type /msg @name to message someone.';
171+
surface.text(1, 1, hint, { fg: theme.muted });
160172
return;
161173
}
162174
if (!messages.length) {
@@ -256,7 +268,7 @@ export function render({ ui, width, height, theme }, state, on = {}) {
256268
p.textInput({
257269
value: state.field.value,
258270
cursor: state.field.cursor,
259-
placeholder: active ? `Message ${active.title} (@name Tab, Ctrl+E emoji, :rocket: works too)` : 'Pick a chat first',
271+
placeholder: active ? `Message ${active.title} (@name Tab, Ctrl+E emoji, /msg @name for a new chat)` : '/msg @name to start a chat',
260272
focused: state.focus === 'compose' && !state.picker,
261273
size: 1,
262274
});
@@ -270,7 +282,7 @@ export function render({ ui, width, height, theme }, state, on = {}) {
270282
{ key: 'Ctrl+E', label: 'emoji' },
271283
{ key: 'Tab', label: state.focus === 'list' ? 'compose' : mention?.matches.length ? 'complete @' : 'chats' },
272284
{ key: 'PgUp', label: 'scroll' },
273-
...(state.focus === 'list' ? [{ key: 'a', label: state.showArchived ? 'unarchive' : 'archive' }] : []),
285+
...(state.focus === 'list' ? [{ key: 'n', label: 'new chat' }, { key: 'a', label: state.showArchived ? 'unarchive' : 'archive' }] : []),
274286
{ key: 'Ctrl+O', label: state.showArchived ? 'chats' : 'archived' },
275287
{ key: 'Ctrl+C', label: 'quit' },
276288
],
@@ -439,13 +451,43 @@ export async function runTui(client, { initialChat } = {}) {
439451
}
440452
}
441453

454+
/** Open a chat already in state.conversations, switching to the archived view if that is where it is. */
455+
function openById(id) {
456+
const chat = state.conversations.find((c) => c.id === id);
457+
if (!chat) return;
458+
state.showArchived = !!chat.is_archived;
459+
openAt(listed(state).findIndex((c) => c.id === id));
460+
state.focus = 'compose';
461+
}
462+
463+
/** /msg @name [text]: find the person, open (or reuse) your direct chat, send the text if any. */
464+
async function startChat(args) {
465+
const want = parseNewChat(args);
466+
if (!want) return note('Usage: /msg @name [message] (a username, or a name only one person has)');
467+
note(`Looking up ${want.who}…`, { sticky: true });
468+
try {
469+
const chat = await client.startDirect(want.who);
470+
if (!state.conversations.some((c) => c.id === chat.id)) state.conversations = [chat, ...state.conversations];
471+
openById(chat.id);
472+
note(`Chat with ${chat.title} is open`);
473+
if (want.text) {
474+
state.field = { value: want.text, cursor: want.text.length };
475+
await send({ text: true });
476+
}
477+
} catch (err) {
478+
note(err.message, { sticky: err.status === 409 });
479+
}
480+
redraw();
481+
}
482+
442483
/** A slash command typed in the composer; true when it was one. */
443484
function runSlash(raw) {
444485
const cmd = parseSlashCommand(raw, SLASH_COMMANDS);
445486
if (!cmd) return false;
446487
const active = state.conversations.find((c) => c.id === state.activeId);
447488
state.field = { value: '', cursor: 0 };
448-
if (cmd.name === 'archived') toggleArchivedView();
489+
if (NEW_CHAT_COMMANDS.includes(cmd.name)) startChat(cmd.args);
490+
else if (cmd.name === 'archived') toggleArchivedView();
449491
else if (!active) note('Pick a chat first');
450492
else if (cmd.name === 'names' || cmd.name === 'list') {
451493
// IRC-style, answered from the loaded chat list and shown only here.
@@ -465,8 +507,9 @@ export async function runTui(client, { initialChat } = {}) {
465507
return true;
466508
}
467509

468-
async function send({ raw = false } = {}) {
469-
if (!raw && runSlash(state.field.value)) return;
510+
/** raw: send exactly as typed (no :shortcodes:); text: plain text, never a slash command. */
511+
async function send({ raw = false, text: plain = false } = {}) {
512+
if (!raw && !plain && runSlash(state.field.value)) return;
470513
const text = raw ? state.field.value.trim() : emojify(state.field.value.trim(), { mode: 'emoji' });
471514
if (!text || !state.activeId) return;
472515
const id = state.activeId;
@@ -544,7 +587,10 @@ export async function runTui(client, { initialChat } = {}) {
544587
if (event.key === 'up' || event.key === 'k') state.selected = Math.max(0, state.selected - 1);
545588
else if (event.key === 'down' || event.key === 'j') state.selected = Math.min(listed(state).length - 1, state.selected + 1);
546589
else if (event.key === 'enter' || event.key === 'right' || event.key === 'l') handlers.open(state.selected);
547-
else if (event.key === 'a') {
590+
else if (event.key === 'n') {
591+
state.field = { value: '/msg @', cursor: 6 };
592+
state.focus = 'compose';
593+
} else if (event.key === 'a') {
548594
const c = listed(state)[state.selected];
549595
if (c) setArchived(c, !c.is_archived);
550596
}

‎tests/cli/qc.test.js‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect, vi } from 'vitest';
22
import { ephemeralKeypair, keyring, openKeyBlob } from '../../src/cli/crypto.js';
3-
import { QcClient, readSse, conversationTitle } from '../../src/cli/api.js';
3+
import { QcClient, readSse, conversationTitle, pickUser } from '../../src/cli/api.js';
44
import { parseArgs, findChat } from '../../src/cli/commands.js';
55
import { handle } from '../../src/cli/mcp.js';
66
import { matchCode } from '../../src/lib/auth/cli-match.js';
@@ -180,7 +180,7 @@ describe('qc mcp', () => {
180180
const init = await handle(fake, { id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } }, { version: '1' });
181181
expect(init.serverInfo.name).toBe('qc');
182182
const { tools } = await handle(fake, { id: 2, method: 'tools/list' }, { version: '1' });
183-
expect(tools.map((t) => t.name)).toEqual(['list_chats', 'read_chat', 'send_message', 'list_mentions', 'react_to_message']);
183+
expect(tools.map((t) => t.name)).toEqual(['list_chats', 'read_chat', 'send_message', 'start_direct_chat', 'list_mentions', 'react_to_message']);
184184
});
185185

186186
it('calls tools and reports errors as tool errors', async () => {
@@ -251,3 +251,41 @@ describe('qc login on a remote box', () => {
251251
expect(parsePasted('', 'abc')).toBeNull();
252252
});
253253
});
254+
255+
describe('starting a direct chat', () => {
256+
const people = [
257+
{ id: 'u1', username: 'alice', display_name: 'Alice Liddell' },
258+
{ id: 'u2', username: 'alicia', display_name: 'Alicia' },
259+
];
260+
261+
it('picks an exact @username, else the only match, else names the candidates', () => {
262+
expect(pickUser(people, '@alice').id).toBe('u1');
263+
expect(pickUser(people, 'ALICE').id).toBe('u1');
264+
expect(pickUser([people[1]], 'ali').id).toBe('u2');
265+
expect(() => pickUser(people, 'ali')).toThrow(/matches 2 people: @alice \(Alice Liddell\), @alicia/);
266+
expect(() => pickUser([], 'zed')).toThrow(/Nobody on qrypt.chat matches "zed"/);
267+
});
268+
269+
it('searches with the Bearer token, creates the direct chat and returns it from the chat list', async () => {
270+
const calls = [];
271+
const fetch = vi.fn(async (url, init) => {
272+
const u = new URL(url);
273+
calls.push(`${init?.method || 'GET'} ${u.pathname}${u.search}`);
274+
expect(init.headers.Authorization).toBe('Bearer tok');
275+
if (u.pathname === '/api/users/search') return json({ users: people });
276+
if (u.pathname === '/api/chat/conversations') {
277+
expect(JSON.parse(init.body)).toEqual({ type: 'direct', participant_ids: ['u1'] });
278+
return json({ conversation_id: 'c9' });
279+
}
280+
if (u.pathname === '/api/conversations/load') return json({ conversations: [{ id: 'c9', participants: [{ user_id: 'me' }, { user_id: 'u1', user: { username: 'alice', display_name: 'Alice Liddell' } }] }] });
281+
return json({ error: 'nope' }, 404);
282+
});
283+
const client = new QcClient(
284+
{ base: 'https://chat.test', access_token: 'tok', refresh_token: 'r', expires_at: Date.now() / 1000 + 3600, user: { id: 'me' }, keys: await account() },
285+
{ fetch, save: () => {} },
286+
);
287+
const chat = await client.startDirect('@alice');
288+
expect(chat).toMatchObject({ id: 'c9', title: 'Alice Liddell' });
289+
expect(calls).toEqual(['GET /api/users/search?q=alice', 'POST /api/chat/conversations', 'POST /api/conversations/load']);
290+
});
291+
});

0 commit comments

Comments
 (0)