diff --git a/AGENTS.md b/AGENTS.md index 88ef0e2..bffa0b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,14 +39,27 @@ src/ index.ts Aggregates every command into a name -> command map. util/ close, reopen, walkthrough. product/ notes (ProductBoard context-menu command). - events/ commands, messages, channels, walkthrough handlers. + events/ commands, messages, channels, walkthrough, bridge handlers. + bridge/ + linear/ Discord -> Linear mirror (client, api, orchestration). lib/ config.ts Typed config loader + mandatory field list. - discord/ channels, users, messages helpers. + discord/ channels, users, messages, help, helpThread helpers. ui/components/ StringSelectMenu builders for the walkthrough. +scripts/ + discord-linear-sync.ts One-shot Linear backfill (bun run sync:linear). assets/tags.json Canned response text. ``` +## Linear bridge + +The Linear bridge (`src/events/bridge.ts`) registers its own Discord listeners +(`ThreadCreate`, `MessageCreate`, `ThreadUpdate`) and mirrors #help forum posts +into Linear via `src/bridge/linear`. It reads enriched thread state through +`new HelpThread(thread)` (`src/lib/discord/helpThread.ts`), whose getters derive +status/waiting/tags from the thread's applied tags. Disabled by default via +`config.linearBridge.enabled`. + ## Conventions - **Imports**: Use the `.js` extension on relative imports (ESM/NodeNext), @@ -68,6 +81,9 @@ environment file -> process environment. Keys are **case-sensitive**. - Copy `config.json.example` to `config.json` (gitignored) for local IDs. - Secrets come from the environment, e.g. `Codercord_token` (the bot token). +- The Linear bridge API key is a secret too: `Codercord_linearBridge__apiKey` + (nested keys use `__`). `linearBridge.teamId` and `enabled` live in + `config.json`; the bridge exits at startup if enabled without apiKey/teamId. - Mandatory fields are declared in `src/lib/config.ts`; the process exits if any are missing. diff --git a/bun.lockb b/bun.lockb index b986e48..db7040a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/config.json.example b/config.json.example index 3fa52ac..70f91c1 100644 --- a/config.json.example +++ b/config.json.example @@ -19,5 +19,14 @@ "macos": "1078432543696748634", "windows": "1078432538940416030", "vscode": "1078432889995268248" + }, + + "linearBridge": { + "enabled": false, + "teamId": "", + "labels": { + "enabled": true, + "groupName": "Discord (#help)" + } } } diff --git a/package.json b/package.json index da64a9c..53584d3 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "typescript": "^5.9.3" }, "dependencies": { + "@linear/sdk": "^90.0.0", "@uwu/configmasher": "^2.0.2", "discord.js": "^14.27.0", "ofetch": "^1.5.1", diff --git a/src/bridge/ARCHITECTURE.md b/src/bridge/ARCHITECTURE.md new file mode 100644 index 0000000..59902aa --- /dev/null +++ b/src/bridge/ARCHITECTURE.md @@ -0,0 +1,87 @@ +# Bridge architecture + +The bridge mirrors community conversations into Linear. Today it runs one way, +Discord `#help` -> Linear, but the code is organized around a source-agnostic +model so more platforms (e.g. GitHub Discussions) and the reverse direction can +be added without threading platform specifics through the whole system. + +## Layout + +``` +src/bridge/ + core/ # platform-agnostic: model, interfaces, orchestration, reconciler + discord/ # Discord connector: listeners + model mapping + linear/ # Linear connector: the hub store, split by concern +``` + +- `core/model.ts` - the shared vocabulary: `Post`, `Message`, `Author`, + `Attachment`, `Reaction`, `Reference`, `Label`, and `ExternalRef` + (`{ source, id, url }`). A connector maps its native objects onto these. +- `core/connector.ts` - the `Source` and `Target` capability interfaces. +- `core/mirror.ts` - `Mirror`, the orchestrator. Consumes model objects a + connector produces and drives the `Target`. All logic here is source-agnostic. +- `core/reconciler.ts` - maps a post's lifecycle onto the hub workflow state. +- `core/references.ts` - extractors for cross-links (other threads, GitHub + issues) that any connector can reuse. +- `core/backfill.ts` - rate-limit retry used by startup import. +- `discord/` - `DiscordConnector` (a `Source`) plus `map.ts`, which converts + discord.js objects into the model (mentions, emojis, attachments, references). +- `linear/` - the hub, split into `client`, `issues`, `comments`, `reactions`, + `labels`, `emojis`, `attachments`, `state`, `assets`, with `index.ts` exposing + `LinearConnector` (a `Target`). + +## Source and Target are capabilities, not layers + +Everything syncs both ways eventually, so a platform is one module, not split +across "source" and "target" folders. `Source` (reads events, enumerates for +backfill, writes the hub link back) and `Target` (the hub store) are capability +interfaces. Discord implements `Source` today; Linear implements `Target`. When +a platform's reverse direction is built, its connector grows the other +capability rather than moving between folders. + +## Identity and mapping + +A conversation maps to one hub issue. The mapping lives in a Linear **attachment** +on the issue whose `url` is the source conversation's canonical URL; lookups are +scoped to the configured team so a shared link (e.g. a GitHub URL attached to an +unrelated issue) never resolves cross-team. + +Mirrored comments carry an invisible marker, a markdown reference-link definition +`[-msg]: `, so a later edit/delete/reply finds the right comment. The +marker is namespaced per source; Discord's is `discord-msg`. + +**Cardinality (future).** One issue is the hub, linked to N source entities at +once: the same conversation can map to a Discord thread and a GitHub discussion +via one attachment each. The marker's source namespace keeps per-source comments +distinct on the shared issue. + +## Reconciliation model + +- **Posts always originate at a source.** Nothing is created in Linear; Linear + is a relay hub. +- **The originating source is authoritative for its own content**: title, body, + lifecycle (open/closed and waiting state), and messages. If a source and Linear + disagree on a source-owned field, the source wins. +- **Linear relays A -> Linear -> B.** The hub holds cross-source identity but + does not author content. +- **No historical catch-up for Linear-originated changes.** Linear edits + propagate only when received live. Propagation of Linear-originated *comments* + is an open question, deferred. +- **State transitions are computed against the current hub state**, not a source + old/new diff, so out-of-band changes (e.g. a `/close` command) are detected + reliably. See `core/reconciler.ts`: closed -> Done, waiting-on-user -> Blocked, + waiting-on-team -> In Progress, with a new live thread held in Triage until the + team engages; backfilled threads bypass that gate. + +## Future work (not built) + +- **Reverse direction (Linear -> source).** The intended inbound channel is + **Linear webhooks** (the SDK ships a webhook client). Each connector would grow + the write side of its platform. +- **Echo suppression.** Every mirrored write is tagged with its origin (comments + already carry the source marker). Inbound events that match a mirror we just + wrote must be ignored so a `Linear -> Discord` write does not bounce back as a + new Discord event and loop. Only the origin tagging exists today; the ignore + step lands with the reverse direction. +- **GitHub Discussions.** A new `github/` connector implementing `Source`, + reusing `core` unchanged. Its marker namespace would be `github-msg`. diff --git a/src/bridge/core/backfill.ts b/src/bridge/core/backfill.ts new file mode 100644 index 0000000..cd87fdf --- /dev/null +++ b/src/bridge/core/backfill.ts @@ -0,0 +1,22 @@ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Retries an operation through hub rate limits. Linear's limits reset on a +// rolling window, so back off and keep waiting rather than dropping work. +export async function withRateLimitRetry( + fn: () => Promise, + isRateLimited: (err: unknown) => boolean, +): Promise { + let delayMs = 60_000; + for (;;) { + try { + return await fn(); + } catch (err) { + if (!isRateLimited(err)) throw err; + console.warn("[bridge]", "rate limited, waiting", `${delayMs / 1000}s`); + await sleep(delayMs); + delayMs = Math.min(delayMs * 2, 15 * 60_000); + } + } +} diff --git a/src/bridge/core/bridge.ts b/src/bridge/core/bridge.ts new file mode 100644 index 0000000..ddb985d --- /dev/null +++ b/src/bridge/core/bridge.ts @@ -0,0 +1,27 @@ +import type { Client } from "discord.js"; + +import { config, validateLinearBridgeConfig } from "@lib/config.js"; + +import { DiscordConnector } from "@bridge/discord/index.js"; +import { LinearConnector } from "@bridge/linear/index.js"; + +// Composition root: wires the Discord source to the Linear hub. Adding a source +// (e.g. GitHub Discussions) means constructing another connector here. +let connector: DiscordConnector | undefined; + +export function registerBridge(client: Client): void { + if (!config.linearBridge.enabled) { + console.log("[bridge]", "disabled"); + return; + } + validateLinearBridgeConfig(); + connector = new DiscordConnector(client, new LinearConnector()); + connector.register(); +} + +export async function backfillBridge(client: Client): Promise { + if (!config.linearBridge.enabled) return; + const source = + connector ?? new DiscordConnector(client, new LinearConnector()); + await source.backfill(); +} diff --git a/src/bridge/core/connector.ts b/src/bridge/core/connector.ts new file mode 100644 index 0000000..a45b579 --- /dev/null +++ b/src/bridge/core/connector.ts @@ -0,0 +1,82 @@ +import type { + ExternalRef, + Message, + Post, + Reaction, +} from "@bridge/core/model.js"; + +export interface IssueState { + type: string; + name: string; +} + +export interface LinkedIssue { + id: string; + identifier: string; + url: string; +} + +export type ReactionTarget = { issueId: string } | { commentId: string }; + +// The hub store. Linear implements this today; every method speaks the +// source-agnostic model so another hub could be swapped in. Keyed by the source +// entity's ExternalRef (resolved to a hub issue via its URL attachment). +export interface Target { + findIssueId(ref: ExternalRef): Promise; + ensureIssue(post: Post): Promise; + deleteIssue(ref: ExternalRef): Promise; + + // Refresh linking attachment, title, project and labels from the post. + reconcile(issueId: string, post: Post): Promise; + syncLabels(issueId: string, post: Post): Promise; + + setDescription(issueId: string, text: string): Promise; + updateDescription(issueId: string, message: Message): Promise; + + addComment( + issueId: string, + message: Message, + parentId?: string, + ): Promise; + editComment(issueId: string, message: Message): Promise; + deleteComment(issueId: string, ref: ExternalRef): Promise; + mirroredMessageIds(issueId: string): Promise>; + resolveReplyParent( + issueId: string, + messageId: string, + ): Promise; + findCommentId(issueId: string, messageId: string): Promise; + + // Plain system note (no marker), e.g. "thread closed". + note(issueId: string, body: string, createdAt?: Date): Promise; + + getState(issueId: string): Promise; + setState( + issueId: string, + type: "completed" | "triage" | "started", + name?: string, + ): Promise; + + addReaction(target: ReactionTarget, reaction: Reaction): Promise; + removeReaction(target: ReactionTarget, reaction: Reaction): Promise; + + resolveByUrl(url: string): Promise; + relate(issueId: string, otherId: string): Promise; + + issueRef(issueId: string): Promise<{ identifier: string; url: string }>; +} + +// A platform that originates conversations (Discord today, GitHub Discussions +// planned). It registers listeners that drive the mirror, enumerates posts for +// backfill, and writes the hub link back into the source. Everything syncs both +// ways eventually; a connector grows into the hub's role by implementing more of +// the reverse direction, so Source and Target are capabilities one module can +// hold rather than separate layers. +export interface Source { + register(): void; + backfill(): Promise; + announce( + post: Post, + issue: { identifier: string; url: string }, + ): Promise; +} diff --git a/src/bridge/core/mirror.ts b/src/bridge/core/mirror.ts new file mode 100644 index 0000000..a6b3ed8 --- /dev/null +++ b/src/bridge/core/mirror.ts @@ -0,0 +1,209 @@ +import type { + ExternalRef, + Message, + Post, + Reaction, +} from "@bridge/core/model.js"; +import type { Source, Target } from "@bridge/core/connector.js"; +import { dedupeReferences, type Reference } from "@bridge/core/references.js"; +import { syncState } from "@bridge/core/reconciler.js"; + +// Orchestrates one source against the hub. All logic here is source-agnostic: +// it consumes model objects a connector produces and drives the Target. A +// connector holds a Mirror and feeds it from its own listeners and backfill. +export class Mirror { + // Caches the ensure-issue promise per post so concurrent events resolve to a + // single issue instead of racing to create duplicates. + private readonly issueByRef = new Map>(); + + constructor( + private readonly target: Target, + private readonly source: Pick, + ) {} + + // Mirrors a post: issue (with the opening message as its body), linking + // attachment, and labels. Announces the issue back to the source unless + // suppressed (e.g. during startup backfill of old posts). + async createPost(post: Post, announce = true): Promise { + console.debug("[bridge]", "mirroring post", post.ref.id, post.title); + const existed = (await this.target.findIssueId(post.ref)) !== null; + const issueId = await this.ensureIssue(post); + await this.target.syncLabels(issueId, post); + + const rewrites = await this.resolveReferences(issueId, post.references); + if (rewrites.size > 0) { + await this.target.setDescription( + issueId, + applyRewrites(post.body, rewrites), + ); + } + + if (announce && !existed) { + if (post.openNote) await this.target.note(issueId, post.openNote); + try { + await this.source.announce(post, await this.target.issueRef(issueId)); + } catch (err) { + console.error("[bridge]", "issue announce failed", err); + } + } + } + + // Mirrors a message as an issue comment. + async addMessage(post: Post, message: Message): Promise { + // Skip messages with no text and no attachments (e.g. a sticker-only post). + if (!message.text.trim() && message.attachments.length === 0) return; + const issueId = await this.ensureIssue(post); + const rewrites = await this.resolveReferences(issueId, message.references); + const parentId = message.replyToId + ? ((await this.target.resolveReplyParent(issueId, message.replyToId)) ?? + undefined) + : undefined; + await this.target.addComment( + issueId, + { ...message, text: applyRewrites(message.text, rewrites) }, + parentId, + ); + } + + // Reflects a message edit onto its mirrored comment, or the issue description + // for the opening message. No-op if the post isn't mirrored. + async editMessage( + post: Post, + message: Message, + isStarter: boolean, + ): Promise { + const issueId = await this.target.findIssueId(post.ref); + if (!issueId) return; + if (isStarter) { + await this.target.updateDescription(issueId, message); + } else { + await this.target.editComment(issueId, message); + } + } + + // Removes a deleted message from the hub. Regular messages map to comments; + // the opening message maps to the issue description, which is cleared. + async deleteMessage( + post: Post, + ref: ExternalRef, + isStarter: boolean, + ): Promise { + const issueId = await this.target.findIssueId(post.ref); + if (!issueId) { + console.debug( + "[bridge]", + "deleteMessage: no issue mapping", + post.ref.url, + ); + return; + } + if (isStarter) { + await this.target.setDescription(issueId, ""); + return; + } + const ok = await this.target.deleteComment(issueId, ref); + console.debug("[bridge]", "deleteMessage", ref.id, "deleted", ok); + } + + // Refreshes attachment metadata, title, project and labels, then reconciles + // the workflow state. + async syncStatus(post: Post, backfill = false): Promise { + const issueId = await this.ensureIssue(post); + await this.target.reconcile(issueId, post); + await syncState(this.target, issueId, post, backfill); + } + + // Trashes the mirrored issue when its source post is deleted. + async deletePost(post: Post): Promise { + await this.target.deleteIssue(post.ref); + this.issueByRef.delete(post.ref.id); + } + + // Mirrors a reaction onto the mapped issue (opening message) or comment. + // A null message ref targets the issue itself. + async addReaction( + post: Post, + message: ExternalRef | null, + reaction: Reaction, + ): Promise { + const target = await this.reactionTarget(post, message); + if (target) await this.target.addReaction(target, reaction); + } + + async removeReaction( + post: Post, + message: ExternalRef | null, + reaction: Reaction, + ): Promise { + const target = await this.reactionTarget(post, message); + if (target) await this.target.removeReaction(target, reaction); + } + + // Mirrors messages not already on the issue, in the order given. Idempotent: + // existing messages (matched by marker) are skipped, so it is safe to re-run. + async backfillMessages(post: Post, messages: Message[]): Promise { + const issueId = await this.ensureIssue(post); + const mirrored = await this.target.mirroredMessageIds(issueId); + console.debug( + "[bridge]", + "backfilling messages", + messages.length, + "issue", + issueId, + "already", + mirrored.size, + ); + for (const message of messages) { + if (mirrored.has(message.ref.id)) continue; + await this.addMessage(post, message); + } + } + + private async reactionTarget( + post: Post, + message: ExternalRef | null, + ): Promise<{ issueId: string } | { commentId: string } | null> { + const issueId = await this.target.findIssueId(post.ref); + if (!issueId) return null; + if (!message) return { issueId }; + const commentId = await this.target.findCommentId(issueId, message.id); + return commentId ? { commentId } : null; + } + + private async ensureIssue(post: Post): Promise { + const cached = this.issueByRef.get(post.ref.id); + if (cached) return cached; + + const pending = this.target.ensureIssue(post); + this.issueByRef.set(post.ref.id, pending); + try { + return await pending; + } catch (err) { + this.issueByRef.delete(post.ref.id); + throw err; + } + } + + // Resolves each reference to a hub issue, relates it, and returns token -> + // markdown link rewrites that turn each mention into a link to the issue. + private async resolveReferences( + issueId: string, + references: Reference[], + ): Promise> { + const rewrites = new Map(); + for (const ref of dedupeReferences(references)) { + const target = await this.target.resolveByUrl(ref.url); + if (!target || target.id === issueId) continue; + await this.target.relate(issueId, target.id); + rewrites.set(ref.token, `[${target.identifier}](${target.url})`); + } + return rewrites; + } +} + +function applyRewrites(body: string, rewrites: Map): string { + for (const [token, replacement] of rewrites) { + body = body.split(token).join(replacement); + } + return body; +} diff --git a/src/bridge/core/model.ts b/src/bridge/core/model.ts new file mode 100644 index 0000000..8896c5e --- /dev/null +++ b/src/bridge/core/model.ts @@ -0,0 +1,126 @@ +// Source-agnostic domain model shared by every bridge connector. A connector +// maps its platform's native objects onto these types; the core orchestrator and +// the hub (Linear) speak only this vocabulary, so a new platform is just another +// connector rather than changes threaded through the whole bridge. + +// Platforms the bridge knows about. Discord and Linear exist today; github is +// reserved for the planned GitHub Discussions connector. +export type SourceId = "discord" | "linear" | "github"; + +// Identifies an entity on its origin platform. `url` is the canonical link used +// to locate the matching hub issue (via its attachments); `id` is the native id. +export interface ExternalRef { + source: SourceId; + id: string; + url: string; +} + +// External author of mirrored content, for attribution on the hub. +export interface Author { + name: string; + iconUrl?: string; +} + +// A custom emoji that the hub must register before its shortcode renders. +export interface CustomEmoji { + id: string; + animated: boolean; +} + +export interface Attachment { + name: string; + url: string; + contentType: string | null; + isImage: boolean; +} + +// A reaction normalized to the hub's key: a registered `discord-` shortcode +// for custom emojis, or the unicode character for standard ones. `custom` is set +// when the hub must register the emoji first. +export interface Reaction { + key: string; + custom?: CustomEmoji; +} + +// A source tag mirrored as a hub label. `id` is the source tag id, stored in the +// label description so the mapping survives restarts. +export interface Label { + id: string; + name: string; +} + +export type Lifecycle = "open" | "closed"; +export type Waiting = "user" | "team" | null; + +// Descriptor for the hub attachment that links an issue back to its source +// conversation. The source supplies the exact strings/metadata so the hub only +// stores them, keeping the on-the-wire shape owned by the connector. +export interface SourceAttachment { + title: string; + subtitle: string; + metadata: Record; +} + +// The opening entity of a mirrored conversation (a Discord forum post, later a +// GitHub discussion). Maps to one hub issue. +export interface Post { + ref: ExternalRef; + title: string; + // Rendered starter text (mentions and emojis resolved), used verbatim as the + // issue description. Attachments are intentionally not composed in here to + // match the hub's existing description shape. + body: string; + author?: Author; + customEmojis: CustomEmoji[]; + references: Reference[]; + labels: Label[]; + lifecycle: Lifecycle; + waiting: Waiting; + closedAt: Date | null; + createdAt?: Date; + attachment: SourceAttachment; + // Optional note posted on the hub issue when it is first mirrored, e.g. a + // deep link back to the source conversation. + openNote?: string; +} + +// A single message within a conversation. The starter message is represented by +// the Post, not a Message. +export interface Message { + ref: ExternalRef; + author?: Author; + // Rendered text (mentions and emojis resolved, reference tokens still present + // for the orchestrator to rewrite). Attachments are composed by the hub. + text: string; + attachments: Attachment[]; + customEmojis: CustomEmoji[]; + references: Reference[]; + replyToId?: string; + createdAt?: Date; +} + +// A reference to another entity found in mirrored content. `url` is the canonical +// link used to locate a matching hub issue; `token` is the exact substring in +// the content to rewrite when a match is found. +export interface Reference { + url: string; + token: string; +} + +// Composes a message body from its text and attachments, resolving each +// attachment URL via urlFor (a CDN link for the fast path, a re-hosted asset URL +// for the durable path). Images render inline, other files as links. +export function composeBody( + text: string, + attachments: Attachment[], + urlFor: (a: Attachment) => string, +): string { + const parts: string[] = []; + const trimmed = text.trim(); + if (trimmed) parts.push(trimmed); + for (const a of attachments) { + const link = `[${a.name}](${urlFor(a)})`; + parts.push(a.isImage ? `!${link}` : link); + } + return parts.join("\n\n"); +} diff --git a/src/bridge/core/reconciler.ts b/src/bridge/core/reconciler.ts new file mode 100644 index 0000000..2a2f775 --- /dev/null +++ b/src/bridge/core/reconciler.ts @@ -0,0 +1,57 @@ +import type { Post } from "@bridge/core/model.js"; +import type { Target } from "@bridge/core/connector.js"; + +// Maps a post's lifecycle onto the hub's workflow state. The source is +// authoritative for its own lifecycle: closed -> Done, waiting on the user -> +// Blocked, waiting on the team -> In Progress. A new post stays in Triage until +// the team first engages (moves it out of Triage); a reopened issue with no +// waiting signal falls back to Triage. During backfill the issue is freshly +// created in Triage, so the "team engaged" gate is skipped and the waiting +// signal drives the state directly. +// +// Transitions are decided against the current hub state, not a source old/new +// diff, so out-of-band changes (e.g. a /close command) are detected reliably. +export async function syncState( + target: Target, + issueId: string, + post: Post, + backfill: boolean, +): Promise { + const state = await target.getState(issueId); + + if (post.lifecycle === "closed") { + if (state?.type !== "completed") { + await target.setState(issueId, "completed"); + await target.note( + issueId, + "_Thread closed on Discord._", + post.closedAt ?? undefined, + ); + } + return; + } + + if (state?.type === "completed") { + await target.note(issueId, "_Thread reopened on Discord._"); + } + + if (post.waiting === "user") { + if (state?.name !== "Blocked") { + await target.setState(issueId, "started", "Blocked"); + } + return; + } + + if (post.waiting === "team") { + if (!backfill && state?.type === "triage") return; + if (state?.name !== "In Progress") { + await target.setState(issueId, "started", "In Progress"); + } + return; + } + + // No waiting signal: send a reopened issue back to Triage. + if (state?.type === "completed") { + await target.setState(issueId, "triage"); + } +} diff --git a/src/bridge/core/references.ts b/src/bridge/core/references.ts new file mode 100644 index 0000000..45a0e26 --- /dev/null +++ b/src/bridge/core/references.ts @@ -0,0 +1,45 @@ +// Extracts cross-references from mirrored content: links to other conversations +// or external issues that may already map to a hub issue. The extractors are +// source-agnostic so every connector can reuse them; the orchestrator resolves +// each reference against the hub and rewrites it to a hub issue link. + +import type { Reference } from "@bridge/core/model.js"; + +export type { Reference }; + +// GitHub issue and pull request references (full URLs). +export function githubReferences(content: string): Reference[] { + const re = /https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/\d+/g; + return [...content.matchAll(re)].map((m) => ({ url: m[0], token: m[0] })); +} + +// Discord thread references: channel mentions (<#id>) and message/thread URLs, +// normalized to the canonical thread URL for the guild. +export function discordThreadReferences( + content: string, + guildId: string, +): Reference[] { + const refs: Reference[] = []; + for (const m of content.matchAll(/<#(\d+)>/g)) { + refs.push({ token: m[0], url: threadUrl(guildId, m[1]) }); + } + const urlRe = + /https?:\/\/(?:\w+\.)?discord(?:app)?\.com\/channels\/(\d+)\/(\d+)(?:\/\d+)?/g; + for (const m of content.matchAll(urlRe)) { + refs.push({ token: m[0], url: threadUrl(m[1], m[2]) }); + } + return refs; +} + +function threadUrl(guildId: string, threadId: string): string { + return `https://discord.com/channels/${guildId}/${threadId}`; +} + +// Removes duplicate references that share a token, keeping the first. +export function dedupeReferences(refs: Reference[]): Reference[] { + const byToken = new Map(); + for (const ref of refs) { + if (!byToken.has(ref.token)) byToken.set(ref.token, ref); + } + return [...byToken.values()]; +} diff --git a/src/bridge/discord/index.ts b/src/bridge/discord/index.ts new file mode 100644 index 0000000..9dabfc4 --- /dev/null +++ b/src/bridge/discord/index.ts @@ -0,0 +1,322 @@ +import { debounce } from "throttle-debounce"; + +import { + ChannelType, + type Client, + Events, + SnowflakeUtil, + type ThreadChannel, +} from "discord.js"; + +import { config } from "@lib/config.js"; +import { isHelpPost } from "@lib/discord/channels.js"; +import { isHumanMessage, reconcileThread } from "@lib/discord/help.js"; +import { HelpThread } from "@lib/discord/helpThread.js"; + +import type { ExternalRef, Post } from "@bridge/core/model.js"; +import type { Source, Target } from "@bridge/core/connector.js"; +import { Mirror } from "@bridge/core/mirror.js"; +import { withRateLimitRetry } from "@bridge/core/backfill.js"; + +import { isRateLimited } from "@bridge/linear/client.js"; + +import { isStarter, toMessage, toPost, toReaction } from "./map.js"; + +// Discord #help forum as a bridge source: listens for thread/message/reaction +// events, maps them onto the canonical model, and drives the mirror. Also +// enumerates threads for the startup backfill and writes the hub issue link back +// into the thread. +export class DiscordConnector implements Source { + private readonly mirror: Mirror; + + constructor( + private readonly client: Client, + target: Target, + ) { + this.mirror = new Mirror(target, this); + } + + register(): void { + const client = this.client; + + client.on(Events.ThreadCreate, async (thread) => { + if (!(await isHelpPost(thread))) return; + try { + await this.mirror.createPost(await this.postFor(thread)); + } catch (err) { + console.error("[bridge]", "thread create failed", err); + } + }); + + client.on(Events.ThreadDelete, async (thread) => { + if (!(await isHelpPost(thread))) return; + try { + await this.mirror.deletePost( + await toPost(new HelpThread(thread), null), + ); + } catch (err) { + console.error("[bridge]", "thread delete failed", err); + } + }); + + client.on(Events.MessageCreate, async (message) => { + if (!message.inGuild() || !(await isHelpPost(message.channel))) return; + if (!isHumanMessage(message) || isStarter(message)) return; + try { + const post = await this.postFor(message.channel as ThreadChannel); + await this.mirror.addMessage(post, await toMessage(message)); + } catch (err) { + console.error("[bridge]", "message create failed", err); + } + }); + + client.on(Events.MessageUpdate, async (oldMessage, newMessage) => { + try { + const message = newMessage.partial + ? await newMessage.fetch() + : newMessage; + if (!message.inGuild() || !(await isHelpPost(message.channel))) return; + if (!isHumanMessage(message)) return; + // Ignore edits that changed neither text nor attachments (e.g. an embed + // unfurling or a pin) when the previous state is known. + if ( + !oldMessage.partial && + oldMessage.content === message.content && + oldMessage.attachments.size === message.attachments.size && + oldMessage.attachments.every((_a, id) => message.attachments.has(id)) + ) { + return; + } + const post = await toPost( + new HelpThread(message.channel as ThreadChannel), + null, + ); + await this.mirror.editMessage( + post, + await toMessage(message), + isStarter(message), + ); + } catch (err) { + console.error("[bridge]", "message update failed", err); + } + }); + + client.on(Events.MessageDelete, async (message) => { + try { + const channel = message.channel; + if (!channel.isThread() || !(await isHelpPost(channel))) return; + const post = await toPost(new HelpThread(channel), null); + const ref: ExternalRef = { + source: "discord", + id: message.id, + url: "", + }; + await this.mirror.deleteMessage(post, ref, message.id === channel.id); + } catch (err) { + console.error("[bridge]", "message delete failed", err); + } + }); + + client.on(Events.MessageReactionAdd, async (reaction, user) => { + try { + if (user.bot) return; + const message = reaction.message.partial + ? await reaction.message.fetch() + : reaction.message; + if (!message.inGuild() || !(await isHelpPost(message.channel))) return; + // The app aggregates reactions under one identity, so only the first + // Discord reaction of an emoji is mirrored. + const resolved = message.reactions.resolve( + reaction.emoji.id ?? reaction.emoji.name, + ); + if (resolved?.count !== 1) return; + const post = await toPost( + new HelpThread(message.channel as ThreadChannel), + null, + ); + await this.mirror.addReaction( + post, + this.messageRef(message.id, message.channelId), + toReaction(reaction.emoji), + ); + } catch (err) { + console.error("[bridge]", "reaction add failed", err); + } + }); + + client.on(Events.MessageReactionRemove, async (reaction) => { + try { + const message = reaction.message.partial + ? await reaction.message.fetch() + : reaction.message; + if (!message.inGuild() || !(await isHelpPost(message.channel))) return; + // Only remove the mirrored reaction once the last Discord user removes it. + const resolved = message.reactions.resolve( + reaction.emoji.id ?? reaction.emoji.name, + ); + if (resolved && resolved.count > 0) return; + const post = await toPost( + new HelpThread(message.channel as ThreadChannel), + null, + ); + await this.mirror.removeReaction( + post, + this.messageRef(message.id, message.channelId), + toReaction(reaction.emoji), + ); + } catch (err) { + console.error("[bridge]", "reaction remove failed", err); + } + }); + + // Coalesce bursts of tag edits per thread. syncStatus is idempotent and + // reconciles against the hub state, so no before/after diff is kept. + const flushers = new Map void>(); + client.on(Events.ThreadUpdate, async (_oldThread, newThread) => { + if (!(await isHelpPost(newThread))) return; + let flush = flushers.get(newThread.id); + if (!flush) { + flush = debounce(1000, async (thread: ThreadChannel) => { + flushers.delete(thread.id); + try { + await this.mirror.syncStatus(await this.postFor(thread)); + } catch (err) { + console.error("[bridge]", "thread update failed", err); + } + }); + flushers.set(newThread.id, flush); + } + flush(newThread); + }); + + console.log("[bridge]", "enabled"); + } + + async announce( + post: Post, + issue: { identifier: string; url: string }, + ): Promise { + const channel = await this.client.channels.fetch(post.ref.id); + if (!channel?.isThread() || channel.archived) return; + await channel.send({ + embeds: [{ description: `[${issue.identifier}](${issue.url})` }], + }); + console.debug( + "[bridge]", + "announced", + issue.identifier, + "in thread", + post.ref.id, + ); + } + + // Mirrors #help threads that aren't fully in the hub yet, so threads and + // messages from while the bridge was off still land as issues. With backfillAll + // it imports every thread, paging through all archived threads and waiting out + // rate limits. + async backfill(): Promise { + const { backfillAll, backfillLimit, backfillDays } = config.linearBridge; + if (!backfillAll && backfillLimit <= 0) return; + + const forum = await this.client.channels.fetch(config.helpChannel.id); + if (!forum || forum.type !== ChannelType.GuildForum) return; + + const byId = new Map(); + const active = await forum.threads.fetchActive(); + for (const thread of active.threads.values()) byId.set(thread.id, thread); + + // Pull archived threads too. For a full import, page through every archived + // thread; otherwise a single page bounded by the limit is enough. + let before: Date | undefined; + do { + const page = await forum.threads.fetchArchived({ + limit: backfillAll ? 100 : backfillLimit, + before, + }); + const last = [...page.threads.values()].at(-1); + for (const thread of page.threads.values()) byId.set(thread.id, thread); + before = + backfillAll && page.hasMore + ? (last?.archivedAt ?? undefined) + : undefined; + } while (before); + + const sorted = [...byId.values()].sort((a, b) => + (b.lastMessageId ?? "").localeCompare(a.lastMessageId ?? ""), + ); + // A normal backfill is bounded by both a count and a recency window, so it + // can't reach ancient threads in a low-traffic channel. A full import takes + // everything. + const cutoff = Date.now() - backfillDays * 24 * 60 * 60 * 1000; + const threads = backfillAll + ? sorted + : sorted.filter((t) => lastActivity(t) >= cutoff).slice(0, backfillLimit); + + console.log( + "[bridge]", + "startup backfill:", + threads.length, + "thread(s)", + backfillAll + ? "(full import)" + : `of ${byId.size} fetched (limit ${backfillLimit}, ${backfillDays}d)`, + ); + for (const thread of threads) { + try { + await withRateLimitRetry( + () => this.backfillThread(thread), + isRateLimited, + ); + } catch (err) { + console.error("[bridge]", "backfill failed for thread", thread.id, err); + } + } + console.log("[bridge]", "startup backfill complete"); + } + + // Mirrors a thread: ensures the issue exists, fills in missing messages, then + // reconciles state. Safe to re-run over already-mirrored threads. + private async backfillThread(thread: ThreadChannel): Promise { + console.log("[bridge]", "backfilling thread", thread.id, thread.name); + const help = new HelpThread(thread); + + // Older threads may predate the waiting-tag automation. If an open, active + // thread has no waiting tag, derive one from its last message so the + // mirrored issue gets a meaningful status. Skip archived threads: writing + // tags would unarchive and bump them. + if (help.isOpen && !thread.archived && help.waiting === null) { + await reconcileThread(thread); + } + + const starter = await thread.fetchStarterMessage().catch(() => null); + const post = await toPost(help, starter); + await this.mirror.createPost(post, false); + + const fetched = await thread.messages.fetch({ limit: 100 }); + const messages = await Promise.all( + [...fetched.values()] + .reverse() + .filter((m) => isHumanMessage(m) && !isStarter(m)) + .map(toMessage), + ); + await this.mirror.backfillMessages(post, messages); + + await this.mirror.syncStatus(post, true); + } + + private async postFor(thread: ThreadChannel): Promise { + const starter = await thread.fetchStarterMessage().catch(() => null); + return await toPost(new HelpThread(thread), starter); + } + + private messageRef(messageId: string, threadId: string): ExternalRef | null { + if (messageId === threadId) return null; + return { source: "discord", id: messageId, url: "" }; + } +} + +// Best-effort last-activity time of a thread, from its last message (or its id +// when empty), decoded from the Discord snowflake. +function lastActivity(thread: ThreadChannel): number { + return SnowflakeUtil.timestampFrom(thread.lastMessageId ?? thread.id); +} diff --git a/src/bridge/discord/map.ts b/src/bridge/discord/map.ts new file mode 100644 index 0000000..aba2473 --- /dev/null +++ b/src/bridge/discord/map.ts @@ -0,0 +1,222 @@ +import type { Message as DiscordMessage } from "discord.js"; + +import { config } from "@lib/config.js"; +import type { HelpThread } from "@lib/discord/helpThread.js"; + +import type { + Attachment, + Author, + CustomEmoji, + Message, + Post, + Reaction, + SourceAttachment, +} from "@bridge/core/model.js"; +import { + dedupeReferences, + discordThreadReferences, + githubReferences, + type Reference, +} from "@bridge/core/references.js"; + +const SOURCE = "discord" as const; + +// Maps a Discord forum post onto the canonical Post. Attachments are not +// composed into the body: the opening message becomes the issue description as +// text only, matching the hub's existing shape. +export async function toPost( + help: HelpThread, + starter: DiscordMessage | null, +): Promise { + const content = starter?.content ?? ""; + return { + ref: { source: SOURCE, id: help.thread.id, url: help.url }, + title: help.title, + body: starter ? (await renderText(starter)).trim() : "", + author: starter ? authorOf(starter) : undefined, + customEmojis: customEmojisIn(content), + references: referencesOf(content), + labels: help.tags, + lifecycle: help.isClosed ? "closed" : "open", + waiting: help.waiting, + closedAt: help.closedAt, + createdAt: starter?.createdAt, + attachment: attachmentOf(help), + openNote: `(open in [Discord Desktop](discord://-/channels/${config.serverId}/${help.thread.id}))`, + }; +} + +// Maps a Discord message onto the canonical Message (never the starter, which is +// represented by the Post). +export async function toMessage(message: DiscordMessage): Promise { + const content = message.content ?? ""; + return { + ref: { source: SOURCE, id: message.id, url: message.url }, + author: authorOf(message), + text: await renderText(message), + attachments: attachmentsOf(message), + customEmojis: customEmojisIn(content), + references: referencesOf(content), + replyToId: message.reference?.messageId, + createdAt: message.createdAt, + }; +} + +// Whether a message is the forum starter (its id equals the thread id). +export function isStarter(message: DiscordMessage): boolean { + return message.id === message.channelId; +} + +// Maps a Discord emoji onto a hub reaction: a registered discord- shortcode +// for custom emojis (which the hub must register first), or the unicode +// character for standard ones. +export function toReaction(emoji: { + id: string | null; + name: string | null; + animated?: boolean | null; +}): Reaction { + if (emoji.id) { + return { + key: `discord-${emoji.id}`, + custom: { id: emoji.id, animated: emoji.animated ?? false }, + }; + } + return { key: emoji.name ?? "" }; +} + +// Message text with mentions and custom emojis resolved for the hub. +async function renderText(message: DiscordMessage): Promise { + return resolveEmojis(await resolveMentions(message)); +} + +// Resolves user and role mentions the hub can't resolve from ids. User mentions +// become a link to the Discord profile; role mentions become @name. Channel +// mentions are left for reference linking. The message's own mention collections +// are unreliable for backfilled/edited messages, so ids are resolved from cache +// and fetched as a fallback. +async function resolveMentions(message: DiscordMessage): Promise { + const content = message.content ?? ""; + + const users = new Map(); + for (const m of content.matchAll(/<@!?(\d+)>/g)) { + const id = m[1]; + if (users.has(id)) continue; + const name = await userName(message, id); + if (name) users.set(id, name); + } + + const roles = new Map(); + for (const m of content.matchAll(/<@&(\d+)>/g)) { + const id = m[1]; + if (roles.has(id)) continue; + const name = await roleName(message, id); + if (name) roles.set(id, name); + } + + return content + .replace(/<@!?(\d+)>/g, (m, id) => + users.has(id) + ? `[@${users.get(id)}](https://discord.com/users/${id})` + : m, + ) + .replace(/<@&(\d+)>/g, (m, id) => + roles.has(id) ? `@${roles.get(id)}` : m, + ); +} + +// Guild display name (nickname) if resolvable, else the global username. +async function userName( + message: DiscordMessage, + id: string, +): Promise { + const member = + message.mentions.members?.get(id) ?? + message.guild?.members.cache.get(id) ?? + (await message.guild?.members.fetch(id).catch(() => null)); + if (member) return member.displayName; + + const user = + message.mentions.users.get(id) ?? + message.client.users.cache.get(id) ?? + (await message.client.users.fetch(id).catch(() => null)); + return user?.username ?? null; +} + +async function roleName( + message: DiscordMessage, + id: string, +): Promise { + const role = + message.mentions.roles.get(id) ?? + message.guild?.roles.cache.get(id) ?? + (await message.guild?.roles.fetch(id).catch(() => null)); + return role?.name ?? null; +} + +// Rewrites custom emojis (<:name:id>, ) as :discord-: shortcodes +// that resolve to the registered hub emojis. +function resolveEmojis(content: string): string { + return content.replace(//g, (_m, id) => `:discord-${id}:`); +} + +function customEmojisIn(content: string): CustomEmoji[] { + const seen = new Set(); + const emojis: CustomEmoji[] = []; + for (const [, animated, id] of content.matchAll(/<(a?):\w+:(\d+)>/g)) { + if (seen.has(id)) continue; + seen.add(id); + emojis.push({ id, animated: animated === "a" }); + } + return emojis; +} + +function attachmentsOf(message: DiscordMessage): Attachment[] { + return [...message.attachments.values()].map((a) => ({ + name: a.name, + url: a.url, + contentType: a.contentType, + isImage: a.contentType?.startsWith("image/") ?? false, + })); +} + +function referencesOf(content: string): Reference[] { + return dedupeReferences([ + ...githubReferences(content), + ...discordThreadReferences(content, config.serverId), + ]); +} + +function authorOf(message: DiscordMessage): Author { + const handle = message.author.username; + const displayName = + message.member?.displayName ?? message.author.displayName ?? handle; + return { + name: displayName === handle ? handle : `${displayName} (${handle})`, + iconUrl: + message.member?.displayAvatarURL() ?? message.author.displayAvatarURL(), + }; +} + +function attachmentOf(help: HelpThread): SourceAttachment { + return { + title: "Discord thread", + subtitle: subtitle(help), + metadata: { + threadId: help.thread.id, + tagIds: help.tags.map((t) => t.id), + tagNames: help.tags.map((t) => t.name), + status: help.status, + waiting: help.waiting, + }, + }; +} + +function subtitle(help: HelpThread): string { + const parts = ["#help"]; + if (help.isClosed) parts.push("closed"); + if (help.waiting) parts.push(`waiting: ${help.waiting}`); + if (help.tags.length > 0) { + parts.push(`tags: ${help.tags.map((t) => t.name).join(", ")}`); + } + return parts.join(" - "); +} diff --git a/src/bridge/linear/assets.ts b/src/bridge/linear/assets.ts new file mode 100644 index 0000000..8b70f1b --- /dev/null +++ b/src/bridge/linear/assets.ts @@ -0,0 +1,53 @@ +import { linear } from "./client.js"; + +// Fetches a remote file and uploads its bytes to Linear storage, returning the +// permanent asset URL. Linear only accepts asset URLs on its own upload domain, +// so emojis and attachments must be re-hosted here rather than hotlinked. +export async function rehost( + sourceUrl: string, + filename: string, + type: string, +): Promise { + const source = await fetch(sourceUrl); + if (!source.ok) return null; + const bytes = await source.arrayBuffer(); + + const upload = (await linear().fileUpload(type, filename, bytes.byteLength)) + .uploadFile; + if (!upload) return null; + + const headers = new Headers({ "Content-Type": type }); + for (const { key, value } of upload.headers) headers.set(key, value); + + const put = await fetch(upload.uploadUrl, { + method: "PUT", + headers, + body: bytes, + }); + return put.ok ? upload.assetUrl : null; +} + +// Re-hosts a remote file for durable storage, or null if the upload fails (the +// caller falls back to the source URL). +export async function uploadFile( + sourceUrl: string, + filename: string, + contentType: string | null, +): Promise { + try { + const asset = await rehost( + sourceUrl, + filename, + contentType || "application/octet-stream", + ); + console.debug( + "[bridge]", + "uploaded file", + filename, + asset ? "ok" : "failed", + ); + return asset; + } catch { + return null; + } +} diff --git a/src/bridge/linear/attachments.ts b/src/bridge/linear/attachments.ts new file mode 100644 index 0000000..89e072e --- /dev/null +++ b/src/bridge/linear/attachments.ts @@ -0,0 +1,67 @@ +import type { Attachment, Issue } from "@linear/sdk"; + +import { bridgeConfig, linear } from "./client.js"; + +// Fields stored on the hub issue's attachment that links back to the source +// conversation. Kept byte-compatible with existing mirrored issues. +export interface ThreadAttachmentFields { + url: string; + title: string; + subtitle: string; + metadata: Record; +} + +// Returns the first attachment on the URL whose issue lives in the configured +// team. Attachments match across the whole workspace, so scoping to the team +// keeps lookups from touching issues in unrelated Linear teams. +export async function attachmentInTeam( + url: string, +): Promise<{ attachment: Attachment; issue: Issue } | null> { + const { teamId } = bridgeConfig(); + const attachments = await linear().attachmentsForURL(url); + for (const attachment of attachments.nodes) { + const issue = await attachment.issue; + if (!issue) continue; + const team = await issue.team; + if (team?.id === teamId) return { attachment, issue }; + } + return null; +} + +// Finds the issue mapped to a conversation via its URL attachment, returning the +// issue and attachment ids. +export async function findThreadMapping( + url: string, +): Promise<{ issueId: string; attachmentId: string } | null> { + const match = await attachmentInTeam(url); + if (!match) return null; + return { issueId: match.issue.id, attachmentId: match.attachment.id }; +} + +// Creates the linking attachment on a freshly created issue. +export async function createThreadAttachment( + issueId: string, + fields: ThreadAttachmentFields, +): Promise { + await linear().createAttachment({ issueId, ...fields }); + console.debug("[bridge]", "created attachment", issueId, fields.url); +} + +// Updates the issue's linking attachment in place, or creates it if missing. +export async function upsertThreadAttachment( + issueId: string, + fields: ThreadAttachmentFields, +): Promise { + const mapping = await findThreadMapping(fields.url); + if (!mapping) { + await createThreadAttachment(issueId, fields); + return; + } + + await linear().updateAttachment(mapping.attachmentId, { + title: fields.title, + subtitle: fields.subtitle, + metadata: fields.metadata, + }); + console.debug("[bridge]", "updated attachment", issueId); +} diff --git a/src/bridge/linear/client.ts b/src/bridge/linear/client.ts new file mode 100644 index 0000000..7b7cd2c --- /dev/null +++ b/src/bridge/linear/client.ts @@ -0,0 +1,68 @@ +import { LinearClient } from "@linear/sdk"; + +import { config } from "@lib/config.js"; + +// Validated bridge credentials. Present whenever the bridge is enabled. +export function bridgeConfig(): { + appToken: string; + userToken: string; + teamId: string; +} { + const { appToken, userToken, teamId } = config.linearBridge; + if (!appToken || !userToken || !teamId) { + throw new Error( + "linearBridge is enabled but appToken/userToken/teamId are missing", + ); + } + return { appToken, userToken, teamId }; +} + +let appClient: LinearClient | undefined; +let userClient: LinearClient | undefined; + +// App-actor client. Issues, comments and reactions run here so they are +// attributed to the external author (OAuth tokens use accessToken). +export function linear(): LinearClient { + if (!appClient) { + appClient = new LinearClient({ accessToken: bridgeConfig().appToken }); + } + return appClient; +} + +// Personal-key client for writes the app actor cannot make: creating custom +// emojis and labels. +export function linearUser(): LinearClient { + if (!userClient) { + userClient = new LinearClient({ apiKey: bridgeConfig().userToken }); + } + return userClient; +} + +// Extracts a readable message from a Linear SDK error, whose default string +// form is unhelpful ("[object Object]"). +export function linearError(err: unknown): string { + const e = err as { errors?: { message?: string }[]; message?: string }; + return ( + e?.errors + ?.map((x) => x.message) + .filter(Boolean) + .join("; ") || + e?.message || + String(err) + ); +} + +// Whether an error is a rate-limit rejection, so a bulk import can wait and +// retry rather than abort. +export function isRateLimited(err: unknown): boolean { + const e = err as { + type?: string; + status?: number; + errors?: { extensions?: { type?: string } }[]; + }; + return ( + e?.type === "Ratelimited" || + e?.status === 429 || + (e?.errors?.some((x) => x.extensions?.type === "Ratelimited") ?? false) + ); +} diff --git a/src/bridge/linear/comments.ts b/src/bridge/linear/comments.ts new file mode 100644 index 0000000..30d2d8c --- /dev/null +++ b/src/bridge/linear/comments.ts @@ -0,0 +1,166 @@ +import type { Comment } from "@linear/sdk"; + +import type { SourceId } from "@bridge/core/model.js"; + +import { linear } from "./client.js"; + +// Locates the mirrored comment for a source message so a later edit/delete finds +// the right one. `source` namespaces the marker so different platforms don't +// collide; Discord's marker is `discord-msg`, kept byte-compatible with data +// already written to Linear. +export interface Marker { + source: SourceId; + id: string; +} + +// Invisible marker (an unused markdown reference-link definition) appended to +// mirrored comments. It renders as nothing in Linear but round-trips in the raw +// body. +function withMarker(body: string, marker: Marker): string { + return `${body}\n\n[${marker.source}-msg]: ${marker.id}`; +} + +function markerMessageId(body: string): string | null { + return body.match(/^\[[a-z]+-msg\]:\s*(\S+)/m)?.[1] ?? null; +} + +// Adds a comment. When a marker is given the message id is embedded so the +// comment can be found again; a plain note (no marker) is used for system +// messages like "thread closed". +export async function addComment( + issueId: string, + body: string, + author?: { name: string; iconUrl?: string }, + marker?: Marker, + parentId?: string, + createdAt?: Date, +): Promise { + const input = { + issueId, + body: marker ? withMarker(body, marker) : body, + createdAt, + // Attributes the comment to an external author. Requires OAuth app-actor + // auth; Linear rejects these fields for personal API keys, so the caller + // only supplies an author when that mode is configured. + createAsUser: author?.name, + displayIconUrl: author?.iconUrl, + }; + + console.debug( + "[bridge]", + "adding comment", + issueId, + "msg", + marker?.id ?? "-", + "parent", + parentId ?? "-", + ); + + try { + await linear().createComment({ ...input, parentId }); + } catch (err) { + // Linear threads are one level deep; if the parent is itself a reply, fall + // back to a top-level comment rather than dropping the message. + if (!parentId) throw err; + await linear().createComment(input); + } +} + +// Returns the source message ids already mirrored as comments on the issue, read +// from the invisible markers, so a backfill can skip them. +export async function mirroredMessageIds( + issueId: string, +): Promise> { + const issue = await linear().issue(issueId); + const ids = new Set(); + + let page = await issue.comments({ first: 100 }); + while (true) { + for (const comment of page.nodes) { + const id = markerMessageId(comment.body); + if (id) ids.add(id); + } + if (!page.pageInfo.hasNextPage) break; + page = await issue.comments({ + first: 100, + after: page.pageInfo.endCursor ?? undefined, + }); + } + return ids; +} + +// Updates the mirrored comment for a message. Returns false if the message has +// no mirrored comment. +export async function editComment( + issueId: string, + marker: Marker, + body: string, +): Promise { + const commentId = await findCommentByMessage(issueId, marker.id); + if (!commentId) return false; + console.debug("[bridge]", "editing comment", marker.id, issueId); + await linear().updateComment(commentId, { body: withMarker(body, marker) }); + return true; +} + +// Deletes the mirrored comment for a message. Returns false if the message has +// no mirrored comment. If the comment has replies, its body is blanked instead +// of deleted, since Linear removes a comment's replies along with it. +export async function deleteComment( + issueId: string, + marker: Marker, +): Promise { + const node = await findCommentNode(issueId, marker.id); + if (!node) return false; + + const children = await node.children(); + if (children.nodes.length > 0) { + console.debug( + "[bridge]", + "tombstoning comment (has replies)", + marker.id, + issueId, + ); + await linear().updateComment(node.id, { + body: withMarker("_Message deleted._", marker), + }); + } else { + console.debug("[bridge]", "deleting comment", marker.id, issueId); + await linear().deleteComment(node.id); + } + return true; +} + +// Finds the mirrored comment node for a source message id, or null. +async function findCommentNode( + issueId: string, + messageId: string, +): Promise { + const issue = await linear().issue(issueId); + const { nodes } = await issue.comments(); + for (const comment of nodes) { + if (markerMessageId(comment.body) === messageId) return comment; + } + return null; +} + +// Finds the mirrored comment id for a source message id, or null. +export async function findCommentByMessage( + issueId: string, + messageId: string, +): Promise { + return (await findCommentNode(issueId, messageId))?.id ?? null; +} + +// Resolves the comment a reply should attach to: the mirrored comment of the +// referenced message, collapsed to its thread root since Linear threads are only +// one level deep. Returns null when the reference isn't mirrored. +export async function resolveReplyParent( + issueId: string, + messageId: string, +): Promise { + const node = await findCommentNode(issueId, messageId); + if (!node) return null; + const parent = await node.parent; + return parent?.id ?? node.id; +} diff --git a/src/bridge/linear/emojis.ts b/src/bridge/linear/emojis.ts new file mode 100644 index 0000000..5c2779a --- /dev/null +++ b/src/bridge/linear/emojis.ts @@ -0,0 +1,53 @@ +import { linearUser, linearError } from "./client.js"; +import { rehost } from "./assets.js"; + +// Names of the workspace's custom emojis, loaded once and updated as we create +// new ones, so we don't recreate existing emojis or spam duplicate errors. +let emojiNames: Set | undefined; + +async function loadEmojiNames(): Promise> { + if (emojiNames) return emojiNames; + const names = new Set(); + let after: string | undefined; + do { + const page = await linearUser().emojis({ first: 250, after }); + for (const e of page.nodes) names.add(e.name); + after = page.pageInfo.hasNextPage + ? (page.pageInfo.endCursor ?? undefined) + : undefined; + } while (after); + emojiNames = names; + return names; +} + +// Registers a custom emoji as a workspace emoji named discord- so that +// :discord-: renders inline. Idempotent: skips emojis that already exist and +// needs the user token, as the app actor cannot create emojis. +export async function ensureEmoji( + id: string, + animated: boolean, +): Promise { + const name = `discord-${id}`; + const names = await loadEmojiNames(); + if (names.has(name)) return; + + const ext = animated ? "gif" : "png"; + // Linear rejects external image URLs, so re-host the source emoji first. + const asset = await rehost( + `https://cdn.discordapp.com/emojis/${id}.${ext}`, + `${name}.${ext}`, + animated ? "image/gif" : "image/png", + ).catch(() => null); + if (!asset) { + console.error("[bridge]", "ensureEmoji upload failed", name); + return; + } + + try { + await linearUser().createEmoji({ name, url: asset }); + names.add(name); + console.debug("[bridge]", "registered emoji", name); + } catch (err) { + console.error("[bridge]", "ensureEmoji failed", name, linearError(err)); + } +} diff --git a/src/bridge/linear/index.ts b/src/bridge/linear/index.ts new file mode 100644 index 0000000..cd67530 --- /dev/null +++ b/src/bridge/linear/index.ts @@ -0,0 +1,237 @@ +import { config } from "@lib/config.js"; + +import type { Author, ExternalRef, Message, Post } from "@bridge/core/model.js"; +import { composeBody } from "@bridge/core/model.js"; +import type { + IssueState, + LinkedIssue, + ReactionTarget, + Target, +} from "@bridge/core/connector.js"; + +import { + addReaction, + removeReaction, + type ReactionTarget as LinearReactionTarget, +} from "./reactions.js"; +import { + createThreadAttachment, + findThreadMapping, + upsertThreadAttachment, + type ThreadAttachmentFields, +} from "./attachments.js"; +import { + createIssue, + deleteIssue, + getIssueRef, + reconcileIssue, + relateIssues, + resolveIssueByUrl, + setIssueDescription, +} from "./issues.js"; +import { + addComment, + deleteComment, + editComment, + findCommentByMessage, + mirroredMessageIds, + resolveReplyParent, +} from "./comments.js"; +import { getIssueState, setIssueState } from "./state.js"; +import { ensureLabel, setNamespacedLabels } from "./labels.js"; +import { ensureEmoji } from "./emojis.js"; +import { uploadFile } from "./assets.js"; + +// Linear as the hub store, adapting the source-agnostic model onto the Linear +// SDK modules. Attachment shape, comment markers and timestamps are kept +// byte-compatible with issues already mirrored into Linear. +export class LinearConnector implements Target { + findIssueId(ref: ExternalRef): Promise { + return findThreadMapping(ref.url).then((m) => m?.issueId ?? null); + } + + async ensureIssue(post: Post): Promise { + const mapping = await findThreadMapping(post.ref.url); + if (mapping) return mapping.issueId; + + const issueId = await createIssue({ + title: post.title, + description: post.body, + author: this.attribution(post.author), + createdAt: post.createdAt, + }); + await createThreadAttachment(issueId, this.attachment(post)); + return issueId; + } + + async deleteIssue(ref: ExternalRef): Promise { + const mapping = await findThreadMapping(ref.url); + if (mapping) await deleteIssue(mapping.issueId); + } + + async reconcile(issueId: string, post: Post): Promise { + await upsertThreadAttachment(issueId, this.attachment(post)); + await reconcileIssue(issueId, post.title); + await this.syncLabels(issueId, post); + } + + async syncLabels(issueId: string, post: Post): Promise { + if (!config.linearBridge.labels.enabled) return; + const prefix = `${config.linearBridge.labels.namespace} > `; + const desiredIds: string[] = []; + for (const label of post.labels) { + desiredIds.push(await ensureLabel(`${prefix}${label.name}`, label.id)); + } + await setNamespacedLabels(issueId, prefix, desiredIds); + } + + setDescription(issueId: string, text: string): Promise { + return setIssueDescription(issueId, text); + } + + async updateDescription(issueId: string, message: Message): Promise { + await this.ensureEmojis(message); + await setIssueDescription(issueId, await this.renderBody(message)); + } + + async addComment( + issueId: string, + message: Message, + parentId?: string, + ): Promise { + await this.ensureEmojis(message); + const fast = composeBody(message.text, message.attachments, (a) => a.url); + await addComment( + issueId, + fast, + this.attribution(message.author), + { source: message.ref.source, id: message.ref.id }, + parentId, + message.createdAt, + ); + + // Attachments mirror instantly as CDN links (which expire), then the comment + // is edited to swap in permanent Linear-hosted URLs. + if (message.attachments.length > 0) { + await editComment( + issueId, + { source: message.ref.source, id: message.ref.id }, + await this.renderBody(message), + ); + } + } + + async editComment(issueId: string, message: Message): Promise { + await this.ensureEmojis(message); + return editComment( + issueId, + { source: message.ref.source, id: message.ref.id }, + await this.renderBody(message), + ); + } + + deleteComment(issueId: string, ref: ExternalRef): Promise { + return deleteComment(issueId, { source: ref.source, id: ref.id }); + } + + mirroredMessageIds(issueId: string): Promise> { + return mirroredMessageIds(issueId); + } + + resolveReplyParent( + issueId: string, + messageId: string, + ): Promise { + return resolveReplyParent(issueId, messageId); + } + + findCommentId(issueId: string, messageId: string): Promise { + return findCommentByMessage(issueId, messageId); + } + + note(issueId: string, body: string, createdAt?: Date): Promise { + return addComment( + issueId, + body, + undefined, + undefined, + undefined, + createdAt, + ); + } + + getState(issueId: string): Promise { + return getIssueState(issueId); + } + + setState( + issueId: string, + type: "completed" | "triage" | "started", + name?: string, + ): Promise { + return setIssueState(issueId, type, name); + } + + async addReaction( + target: ReactionTarget, + reaction: { key: string; custom?: { id: string; animated: boolean } }, + ): Promise { + if (reaction.custom) { + await ensureEmoji(reaction.custom.id, reaction.custom.animated); + } + await addReaction(target as LinearReactionTarget, reaction.key); + } + + removeReaction( + target: ReactionTarget, + reaction: { key: string }, + ): Promise { + return removeReaction(target as LinearReactionTarget, reaction.key); + } + + resolveByUrl(url: string): Promise { + return resolveIssueByUrl(url); + } + + relate(issueId: string, otherId: string): Promise { + return relateIssues(issueId, otherId); + } + + issueRef(issueId: string): Promise<{ identifier: string; url: string }> { + return getIssueRef(issueId); + } + + // Body with attachments re-hosted in Linear for permanence, falling back to + // the CDN URL for any upload that fails. + private async renderBody(message: Message): Promise { + const assetByUrl = new Map(); + for (const a of message.attachments) { + const asset = await uploadFile(a.url, a.name, a.contentType); + if (asset) assetByUrl.set(a.url, asset); + } + return composeBody( + message.text, + message.attachments, + (a) => assetByUrl.get(a.url) ?? a.url, + ); + } + + private async ensureEmojis(message: Message): Promise { + for (const e of message.customEmojis) await ensureEmoji(e.id, e.animated); + } + + private attachment(post: Post): ThreadAttachmentFields { + return { + url: post.ref.url, + title: post.attachment.title, + subtitle: post.attachment.subtitle, + metadata: post.attachment.metadata, + }; + } + + // External-author attribution, gated by createAsUser (a personal API key + // rejects these fields, so they're dropped when the mode is off). + private attribution(author?: Author): Author | undefined { + return config.linearBridge.createAsUser ? author : undefined; + } +} diff --git a/src/bridge/linear/issues.ts b/src/bridge/linear/issues.ts new file mode 100644 index 0000000..016d9b4 --- /dev/null +++ b/src/bridge/linear/issues.ts @@ -0,0 +1,120 @@ +import { IssueRelationType } from "@linear/sdk"; + +import { config } from "@lib/config.js"; + +import { linear, linearError } from "./client.js"; +import { attachmentInTeam } from "./attachments.js"; + +// Creates an issue in the configured team and returns its id. +export async function createIssue(input: { + title: string; + description: string; + author?: { name: string; iconUrl?: string }; + createdAt?: Date; +}): Promise { + const payload = await linear().createIssue({ + teamId: config.linearBridge.teamId, + projectId: config.linearBridge.projectId, + title: input.title, + description: input.description, + createdAt: input.createdAt, + // Attributes the issue to an external author under app-actor auth; ignored + // fields are safe to omit for personal keys (author is undefined). + createAsUser: input.author?.name, + displayIconUrl: input.author?.iconUrl, + }); + + const issue = await payload.issue; + if (!issue) throw new Error("Linear did not return the created issue"); + console.debug("[bridge]", "created issue", issue.identifier, input.title); + return issue.id; +} + +// Trashes an issue (recoverable in Linear). +export async function deleteIssue(issueId: string): Promise { + console.debug("[bridge]", "trashing issue", issueId); + await linear().deleteIssue(issueId); +} + +// Replaces an issue's description. +export async function setIssueDescription( + issueId: string, + description: string, +): Promise { + console.debug("[bridge]", "updating description", issueId); + await linear().updateIssue(issueId, { description }); +} + +// Reconciles the issue's title and project when they drift (e.g. a renamed +// conversation, or an issue created before the project was configured). One +// fetch, one update, only when something actually changed. +export async function reconcileIssue( + issueId: string, + title: string, +): Promise { + const { projectId } = config.linearBridge; + const issue = await linear().issue(issueId); + + const update: { title?: string; projectId?: string } = {}; + if (issue.title !== title) update.title = title; + if (projectId && issue.projectId !== projectId) update.projectId = projectId; + if (Object.keys(update).length === 0) return; + + console.debug( + "[bridge]", + "reconciling issue", + issueId, + Object.keys(update).join(", "), + ); + await linear().updateIssue(issueId, update); +} + +// Returns an issue's identifier and URL, e.g. for linking back from a source. +export async function getIssueRef( + issueId: string, +): Promise<{ identifier: string; url: string }> { + const issue = await linear().issue(issueId); + return { identifier: issue.identifier, url: issue.url }; +} + +export interface LinkedIssue { + id: string; + identifier: string; + url: string; +} + +// Finds the issue mapped to a URL via its attachments (a mirrored conversation, +// or a GitHub issue linked through Linear's integration), scoped to the team. +export async function resolveIssueByUrl( + url: string, +): Promise { + const match = await attachmentInTeam(url); + if (!match) return null; + const { issue } = match; + return { id: issue.id, identifier: issue.identifier, url: issue.url }; +} + +// Relation pairs created this session, to avoid duplicate "related" links when +// the same issue is referenced more than once. +const relatedPairs = new Set(); + +// Marks two issues as related. Idempotent within a session and tolerant of +// Linear rejecting an existing relation. +export async function relateIssues( + issueId: string, + relatedIssueId: string, +): Promise { + const key = [issueId, relatedIssueId].sort().join("|"); + if (relatedPairs.has(key)) return; + relatedPairs.add(key); + try { + await linear().createIssueRelation({ + issueId, + relatedIssueId, + type: IssueRelationType.Related, + }); + console.debug("[bridge]", "related issues", key); + } catch (err) { + console.error("[bridge]", "relateIssues failed", key, linearError(err)); + } +} diff --git a/src/bridge/linear/labels.ts b/src/bridge/linear/labels.ts new file mode 100644 index 0000000..8d6ddb5 --- /dev/null +++ b/src/bridge/linear/labels.ts @@ -0,0 +1,67 @@ +import { bridgeConfig, linearUser } from "./client.js"; + +// Flat (ungrouped) labels are used rather than a label group because Linear +// allows only one label per group on an issue, while a conversation can carry +// several tags. Each label is namespaced by name, e.g. "#help > tag". +const labelIdByName = new Map(); + +// Finds or creates a team label with the given name, tagging its description +// with the source tag id. Cached by name. Runs on the user token, which owns +// label management. +export async function ensureLabel( + name: string, + tagId: string, +): Promise { + const cached = labelIdByName.get(name); + if (cached) return cached; + + const { teamId } = bridgeConfig(); + const existing = await linearUser().issueLabels({ + filter: { name: { eq: name }, team: { id: { eq: teamId } } }, + }); + + let id = existing.nodes[0]?.id; + if (!id) { + const payload = await linearUser().createIssueLabel({ + name, + description: tagId, + teamId, + }); + const label = await payload.issueLabel; + if (!label) throw new Error("Linear did not return the created label"); + id = label.id; + console.debug("[bridge]", "created label", name); + } + + labelIdByName.set(name, id); + return id; +} + +// Reconciles the issue's namespaced labels to exactly match desiredIds, adding +// missing ones and removing only stale labels that share the namespace prefix +// (so unrelated labels are never touched, and labels already absent are never +// "removed"). Runs on the user token that owns the labels. +export async function setNamespacedLabels( + issueId: string, + prefix: string, + desiredIds: string[], +): Promise { + const issue = await linearUser().issue(issueId); + const current = (await issue.labels()).nodes; + const ours = current + .filter((l) => l.name.startsWith(prefix)) + .map((l) => l.id); + + const addedLabelIds = desiredIds.filter((id) => !ours.includes(id)); + const removedLabelIds = ours.filter((id) => !desiredIds.includes(id)); + if (addedLabelIds.length === 0 && removedLabelIds.length === 0) return; + + console.debug( + "[bridge]", + "updating labels", + issueId, + `+${addedLabelIds.length}`, + `-${removedLabelIds.length}`, + ); + await linearUser().updateIssue(issueId, { addedLabelIds, removedLabelIds }); +} diff --git a/src/bridge/linear/reactions.ts b/src/bridge/linear/reactions.ts new file mode 100644 index 0000000..3a53085 --- /dev/null +++ b/src/bridge/linear/reactions.ts @@ -0,0 +1,54 @@ +import { linear, linearError } from "./client.js"; + +export type ReactionTarget = { issueId: string } | { commentId: string }; + +// Reaction ids we created, keyed by target+emoji, so a later removal can delete +// the exact reaction even for unicode emojis whose stored name differs from the +// input we sent. +const reactionIds = new Map(); + +function reactionKey(target: ReactionTarget, emoji: string): string { + const scope = + "issueId" in target ? `i:${target.issueId}` : `c:${target.commentId}`; + return `${scope}|${emoji}`; +} + +export async function addReaction( + target: ReactionTarget, + emoji: string, +): Promise { + try { + const payload = await linear().createReaction({ ...target, emoji }); + const reaction = await payload.reaction; + if (reaction) reactionIds.set(reactionKey(target, emoji), reaction.id); + console.debug("[bridge]", "added reaction", reactionKey(target, emoji)); + } catch (err) { + console.error("[bridge]", "addReaction failed", emoji, linearError(err)); + } +} + +export async function removeReaction( + target: ReactionTarget, + emoji: string, +): Promise { + const key = reactionKey(target, emoji); + const id = reactionIds.get(key) ?? (await findReaction(target, emoji)); + if (!id) return; + await linear().deleteReaction(id); + reactionIds.delete(key); + console.debug("[bridge]", "removed reaction", key); +} + +// Finds a reaction on the target whose stored emoji matches, used as a fallback +// when the created id is not cached (e.g. after a restart). Reliable for custom +// emojis; unicode names are normalized by Linear so may not match. +async function findReaction( + target: ReactionTarget, + emoji: string, +): Promise { + const reactions = + "issueId" in target + ? (await linear().issue(target.issueId)).reactions + : (await linear().comment({ id: target.commentId })).reactions; + return reactions.find((r) => r.emoji === emoji)?.id ?? null; +} diff --git a/src/bridge/linear/state.ts b/src/bridge/linear/state.ts new file mode 100644 index 0000000..b5d815d --- /dev/null +++ b/src/bridge/linear/state.ts @@ -0,0 +1,59 @@ +import { bridgeConfig, linear } from "./client.js"; + +// Returns the workflow state type and name of an issue (e.g. type "started", +// name "In Progress"). +export async function getIssueState( + issueId: string, +): Promise<{ type: string; name: string } | null> { + const issue = await linear().issue(issueId); + const state = await issue.state; + return state ? { type: state.type, name: state.name } : null; +} + +// Moves an issue to a workflow state of the given type in the team. The started +// type has several states (In Progress, Blocked, In Review), so pass the state +// name; without one, the lowest-position state of the type is used. +export async function setIssueState( + issueId: string, + type: "completed" | "triage" | "started", + preferredName?: string, +): Promise { + const stateId = await findStateId(type, preferredName); + if (!stateId) return; + console.debug( + "[bridge]", + "setting issue state", + issueId, + preferredName ?? type, + ); + await linear().updateIssue(issueId, { stateId }); +} + +const stateIdByType = new Map(); + +// Finds a workflow state of the given type in the team. When preferredName is +// set, a state with that name wins; otherwise the lowest-position state of the +// type is used, since Linear does not order the results. +async function findStateId( + type: string, + preferredName?: string, +): Promise { + const cacheKey = preferredName ? `${type}:${preferredName}` : type; + const cached = stateIdByType.get(cacheKey); + if (cached) return cached; + + const { teamId } = bridgeConfig(); + const states = await linear().workflowStates({ + filter: { team: { id: { eq: teamId } }, type: { eq: type } }, + }); + + const named = + preferredName && + states.nodes.find( + (s) => s.name.toLowerCase() === preferredName.toLowerCase(), + ); + const byPosition = [...states.nodes].sort((a, b) => a.position - b.position); + const id = (named || byPosition[0])?.id ?? null; + if (id) stateIdByType.set(cacheKey, id); + return id; +} diff --git a/src/commands/util/close.ts b/src/commands/util/close.ts index de90bc1..25b83a9 100644 --- a/src/commands/util/close.ts +++ b/src/commands/util/close.ts @@ -79,7 +79,7 @@ export async function handleIssueState( await threadChannel.setArchived(true); } } catch (err) { - console.error("Error archiving thread:", err); + console.error("[close]", "archiving thread failed", err); } } } catch { diff --git a/src/deploy-commands.ts b/src/deploy-commands.ts index 937f7f6..225daf6 100644 --- a/src/deploy-commands.ts +++ b/src/deploy-commands.ts @@ -13,7 +13,10 @@ const commandData = Object.values(commands).map((command) => ); console.log( - `Started refreshing ${commandData.length} application (/) commands.`, + "[commands]", + "refreshing", + commandData.length, + "application (/) commands", ); // The put method is used to fully refresh all commands in the guild with the current set @@ -26,4 +29,4 @@ const data: any = await rest.put( { body: commandData }, ); -console.log(`Successfully reloaded ${data.length} application (/) commands.`); +console.log("[commands]", "reloaded", data.length, "application (/) commands"); diff --git a/src/events/commands.ts b/src/events/commands.ts index 719a556..fcca97a 100644 --- a/src/events/commands.ts +++ b/src/events/commands.ts @@ -12,7 +12,9 @@ export default function registerEvents(client: Client) { if (!command) { console.error( - `No command matching "${interaction.commandName}" was found.`, + "[commands]", + "no command matching", + interaction.commandName, ); return; } @@ -20,7 +22,7 @@ export default function registerEvents(client: Client) { try { await command.execute(interaction); } catch (error) { - console.error(error); + console.error("[commands]", "execution failed", error); // TODO: make generic replyOrFollowUp method // TODO: log error if the user is admin diff --git a/src/index.ts b/src/index.ts index b2eb80e..d4cefae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,11 +5,30 @@ import registerCommandEvents from "./events/commands.js"; import registerWalkthroughEvents from "./events/walkthrough.js"; import registerMessageEvents from "./events/messages.js"; import registerChannelEvents from "./events/channels.js"; +import { registerBridge, backfillBridge } from "@bridge/core/bridge.js"; -import { Client, Events, GatewayIntentBits, ActivityType } from "discord.js"; +import { + Client, + Events, + GatewayIntentBits, + ActivityType, + Partials, +} from "discord.js"; const client = new Client({ - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.MessageContent, + ], + // Needed so edits/deletes/reactions on uncached messages still emit events. + partials: [ + Partials.Message, + Partials.Channel, + Partials.Reaction, + Partials.User, + ], }); const presenceList = [ @@ -34,18 +53,23 @@ function shufflePresence() { } client.once(Events.ClientReady, () => { - console.log(`Logged in as ${client.user?.tag}!`); + console.log("[bot]", "logged in as", client.user?.tag); registerCommandEvents(client); registerWalkthroughEvents(client); registerMessageEvents(client); registerChannelEvents(client); + registerBridge(client); shufflePresence(); setInterval(shufflePresence, config.presenceDelay); catchUpHelpPosts(client).catch((err) => - console.error("Failed to catch up on help posts:", err), + console.error("[help]", "catch-up failed", err), + ); + + backfillBridge(client).catch((err) => + console.error("[bridge]", "backfill failed", err), ); }); diff --git a/src/lib/config.ts b/src/lib/config.ts index 9b2cedc..27e32c7 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -35,6 +35,38 @@ interface Config { companyId: string; }; + // One-way Discord -> Linear bridge for #help threads. Disabled by default. + linearBridge: { + enabled: boolean; + // OAuth app-actor token. Used for issues, comments and reactions so they + // are attributed to the external Discord author. + appToken?: string; + // Personal API key. Used for workspace/team admin writes the app actor is + // not allowed to make: creating custom emojis and labels. + userToken?: string; + teamId?: string; + // Optional Linear project that mirrored thread issues are filed under. + projectId?: string; + // Number of most recently active help threads to mirror on startup. 0 to + // disable. Threads already mirrored are skipped. + backfillLimit: number; + // Only mirror threads active within this many days on a normal (non-full) + // startup backfill, so it can't reach ancient threads. Ignored by + // backfillAll. + backfillDays: number; + // Mirror every #help thread on startup (all archived pages, ignoring + // backfillLimit), retrying through Linear rate limits. Slow; intended for + // the initial bulk import. + backfillAll: boolean; + // Attribute mirrored comments to the Discord author via Linear's + // createAsUser. Requires the app-actor token; turn off to post as the app. + createAsUser: boolean; + labels: { + enabled: boolean; + namespace: string; + }; + }; + presenceDelay: number; } @@ -51,6 +83,20 @@ export const { config, layers } = await loadConfig({ defaults: { presenceDelay: 10 * 60 * 1000, startupCatchupLimit: 20, + linearBridge: { + enabled: false, + createAsUser: false, + backfillLimit: 50, + backfillDays: 14, + backfillAll: false, + labels: { + // Label creation runs on the user token, which can manage the team's + // labels. Each #help tag becomes a flat label named " > tag"; + // groups are avoided since Linear allows only one group label per issue. + enabled: true, + namespace: "#help", + }, + }, }, mandatory: [ "token", @@ -75,3 +121,56 @@ export const { config, layers } = await loadConfig({ ["productBoard", "companyId"], ], }); + +// configmasher does not coerce types: values from env files or process.env +// arrive as strings, so a boolean like `backfillAll=false` would be the truthy +// string "false". Coerce the env-overridable booleans and numbers to their real +// types after loading. +function bool(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") return value; + if (value === "true") return true; + if (value === "false") return false; + return fallback; +} + +function num(value: unknown, fallback: number): number { + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) ? n : fallback; +} + +config.presenceDelay = num(config.presenceDelay, 10 * 60 * 1000); +config.startupCatchupLimit = num(config.startupCatchupLimit, 20); +config.linearBridge.enabled = bool(config.linearBridge.enabled, false); +config.linearBridge.createAsUser = bool( + config.linearBridge.createAsUser, + false, +); +config.linearBridge.backfillAll = bool(config.linearBridge.backfillAll, false); +config.linearBridge.backfillLimit = num(config.linearBridge.backfillLimit, 50); +config.linearBridge.backfillDays = num(config.linearBridge.backfillDays, 14); +config.linearBridge.labels.enabled = bool( + config.linearBridge.labels.enabled, + true, +); + +// linearBridge fields are conditionally required: only when the bridge is +// enabled. configmasher's `mandatory` list is static, so validate here and exit +// the same way a missing mandatory field would. +export function validateLinearBridgeConfig(): void { + const { linearBridge } = config; + if (!linearBridge.enabled) return; + + const missing: string[] = []; + if (!linearBridge.appToken) missing.push("linearBridge.appToken"); + if (!linearBridge.userToken) missing.push("linearBridge.userToken"); + if (!linearBridge.teamId) missing.push("linearBridge.teamId"); + + if (missing.length > 0) { + console.error( + "[config]", + "linearBridge.enabled is true but required config is missing:", + missing.join(", "), + ); + process.exit(1); + } +} diff --git a/src/lib/discord/help.ts b/src/lib/discord/help.ts index 573e749..db98518 100644 --- a/src/lib/discord/help.ts +++ b/src/lib/discord/help.ts @@ -14,17 +14,16 @@ import { // to system notices (pins, joins, etc). const humanMessageTypes = new Set([MessageType.Default, MessageType.Reply]); -function isHumanMessage(message: Message): boolean { +export function isHumanMessage(message: Message): boolean { return !message.author.bot && humanMessageTypes.has(message.type); } -// Picks the waiting tag for a help post based on who sent the last message. -// When the last interaction comes from a community member the team still needs -// to respond, so we apply waitingForTeamTag; when it comes from the Coder team -// we apply waitingForUserTag. Adding one always removes the other. +// Picks the waiting tag for a help post. A post waits on the user once the team +// has the last word, and waits on the team otherwise. Adding one always removes +// the other. export async function applyWaitingTag( thread: ThreadChannel, - lastFromTeam: boolean, + awaitingUser: boolean, ): Promise { const { waitingForUserTag, waitingForTeamTag, closedTag } = config.helpChannel; @@ -32,8 +31,8 @@ export async function applyWaitingTag( // Leave closed posts untouched. if (thread.appliedTags.includes(closedTag)) return; - const desired = lastFromTeam ? waitingForUserTag : waitingForTeamTag; - const opposite = lastFromTeam ? waitingForTeamTag : waitingForUserTag; + const desired = awaitingUser ? waitingForUserTag : waitingForTeamTag; + const opposite = awaitingUser ? waitingForTeamTag : waitingForUserTag; const alreadyCorrect = thread.appliedTags.includes(desired) && @@ -50,23 +49,30 @@ export async function applyWaitingTag( await thread.setAppliedTags(nextTags, "Help post waiting state"); } -async function resolveMember(message: Message): Promise { +export async function resolveMember( + message: Message, +): Promise { if (message.member) return message.member; try { - return await message.guild?.members.fetch(message.author.id); + return (await message.guild?.members.fetch(message.author.id)) ?? null; } catch { return null; } } // Applies the waiting tag for a help post based on who sent the given message. +// A post only waits on the user when the last message is from a team member who +// is not the OP; a team member asking their own question still waits on the +// team, as does any message from the OP or a community member. async function applyWaitingTagFromMessage( thread: ThreadChannel, message: Message, ): Promise { const member = await resolveMember(message); - await applyWaitingTag(thread, member ? isTeamMember(member) : false); + const fromTeam = member ? isTeamMember(member) : false; + const isOp = message.author.id === thread.ownerId; + await applyWaitingTag(thread, fromTeam && !isOp); } // Reconciles a single help post from a freshly received message. @@ -103,7 +109,7 @@ export async function catchUpHelpPosts(client: Client): Promise { try { await reconcileThread(thread); } catch (err) { - console.error(`Failed to reconcile help post ${thread.id}:`, err); + console.error("[help]", "failed to reconcile post", thread.id, err); } } } diff --git a/src/lib/discord/helpThread.ts b/src/lib/discord/helpThread.ts new file mode 100644 index 0000000..c345457 --- /dev/null +++ b/src/lib/discord/helpThread.ts @@ -0,0 +1,77 @@ +import { config } from "@lib/config.js"; + +import type { ThreadChannel } from "discord.js"; + +// A Discord forum tag applied to a help post. +export interface HelpTag { + id: string; + name: string; +} + +// Enriched view over a #help forum post. Wrap a thread with +// `new HelpThread(thread)` and read its lifecycle state via getters, computed +// lazily from the thread's applied tags (cached or freshly fetched upstream). +export class HelpThread { + constructor(readonly thread: ThreadChannel) {} + + get url(): string { + return this.thread.url; + } + + get title(): string { + return this.thread.name; + } + + get status(): "open" | "closed" { + return this.isClosed ? "closed" : "open"; + } + + get isClosed(): boolean { + const { closedTag, openedTag } = config.helpChannel; + const tags = this.thread.appliedTags; + if (tags.includes(closedTag)) return true; + if (tags.includes(openedTag)) return false; + // With no lifecycle tag, an archived post is inactive: treat it as closed. + return this.thread.archived === true; + } + + get isOpen(): boolean { + return !this.isClosed; + } + + // Best-effort time the post was closed, from the archive timestamp. Null when + // open, or closed via tag without archiving. + get closedAt(): Date | null { + return this.isClosed ? (this.thread.archivedAt ?? null) : null; + } + + get waiting(): "user" | "team" | null { + const { waitingForTeamTag, waitingForUserTag } = config.helpChannel; + const tags = this.thread.appliedTags; + if (tags.includes(waitingForTeamTag)) return "team"; + if (tags.includes(waitingForUserTag)) return "user"; + return null; + } + + // Applied tags (minus the lifecycle tags: open/closed and the waiting-for + // tags, which are surfaced as the mirrored issue's status), resolved to + // id + name from the parent forum's tag list. + get tags(): HelpTag[] { + const forum = this.thread.parent; + const available = + forum && "availableTags" in forum ? forum.availableTags : []; + const nameById = new Map(available.map((t) => [t.id, t.name])); + + const { closedTag, openedTag, waitingForUserTag, waitingForTeamTag } = + config.helpChannel; + const hidden = new Set([ + closedTag, + openedTag, + waitingForUserTag, + waitingForTeamTag, + ]); + return this.thread.appliedTags + .filter((id) => !hidden.has(id)) + .map((id) => ({ id, name: nameById.get(id) ?? id })); + } +} diff --git a/tsconfig.json b/tsconfig.json index 3d93445..2154f61 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "@commands/*": ["./src/commands/*"], "@events/*": ["./src/events/*"], "@lib/*": ["./src/lib/*"], + "@bridge/*": ["./src/bridge/*"], "@components/*": ["./src/ui/components/*"] }