Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
"version": "0.16.1",
"version": "0.17.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
1 change: 1 addition & 0 deletions packages/qryptchat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ The mouse works too: click a chat, scroll the transcript, click an emoji. Unread
```sh
qc chats # id and name of every chat
qc read alice -n 20 # the last 20 messages, decrypted
qc dm @alice "hey, it's me" # start (or reopen) a direct chat with anyone, by @username
qc send alice "on my way" # encrypted to every participant
qc send ops "@ri deploy is done" # an @partial matching one person is sent as @riotcoder
qc mentions ops @al # who @al completes to: @alice, @alina
Expand Down
2 changes: 1 addition & 1 deletion packages/qryptchat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/qryptchat",
"version": "0.12.0",
"version": "0.13.0",
"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.",
"type": "module",
"bin": {
Expand Down
15 changes: 10 additions & 5 deletions src/app/api/chat/conversations/route.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { NextResponse } from 'next/server';
import { createSupabaseServerClient } from '@/lib/supabase.js';
import { authenticateRequest } from '@/lib/api/middleware/auth.js';
import { sseManager } from '@/lib/api/sse-manager.js';
import { announceJoined } from '@/lib/chat/membership-events.js';


export async function GET(request, { params } = {}) {
Expand Down Expand Up @@ -84,12 +87,11 @@ export async function GET(request, { params } = {}) {

export async function POST(request, { params } = {}) {
try {
const supabase = await createSupabaseServerClient();
const { type, name, participant_ids, group_id } = await request.json();
// Get user from session
const { data: { user }, error: userError } = await supabase.auth.getUser();
if (userError || !user) {

// Bearer (qc, MCP, passkey and CoinPay sessions) or the cookie session.
const { user, supabase } = await authenticateRequest(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

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

// Every member's open devices get the new chat in their list now.
announceJoined(sseManager, conversationId, participants.map((p) => p.user_id));

return NextResponse.json({
conversation_id: conversationId,
message: 'Conversation created successfully'
Expand Down
11 changes: 5 additions & 6 deletions src/app/api/users/search/route.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { createSupabaseServerClient } from '@/lib/supabase.js';
import { authenticateRequest } from '@/lib/api/middleware/auth.js';
import { getServiceRoleClient } from '@/lib/supabase/service-role.js';

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

export async function GET(request, { params } = {}) {
try {
const supabase = await createSupabaseServerClient();
const url = new URL(request.url);
const query = url.searchParams.get('q');
// Get user from session
const { data: { user }, error: userError } = await supabase.auth.getUser();
if (userError || !user) {

// Bearer (qc, MCP, passkey and CoinPay sessions) or the cookie session.
const { user } = await authenticateRequest(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

Expand Down
38 changes: 38 additions & 0 deletions src/cli/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,30 @@ export class QcClient {
return { messages: out, hasMore };
}

/** People on qrypt.chat matching a username, name or unique id (best first, never you). */
async searchUsers(query) {
const q = String(query ?? '').trim().replace(/^@/, '');
if (!q) return [];
const { users = [] } = await this.request(`/api/users/search?q=${encodeURIComponent(q)}`);
return users;
}

/**
* Open a direct chat with one person, given their @username (or a name that
* matches only them). An existing chat between the two of you is reused.
* Returns the chat as conversations() lists it.
*/
async startDirect(who) {
const user = pickUser(await this.searchUsers(who), who);
const { conversation_id: id } = await this.request('/api/chat/conversations', {
method: 'POST',
body: { type: 'direct', participant_ids: [user.id] },
});
if (!id) throw new QcError(`Could not start a chat with @${user.username}`);
const chat = (await this.conversations()).find((c) => c.id === id);
return chat ?? { id, title: user.display_name || user.username, participants: [] };
}

/** Join a group from its invite link or code. Returns { conversation_id, joined }. */
async joinGroup(code) {
return this.request('/api/conversations/join', { method: 'POST', body: { code } });
Expand Down Expand Up @@ -330,6 +354,20 @@ function sleep(ms, signal) {
});
}

/**
* The one person `query` means among search results: an exact @username (or
* unique id) first, else the only result. Anything else says who it could be.
*/
export function pickUser(users, query) {
const q = String(query ?? '').trim().replace(/^@/, '').toLowerCase();
const exact = users.filter((u) => u.username?.toLowerCase() === q || u.unique_identifier?.toLowerCase() === q);
if (exact.length === 1) return exact[0];
if (users.length === 1) return users[0];
if (!users.length) throw new QcError(`Nobody on qrypt.chat matches "${query}"`, 404);
const names = users.slice(0, 8).map((u) => `@${u.username}${u.display_name && u.display_name !== u.username ? ` (${u.display_name})` : ''}`);
throw new QcError(`"${query}" matches ${users.length} people: ${names.join(', ')}${users.length > 8 ? ', …' : ''}. Use their @username.`, 409);
}

/** A conversation's name, or the other participants' names for a direct chat. */
export function conversationTitle(c, me) {
if (c.name) return c.name;
Expand Down
15 changes: 14 additions & 1 deletion src/cli/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@ Usage:
qc read <chat> [-n 20] print the last messages of a chat
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply);
an @partial that matches one person becomes their @username
qc dm <@user> [text] start (or reopen) a direct chat with anyone on qrypt.chat and send
text if given ("-" reads stdin); qc msg is the same. In the client: /msg @user
qc mentions <chat> [@partial] who @ completes to in a chat, best match first (one per line)
qc react <chat> <message-id> <emoji> [--remove] react to a message
qc listen print new messages as they arrive (NDJSON with --json; --notify alerts with it)
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, list_mentions)
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, start_direct_chat, list_mentions)
qc join <link> join a group chat from its invite link (https://qrypt.chat/join/...)
qc invite <chat> print a group's invite link (group admins) [--reset makes a new one, --off turns it off]

Expand Down Expand Up @@ -367,6 +369,17 @@ export async function main(argv, { version = '0.0.0' } = {}) {
for (const a of m.ambiguous) out(warn(`@${a.query} could be ${a.matches.join(', ')}: sent as typed.`));
return out(`${mark('send')}Sent to ${chat.title}.`);
}
case 'dm':
case 'msg': {
if (!rest[0]) throw new Error(`Usage: qc ${cmd} <@user> [text] (a username, or a name only one person has)`);
const text = rest[1] === '-' && rest.length === 2 ? (await readStdin()).trim() : rest.slice(1).join(' ');
const c = await client(flags);
const chat = await c.startDirect(rest[0]);
const sent = text ? await c.send(chat.id, text) : null;
if (flags.json) return json({ conversation: chat.id, title: chat.title, ...(sent ? { id: sent.message?.id, skipped: sent.skipped } : {}) });
if (sent?.skipped) out(warn(`${chat.title} has no key yet and will not see it until they sign in.`));
return out(`${mark('send')}${sent ? `Sent to ${chat.title}` : `Chat with ${chat.title} is open (qc send "${chat.title}" <text>)`}.`);
}
case 'listen': {
const c = await client(flags);
const chats = await c.conversations();
Expand Down
19 changes: 19 additions & 0 deletions src/cli/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ const TOOLS = [
additionalProperties: false,
},
},
{
name: 'start_direct_chat',
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.',
inputSchema: {
type: 'object',
properties: {
user: { type: 'string', description: 'Their @username (or a name that matches only them)' },
text: { type: 'string', description: 'Optional: the first message' },
},
required: ['user'],
additionalProperties: false,
},
},
{
name: 'list_mentions',
description: 'Who an @mention can complete to in a conversation, best match first (like typing @ali then Tab). Without partial: everyone you can mention.',
Expand Down Expand Up @@ -97,6 +110,12 @@ export async function callTool(client, name, args = {}) {
const { message, skipped } = await client.send(chat.id, m.text, { replyTo: typeof args.reply_to === 'string' ? args.reply_to : undefined });
return { sent: true, id: message?.id, chat: chat.title, skipped, ...(m.mentions.length ? { mentions: m.mentions } : {}), ...(m.ambiguous.length ? { ambiguous: m.ambiguous } : {}) };
}
if (name === 'start_direct_chat') {
if (!args.user || typeof args.user !== 'string') throw new Error('user is required');
const chat = await client.startDirect(args.user);
const sent = typeof args.text === 'string' && args.text.trim() ? await client.send(chat.id, args.text.trim()) : null;
return { chat: chat.title, id: chat.id, ...(sent ? { sent: true, message_id: sent.message?.id, skipped: sent.skipped } : {}) };
}
if (name === 'list_mentions') {
const chat = findChat(await client.conversations(), args.chat);
const matches = mentionMatches(chat, client.me, typeof args.partial === 'string' ? args.partial : '');
Expand Down
64 changes: 55 additions & 9 deletions src/cli/tui.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
* └─────────────┘└──────────────────────────────────────────────┘
* ENTER send CTRL+E emoji TAB chats PGUP scroll ● live
*
* Composer commands: /voice posts a fresh end-to-end encrypted PairUX room,
* Composer commands: /msg @name [text] starts (or reopens) a direct chat with
* anyone on qrypt.chat, IRC-style (/dm, /query and /new too; `n` in the list
* types it for you), /voice posts a fresh end-to-end encrypted PairUX room,
* /archive and /unarchive file the open chat away, /archived flips the list
* between your chats and the archived ones (also Ctrl+O; `a` in the list).
* /names (who is here) and /list (your chats) answer IRC-style in the
Expand Down Expand Up @@ -95,7 +97,16 @@ export function chatsWithNews(prev, next) {
}

/** Composer slash commands the TUI handles itself; anything else is sent as typed. */
export const SLASH_COMMANDS = ['voice', 'archive', 'unarchive', 'archived', 'names', 'list'];
export const SLASH_COMMANDS = ['voice', 'archive', 'unarchive', 'archived', 'names', 'list', 'msg', 'dm', 'query', 'new'];

/** The slash commands that start a direct chat: /msg @name [first message]. */
export const NEW_CHAT_COMMANDS = ['msg', 'dm', 'query', 'new'];

/** "/msg @alice hi there" args as { who: '@alice', text: 'hi there' }. */
export function parseNewChat(args) {
const m = /^(\S+)(?:\s+([\s\S]*))?$/.exec(String(args ?? '').trim());
return m ? { who: m[1], text: (m[2] ?? '').trim() } : null;
}

const pad2 = (n) => String(n).padStart(2, '0');
const timeOf = (iso) => {
Expand Down Expand Up @@ -156,7 +167,8 @@ function drawTranscript(surface, state) {
const theme = surface.theme;
const messages = state.messages[state.activeId] || [];
if (!state.activeId) {
surface.text(1, 1, state.loading ? 'Loading your chats…' : 'Pick a chat on the left.', { fg: theme.muted });
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.';
surface.text(1, 1, hint, { fg: theme.muted });
return;
}
if (!messages.length) {
Expand Down Expand Up @@ -256,7 +268,7 @@ export function render({ ui, width, height, theme }, state, on = {}) {
p.textInput({
value: state.field.value,
cursor: state.field.cursor,
placeholder: active ? `Message ${active.title} (@name Tab, Ctrl+E emoji, :rocket: works too)` : 'Pick a chat first',
placeholder: active ? `Message ${active.title} (@name Tab, Ctrl+E emoji, /msg @name for a new chat)` : '/msg @name to start a chat',
focused: state.focus === 'compose' && !state.picker,
size: 1,
});
Expand All @@ -270,7 +282,7 @@ export function render({ ui, width, height, theme }, state, on = {}) {
{ key: 'Ctrl+E', label: 'emoji' },
{ key: 'Tab', label: state.focus === 'list' ? 'compose' : mention?.matches.length ? 'complete @' : 'chats' },
{ key: 'PgUp', label: 'scroll' },
...(state.focus === 'list' ? [{ key: 'a', label: state.showArchived ? 'unarchive' : 'archive' }] : []),
...(state.focus === 'list' ? [{ key: 'n', label: 'new chat' }, { key: 'a', label: state.showArchived ? 'unarchive' : 'archive' }] : []),
{ key: 'Ctrl+O', label: state.showArchived ? 'chats' : 'archived' },
{ key: 'Ctrl+C', label: 'quit' },
],
Expand Down Expand Up @@ -439,13 +451,43 @@ export async function runTui(client, { initialChat } = {}) {
}
}

/** Open a chat already in state.conversations, switching to the archived view if that is where it is. */
function openById(id) {
const chat = state.conversations.find((c) => c.id === id);
if (!chat) return;
state.showArchived = !!chat.is_archived;
openAt(listed(state).findIndex((c) => c.id === id));
state.focus = 'compose';
}

/** /msg @name [text]: find the person, open (or reuse) your direct chat, send the text if any. */
async function startChat(args) {
const want = parseNewChat(args);
if (!want) return note('Usage: /msg @name [message] (a username, or a name only one person has)');
note(`Looking up ${want.who}…`, { sticky: true });
try {
const chat = await client.startDirect(want.who);
if (!state.conversations.some((c) => c.id === chat.id)) state.conversations = [chat, ...state.conversations];
openById(chat.id);
note(`Chat with ${chat.title} is open`);
if (want.text) {
state.field = { value: want.text, cursor: want.text.length };
await send({ text: true });
}
} catch (err) {
note(err.message, { sticky: err.status === 409 });
}
redraw();
}

/** A slash command typed in the composer; true when it was one. */
function runSlash(raw) {
const cmd = parseSlashCommand(raw, SLASH_COMMANDS);
if (!cmd) return false;
const active = state.conversations.find((c) => c.id === state.activeId);
state.field = { value: '', cursor: 0 };
if (cmd.name === 'archived') toggleArchivedView();
if (NEW_CHAT_COMMANDS.includes(cmd.name)) startChat(cmd.args);
else if (cmd.name === 'archived') toggleArchivedView();
else if (!active) note('Pick a chat first');
else if (cmd.name === 'names' || cmd.name === 'list') {
// IRC-style, answered from the loaded chat list and shown only here.
Expand All @@ -465,8 +507,9 @@ export async function runTui(client, { initialChat } = {}) {
return true;
}

async function send({ raw = false } = {}) {
if (!raw && runSlash(state.field.value)) return;
/** raw: send exactly as typed (no :shortcodes:); text: plain text, never a slash command. */
async function send({ raw = false, text: plain = false } = {}) {
if (!raw && !plain && runSlash(state.field.value)) return;
const text = raw ? state.field.value.trim() : emojify(state.field.value.trim(), { mode: 'emoji' });
if (!text || !state.activeId) return;
const id = state.activeId;
Expand Down Expand Up @@ -544,7 +587,10 @@ export async function runTui(client, { initialChat } = {}) {
if (event.key === 'up' || event.key === 'k') state.selected = Math.max(0, state.selected - 1);
else if (event.key === 'down' || event.key === 'j') state.selected = Math.min(listed(state).length - 1, state.selected + 1);
else if (event.key === 'enter' || event.key === 'right' || event.key === 'l') handlers.open(state.selected);
else if (event.key === 'a') {
else if (event.key === 'n') {
state.field = { value: '/msg @', cursor: 6 };
state.focus = 'compose';
} else if (event.key === 'a') {
const c = listed(state)[state.selected];
if (c) setArchived(c, !c.is_archived);
}
Expand Down
42 changes: 40 additions & 2 deletions tests/cli/qc.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { ephemeralKeypair, keyring, openKeyBlob } from '../../src/cli/crypto.js';
import { QcClient, readSse, conversationTitle } from '../../src/cli/api.js';
import { QcClient, readSse, conversationTitle, pickUser } from '../../src/cli/api.js';
import { parseArgs, findChat } from '../../src/cli/commands.js';
import { handle } from '../../src/cli/mcp.js';
import { matchCode } from '../../src/lib/auth/cli-match.js';
Expand Down Expand Up @@ -180,7 +180,7 @@ describe('qc mcp', () => {
const init = await handle(fake, { id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } }, { version: '1' });
expect(init.serverInfo.name).toBe('qc');
const { tools } = await handle(fake, { id: 2, method: 'tools/list' }, { version: '1' });
expect(tools.map((t) => t.name)).toEqual(['list_chats', 'read_chat', 'send_message', 'list_mentions', 'react_to_message']);
expect(tools.map((t) => t.name)).toEqual(['list_chats', 'read_chat', 'send_message', 'start_direct_chat', 'list_mentions', 'react_to_message']);
});

it('calls tools and reports errors as tool errors', async () => {
Expand Down Expand Up @@ -251,3 +251,41 @@ describe('qc login on a remote box', () => {
expect(parsePasted('', 'abc')).toBeNull();
});
});

describe('starting a direct chat', () => {
const people = [
{ id: 'u1', username: 'alice', display_name: 'Alice Liddell' },
{ id: 'u2', username: 'alicia', display_name: 'Alicia' },
];

it('picks an exact @username, else the only match, else names the candidates', () => {
expect(pickUser(people, '@alice').id).toBe('u1');
expect(pickUser(people, 'ALICE').id).toBe('u1');
expect(pickUser([people[1]], 'ali').id).toBe('u2');
expect(() => pickUser(people, 'ali')).toThrow(/matches 2 people: @alice \(Alice Liddell\), @alicia/);
expect(() => pickUser([], 'zed')).toThrow(/Nobody on qrypt.chat matches "zed"/);
});

it('searches with the Bearer token, creates the direct chat and returns it from the chat list', async () => {
const calls = [];
const fetch = vi.fn(async (url, init) => {
const u = new URL(url);
calls.push(`${init?.method || 'GET'} ${u.pathname}${u.search}`);
expect(init.headers.Authorization).toBe('Bearer tok');
if (u.pathname === '/api/users/search') return json({ users: people });
if (u.pathname === '/api/chat/conversations') {
expect(JSON.parse(init.body)).toEqual({ type: 'direct', participant_ids: ['u1'] });
return json({ conversation_id: 'c9' });
}
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' } }] }] });
return json({ error: 'nope' }, 404);
});
const client = new QcClient(
{ base: 'https://chat.test', access_token: 'tok', refresh_token: 'r', expires_at: Date.now() / 1000 + 3600, user: { id: 'me' }, keys: await account() },
{ fetch, save: () => {} },
);
const chat = await client.startDirect('@alice');
expect(chat).toMatchObject({ id: 'c9', title: 'Alice Liddell' });
expect(calls).toEqual(['GET /api/users/search?q=alice', 'POST /api/chat/conversations', 'POST /api/conversations/load']);
});
});
Loading
Loading