Keep file refs working in answers passed on from chatrooms
A ref quoted alone in a code span now links like any ref, and refs inside longer code stay literal instead of turning into broken link text. A file chip opens a file from another bot's folder for a user who reaches that folder, by the same rule as its bytes, so the asked bot's files open from the asking bot's conversation. Bots are told to keep refs out of backticks, and the coordinator to pass them on as given. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -94,7 +94,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
|
|||||||
- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due the page polls `session-messages` (`use-reply-catch-up`) in case a push was missed.
|
- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due the page polls `session-messages` (`use-reply-catch-up`) in case a push was missed.
|
||||||
- **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.
|
- **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`).
|
- **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:<volume>:<owner>:<path>]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card), `[graphic:ID]` (a bot graphic, framed inline), `[room:ID]` (a chatroom). Parsers in `src/utils/*-ref.ts`.
|
- **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:<volume>:<owner>:<path>]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card), `[graphic:ID]` (a bot graphic, framed inline), `[room:ID]` (a chatroom). Parsers in `src/utils/*-ref.ts`. A ref quoted alone in a code span still links; one inside longer code stays literal (`linkComputerRefs` splits out code first). A file chip opens a file from another bot's folder for a user who reaches that folder (`userReachesFileScope`, the same rule as its bytes), so answers passed on from chatrooms keep working.
|
||||||
- **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.
|
- **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`.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ You have a folder of your own, kept with the app (not on a computer), and it sta
|
|||||||
- A file you saved is a [file:ID] ref (from write_file, list_files, search_files or import_file). The user sees a chip that opens the file.
|
- A file you saved is a [file:ID] ref (from write_file, list_files, search_files or import_file). The user sees a chip that opens the file.
|
||||||
- A folder is a folder ref, e.g. ${ownFolderRef({ bot_id })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one).
|
- A folder is a folder ref, e.g. ${ownFolderRef({ bot_id })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one).
|
||||||
- Never write a path as plain text and expect the user to find it; never write a [file:ID] ref for something that is a folder.
|
- Never write a path as plain text and expect the user to find it; never write a [file:ID] ref for something that is a folder.
|
||||||
|
- Write refs as they are, never in backticks or a code block: inside code they show as plain text, not a chip.
|
||||||
- A file you only described is not saved. Save it first, then say it's saved.
|
- A file you only described is not saved. Save it first, then say it's saved.
|
||||||
- Save what the user will want before the long tail of a run. A step limit ends a run where it stands: write the report first with write_file (or copy it off the computer with import_file), then carry on verifying.
|
- Save what the user will want before the long tail of a run. A step limit ends a run where it stands: write the report first with write_file (or copy it off the computer with import_file), then carry on verifying.
|
||||||
- One copy of a thing is enough. Before you write or import something, list_files (or search_files) for it: if it's already there, update it (write_file over the same path) instead of saving a second copy under a new name. Never split a file into parts unless the user asked for parts.
|
- One copy of a thing is enough. Before you write or import something, list_files (or search_files) for it: if it's already there, update it (write_file over the same path) instead of saving a second copy under a new name. Never split a file into parts unless the user asked for parts.
|
||||||
@@ -340,7 +341,7 @@ Each turn, decide and act with one of your tools:
|
|||||||
? askable.map((bot) => `${bot.id}: ${bot.name || "A bot"}${bot.role ? ` (${bot.role})` : ""}`).join("; ")
|
? askable.map((bot) => `${bot.id}: ${bot.name || "A bot"}${bot.role ? ` (${bot.role})` : ""}`).join("; ")
|
||||||
: "none"
|
: "none"
|
||||||
}. Their work comes back to you in the room, and you get another turn.
|
}. Their work comes back to you in the room, and you get another turn.
|
||||||
- answer_requester: once you have what was asked for, send it back. Write the answer itself, complete and in markdown, not a pointer to the room.
|
- answer_requester: once you have what was asked for, send it back. Write the answer itself, complete and in markdown, not a pointer to the room. Keep every ref the bot gave ([file:ID], [bot:ID], ...) exactly as written and outside backticks, so the asking bot and the user can open what it made.
|
||||||
- refuse_request: when no one here can answer it, or it shouldn't be done (it asks for something outside what the user would want, or reaches beyond the bots above). Say why.
|
- refuse_request: when no one here can answer it, or it shouldn't be done (it asks for something outside what the user would want, or reaches beyond the bots above). Say why.
|
||||||
|
|
||||||
Ask one bot at a time and only ask again when the answer falls short. You have ${turns_left} turn${turns_left == 1 ? "" : "s"} left in this room; on the last one, answer with what you have or refuse. Your own words in the room are short notes on what you're doing.`;
|
Ask one bot at a time and only ask again when the answer falls short. You have ${turns_left} turn${turns_left == 1 ? "" : "s"} left in this room; on the last one, answer with what you have or refuse. Your own words in the room are short notes on what you're doing.`;
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import type {
|
|||||||
BUN_SQLITE_COBBLE_BOT_FILES,
|
BUN_SQLITE_COBBLE_BOT_FILES,
|
||||||
BUN_SQLITE_COBBLE_MEDIA,
|
BUN_SQLITE_COBBLE_MEDIA,
|
||||||
} from "@/db/types/db";
|
} from "@/db/types/db";
|
||||||
import grabCrewRole from "../access/grab-crew-role";
|
import userReachesFileScope from "./user-reaches-file-scope";
|
||||||
import grabUserBot from "../bots/grab-user-bot";
|
|
||||||
import grabUserWorkspace from "../workspaces/grab-user-workspace";
|
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
media_id: number | string;
|
media_id: number | string;
|
||||||
@@ -32,20 +30,7 @@ export default async function grabBotFileMedia({
|
|||||||
|
|
||||||
if (!file?.id) return null;
|
if (!file?.id) return null;
|
||||||
|
|
||||||
const role = await grabCrewRole({ user_id });
|
if (!(await userReachesFileScope({ scope: file.scope, owner_id: file.owner_id, user_id }))) return null;
|
||||||
const reaches =
|
|
||||||
file.scope == "workspace"
|
|
||||||
? Boolean(
|
|
||||||
await grabUserWorkspace({
|
|
||||||
workspace_id: file.owner_id!,
|
|
||||||
user_id,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: role == "super_admin"
|
|
||||||
? true
|
|
||||||
: Boolean(await grabUserBot({ bot_id: file.owner_id!, user_id }));
|
|
||||||
|
|
||||||
if (!reaches) return null;
|
|
||||||
|
|
||||||
const media = await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
|
const media = await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
|
||||||
sql: `SELECT * FROM media WHERE id = ? LIMIT 1`,
|
sql: `SELECT * FROM media WHERE id = ? LIMIT 1`,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
|
||||||
|
import type { StorageScope } from "@/src/utils/storage-access";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
file_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directory a bot file is in, unchecked: callers check the user
|
||||||
|
* reaches it (`userReachesFileScope`) before showing the file.
|
||||||
|
*/
|
||||||
|
export default async function grabBotFileScope({ file_id }: Params): Promise<StorageScope | null> {
|
||||||
|
const row = (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
|
||||||
|
sql: `SELECT scope, owner_id FROM bot_files WHERE id = ? LIMIT 1`,
|
||||||
|
values: [file_id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!row?.owner_id || (row.scope != "bot" && row.scope != "workspace")) return null;
|
||||||
|
|
||||||
|
return { scope: row.scope, owner_id: Number(row.owner_id) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import grabCrewRole from "../access/grab-crew-role";
|
||||||
|
import grabUserBot from "../bots/grab-user-bot";
|
||||||
|
import grabUserWorkspace from "../workspaces/grab-user-workspace";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
scope?: string | null;
|
||||||
|
owner_id?: number | string | null;
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a user reaches a bot file's directory: the workspace it's shared
|
||||||
|
* in, or the bot's own (any bot for the super admin, else the bots the user
|
||||||
|
* reaches). Files belong to the install, like the crew, so whoever sees the
|
||||||
|
* bot sees what it saved.
|
||||||
|
*/
|
||||||
|
export default async function userReachesFileScope({ scope, owner_id, user_id }: Params) {
|
||||||
|
if (!owner_id) return false;
|
||||||
|
|
||||||
|
if (scope == "workspace") {
|
||||||
|
return Boolean(await grabUserWorkspace({ workspace_id: owner_id, user_id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((await grabCrewRole({ user_id })) == "super_admin") return true;
|
||||||
|
|
||||||
|
return Boolean(await grabUserBot({ bot_id: owner_id, user_id }));
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file";
|
import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file";
|
||||||
|
import grabBotFileScope from "@/src/functions/backend/db/bot-files/grab-bot-file-scope";
|
||||||
|
import userReachesFileScope from "@/src/functions/backend/db/bot-files/user-reaches-file-scope";
|
||||||
import grabBotFileQuotaBytes, {
|
import grabBotFileQuotaBytes, {
|
||||||
grabBotFileQuotaMb,
|
grabBotFileQuotaMb,
|
||||||
} from "@/src/functions/backend/db/bot-files/grab-bot-file-quota-bytes";
|
} from "@/src/functions/backend/db/bot-files/grab-bot-file-quota-bytes";
|
||||||
@@ -66,7 +68,20 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BotFileScopesPaylo
|
|||||||
scope.scope == "workspace" && bot_id && (await grabUserBot({ bot_id, user_id: user.id }))
|
scope.scope == "workspace" && bot_id && (await grabUserBot({ bot_id, user_id: user.id }))
|
||||||
? [...scopes, { scope: "bot" as const, owner_id: bot_id }]
|
? [...scopes, { scope: "bot" as const, owner_id: bot_id }]
|
||||||
: scopes;
|
: scopes;
|
||||||
const file = await grabBotFile({ scopes: file_scopes, file_id });
|
/**
|
||||||
|
* A file from another bot's folder (an answer passed on from a
|
||||||
|
* chatroom names the asked bot's file) opens for a user who
|
||||||
|
* reaches that folder, as its bytes do (`grabBotFileMedia`).
|
||||||
|
*/
|
||||||
|
let file = await grabBotFile({ scopes: file_scopes, file_id });
|
||||||
|
|
||||||
|
if (!file?.id) {
|
||||||
|
const elsewhere = await grabBotFileScope({ file_id });
|
||||||
|
|
||||||
|
if (elsewhere && (await userReachesFileScope({ ...elsewhere, user_id: user.id }))) {
|
||||||
|
file = await grabBotFile({ scopes: [elsewhere], file_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!file?.id) return { success: false, msg: `Not found.` };
|
if (!file?.id) return { success: false, msg: `Not found.` };
|
||||||
|
|
||||||
|
|||||||
@@ -66,3 +66,17 @@ describe("linkComputerRefs", () => {
|
|||||||
).toBe("Added to [workspace 2](#workspace-2). Not [workspace:x] or [workspace:3](https://example.com).");
|
).toBe("Added to [workspace 2](#workspace-2). Not [workspace:x] or [workspace:3](https://example.com).");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("linkComputerRefs and code", () => {
|
||||||
|
test("a ref quoted alone in a code span is the ref", () => {
|
||||||
|
expect(linkComputerRefs({ text: "saved as `[file:131]` today" })).toBe("saved as [file 131](#file-131) today");
|
||||||
|
expect(linkComputerRefs({ text: "see `` [room:4] ``" })).toBe("see [room 4](#room-4)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refs inside longer code stay as written", () => {
|
||||||
|
expect(linkComputerRefs({ text: "write `[file:3] here` like so" })).toBe("write `[file:3] here` like so");
|
||||||
|
expect(linkComputerRefs({ text: "```\n[bot:2]\n```\nthen [bot:2]" })).toBe(
|
||||||
|
"```\n[bot:2]\n```\nthen [bot 2](#bot-2)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ChatroomRefHrefPrefix } from "./chatroom-ref";
|
import linkRefsInText from "./link-refs-in-text";
|
||||||
import { GraphicRefHrefPrefix } from "./graphic-ref";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The link target a computer ref (`computerRef`) becomes, e.g.
|
* The link target a computer ref (`computerRef`) becomes, e.g.
|
||||||
@@ -68,56 +67,33 @@ export const SecretRefHrefPrefix = "#secret-ref-";
|
|||||||
export const SecretRequestRefHrefPrefix = "#secret-request-";
|
export const SecretRequestRefHrefPrefix = "#secret-request-";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns every computer ref (`[computer:3]`) and take-over ref
|
* The kinds of ref a message can carry, as written before the colon.
|
||||||
* (`[take-over:3:browser]`), bot ref (`[bot:3]`), workspace ref
|
*/
|
||||||
* (`[workspace:2]`), file ref (`[file:12]`), folder ref
|
const RefKinds = "computer|take-over|bot|workspace|proposal|hook-proposal|secret|secret-request|graphic|room|file|folder";
|
||||||
* (`[folder:computer:3:seo/raw]`), proposal ref (`[proposal:7]`) and hook
|
|
||||||
* proposal ref (`[hook-proposal:4]`), secret ref (`[secret:12]`) and secret
|
/**
|
||||||
* request ref (`[secret-request:4]`), graphic ref (`[graphic:5]`) and
|
* A code span holding nothing but one ref (`` `[file:12]` ``): bots often
|
||||||
* chatroom ref (`[room:<id>]`) in a
|
* quote refs that way, and it still means the ref.
|
||||||
* message into a markdown link, so it
|
*/
|
||||||
* survives markdown rendering as a link the chat can swap for a button,
|
const LoneRefInCode = new RegExp(`(?<!\`)(\`{1,2})[ \\t]*(\\[(?:${RefKinds}):[^\\]\`\\n]+\\])[ \\t]*\\1(?!\`)`, "g");
|
||||||
* chip or download.
|
|
||||||
*
|
/**
|
||||||
* A file ref and a folder ref are different things and are never guessed
|
* Fenced code blocks (closed or still open at the end) and inline code
|
||||||
* from a name: `[file:12]` is a saved file (its chip downloads it),
|
* spans, kept apart so refs inside them stay as written.
|
||||||
* `[folder:...]` a folder (its chip opens the files browser there).
|
*/
|
||||||
|
const CodeParts = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]+`)/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the refs in a message into markdown links the chat swaps for
|
||||||
|
* buttons, chips and cards (`linkRefsInText`). A ref quoted alone in a code
|
||||||
|
* span counts as the ref; one inside longer code (a span with other text, a
|
||||||
|
* fenced block) stays literal text, since a link there would only show as
|
||||||
|
* markdown.
|
||||||
*/
|
*/
|
||||||
export default function linkComputerRefs({ text }: { text: string }) {
|
export default function linkComputerRefs({ text }: { text: string }) {
|
||||||
return text
|
return text
|
||||||
.replace(
|
.replace(LoneRefInCode, (_, _ticks: string, ref: string) => ref)
|
||||||
/\[computer:(\d+)\](?!\()/g,
|
.split(CodeParts)
|
||||||
(_, id: string) => `[computer ${id}](${ComputerRefHrefPrefix}${id})`,
|
.map((part, index) => (index % 2 ? part : linkRefsInText({ text: part })))
|
||||||
)
|
.join("");
|
||||||
.replace(
|
|
||||||
/\[take-over:(\d+):([a-z]+)\](?!\()/g,
|
|
||||||
(_, id: string, mode: string) => `[take over](${TakeOverRefHrefPrefix}${id}-${mode})`,
|
|
||||||
)
|
|
||||||
.replace(/\[bot:(\d+)\](?!\()/g, (_, id: string) => `[bot ${id}](${BotRefHrefPrefix}${id})`)
|
|
||||||
.replace(
|
|
||||||
/\[workspace:(\d+)\](?!\()/g,
|
|
||||||
(_, id: string) => `[workspace ${id}](${WorkspaceRefHrefPrefix}${id})`,
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
/\[proposal:(\d+)\](?!\()/g,
|
|
||||||
(_, id: string) => `[proposal ${id}](${ProposalRefHrefPrefix}${id})`,
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
/\[hook-proposal:(\d+)\](?!\()/g,
|
|
||||||
(_, id: string) => `[hook proposal ${id}](${HookProposalRefHrefPrefix}${id})`,
|
|
||||||
)
|
|
||||||
.replace(/\[secret:(\d+)\](?!\()/g, (_, id: string) => `[secret ${id}](${SecretRefHrefPrefix}${id})`)
|
|
||||||
.replace(
|
|
||||||
/\[secret-request:(\d+)\](?!\()/g,
|
|
||||||
(_, id: string) => `[secret request ${id}](${SecretRequestRefHrefPrefix}${id})`,
|
|
||||||
)
|
|
||||||
.replace(/\[graphic:(\d+)\](?!\()/g, (_, id: string) => `[graphic ${id}](${GraphicRefHrefPrefix}${id})`)
|
|
||||||
.replace(/\[room:(\d+)\](?!\()/g, (_, id: string) => `[room ${id}](${ChatroomRefHrefPrefix}${id})`)
|
|
||||||
.replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`)
|
|
||||||
.replace(/\[folder:([a-z]+):(\d+):([^\]\n]+)\](?!\()/g, (_, volume: string, owner: string, path: string) =>
|
|
||||||
`[${path}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}:${path}`)})`,
|
|
||||||
)
|
|
||||||
.replace(/\[folder:([a-z]+):(\d+)\](?!\()/g, (_, volume: string, owner: string) =>
|
|
||||||
`[${volume} ${owner}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}`)})`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { ChatroomRefHrefPrefix } from "./chatroom-ref";
|
||||||
|
import { GraphicRefHrefPrefix } from "./graphic-ref";
|
||||||
|
import {
|
||||||
|
BotRefHrefPrefix,
|
||||||
|
ComputerRefHrefPrefix,
|
||||||
|
FileRefHrefPrefix,
|
||||||
|
FolderRefHrefPrefix,
|
||||||
|
HookProposalRefHrefPrefix,
|
||||||
|
ProposalRefHrefPrefix,
|
||||||
|
SecretRefHrefPrefix,
|
||||||
|
SecretRequestRefHrefPrefix,
|
||||||
|
TakeOverRefHrefPrefix,
|
||||||
|
WorkspaceRefHrefPrefix,
|
||||||
|
} from "./link-computer-refs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns every computer ref (`[computer:3]`) and take-over ref
|
||||||
|
* (`[take-over:3:browser]`), bot ref (`[bot:3]`), workspace ref
|
||||||
|
* (`[workspace:2]`), file ref (`[file:12]`), folder ref
|
||||||
|
* (`[folder:computer:3:seo/raw]`), proposal ref (`[proposal:7]`) and hook
|
||||||
|
* proposal ref (`[hook-proposal:4]`), secret ref (`[secret:12]`) and secret
|
||||||
|
* request ref (`[secret-request:4]`), graphic ref (`[graphic:5]`) and
|
||||||
|
* chatroom ref (`[room:<id>]`) in a
|
||||||
|
* stretch of message text (never code) into a markdown link, so it
|
||||||
|
* survives markdown rendering as a link the chat can swap for a button,
|
||||||
|
* chip or download.
|
||||||
|
*
|
||||||
|
* A file ref and a folder ref are different things and are never guessed
|
||||||
|
* from a name: `[file:12]` is a saved file (its chip downloads it),
|
||||||
|
* `[folder:...]` a folder (its chip opens the files browser there).
|
||||||
|
*/
|
||||||
|
export default function linkRefsInText({ text }: { text: string }) {
|
||||||
|
return text
|
||||||
|
.replace(
|
||||||
|
/\[computer:(\d+)\](?!\()/g,
|
||||||
|
(_, id: string) => `[computer ${id}](${ComputerRefHrefPrefix}${id})`,
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/\[take-over:(\d+):([a-z]+)\](?!\()/g,
|
||||||
|
(_, id: string, mode: string) => `[take over](${TakeOverRefHrefPrefix}${id}-${mode})`,
|
||||||
|
)
|
||||||
|
.replace(/\[bot:(\d+)\](?!\()/g, (_, id: string) => `[bot ${id}](${BotRefHrefPrefix}${id})`)
|
||||||
|
.replace(
|
||||||
|
/\[workspace:(\d+)\](?!\()/g,
|
||||||
|
(_, id: string) => `[workspace ${id}](${WorkspaceRefHrefPrefix}${id})`,
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/\[proposal:(\d+)\](?!\()/g,
|
||||||
|
(_, id: string) => `[proposal ${id}](${ProposalRefHrefPrefix}${id})`,
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/\[hook-proposal:(\d+)\](?!\()/g,
|
||||||
|
(_, id: string) => `[hook proposal ${id}](${HookProposalRefHrefPrefix}${id})`,
|
||||||
|
)
|
||||||
|
.replace(/\[secret:(\d+)\](?!\()/g, (_, id: string) => `[secret ${id}](${SecretRefHrefPrefix}${id})`)
|
||||||
|
.replace(
|
||||||
|
/\[secret-request:(\d+)\](?!\()/g,
|
||||||
|
(_, id: string) => `[secret request ${id}](${SecretRequestRefHrefPrefix}${id})`,
|
||||||
|
)
|
||||||
|
.replace(/\[graphic:(\d+)\](?!\()/g, (_, id: string) => `[graphic ${id}](${GraphicRefHrefPrefix}${id})`)
|
||||||
|
.replace(/\[room:(\d+)\](?!\()/g, (_, id: string) => `[room ${id}](${ChatroomRefHrefPrefix}${id})`)
|
||||||
|
.replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`)
|
||||||
|
.replace(/\[folder:([a-z]+):(\d+):([^\]\n]+)\](?!\()/g, (_, volume: string, owner: string, path: string) =>
|
||||||
|
`[${path}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}:${path}`)})`,
|
||||||
|
)
|
||||||
|
.replace(/\[folder:([a-z]+):(\d+)\](?!\()/g, (_, volume: string, owner: string) =>
|
||||||
|
`[${volume} ${owner}](${FolderRefHrefPrefix}${encodeURIComponent(`${volume}:${owner}`)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user