| 1 | import { DEFAULT_HOSTED_APP_URL } from "./connectAuth.ts"; |
| 2 | |
| 3 | /** |
| 4 | * Prompt links open T3 Code from outside it with a prompt ready to send. They never send it: |
| 5 | * a link from a stranger fills a draft, and the user reads it and presses Send. A link also |
| 6 | * cannot pick a runtime mode, branch, or worktree. Those stay the user's own defaults. |
| 7 | * |
| 8 | * Shared links are https on the hosted app, because that is what Twitter and GitHub make |
| 9 | * clickable. The `t3code://` scheme carries the same paths into desktop and mobile: |
| 10 | * |
| 11 | * https://app.t3.codes/new?prompt=…&repo=owner/name&provider=codex&model=gpt-5 |
| 12 | * https://app.t3.codes/new?pr=owner/name/123&prompt=… |
| 13 | * https://app.t3.codes/<environmentId>/<threadId>?prompt=… |
| 14 | * t3code://new?… t3code://threads/<environmentId>/<threadId>?… |
| 15 | */ |
| 16 | |
| 17 | /** URLs stay well under the limits browsers and link shorteners apply. */ |
| 18 | export const PROMPT_LINK_MAX_PROMPT_LENGTH = 8_000; |
| 19 | |
| 20 | export interface PromptLinkRepository { |
| 21 | readonly owner: string; |
| 22 | readonly name: string; |
| 23 | } |
| 24 | |
| 25 | export interface PromptLinkPullRequest extends PromptLinkRepository { |
| 26 | readonly number: number; |
| 27 | } |
| 28 | |
| 29 | /** A prompt as it arrived, after hidden characters were removed. */ |
| 30 | export interface LinkedPrompt { |
| 31 | readonly text: string; |
| 32 | /** How many invisible characters were removed. Any count above zero is a warning sign. */ |
| 33 | readonly hiddenCharacters: number; |
| 34 | } |
| 35 | |
| 36 | export type NewThreadLink = |
| 37 | | { |
| 38 | readonly kind: "new"; |
| 39 | readonly prompt: LinkedPrompt; |
| 40 | readonly repository: PromptLinkRepository | null; |
| 41 | /** A provider driver kind such as `codex`. Users map it to one of their own instances. */ |
| 42 | readonly provider: string | null; |
| 43 | readonly model: string | null; |
| 44 | } |
| 45 | | { |
| 46 | readonly kind: "pull-request"; |
| 47 | readonly pullRequest: PromptLinkPullRequest; |
| 48 | readonly prompt: LinkedPrompt | null; |
| 49 | }; |
| 50 | |
| 51 | export type PromptLinkResult<T> = |
| 52 | | { readonly ok: true; readonly value: T } |
| 53 | | { readonly ok: false; readonly error: string }; |
| 54 | |
| 55 | // Zero-width and bidi controls, tag and variation-selector blocks, and other characters that |
| 56 | // render as nothing. A model reads them; the person checking the draft cannot see them, which |
| 57 | // makes them the usual way to hide instructions in a prompt. U+FE0E/U+FE0F stay: emoji use them. |
| 58 | // Line and paragraph separators become plain newlines, so the draft shows the break the model reads. |
| 59 | const HIDDEN_CHARACTERS = |
| 60 | // oxlint-disable-next-line no-control-regex, no-misleading-character-class -- these are the characters it removes. |
| 61 | /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u200B\u200C\u200E\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0D\uFEFF\uFFA0\uFFF0-\uFFFB\u{1BCA0}-\u{1BCA3}\u{1D173}-\u{1D17A}\u{E0000}-\u{E0FFF}]/gu; |
| 62 | // Emoji and the pieces emoji sequences are built from: pictographs, symbols, skin tones, and the |
| 63 | // emoji presentation selector. Numeric ranges, because Hermes lacks Unicode property escapes. |
| 64 | const isEmojiPart = (codePoint: number | undefined) => |
| 65 | codePoint !== undefined && |
| 66 | ((codePoint >= 0x1f000 && codePoint <= 0x1faff) || |
| 67 | (codePoint >= 0x2190 && codePoint <= 0x2bff) || |
| 68 | codePoint === 0xfe0f); |
| 69 | |
| 70 | /** Removes characters a reader cannot see, and counts them. */ |
| 71 | export function sanitizeLinkedPrompt(raw: string): LinkedPrompt { |
| 72 | let hiddenCharacters = 0; |
| 73 | const visible = Array.from( |
| 74 | raw.replace(/\r\n?|[\u2028\u2029]/g, "\n").replace(HIDDEN_CHARACTERS, () => { |
| 75 | hiddenCharacters++; |
| 76 | return ""; |
| 77 | }), |
| 78 | ); |
| 79 | // A zero-width joiner is visible work only inside an emoji sequence, such as a family. |
| 80 | const text = visible |
| 81 | .filter((character, index) => { |
| 82 | if (character !== "\u200D") return true; |
| 83 | const kept = |
| 84 | isEmojiPart(visible[index - 1]?.codePointAt(0)) && |
| 85 | isEmojiPart(visible[index + 1]?.codePointAt(0)); |
| 86 | if (!kept) hiddenCharacters++; |
| 87 | return kept; |
| 88 | }) |
| 89 | .join(""); |
| 90 | return { text: text.trim(), hiddenCharacters }; |
| 91 | } |
| 92 | |
| 93 | const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; |
| 94 | const NAME = /^[A-Za-z0-9._-]{1,100}$/; |
| 95 | |
| 96 | function repositoryOf(owner: string, name: string): PromptLinkRepository | null { |
| 97 | const trimmedName = name.replace(/\.git$/i, ""); |
| 98 | if (!OWNER.test(owner) || !NAME.test(trimmedName) || /^\.+$/.test(trimmedName)) return null; |
| 99 | return { owner, name: trimmedName }; |
| 100 | } |
| 101 | |
| 102 | /** `owner/name`, or a GitHub repository URL. */ |
| 103 | export function parsePromptLinkRepository(value: string): PromptLinkRepository | null { |
| 104 | const trimmed = value.trim(); |
| 105 | const url = /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/?$/i.exec(trimmed); |
| 106 | const short = /^([^/\s]+)\/([^/\s]+)$/.exec(trimmed); |
| 107 | const match = url ?? short; |
| 108 | return match?.[1] && match[2] ? repositoryOf(match[1], match[2]) : null; |
| 109 | } |
| 110 | |
| 111 | /** `owner/name/123`, `owner/name#123`, or a GitHub pull request URL. */ |
| 112 | function parsePromptLinkPullRequest(value: string): PromptLinkPullRequest | null { |
| 113 | const trimmed = value.trim(); |
| 114 | const match = |
| 115 | /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec( |
| 116 | trimmed, |
| 117 | ) ?? /^([^/\s#]+)\/([^/\s#]+)(?:\/|#)(\d+)$/.exec(trimmed); |
| 118 | if (!match?.[1] || !match[2] || !match[3]) return null; |
| 119 | const repository = repositoryOf(match[1], match[2]); |
| 120 | const number = Number(match[3]); |
| 121 | return repository && Number.isSafeInteger(number) && number > 0 |
| 122 | ? { ...repository, number } |
| 123 | : null; |
| 124 | } |
| 125 | |
| 126 | /** The pull request's page on GitHub, the reference `preparePullRequestThread` takes. */ |
| 127 | export function promptLinkPullRequestUrl(pullRequest: PromptLinkPullRequest): string { |
| 128 | return `https://github.com/${pullRequest.owner}/${pullRequest.name}/pull/${pullRequest.number}`; |
| 129 | } |
| 130 | |
| 131 | function readPrompt(params: URLSearchParams): PromptLinkResult<LinkedPrompt | null> { |
| 132 | const raw = params.get("prompt"); |
| 133 | if (raw === null) return { ok: true, value: null }; |
| 134 | if (raw.length > PROMPT_LINK_MAX_PROMPT_LENGTH) { |
| 135 | return { |
| 136 | ok: false, |
| 137 | error: `This prompt is longer than ${PROMPT_LINK_MAX_PROMPT_LENGTH.toLocaleString("en-US")} characters, so T3 Code did not open it.`, |
| 138 | }; |
| 139 | } |
| 140 | const prompt = sanitizeLinkedPrompt(raw); |
| 141 | if (prompt.text.length === 0 && prompt.hiddenCharacters > 0) { |
| 142 | return { |
| 143 | ok: false, |
| 144 | error: |
| 145 | "This prompt had only hidden characters, so it may be malicious. T3 Code did not open it.", |
| 146 | }; |
| 147 | } |
| 148 | return { ok: true, value: prompt.text.length > 0 ? prompt : null }; |
| 149 | } |
| 150 | |
| 151 | const optionalParam = (params: URLSearchParams, key: string) => params.get(key)?.trim() || null; |
| 152 | |
| 153 | /** Reads the query of a `/new` link. */ |
| 154 | export function parseNewThreadLink(search: string): PromptLinkResult<NewThreadLink> { |
| 155 | const params = new URLSearchParams(search); |
| 156 | const prompt = readPrompt(params); |
| 157 | if (!prompt.ok) return prompt; |
| 158 | const pr = params.get("pr"); |
| 159 | if (pr !== null) { |
| 160 | const pullRequest = parsePromptLinkPullRequest(pr); |
| 161 | if (pullRequest === null) { |
| 162 | return { ok: false, error: "This link names a pull request T3 Code cannot read." }; |
| 163 | } |
| 164 | return { ok: true, value: { kind: "pull-request", pullRequest, prompt: prompt.value } }; |
| 165 | } |
| 166 | if (prompt.value === null) return { ok: false, error: "This link has no prompt." }; |
| 167 | const repo = params.get("repo"); |
| 168 | const repository = repo === null ? null : parsePromptLinkRepository(repo); |
| 169 | if (repo !== null && repository === null) { |
| 170 | return { ok: false, error: "This link names a repository T3 Code cannot read." }; |
| 171 | } |
| 172 | return { |
| 173 | ok: true, |
| 174 | value: { |
| 175 | kind: "new", |
| 176 | prompt: prompt.value, |
| 177 | repository, |
| 178 | provider: optionalParam(params, "provider")?.toLowerCase() ?? null, |
| 179 | model: optionalParam(params, "model"), |
| 180 | }, |
| 181 | }; |
| 182 | } |
| 183 | |
| 184 | /** Reads the `prompt` of a thread link. Null when the link carries none. */ |
| 185 | export function parseThreadLinkPrompt(search: string): PromptLinkResult<LinkedPrompt | null> { |
| 186 | return readPrompt(new URLSearchParams(search)); |
| 187 | } |
| 188 | |
| 189 | /** Drops the link's own parameters from a query, keeping anything else the route uses. */ |
| 190 | export function withoutPromptLinkParams(search: string): string { |
| 191 | const params = new URLSearchParams(search); |
| 192 | for (const key of ["prompt", "repo", "pr", "provider", "model"]) params.delete(key); |
| 193 | const rest = params.toString(); |
| 194 | return rest ? `?${rest}` : ""; |
| 195 | } |
| 196 | |
| 197 | /** A shareable `/new` link on the hosted app. */ |
| 198 | export function buildNewThreadLink(input: { |
| 199 | readonly prompt: string; |
| 200 | readonly repository?: PromptLinkRepository | null; |
| 201 | readonly provider?: string | null; |
| 202 | readonly model?: string | null; |
| 203 | readonly origin?: string; |
| 204 | }): string { |
| 205 | const url = new URL("/new", input.origin ?? DEFAULT_HOSTED_APP_URL); |
| 206 | url.searchParams.set("prompt", input.prompt); |
| 207 | if (input.repository) { |
| 208 | url.searchParams.set("repo", `${input.repository.owner}/${input.repository.name}`); |
| 209 | } |
| 210 | if (input.provider) url.searchParams.set("provider", input.provider); |
| 211 | if (input.provider && input.model) url.searchParams.set("model", input.model); |
| 212 | return url.toString(); |
| 213 | } |
| 214 | |
| 215 | const APP_SCHEME = /^t3code(?:-[a-z]+)?:$/; |
| 216 | const THREAD_PATH = /^\/([^/]+)\/([^/]+)\/?$/; |
| 217 | |
| 218 | /** |
| 219 | * The in-app path a prompt link or thread deep link opens, or null for any other URL. |
| 220 | * Desktop uses it for links the OS hands over, both `t3code://` ones and, as the default |
| 221 | * browser on macOS, hosted-app ones. |
| 222 | */ |
| 223 | export function promptLinkAppPath( |
| 224 | value: string, |
| 225 | hostedAppUrl: string = DEFAULT_HOSTED_APP_URL, |
| 226 | ): string | null { |
| 227 | if (!URL.canParse(value)) return null; |
| 228 | const url = new URL(value); |
| 229 | if (APP_SCHEME.test(url.protocol)) { |
| 230 | if (url.host === "new" && (url.pathname === "" || url.pathname === "/")) { |
| 231 | return `/new${url.search}`; |
| 232 | } |
| 233 | const thread = url.host === "threads" ? THREAD_PATH.exec(url.pathname) : null; |
| 234 | return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null; |
| 235 | } |
| 236 | if (!URL.canParse(hostedAppUrl) || url.origin !== new URL(hostedAppUrl).origin) return null; |
| 237 | if (url.pathname === "/new") return `/new${url.search}`; |
| 238 | // Hosted thread links only open in the app when they carry a prompt. A plain one is just a |
| 239 | // page on the hosted app. |
| 240 | const thread = url.searchParams.has("prompt") ? THREAD_PATH.exec(url.pathname) : null; |
| 241 | return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null; |
| 242 | } |
| 243 | |
| 244 | /** The `t3code://` link that hands an in-app path to an installed desktop or mobile app. */ |
| 245 | export function promptLinkDeepLink(appPath: string, scheme = "t3code"): string { |
| 246 | const url = new URL(appPath, "https://app.invalid"); |
| 247 | const host = url.pathname === "/new" ? "new" : `threads${url.pathname}`; |
| 248 | return `${scheme}://${host}${url.search}`; |
| 249 | } |