From 84f8c0cac8a8efb5d3176cc16b4e9e2b90bff370 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sun, 25 Oct 2026 11:49:46 +0100 Subject: [PATCH] Let users copy the current chat session as markdown The conversation header menu gets a Copy chat row that puts the active session on the clipboard as one markdown document: a summary, then each message under its author and time, with quotes, attachments and reactions. Bot and workspace refs read as names, message headings drop below the message headings and code blocks stay untouched. The clipboard write starts on the click with a pending ClipboardItem so Safari allows it after the fetch. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + .../bot-sessions/grab-conversation-export.ts | 102 +++++++++++++++++ .../(functions)/fetch-conversation-export.ts | 20 ++++ .../(partials)/conversation-header-menu.tsx | 10 +- .../(partials)/copy-chat-menu-item.tsx | 78 +++++++++++++ .../api/admin/bots/conversation-export.ts | 56 ++++++++++ src/types/index.ts | 32 ++++++ src/utils/conversation-markdown.test.ts | 85 ++++++++++++++ src/utils/conversation-markdown.ts | 105 ++++++++++++++++++ src/utils/copy-pending-text.ts | 33 ++++++ src/utils/export-message-text.test.ts | 34 ++++++ src/utils/export-message-text.ts | 87 +++++++++++++++ 12 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 src/functions/backend/db/bot-sessions/grab-conversation-export.ts create mode 100644 src/pages/admin/bots/[bot_id]/(functions)/fetch-conversation-export.ts create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/copy-chat-menu-item.tsx create mode 100644 src/pages/api/admin/bots/conversation-export.ts create mode 100644 src/utils/conversation-markdown.test.ts create mode 100644 src/utils/conversation-markdown.ts create mode 100644 src/utils/copy-pending-text.ts create mode 100644 src/utils/export-message-text.test.ts create mode 100644 src/utils/export-message-text.ts diff --git a/AGENTS.md b/AGENTS.md index 1d3805e..2e7bd01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker - **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it. - **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`). - **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:::]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`. Parsers in `src/utils/*-ref.ts`. +- **Copy chat** (header menu): the active session (the one on screen) as one markdown document (`api/admin/bots/conversation-export` -> `conversationMarkdown`, refs as names, message headings demoted). The clipboard write starts on the click with a pending `ClipboardItem` (`copyPendingText`), since Safari drops the gesture after a fetch. - **Replies/reactions:** `reply_to_message_id` (not an FK); reactions from the fixed `BotMessageReactions`, toggled by `react-message.ts` and the `react_to_message` tool, pushed as `server:bot-reaction`. - **Attachments:** uploaded on pick (`upload-attachment.ts`, private `media`, text extracted when possible), linked via `bot_message_media`. Replies read files of the last `BotReplyAttachmentMessages` messages. Tasks don't get files yet. - **Thinking row / stop:** `bot-busy-state.ts` decides the row's line and whether Stop shows. Stop (`api/admin/bots/stop.ts`, `stop-bot-jobs`) fails every open job of that bot for the user, aborts the running handler (`job-abort-registry`) and drops late face updates. diff --git a/src/functions/backend/db/bot-sessions/grab-conversation-export.ts b/src/functions/backend/db/bot-sessions/grab-conversation-export.ts new file mode 100644 index 0000000..c1fe49b --- /dev/null +++ b/src/functions/backend/db/bot-sessions/grab-conversation-export.ts @@ -0,0 +1,102 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db"; +import type { ConversationExport } from "@/src/types"; +import grabMessageRefNames from "../bots/grab-message-ref-names"; +import grabMessagesMedia from "../bots/grab-messages-media"; +import grabMessagesReactions from "../bots/grab-messages-reactions"; +import grabMessagesReplies from "../bots/grab-messages-replies"; +import mediaToAttachment from "../bots/media-to-attachment"; +import grabActiveBotSession from "./grab-active-bot-session"; +import grabUserConversation from "./grab-user-conversation"; + +type Params = { + user_id: number | string; + /** + * Who the user messages are from in the copy. + */ + user_name: string; + bot_id: number | string; + /** + * The workspace the conversation is in; none for the bot's own chat. + */ + workspace_id?: number | string | null; +}; + +/** + * The session the conversation page shows (its active one), for "Copy + * chat": every message in it, oldest first, with their attachments, + * quotes and reactions, and the names of the bots and workspaces they + * mention. `grabUserConversation` is the access check. Null when the + * user can't chat with the bot there. + */ +export default async function grabConversationExport({ + user_id, + user_name, + bot_id, + workspace_id, +}: Params): Promise { + const conversation = await grabUserConversation({ user_id, bot_id, workspace_id }).catch( + () => null, + ); + + if (!conversation?.bot?.id) return null; + + const session = await grabActiveBotSession({ + user_id, + bot_id: conversation.bot.id, + workspace_id: conversation.workspace_id, + }); + + const messages = session?.id + ? ( + await BunSQLite.sql({ + sql: `SELECT * FROM bot_messages WHERE session_id = ? ORDER BY created_at ASC, id ASC`, + values: [session.id], + }) + ).payload || [] + : []; + + const message_ids = messages.map((message) => Number(message.id)); + + const [media, replies, reactions, names] = await Promise.all([ + grabMessagesMedia({ message_ids }), + grabMessagesReplies({ message_ids }), + grabMessagesReactions({ message_ids, user_id }), + grabMessageRefNames({ + texts: messages.map((message) => message.text || ""), + user_id, + }), + ]); + + return { + bot_name: conversation.bot.name || "Bot", + workspace_name: conversation.workspace?.name || null, + user_name, + session: session?.id + ? { + id: Number(session.id), + title: session.title || null, + created_at: Number(session.created_at), + } + : null, + messages: messages.map((message) => { + const id = Number(message.id); + const files = media.get(id); + + return { + ...message, + attachments: files?.map((file) => mediaToAttachment({ media: file })), + reply_to: replies.get(id) || null, + reactions: reactions.get(id) || [], + }; + }), + ref_names: { + bots: Object.fromEntries( + [...names.bots].map(([id, bot]) => [id, bot.name]), + ), + workspaces: Object.fromEntries( + [...names.workspaces].map(([id, workspace]) => [id, workspace.name]), + ), + }, + }; +} diff --git a/src/pages/admin/bots/[bot_id]/(functions)/fetch-conversation-export.ts b/src/pages/admin/bots/[bot_id]/(functions)/fetch-conversation-export.ts new file mode 100644 index 0000000..8e47b22 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(functions)/fetch-conversation-export.ts @@ -0,0 +1,20 @@ +import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import type { BotConversationRequest } from "@/src/schemas/bot-schema"; +import type { ConversationExport } from "@/src/types"; + +/** + * The conversation's active session, for "Copy chat". + */ +export default async function fetchConversationExport({ + bot_id, + workspace_id, +}: BotConversationRequest) { + return await fetchApi< + BotConversationRequest, + APIResponseObject + >(`/api/admin/bots/conversation-export`, { + method: "POST", + body: { bot_id, workspace_id }, + }); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx index e9d40f5..e3d17f2 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx @@ -17,6 +17,7 @@ import BotModelsModal from "./bot-models-modal"; import BotAdminMenuItem from "./bot-admin-menu-item"; import HeaderMenuItem from "./header-menu-item"; import NewSessionButton from "./new-session-button"; +import CopyChatMenuItem from "./copy-chat-menu-item"; import StopBotButton from "./stop-bot-button"; type Props = { @@ -54,8 +55,8 @@ type Props = { }; /** - * The conversation header's menu: the sessions, a new session, the bot's - * computer, its models, in a workspace making it the admin there, its + * The conversation header's menu: the sessions, a new session, copying + * the session, the bot's computer, its models, in a workspace making it the admin there, its * settings and, while it works, stopping it. A * row that opens a modal closes the menu first, so the modal never sits * behind an open menu. @@ -117,6 +118,11 @@ export default function ConversationHeaderMenu({ workspace_id={workspace?.id || null} on_open={on_open} /> + void; +}; + +/** + * The header menu's "Copy chat": puts the session on screen on the + * clipboard as one markdown document (`conversationMarkdown`). The row shows a check for a moment, then the + * menu closes. + */ +export default function CopyChatMenuItem({ bot_id, workspace_id, on_open }: Props) { + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + const timeout = useRef | null>(null); + + useEffect(() => { + return () => { + if (timeout.current) clearTimeout(timeout.current); + }; + }, []); + + const copy = async () => { + setLoading(true); + + const markdown = fetchConversationExport({ bot_id, workspace_id }).then((res) => { + if (!res?.success || !res.singleRes) { + throw new Error(res?.msg || "Couldn't load the chat."); + } + + return conversationMarkdown({ conversation: res.singleRes, now: Date.now() }); + }); + + try { + const worked = await copyPendingText({ text: markdown }); + + setLoading(false); + + if (!worked) { + window.alert("Couldn't copy the chat to the clipboard."); + return; + } + + setCopied(true); + timeout.current = setTimeout(() => { + setCopied(false); + on_open?.(); + }, AppData.CopyConfirmationMs); + } catch (error: unknown) { + setLoading(false); + window.alert(error instanceof Error ? error.message : "Couldn't copy the chat."); + } + }; + + return ( + { + if (!copied) void copy(); + }} + /> + ); +} diff --git a/src/pages/api/admin/bots/conversation-export.ts b/src/pages/api/admin/bots/conversation-export.ts new file mode 100644 index 0000000..127e70f --- /dev/null +++ b/src/pages/api/admin/bots/conversation-export.ts @@ -0,0 +1,56 @@ +import userAuth from "@/src/functions/backend/auth/user-auth"; +import grabConversationExport from "@/src/functions/backend/db/bot-sessions/grab-conversation-export"; +import { BotConversationRequestSchema } from "@/src/schemas/bot-schema"; +import type { ConversationExport } from "@/src/types"; +import AppError from "@/src/utils/app-error"; +import parseWithSchema from "@/src/utils/parse-with-schema"; +import safeErrorMessage from "@/src/utils/safe-error-message"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; + +/** + * The active session of the session user's conversation with a bot, which + * the header menu's "Copy chat" turns into markdown. + */ +export const handler: BunextAPIRouteHandler< + APIResponseObject +> = async ({ req, body }) => { + try { + if (req.method != "POST") { + throw new AppError(`Method not allowed`); + } + + const { user } = await userAuth({ req }); + + if (!user?.logged_in_status || !user.id) { + return { success: false, msg: `Unauthorized`, logoutUser: true }; + } + + const { bot_id, workspace_id } = parseWithSchema({ + schema: BotConversationRequestSchema, + data: body, + }); + + const user_name = + [user.first_name, user.last_name].filter(Boolean).join(" ") || + user.username || + "You"; + + const conversation = await grabConversationExport({ + user_id: Number(user.id), + user_name, + bot_id, + workspace_id, + }); + + if (!conversation) { + throw new AppError(`You can't open this conversation.`); + } + + return { success: true, singleRes: conversation }; + } catch (error: unknown) { + return { success: false, msg: safeErrorMessage({ error }) }; + } +}; diff --git a/src/types/index.ts b/src/types/index.ts index b0a1e18..4a10752 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1326,6 +1326,38 @@ export type ConversationHistory = { * where to go instead when the user can't chat with the bot there any * more. */ +/** + * A conversation's active session for "Copy chat" + * (`grabConversationExport`): its messages, oldest first, and the names of + * the bots and workspaces they mention. + */ +export type ConversationExport = { + bot_name: string; + workspace_name: string | null; + /** + * Who the user messages are from. + */ + user_name: string; + /** + * Null while the conversation has no session yet. + */ + session: ConversationExportSession | null; + messages: BotMessageWithAttachments[]; + /** + * Names by id, only for what the user reaches. + */ + ref_names: { + bots: Record; + workspaces: Record; + }; +}; + +export type ConversationExportSession = { + id: number; + title: string | null; + created_at: number; +}; + export type ConversationHistoryPayload = { history?: ConversationHistory; redirect?: string; diff --git a/src/utils/conversation-markdown.test.ts b/src/utils/conversation-markdown.test.ts new file mode 100644 index 0000000..92c4217 --- /dev/null +++ b/src/utils/conversation-markdown.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationExport } from "@/src/types"; +import conversationMarkdown from "./conversation-markdown"; + +const format_time = (time: number) => `t${time}`; + +const conversation: ConversationExport = { + bot_name: "Ada", + workspace_name: "Launch", + user_name: "Ben", + session: { id: 1, title: "Kickoff", created_at: 10 }, + messages: [ + { id: 1, session_id: 1, author: "user", text: "Hi [bot:3]", created_at: 11 }, + { + id: 2, + session_id: 1, + author: "bot", + text: "## Plan\n\nDone.", + created_at: 12, + attachments: [{ id: 5, name: "plan.pdf", media_type: "document", url: "" }], + reactions: [{ emoji: "👍", count: 2, mine: true }], + }, + { + id: 3, + session_id: 1, + author: "user", + text: "Thanks", + created_at: 13, + reply_to: { id: 2, author: "bot", text: "## Plan\n\nDone." }, + }, + ], + ref_names: { bots: { 3: "Ada" }, workspaces: {} }, +}; + +describe("conversationMarkdown", () => { + test("lays out the session's messages", () => { + expect(conversationMarkdown({ conversation, now: 100, format_time })).toBe( + [ + "# Kickoff", + "", + "- **Bot:** Ada", + "- **Workspace:** Launch", + "- **Participants:** Ben, Ada", + "- **Started:** t10", + "- **Messages:** 3", + "- **Copied:** t100", + "", + "---", + "", + "## Ben · t11", + "", + "Hi @Ada", + "", + "## Ada · t12", + "", + "#### Plan", + "", + "Done.", + "", + "*Attached: plan.pdf*", + "", + "*Reactions: 👍 2*", + "", + "## Ben · t13", + "", + "> Replying to Ada: Plan Done.", + "", + "Thanks", + "", + ].join("\n"), + ); + }); + + test("titles an untitled or empty session after the bot", () => { + const markdown = conversationMarkdown({ + conversation: { ...conversation, session: null, messages: [] }, + now: 1, + format_time, + }); + + expect(markdown).toStartWith("# Chat with Ada\n"); + expect(markdown).toContain("*No messages yet.*"); + expect(markdown).not.toContain("**Started:**"); + }); +}); diff --git a/src/utils/conversation-markdown.ts b/src/utils/conversation-markdown.ts new file mode 100644 index 0000000..ba9021f --- /dev/null +++ b/src/utils/conversation-markdown.ts @@ -0,0 +1,105 @@ +import type { BotMessageWithAttachments, ConversationExport } from "@/src/types"; +import exportMessageText from "./export-message-text"; +import messagePreview from "./message-preview"; + +type Params = { + conversation: ConversationExport; + /** + * When the copy was made. + */ + now: number; + /** + * A timestamp as the reader sees it; local time by default. + */ + format_time?: (time: number) => string; +}; + +/** + * Longest a quoted message runs in a reply's "Replying to" line. + */ +const QuoteMaxChars = 120; + +/** + * A conversation's session (`grabConversationExport`) as one markdown + * document: a title and summary, then every message under its author and + * time. Messages' own headings drop below those (`exportMessageText`) and + * their refs read as names. + */ +export default function conversationMarkdown({ + conversation, + now, + format_time = (time) => + new Date(time).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }), +}: Params) { + const { bot_name, workspace_name, user_name, session, messages, ref_names } = conversation; + + const authorName = (author?: string) => (author == "bot" ? bot_name : user_name); + + const messageBlock = (message: BotMessageWithAttachments) => { + const lines = [`## ${authorName(message.author)} · ${format_time(Number(message.created_at))}`, ""]; + + if (message.reply_to) { + const quote = messagePreview({ text: message.reply_to.text || "" }); + + lines.push( + `> Replying to ${authorName(message.reply_to.author)}: ${ + quote.length > QuoteMaxChars ? `${quote.slice(0, QuoteMaxChars).trimEnd()}...` : quote + }`, + "", + ); + } + + const text = exportMessageText({ + text: message.text || "", + bot_names: ref_names.bots, + workspace_names: ref_names.workspaces, + heading_offset: 2, + }); + + if (text) lines.push(text, ""); + + if (message.attachments?.length) { + lines.push(`*Attached: ${message.attachments.map((file) => file.name).join(", ")}*`, ""); + } + + if (message.reactions?.length) { + lines.push( + `*Reactions: ${message.reactions + .map((reaction) => (reaction.count > 1 ? `${reaction.emoji} ${reaction.count}` : reaction.emoji)) + .join(" ")}*`, + "", + ); + } + + return lines.join("\n"); + }; + + const out: string[] = [ + `# ${session?.title?.trim() || `Chat with ${bot_name}`}`, + "", + `- **Bot:** ${bot_name}`, + ]; + + if (workspace_name) out.push(`- **Workspace:** ${workspace_name}`); + + out.push(`- **Participants:** ${user_name}, ${bot_name}`); + + if (session) out.push(`- **Started:** ${format_time(session.created_at)}`); + + out.push( + `- **Messages:** ${messages.length}`, + `- **Copied:** ${format_time(now)}`, + "", + "---", + "", + ); + + if (!messages.length) out.push("*No messages yet.*", ""); + + out.push(...messages.map(messageBlock)); + + return `${out.join("\n").trimEnd()}\n`; +} diff --git a/src/utils/copy-pending-text.ts b/src/utils/copy-pending-text.ts new file mode 100644 index 0000000..ebe4255 --- /dev/null +++ b/src/utils/copy-pending-text.ts @@ -0,0 +1,33 @@ +import copyText from "./copy-text"; + +type Params = { + /** + * The text, still on its way (e.g. from a fetch). + */ + text: Promise; +}; + +/** + * Copies text that is still loading and says whether it worked. Safari + * only lets a click write to the clipboard while it is still handling + * the click, so the write starts at once with a `ClipboardItem` that + * waits for the text. Where that isn't supported it waits and falls back + * to `copyText`. Throws when `text` does. + */ +export default async function copyPendingText({ text }: Params): Promise { + if (typeof ClipboardItem != "undefined" && navigator.clipboard?.write) { + try { + await navigator.clipboard.write([ + new ClipboardItem({ + "text/plain": text.then((value) => new Blob([value], { type: "text/plain" })), + }), + ]); + + return true; + } catch { + // Fall through: `text` failing rethrows below. + } + } + + return copyText({ text: await text }); +} diff --git a/src/utils/export-message-text.test.ts b/src/utils/export-message-text.test.ts new file mode 100644 index 0000000..adb1219 --- /dev/null +++ b/src/utils/export-message-text.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import exportMessageText from "./export-message-text"; + +const names = { bot_names: { 3: "Ada" }, workspace_names: { 2: "Launch" } }; + +describe("exportMessageText", () => { + test("words refs, naming what is known", () => { + expect( + exportMessageText({ + text: "[bot:3] and [bot:9] in [workspace:2] on [computer:4], see [file:12] and [folder:computer:4:seo/raw]. [take-over:4:browser]", + ...names, + heading_offset: 0, + }), + ).toBe("@Ada and a bot in #Launch on computer 4, see file 12 and `seo/raw`. (take over computer 4, browser)"); + }); + + test("leaves refs that are already links alone", () => { + expect(exportMessageText({ text: "[bot:3](#x)", ...names, heading_offset: 0 })).toBe("[bot:3](#x)"); + }); + + test("drops headings, capped at six", () => { + expect( + exportMessageText({ text: "# Top\n## Next\n##### Deep\n#nottag", ...names, heading_offset: 3 }), + ).toBe("#### Top\n##### Next\n###### Deep\n#nottag"); + }); + + test("leaves code blocks exactly as they are", () => { + const text = "```bash\n# comment [bot:3]\n```\n# Title\n~~~~\n## also code\n~~~\n~~~~"; + + expect(exportMessageText({ text, ...names, heading_offset: 3 })).toBe( + "```bash\n# comment [bot:3]\n```\n#### Title\n~~~~\n## also code\n~~~\n~~~~", + ); + }); +}); diff --git a/src/utils/export-message-text.ts b/src/utils/export-message-text.ts new file mode 100644 index 0000000..2b92ccd --- /dev/null +++ b/src/utils/export-message-text.ts @@ -0,0 +1,87 @@ +type Params = { + text: string; + /** + * Names by id; a ref to anything missing reads "a bot" / "a workspace". + */ + bot_names: Record; + workspace_names: Record; + /** + * How many levels the message's own headings drop, so they sit under + * the heading of the message they are in. Capped at `######`. + */ + heading_offset: number; +}; + +/** + * A message's markdown as it reads outside the app: its refs in words + * (`[bot:3]` as `@Ada`, `[workspace:2]` as `#Launch`, `[computer:3]` as + * "computer 3", a folder as its path) and its headings dropped by + * `heading_offset`. Code blocks are left exactly as they are. + */ +export default function exportMessageText({ + text, + bot_names, + workspace_names, + heading_offset, +}: Params) { + let fence: string | null = null; + + return text + .split("\n") + .map((line) => { + const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/)?.[1]; + + if (fence) { + if (marker && marker[0] == fence[0] && marker.length >= fence.length) { + fence = null; + } + + return line; + } + + if (marker) { + fence = marker; + return line; + } + + return wordRefs({ line, bot_names, workspace_names }).replace( + /^(\s{0,3})(#{1,6})(?=\s|$)/, + (_, indent: string, hashes: string) => + `${indent}${"#".repeat(Math.min(6, hashes.length + heading_offset))}`, + ); + }) + .join("\n") + .trim(); +} + +/** + * One line's refs in words. Refs that are already markdown links + * (`[bot:3](...)`) are left alone, as the chat leaves them. + */ +function wordRefs({ + line, + bot_names, + workspace_names, +}: { + line: string; + bot_names: Record; + workspace_names: Record; +}) { + return line + .replace(/\[bot:(\d+)\](?!\()/g, (_, id: string) => { + const name = bot_names[Number(id)]; + + return name ? `@${name}` : "a bot"; + }) + .replace(/\[workspace:(\d+)\](?!\()/g, (_, id: string) => { + const name = workspace_names[Number(id)]; + + return name ? `#${name}` : "a workspace"; + }) + .replace(/\[computer:(\d+)\](?!\()/g, "computer $1") + .replace(/\[take-over:(\d+):([a-z]+)\](?!\()/g, "(take over computer $1, $2)") + .replace(/\[proposal:(\d+)\](?!\()/g, "proposal $1") + .replace(/\[file:(\d+)\](?!\()/g, "file $1") + .replace(/\[folder:[a-z]+:\d+:([^\]\n]+)\](?!\()/g, "`$1`") + .replace(/\[folder:([a-z]+):(\d+)\](?!\()/g, "the $1 $2 folder"); +}