relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
256 lines (246 loc) • 11.5 kB
JavaScript
import { createHash } from "node:crypto";
import e2eeIdentityModule from "./e2ee-identity.cjs";
import { decryptE2eeWireMessage, e2eeThreadItem } from "./e2ee-mls.js";
import {
readImportedE2eeHistory,
removeCachedPlaintext,
removeImportedE2eeHistoryRecord,
removeLocalE2eeAttachmentDirectory,
removePendingE2eeOutbox,
} from "./e2ee-state.js";
const { readPairedIdentity } = e2eeIdentityModule;
function directChatId(leftUserId, rightUserId) {
const keys = [...new Set([`u:${leftUserId}`, `u:${rightUserId}`])].sort();
const digest = createHash("sha256").update(keys.join("\n")).digest("hex");
return `chat_${digest.slice(0, 24)}`;
}
function participantId(relayUserId) {
return `e2eeparty_${createHash("sha256")
.update("relay-e2ee-chat-participant-v1")
.update(String(relayUserId))
.digest("hex")
.slice(0, 24)}`;
}
function authorizedTaskChange(change, root) {
if (root.kind !== "task" || change.task?.taskId !== root.messageId) return false;
const recipientStates = new Set(["accepted", "started", "completed", "failed", "declined"]);
if (recipientStates.has(change.task.state)) return change.senderUserId === root.recipientUserId;
if (change.task.state === "cancelled") return change.senderUserId === root.senderUserId;
return false;
}
export async function e2eeOpenedRecords(client, wires) {
const identity = readPairedIdentity();
if (!identity) throw new Error("This Companion has no enrolled E2EE identity.");
const byId = new Map();
for (const imported of readImportedE2eeHistory(identity)) {
byId.set(imported.wire.relayId, {
...imported,
item: e2eeThreadItem(imported.wire, imported.plaintext),
imported: true,
});
}
for (const wire of wires || []) {
const plaintext = await decryptE2eeWireMessage(client, wire);
byId.set(wire.relayId, { wire, plaintext, item: e2eeThreadItem(wire, plaintext), imported: false });
}
const events = [...byId.values()].sort((left, right) =>
String(left.plaintext.authoredAt).localeCompare(String(right.plaintext.authoredAt)) ||
String(left.wire.relayId).localeCompare(String(right.wire.relayId)));
const byMessage = new Map();
for (const event of events) {
const current = byMessage.get(event.plaintext.messageId) || [];
current.push(event);
byMessage.set(event.plaintext.messageId, current);
}
const opened = [];
for (const messageEvents of byMessage.values()) {
const created = messageEvents.find((entry) => entry.plaintext.eventType === "message.created");
if (!created) continue;
const root = created.plaintext;
const projection = {
...root,
createdAt: root.authoredAt,
updatedAt: root.authoredAt,
reactions: { aggregates: [], events: [] },
};
const activeReactions = new Map();
const reactionHistory = [];
for (const event of messageEvents) {
const value = event.plaintext;
if (value.eventId === root.eventId) continue;
if (
value.conversation?.conversationId !== root.conversation?.conversationId ||
value.senderUserId !== root.senderUserId && value.senderUserId !== root.recipientUserId
) continue;
if (value.eventType === "message.edited" && value.senderUserId === root.senderUserId) {
if (value.forHuman !== undefined) projection.forHuman = value.forHuman;
if (value.forAgent !== undefined) projection.forAgent = value.forAgent;
if (value.title !== undefined) {
if (value.title === null) delete projection.title;
else projection.title = value.title;
}
projection.revision = Math.max(projection.revision, value.revision);
projection.editedAt = value.editedAt;
projection.updatedAt = value.editedAt;
} else if (value.eventType === "message.deleted" && value.senderUserId === root.senderUserId) {
projection.revision = Math.max(projection.revision, value.revision);
projection.deletedAt = value.deletedAt;
projection.updatedAt = value.deletedAt;
} else if (value.eventType === "reaction.changed") {
const key = `${value.senderUserId}\n${value.reaction.emoji}`;
const reactionEvent = {
id: value.eventId,
relayId: root.messageId,
actor: {
relayUserId: value.senderUserId,
name: event.wire.sender.name,
self: value.senderUserId === identity.userId,
},
emoji: value.reaction.emoji,
action: value.reaction.action,
at: value.reaction.reactedAt,
};
reactionHistory.push(reactionEvent);
if (value.reaction.action === "add") {
activeReactions.set(key, reactionEvent);
} else {
activeReactions.delete(key);
}
projection.updatedAt = value.reaction.reactedAt;
} else if (value.eventType === "receipt.changed") {
if (value.senderUserId === root.recipientUserId) {
projection.recipientReceiptState = value.receipt.state;
if (value.receipt.state === "read") projection.recipientReadAt = value.receipt.occurredAt;
}
if (value.senderUserId === identity.userId) projection.localReceiptState = value.receipt.state;
} else if (value.eventType === "task.changed") {
if (!authorizedTaskChange(value, root)) continue;
projection.task = value.task;
projection.taskState = value.task.state;
if (value.task.state === "accepted" && !projection.taskAcceptedAt) {
projection.taskAcceptedAt = value.task.occurredAt;
}
if (["started", "completed"].includes(value.task.state) && !projection.taskStartedAt) {
projection.taskStartedAt = value.task.occurredAt;
}
if (value.task.taskRunOwner && !projection.taskRunOwner) {
projection.taskRunOwner = value.task.taskRunOwner;
}
if (value.task.state === "completed") projection.taskCompletedAt = value.task.occurredAt;
projection.updatedAt = value.task.occurredAt;
}
}
const reactionEvents = [...activeReactions.values()];
const aggregateMap = new Map();
for (const event of reactionEvents) {
const aggregate = aggregateMap.get(event.emoji) || {
emoji: event.emoji,
count: 0,
reactedByMe: false,
actors: [],
};
aggregate.count += 1;
aggregate.reactedByMe ||= event.actor.self;
aggregate.actors.push(event.actor);
aggregateMap.set(event.emoji, aggregate);
}
projection.reactions = { aggregates: [...aggregateMap.values()], events: reactionHistory };
opened.push({ ...created, plaintext: projection, item: e2eeThreadItem(created.wire, projection) });
}
for (const event of events) {
if (event.plaintext.eventType !== "message.deleted") continue;
const messageId = event.plaintext.messageId;
removeCachedPlaintext(identity, messageId);
removeImportedE2eeHistoryRecord(identity, messageId);
removePendingE2eeOutbox(identity, `direct:${messageId}`);
removeLocalE2eeAttachmentDirectory(messageId);
}
// A provider's encrypted completion reply is also the Task's Done
// receipt. Derive that relationship locally from authenticated participants
// and reply metadata instead of sending a second server-readable task call.
const projectedByMessageId = new Map(opened.map((entry) => [entry.plaintext.messageId, entry]));
for (const result of opened) {
if (result.plaintext.type !== "completion") continue;
const target = projectedByMessageId.get(result.plaintext.conversation?.replyToMessageId);
if (
!target ||
target.plaintext.kind !== "task" ||
result.plaintext.senderUserId !== target.plaintext.recipientUserId ||
result.plaintext.recipientUserId !== target.plaintext.senderUserId
) continue;
target.plaintext.task = {
taskId: target.plaintext.messageId,
state: "completed",
occurredAt: result.plaintext.authoredAt,
resultMessageId: result.plaintext.messageId,
};
target.plaintext.taskState = "completed";
target.plaintext.taskStartedAt ||= result.plaintext.authoredAt;
target.plaintext.taskCompletedAt = result.plaintext.authoredAt;
target.plaintext.updatedAt = result.plaintext.authoredAt;
target.item = e2eeThreadItem(target.wire, target.plaintext);
}
opened.sort((left, right) =>
String(left.plaintext.createdAt).localeCompare(String(right.plaintext.createdAt)) ||
String(left.wire.relayId).localeCompare(String(right.wire.relayId)));
return { identity, opened, events };
}
function buildChats(identity, opened) {
const byChat = new Map();
for (const message of opened) {
const otherUserId = message.wire.sender.relayUserId === identity.userId
? message.wire.recipient.relayUserId
: message.wire.sender.relayUserId;
const otherName = message.wire.sender.relayUserId === identity.userId
? message.wire.recipient.name
: message.wire.sender.name;
const chatId = directChatId(identity.userId, otherUserId);
const current = byChat.get(chatId) || { chatId, otherUserId, otherName, messages: [] };
current.otherName = otherName || current.otherName;
current.messages.push(message);
byChat.set(chatId, current);
}
return [...byChat.values()].map((chat) => {
chat.messages.sort((a, b) =>
String(a.plaintext.createdAt).localeCompare(String(b.plaintext.createdAt)) ||
String(a.wire.relayId).localeCompare(String(b.wire.relayId)));
const newest = chat.messages.at(-1);
const items = chat.messages.map((message) => message.item);
return {
chatId: chat.chatId,
title: chat.otherName || "Relay user",
kind: "direct",
participants: [
{ id: participantId(identity.userId), name: "You", self: true },
{ id: participantId(chat.otherUserId), name: chat.otherName || "Relay user", self: false },
],
threadIds: [...new Set(items.map((item) => item.threadId || item.relayId))],
messageCount: items.length,
unreadCount: items.filter((item) => item.direction === "inbound" && item.state === "delivered").length,
lastMessage: newest ? {
relayId: newest.wire.relayId,
...(newest.plaintext.title ? { title: newest.plaintext.title } : {}),
preview: newest.plaintext.deletedAt ? "Message deleted" : newest.plaintext.forHuman,
senderName: newest.item.direction === "outbound" ? "You" : newest.wire.sender.name,
createdAt: newest.plaintext.createdAt,
direction: newest.item.direction,
} : undefined,
updatedAt: newest?.plaintext.createdAt || new Date(0).toISOString(),
items,
hasMoreMessages: false,
};
}).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export async function e2eeChatList(client, wires) {
const { identity, opened } = await e2eeOpenedRecords(client, wires);
return { chats: buildChats(identity, opened).map(({ items: _items, hasMoreMessages: _more, ...summary }) => summary) };
}
export async function e2eeChat(client, wires, chatId) {
const { identity, opened } = await e2eeOpenedRecords(client, wires);
return buildChats(identity, opened).find((chat) => chat.chatId === chatId);
}
export async function e2eeChatForThread(client, wires, threadId) {
const { identity, opened } = await e2eeOpenedRecords(client, wires);
return buildChats(identity, opened).find((chat) =>
chat.items.some((item) => item.threadId === threadId || item.relayId === threadId));
}