diff --git a/.env.example b/.env.example index 5bf7297e..128b8c9b 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,87 @@ -DISCORD_TOKEN=your-bot-token-here -DISCORD_APP_ID=your-application-id-here -DISCORD_GUILD_ID=your-test-guild-id-here +# ============================================================ +# OmegaBot Environment Configuration +# ============================================================ +# +# Copy this file to `.env` and fill in real values. +# NEVER commit `.env` with secrets. +# +# ============================================================ +# Discord (required) +# ============================================================ -# Summary mode: use "llm" to enable OpenAI, or anything else for local summary +# Bot authentication token used to connect to Discord +DISCORD_TOKEN=your-discord-bot-token-here + +# Application ID required for slash command registration +DISCORD_APP_ID=your-discord-application-id + +# Guild where development slash commands are registered +# (guild commands update instantly) +DISCORD_GUILD_ID=your-discord-guild-id + + +# ============================================================ +# Summaries +# ============================================================ + +# Summary mode: +# - "local" → simple local summarizer (default) +# - "llm" → OpenAI-powered summaries SUMMARY_MODE=local -# Only required if SUMMARY_MODE=llm +# OpenAI API key +# Required ONLY when SUMMARY_MODE=llm OPENAI_API_KEY=your-openai-key-if-needed -# GitHub token -GITHUB_TOKEN=your-token-here \ No newline at end of file + +# ============================================================ +# GitHub Integration +# ============================================================ + +# Personal Access Token (PAT) for GitHub REST API +# +# Required for: +# - Issue lookup +# - Pull request lookup +# - PR announcements +# +# Fine-grained token recommended. +# Minimum permissions: +# - Contents: Read +# - Pull requests: Read +# - Issues: Read +GITHUB_TOKEN=your-github-pat-here + + +# ============================================================ +# GitHub PR Announcements (optional) +# ============================================================ + +# Default GitHub repo owner/org for PR polling +# Example: NickTheDevOpsGuy +GITHUB_OWNER=your-github-owner + +# Repository name for PR polling +# Example: OmegaBot +GITHUB_REPO=your-repo-name + +# Discord channel ID where PR announcements will be posted +# +# IMPORTANT: +# This must be a numeric channel ID, not a channel name. +# Enable Developer Mode in Discord → right-click channel → Copy ID +GITHUB_ANNOUNCE_CHANNEL_ID=your-discord-channel-id + + +# ============================================================ +# GitHub PR Polling Interval +# ============================================================ + +# Polling interval in milliseconds +# +# Examples: +# 60000 → every 1 minute +# 300000 → every 5 minutes +# +# Defaults to 60000 (1 minute) if not set +GITHUB_POLL_INTERVAL_MS=60000 \ No newline at end of file diff --git a/.gitignore b/.gitignore index b4dcd2ba..0a2450c5 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,4 @@ Thumbs.db *.zip # Runtime data -data/timezones.json \ No newline at end of file +*.json \ No newline at end of file diff --git a/README.md b/README.md index 79d3c3d4..c7e5831c 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ OmegaBot is online ``` . -├── .env.example +├── .env ├── .github │ ├── ISSUE_TEMPLATE │ │ ├── bug.yml @@ -166,6 +166,9 @@ OmegaBot is online │ ├── github │ │ ├── githubApi.ts │ │ ├── githubClient.ts +│ │ ├── lastSeenStore.ts +│ │ ├── prFormatter.ts +│ │ ├── prPoller.ts │ │ └── types.ts │ ├── summary │ │ ├── llmSummary.ts diff --git a/src/bot.ts b/src/bot.ts index 81de3a10..b9cf1d01 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,39 +1,77 @@ +// src/bot.ts + import { Client, GatewayIntentBits } from "discord.js"; import { loadCommands, type CommandClient } from "./services/discord/commandLoader.js"; import { handleInteraction } from "./services/discord/interactionHandler.js"; +import { pollPullRequestsOnce } from "./services/github/prPoller.js"; import { env } from "./config/env.js"; -/* - * Create the Discord client with only the intents required for slash-command handling. + +/** + * Create the Discord client with only the intents required for slash commands. */ const client = new Client({ intents: [GatewayIntentBits.Guilds], }) as CommandClient; -/* - * Initialize the command registry where loaded slash commands will be stored. +/** + * Command registry populated by the command loader. */ client.commands = new Map(); -/* - * Load and register all compiled command modules before the bot starts handling interactions. +/** + * Load compiled command modules and attach them to client.commands. */ await loadCommands(client); -/* - * Forward every incoming interaction to the central interaction handler. +/** + * Forward every incoming interaction to the central handler. */ client.on("interactionCreate", async (interaction) => { await handleInteraction(interaction, client); }); -/* +/** + * GitHub PR polling is optional. + * We only enable it when all required env vars are present. + */ +const githubPollingEnabled = + !!env.githubToken && + !!env.githubOwner && + !!env.githubRepo && + !!env.githubAnnounceChannelId; + +/** * Log a confirmation once the bot successfully connects. */ client.once("clientReady", () => { console.log("OmegaBot is online"); + + if (githubPollingEnabled) { + console.log( + `GitHub PR polling enabled for ${env.githubOwner}/${env.githubRepo} -> channel ${env.githubAnnounceChannelId}`, + ); + console.log(`GitHub PR polling interval: ${env.githubPollIntervalMs}ms`); + } else { + console.log("GitHub PR polling disabled (missing env config)"); + } }); -/* +/** * Start the bot session using the configured token. */ client.login(env.token); + +/** + * Schedule PR polling (if enabled). + * Uses void to avoid unhandled promise warnings. + */ +if (githubPollingEnabled) { + setInterval(() => { + void pollPullRequestsOnce({ + client, + owner: env.githubOwner!, + repo: env.githubRepo!, + announceChannelId: env.githubAnnounceChannelId!, + }); + }, env.githubPollIntervalMs); +} diff --git a/src/config/env.ts b/src/config/env.ts index 7ff4a33d..c665f9f6 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -3,6 +3,7 @@ dotenv.config(); /** * Helper to enforce that required environment variables are present. + * * If a variable is missing, the bot fails fast at startup instead of * crashing later at runtime in unpredictable ways. */ @@ -17,30 +18,95 @@ function requireEnv(name: string): string { /** * Centralized environment configuration for OmegaBot. * - * Required fields: - * - DISCORD_TOKEN Bot authentication token for connecting to Discord. - * - DISCORD_APP_ID Application ID needed for command registration. - * - DISCORD_GUILD_ID Guild where development commands are registered. + * Keeping this configuration in one place: + * - Prevents scattered process.env lookups + * - Makes configuration explicit and auditable + * - Allows optional features to be gated cleanly + * + * ------------------------------------------------------------------ + * Required (core bot functionality) + * ------------------------------------------------------------------ + * + * - DISCORD_TOKEN + * Bot authentication token used to connect to Discord. + * + * - DISCORD_APP_ID + * Application ID required for slash command registration. + * + * - DISCORD_GUILD_ID + * Guild where development commands are registered. + * + * ------------------------------------------------------------------ + * Optional (feature flags / enhancements) + * ------------------------------------------------------------------ + * + * - SUMMARY_MODE + * "local" (default) or "llm" + * Determines which summarizer implementation is used. + * + * - OPENAI_API_KEY + * Required only when SUMMARY_MODE === "llm". + * + * ------------------------------------------------------------------ + * GitHub integration + * ------------------------------------------------------------------ + * + * - GITHUB_TOKEN + * Personal Access Token used for authenticated GitHub REST calls. + * Required for: + * - Issue lookup + * - PR lookup + * - PR announcements * - * Optional fields: - * - SUMMARY_MODE "local" (default) or "llm" - * Determines which summarizer implementation is used. - * - OPENAI_API_KEY Only required when using LLM summary mode. + * - GITHUB_OWNER + * Default repository owner for polling PRs (optional). * - * Keeping this configuration in one place prevents scattered env lookups - * and makes the bot easier to configure, test, and deploy. + * - GITHUB_REPO + * Default repository name for polling PRs (optional). + * + * - GITHUB_ANNOUNCE_CHANNEL_ID + * Discord channel ID where PR announcements are posted (optional). + * + * - GITHUB_POLL_INTERVAL_MS + * Polling interval for PR announcements. + * Defaults to 60 seconds if not provided. + * + * All GitHub announcement fields are optional so the bot can run + * without polling enabled. */ export const env = { + /* ---------------------------------------------------------------- */ + /* Discord (required) */ + /* ---------------------------------------------------------------- */ + token: requireEnv("DISCORD_TOKEN"), appId: requireEnv("DISCORD_APP_ID"), guildId: requireEnv("DISCORD_GUILD_ID"), - // Summary mode defaults to local so the bot can run without AI keys. + /* ---------------------------------------------------------------- */ + /* Summaries */ + /* ---------------------------------------------------------------- */ + + // Defaults to local so the bot runs without AI keys. summaryMode: process.env.SUMMARY_MODE ?? "local", - // Optional, only needed when user chooses LLM summarization. + // Only required when summaryMode === "llm" openAIKey: process.env.OPENAI_API_KEY ?? null, - // GitHub + /* ---------------------------------------------------------------- */ + /* GitHub */ + /* ---------------------------------------------------------------- */ + + // Auth token for GitHub REST API githubToken: process.env.GITHUB_TOKEN ?? null, + + // Default repo configuration for PR polling (optional) + githubOwner: process.env.GITHUB_OWNER ?? null, + githubRepo: process.env.GITHUB_REPO ?? null, + + // Where PR announcements should be posted + githubAnnounceChannelId: process.env.GITHUB_ANNOUNCE_CHANNEL_ID ?? null, + + // Polling interval (ms). Defaults to 60s. + githubPollIntervalMs: Number(process.env.GITHUB_POLL_INTERVAL_MS ?? "60000"), }; diff --git a/src/services/github/githubApi.ts b/src/services/github/githubApi.ts index a87da5db..230385a5 100644 --- a/src/services/github/githubApi.ts +++ b/src/services/github/githubApi.ts @@ -24,14 +24,16 @@ function prPath(owner: string, repo: string, number: number): string { return `${repoBase(owner, repo)}/pulls/${number}`; } +export type ListIssuesOptions = { + state?: "open" | "closed" | "all"; + limit?: number; + labels?: string[]; +}; + function listIssuesPath( owner: string, repo: string, - options?: { - state?: "open" | "closed" | "all"; - limit?: number; - labels?: string[]; - }, + options?: ListIssuesOptions, ): string { const params = new URLSearchParams(); @@ -46,24 +48,25 @@ function listIssuesPath( return `${repoBase(owner, repo)}/issues${query ? `?${query}` : ""}`; } +export type ListPrOptions = { + state?: "open" | "closed" | "all"; + limit?: number; +}; + function listPullRequestsPath( owner: string, repo: string, - options?: { - state?: "open" | "closed" | "all"; - limit?: number; - }, + options?: ListPrOptions, ): string { const params = new URLSearchParams(); + params.set("state", options?.state ?? "open"); + params.set("per_page", String(options?.limit ?? 20)); - if (options?.state) params.set("state", options.state); - if (options?.limit) params.set("per_page", String(options.limit)); - + // For pulls: GitHub supports sort=updated params.set("sort", "updated"); params.set("direction", "desc"); - const query = params.toString(); - return `${repoBase(owner, repo)}/pulls${query ? `?${query}` : ""}`; + return `${repoBase(owner, repo)}/pulls?${params.toString()}`; } /* ------------------------------------------------------------------ */ @@ -79,7 +82,7 @@ function is404(err: unknown): err is GitHubApiError { } /* ------------------------------------------------------------------ */ -/* Single-item fetchers */ +/* Single item fetchers */ /* ------------------------------------------------------------------ */ export async function getIssue( @@ -117,15 +120,15 @@ export async function getIssueOrPr( /** * List issues (issues-only; PRs filtered out) + * + * Note: + * GitHub's /issues endpoint can include PRs. + * PRs contain `pull_request` marker. We filter those out here. */ export async function listIssues( owner: string, repo: string, - options?: { - state?: "open" | "closed" | "all"; - limit?: number; - labels?: string[]; - }, + options?: ListIssuesOptions, ): Promise { const data = await githubRequest(listIssuesPath(owner, repo, options)); @@ -136,21 +139,22 @@ export async function listIssues( title: i.title, html_url: i.html_url, state: i.state, - user: i.user, + user: { login: i.user.login }, comments: i.comments, })); } /** - * List pull requests + * List pull requests (summary view) + * + * This is what the poller should consume because it includes: + * - number, title, url, author + * - updated_at for last-seen comparisons */ export async function listPullRequests( owner: string, repo: string, - options?: { - state?: "open" | "closed" | "all"; - limit?: number; - }, + options?: ListPrOptions, ): Promise { const data = await githubRequest( listPullRequestsPath(owner, repo, options), @@ -162,7 +166,8 @@ export async function listPullRequests( html_url: pr.html_url, state: pr.state, merged_at: pr.merged_at, - user: pr.user, + updated_at: pr.updated_at, + user: { login: pr.user.login }, comments: pr.comments, review_comments: pr.review_comments, })); diff --git a/src/services/github/lastSeenStore.ts b/src/services/github/lastSeenStore.ts new file mode 100644 index 00000000..c9f3b3e2 --- /dev/null +++ b/src/services/github/lastSeenStore.ts @@ -0,0 +1,91 @@ +import fs from "fs"; +import path from "path"; + +/** + * Absolute path to the persistent store for GitHub announcement state. + * + * This file is intentionally: + * - JSON (human-readable, debuggable) + * - Stored outside src/ so it survives rebuilds + * + * Example contents: + * { + * "owner/repo": 1713291823000 + * } + */ +const STORE_PATH = path.join(process.cwd(), "data", "last-seen.json"); + +type Store = Record; + +/** + * Get the last stored timestamp for a repo. + * + * Returns: + * - unix millis number if present + * - null if the repo has never been seen + */ +export function getLastSeen(owner: string, repo: string): number | null { + const store = loadStore(); + const key = makeRepoKey(owner, repo); + return key in store ? store[key] : null; +} + +/** + * Save the "last seen" timestamp for PR announcements. + * + * We store unix millis (Number) to make comparisons cheap and timezone-agnostic. + */ +export function setLastSeenPr(owner: string, repo: string, updatedAtIso: string): void { + const store = loadStore(); + const key = makeRepoKey(owner, repo); + + const ms = Date.parse(updatedAtIso); + if (Number.isNaN(ms)) { + throw new Error(`Invalid updatedAt timestamp: ${updatedAtIso}`); + } + + store[key] = ms; + saveStore(store); +} + +/** + * Remove any stored "last seen" value for a repo. + * Safe even if key does not exist. + */ +export function clearLastSeen(owner: string, repo: string): void { + const store = loadStore(); + const key = makeRepoKey(owner, repo); + + if (key in store) { + delete store[key]; + saveStore(store); + } +} + +function makeRepoKey(owner: string, repo: string): string { + return `${owner}/${repo}`; +} + +function loadStore(): Store { + if (!fs.existsSync(STORE_PATH)) { + return {}; + } + + const raw = fs.readFileSync(STORE_PATH, "utf8"); + try { + return JSON.parse(raw) as Store; + } catch { + // If the JSON is corrupted, fail safe to empty. + return {}; + } +} + +function saveStore(store: Store): void { + const dir = path.dirname(STORE_PATH); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); +} diff --git a/src/services/github/prFormatter.ts b/src/services/github/prFormatter.ts new file mode 100644 index 00000000..5ceb2d3c --- /dev/null +++ b/src/services/github/prFormatter.ts @@ -0,0 +1,23 @@ +// src/services/github/prFormatter.ts + +import type { GitHubPrSummary } from "./types.js"; + +/** + * PR formatting helpers for Discord output. + * + * Design goals: + * - Keep output readable in a busy channel + * - Use only fields we already have in the PR summary + * - Make it easy to swap formatting later without touching poller logic + */ + +/** + * 2-line PR output (readable, compact): + * Line 1: "#123 Title (by author)" + * Line 2: URL + */ +export function formatPullRequest(pr: GitHubPrSummary): string { + const author = pr.user?.login ?? "unknown"; + + return [`#${pr.number} ${pr.title} (by ${author})`, pr.html_url].join("\n"); +} diff --git a/src/services/github/prPoller.ts b/src/services/github/prPoller.ts new file mode 100644 index 00000000..05925740 --- /dev/null +++ b/src/services/github/prPoller.ts @@ -0,0 +1,70 @@ +// src/services/github/prPoller.ts + +import type { Client } from "discord.js"; +import { listPullRequests } from "./githubApi.js"; +import { getLastSeen, setLastSeenPr } from "./lastSeenStore.js"; +import { formatPullRequest } from "./prFormatter.js"; + +/** + * Minimal shape we need from the GitHub PR list for polling announcements. + * Your githubApi.listPullRequests MUST return objects that include `updated_at`. + */ +type PrForPolling = { + updated_at: string; // ISO timestamp from GitHub +}; + +/** + * Poll GitHub for new or updated PRs and announce them in a Discord channel. + * + * Policy: + * - We track "last seen" using PR.updated_at + * - We announce PRs where updated_at is newer than last seen + * - After announcing, we update last seen to the newest updated_at we announced + */ +export async function pollPullRequestsOnce(args: { + client: Client; + owner: string; + repo: string; + announceChannelId: string; + limit?: number; +}): Promise { + const { client, owner, repo, announceChannelId, limit = 20 } = args; + + // Pull newest-updated first (based on your githubApi query params). + const prs = (await listPullRequests(owner, repo, { + state: "open", + limit, + })) as unknown as (PrForPolling & Parameters[0])[]; + + if (prs.length === 0) return; + + const lastSeen = getLastSeen(owner, repo) ?? 0; + + // Keep only PRs updated after our stored last-seen timestamp. + const fresh = prs.filter((pr) => { + const ms = Date.parse(pr.updated_at); + return !Number.isNaN(ms) && ms > lastSeen; + }); + + if (fresh.length === 0) return; + + // Fetch the announce channel and safely narrow it to something we can `.send()` to. + const fetched = await client.channels.fetch(announceChannelId); + if (!fetched || !fetched.isTextBased()) return; + + // Post oldest-first so announcements read naturally. + const oldestFirst = [...fresh].sort( + (a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at), + ); + + // Extra TS guard: only proceed if this thing actually has a send() function. + if (!("send" in fetched) || typeof fetched.send !== "function") return; + + for (const pr of oldestFirst) { + await fetched.send(formatPullRequest(pr)); + } + + // Update last-seen to the newest updated_at we just announced. + const newest = oldestFirst[oldestFirst.length - 1]; + setLastSeenPr(owner, repo, newest.updated_at); +} diff --git a/src/services/github/types.ts b/src/services/github/types.ts index 2b215ed8..b34ee99e 100644 --- a/src/services/github/types.ts +++ b/src/services/github/types.ts @@ -11,20 +11,14 @@ */ /* ------------------------------------------------------------------ */ -/* Shared base types */ +/* Shared base types */ /* ------------------------------------------------------------------ */ -/** - * GitHub user object - */ export type GitHubUser = { login: string; html_url: string; }; -/** - * GitHub issue/PR label - */ export type GitHubLabel = { name: string; color: string; @@ -32,7 +26,6 @@ export type GitHubLabel = { /* ------------------------------------------------------------------ */ /* Full GitHub API response types */ -/* These match what the GitHub REST API actually returns */ /* ------------------------------------------------------------------ */ /** @@ -59,10 +52,6 @@ export type GitHubIssue = { updated_at: string; closed_at: string | null; - /** - * Present only if this issue is actually a Pull Request - * (when fetched from the /issues API) - */ pull_request?: { html_url: string; }; @@ -100,10 +89,6 @@ export type GitHubPullRequest = { merged_at: string | null; }; -/** - * Lightweight repository info - * Useful for validation, previews, or summaries later - */ export type GitHubRepo = { full_name: string; description: string | null; @@ -114,13 +99,9 @@ export type GitHubRepo = { }; /* ------------------------------------------------------------------ */ -/* Command-friendly / normalized response types */ -/* These are what slash commands should actually consume */ +/* Command-friendly / normalized response types */ /* ------------------------------------------------------------------ */ -/** - * Minimal Issue view used by commands - */ export type GitHubIssueSummary = { number: number; title: string; @@ -131,7 +112,10 @@ export type GitHubIssueSummary = { }; /** - * Minimal PR view used by commands + * PR summary used by commands and the poller. + * + * Important: + * - includes `updated_at` so we can do "last seen" comparisons */ export type GitHubPrSummary = { number: number; @@ -139,6 +123,7 @@ export type GitHubPrSummary = { html_url: string; state: "open" | "closed"; merged_at: string | null; + updated_at: string; user?: { login: string }; comments?: number; review_comments?: number;