diff --git a/.env.example b/.env.example
index 128b8c9b..ed90f7fc 100644
--- a/.env.example
+++ b/.env.example
@@ -1,87 +1,71 @@
-# ============================================================
+# =====================================================
# OmegaBot Environment Configuration
-# ============================================================
-#
+# =====================================================
# Copy this file to `.env` and fill in real values.
-# NEVER commit `.env` with secrets.
-#
-# ============================================================
-# Discord (required)
-# ============================================================
+# Do NOT commit your actual `.env` file.
+# =====================================================
-# Bot authentication token used to connect to Discord
+
+# -----------------------------------------------------
+# Discord (REQUIRED)
+# -----------------------------------------------------
+
+# Bot authentication token
DISCORD_TOKEN=your-discord-bot-token-here
-# Application ID required for slash command registration
-DISCORD_APP_ID=your-discord-application-id
+# Discord Application ID (used for slash command registration)
+DISCORD_APP_ID=your-discord-app-id-here
+
+# Guild ID where commands are registered during development
+DISCORD_GUILD_ID=your-discord-guild-id-here
+
+
+# -----------------------------------------------------
+# Logging
+# -----------------------------------------------------
+
+# Log level:
+# debug | info | warn | error
+LOG_LEVEL=debug
+
+# Pretty-print logs in development (requires pino-pretty)
+# Set to false in production for JSON logs
+LOG_PRETTY=true
-# Guild where development slash commands are registered
-# (guild commands update instantly)
-DISCORD_GUILD_ID=your-discord-guild-id
+# Standard Node environment flag
+# development | production
+NODE_ENV=development
-# ============================================================
+# -----------------------------------------------------
# Summaries
-# ============================================================
+# -----------------------------------------------------
# Summary mode:
-# - "local" → simple local summarizer (default)
-# - "llm" → OpenAI-powered summaries
+# - local : rule-based summary (default, no API key needed)
+# - llm : OpenAI-powered summary
SUMMARY_MODE=local
-# OpenAI API key
-# Required ONLY when SUMMARY_MODE=llm
-OPENAI_API_KEY=your-openai-key-if-needed
+# Required only when SUMMARY_MODE=llm
+OPENAI_API_KEY=your-openai-api-key-here
-# ============================================================
+# -----------------------------------------------------
# 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
+# Personal Access Token for GitHub REST API
+# Required for issue lookup, PR lookup, and PR polling
+GITHUB_TOKEN=your-github-pat-here
-# Repository name for PR polling
-# Example: OmegaBot
+# Default repo configuration for PR polling (optional)
+GITHUB_OWNER=your-github-org-or-username
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
+# Enable Developer Mode in Discord to copy channel IDs
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
+# Poll interval for GitHub PR announcements (milliseconds)
+# Default: 60000 (60 seconds)
GITHUB_POLL_INTERVAL_MS=60000
\ No newline at end of file
diff --git a/README.md b/README.md
index c7e5831c..589400e8 100644
--- a/README.md
+++ b/README.md
@@ -1,221 +1,6 @@
-
-
-
-
-
-
-
-
-
-
-
# OmegaBot
-OmegaBot is a modular Discord bot designed to support development projects with quick summaries, FAQs, GitHub lookups, and automated notifications. The structure is clean and fully service based which makes it easy to extend.
-
----
-
-## Features
-
-Current features
-
-- Modular slash-command system (auto-loaded from dist/commands)
-- /ping command for testing
-- /summary command with local summarizer and optional LLM mode (via SUMMARY_MODE)
-- /history command that DMs recent channel history (with file fallback for long output)
-- Shared transcript builder + consistent transcript defaults
-- Command registration script for fast guild iteration
-
-Planned features
-
-- FAQ storage and quick lookup
-- GitHub issues and pull request lookups
-- Pull request announcements
-- Pagination for large history/playback (buttons or follow-ups)
-- Per-user timezone support (store IANA timezone and apply to transcripts)
-- Improved summary output (highlights, action items, structured sections)
-
----
-
-## Documentation
-
-- 📘 [Command Reference](docs/commands.md)
-- 🧠 [Transcript & Summary Design](docs/transcripts.md)
-- 🛠️ [Development Notes](docs/dev-notes.md)
-
----
-
-## Getting Started
-
-### Requirements
-
-- Node 18 or newer
-- A Discord bot token
-- A development server where you have Manage Server permissions
-
-### Setup
-
-Clone the repo:
-
-```bash
-git clone https://github.com/NickTheDevOpsGuy/OmegaBot.git
-cd OmegaBot
-```
-
-Install dependencies:
-
-```bash
-npm install
-```
-
-Create a `.env` file based on `.env.example`:
-
-```
-DISCORD_TOKEN=your_token_here
-DISCORD_APP_ID=your_application_id
-DISCORD_GUILD_ID=your_guild_id
-SUMMARY_MODE=local
-```
-
-### Register Slash Commands
-
-```bash
-npm run register
-```
-
-### Run the Bot
-
-```bash
-npm run dev
-```
-
-You should see:
-
-```
-OmegaBot is online
-```
-
----
-
-## Project Structure
-
-
-📁 Click to expand file structure
-
-```
-.
-├── .env
-├── .github
-│ ├── ISSUE_TEMPLATE
-│ │ ├── bug.yml
-│ │ ├── config.yml
-│ │ ├── documentation.yml
-│ │ ├── enhancement_refactor.yml
-│ │ ├── feature_request.yml
-│ │ └── question_discussion.yml
-│ ├── pull_request_template.md
-│ └── workflows
-│ └── OmegaBot.yml
-├── .gitignore
-├── .husky
-│ ├── pre-commit
-│ └── pre-push
-├── .prettierignore
-├── .prettierrc.yml
-├── assets
-│ ├── banner.png
-│ └── omegabot.png
-├── CONTRIBUTORS.md
-├── data
-│ └── timezones.json
-├── docs
-│ ├── commands.md
-│ ├── dev-notes.md
-│ └── transcripts.md
-├── eslint.config.ts
-├── LICENSE
-├── package-lock.json
-├── package.json
-├── README.md
-├── scripts
-│ └── precheck.sh
-├── src
-│ ├── bot.ts
-│ ├── commands
-│ │ ├── general
-│ │ │ └── ping.ts
-│ │ ├── github
-│ │ │ ├── gh.ts
-│ │ │ └── pr.ts
-│ │ ├── history
-│ │ │ └── history.ts
-│ │ ├── pagination
-│ │ │ └── pagination.ts
-│ │ ├── playback
-│ │ │ └── playback.ts
-│ │ └── summary
-│ │ └── summary.ts
-│ ├── config
-│ │ └── env.ts
-│ ├── registerCommands.ts
-│ └── services
-│ ├── discord
-│ │ ├── commandLoader.ts
-│ │ ├── fetchChannelMessages.ts
-│ │ └── interactionHandler.ts
-│ ├── github
-│ │ ├── githubApi.ts
-│ │ ├── githubClient.ts
-│ │ ├── lastSeenStore.ts
-│ │ ├── prFormatter.ts
-│ │ ├── prPoller.ts
-│ │ └── types.ts
-│ ├── summary
-│ │ ├── llmSummary.ts
-│ │ ├── localSummary.ts
-│ │ └── summarizer.ts
-│ ├── time
-│ │ ├── formatTimestamp.ts
-│ │ └── validateTimezone.ts
-│ ├── timezone
-│ │ ├── timezone.ts
-│ │ └── timezoneStore.ts
-│ └── transcript
-│ ├── buildTranscript.ts
-│ └── defaults.ts
-└── tsconfig.json
-```
-
-
-
----
-
-## Extending OmegaBot
-
-OmegaBot is designed for small, focused modules. To add new features:
-
-1. Create a new command file under `src/commands//`
-2. Add any logic needed inside `src/services//`
-3. Run `npm run register` to publish new slash commands
-
----
-
-## Contributors
-
-Thanks to everyone who has helped build or improve OmegaBot.
-
-
-
-
-
-Generated using https://contrib.rocks
-
-To learn how to contribute, read the [CONTRIBUTOR.md](CONTRIBUTOR.md) file.
-
-If you would like to contribute, please open an issue or submit a pull request.
-
----
-
-## License
+OmegaBot is a modular Discord bot designed to support development projects with quick summaries, GitHub lookups, and automated notifications.
-MIT License. Use and modify freely.
+See the repository for full documentation:
+https://github.com/NickTheDevOpsGuy/OmegaBot
diff --git a/docs/commands.md b/docs/commands.md
index 09f33bf1..9c110418 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -1,93 +1,23 @@
-# OmegaBot Commands
+# Command Reference
-This document lists all available slash commands supported by OmegaBot.
+## General
----
+- /ping
-## General Commands
+## Summary
-### /ping
+- /summary
+- /history
+- /playback
+- /pagination
-Checks whether the bot is online and reports latency.
+## GitHub
-**Usage**
+- /gh issue
+- /gh issues
+- /gh prs
+- /pr
-```
-/ping
-```
+## Timezone
----
-
-## History & Summary Commands
-
-### /history
-
-Fetches recent messages from the current channel and sends them to you via DM.
-
-**Options**
-
-- `count` (optional): Number of messages to fetch (default: 50)
-
----
-
-### /summary
-
-Generates a summary of recent channel activity and sends it via DM.
-
-**Notes**
-
-- Uses local summarization by default
-- LLM-based summarization may be enabled via configuration
-
----
-
-## GitHub Commands
-
-These commands use the GitHub REST API with authenticated requests.
-
-### /gh issue
-
-Fetch a single GitHub issue by number.
-
-**Usage**
-
-```
-/gh issue owner: repo: number:
-```
-
----
-
-### /gh issues
-
-List open issues for a repository.
-
-**Usage**
-
-```
-/gh issues owner: repo: limit: labels:
-```
-
-**Notes**
-
-- Defaults to open issues
-- Pull requests are filtered out by default
-
----
-
-### /pr
-
-Fetch a GitHub pull request by number.
-
-**Usage**
-
-```
-/pr owner: repo: number:
-```
-
----
-
-## Notes
-
-- GitHub commands require a valid GitHub Personal Access Token
-- Results are returned as ephemeral responses by default
-- Errors are handled gracefully and reported to the user
+- /timezone set | clear | show
diff --git a/docs/dev-notes.md b/docs/dev-notes.md
index 083b10e4..efac36f4 100644
--- a/docs/dev-notes.md
+++ b/docs/dev-notes.md
@@ -1,76 +1,46 @@
# Development Notes
-General development notes and gotchas for OmegaBot.
+This document captures design decisions, conventions, and architectural guidelines for OmegaBot.
---
-## Interaction Lifecycle
+## Architecture Principles
-- Every slash command must reply or defer within 3 seconds
-- Prefer `deferReply()` + `editReply()` for async work
-- Use ephemeral replies for status updates
-- DM output when content is private
+- Commands are thin and delegate logic to services
+- Services are grouped by domain (discord, github, transcript, summary, timezone)
+- Helpers are pure where possible
+- Side effects (network, fs, Discord I/O) are explicit
---
-## Message Fetching
+## Logging
-Standard pipeline:
+OmegaBot uses a centralized logger for structured logs.
-fetch → filter bots → sort → map → transcript
+Guidelines:
-Notes:
-
-- Discord returns `Collection`, convert to arrays before processing
-- Missing permissions can cause silent failures
-
----
-
-## Permissions
-
-Required bot permissions:
-
-- View Channels
-- Read Message History
-- Send Messages
-- Attach Files
-- Use Slash Commands
-
-Important:
-
-- Missing **Read Message History** causes fetches to return empty collections
+- Use logger.info for lifecycle events
+- Use logger.warn for recoverable issues
+- Use logger.error inside catch blocks
+- Avoid logging inside pure helpers
---
## Environment Variables
-Never commit `.env`.
-
-Required:
-
-- `DISCORD_TOKEN`
-- `DISCORD_APP_ID`
-- `DISCORD_GUILD_ID`
-
-Optional:
-
-- `SUMMARY_MODE` (`local` | `llm`)
-
-Always include `.env.example`.
+See env.example for full list.
---
-## Formatting & Limits
+## Error Handling
-- Discord message limit: 2000 characters
-- Safe working limit: ~1900 characters
-- Use file attachments for overflow
+- Commands catch and reply gracefully
+- Services may throw domain-specific errors
+- Pollers and background tasks must never crash the process
---
-## Debugging Checklist
+## Future
-- Slash command not appearing → re-register commands
-- Bot replies but cannot DM → user has DMs closed
-- Interaction timeout → missing defer
-- Errors should be logged internally, not spammed to users
+- Replace file stores with DB
+- Add metrics
diff --git a/docs/transcripts.md b/docs/transcripts.md
index 0adf6d24..c3643a74 100644
--- a/docs/transcripts.md
+++ b/docs/transcripts.md
@@ -1,88 +1,14 @@
-# Transcript & Summary Design
+# Transcripts & Summaries
-This document explains how transcripts and summaries are built in OmegaBot.
+## Pipeline
----
+Messages -> Transcript -> Summary
-## Goals
+## Timezones
-- Human-readable output
-- Chronological order
-- Consistent formatting across commands
-- Reusable and testable helper logic
+Uses IANA identifiers.
----
+## Modes
-## Transcript Format
-
-Default format:
-
-[YYYY-MM-DD HH:mm] username: message
-
-Rules:
-
-- 24-hour time
-- Locale: en-GB
-- Timezone: UTC (for now)
-
----
-
-## buildTranscript Helper
-
-Location:
-`src/services/transcript/buildTranscript.ts`
-
-Responsibilities:
-
-- Accept message-like objects
-- Format timestamps and authors
-- Enforce `maxLines` and `maxChars`
-- Return metadata for callers
-
-Returns:
-
-- `text`
-- `lineCount`
-- `truncated`
-- `tooLong`
-
-Does NOT:
-
-- Fetch messages
-- Send messages or files
-- Perform summarization
-
----
-
-## Defaults
-
-Centralized in:
-`src/services/transcript/defaults.ts`
-
-History defaults:
-
-- includeTimestamp: true
-- includeAuthor: true
-
-Summary defaults:
-
-- includeTimestamp: false
-- includeAuthor: true
-
----
-
-## File Fallback Strategy
-
-If transcript exceeds safe Discord limits:
-
-- Generate `.txt` file
-- Send via DM
-- Confirm delivery via ephemeral reply
-
----
-
-## Future Enhancements
-
-- User-specific timezones
-- Pagination support
-- Markdown transcript output
+- Local
+- LLM
diff --git a/package-lock.json b/package-lock.json
index 81347dad..0cd98eb5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,8 @@
"discord.js": "^14.14.1",
"dotenv": "^16.4.5",
"husky": "^9.1.7",
- "openai": "^4.0.0"
+ "openai": "^4.0.0",
+ "pino": "^10.1.0"
},
"devDependencies": {
"@eslint/js": "^9.12.0",
@@ -19,6 +20,7 @@
"eslint": "^9.12.0",
"eslint-config-prettier": "^9.1.0",
"globals": "^15.11.0",
+ "pino-pretty": "^13.1.3",
"prettier": "^3.3.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.0.0"
@@ -363,6 +365,12 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@pinojs/redact": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
+ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
+ "license": "MIT"
+ },
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
@@ -797,6 +805,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
+ "node_modules/atomic-sleep": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -875,6 +892,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/colorette": {
+ "version": "2.0.20",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
+ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -909,6 +933,16 @@
"node": ">= 8"
}
},
+ "node_modules/dateformat": {
+ "version": "4.6.3",
+ "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
+ "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1005,6 +1039,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1239,6 +1283,13 @@
"node": ">=6"
}
},
+ "node_modules/fast-copy": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
+ "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1259,6 +1310,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-safe-stringify": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
+ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1496,6 +1554,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/help-me": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
+ "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
@@ -1599,6 +1664,16 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/joycon": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
+ "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -1741,6 +1816,16 @@
"node": "*"
}
},
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1794,6 +1879,25 @@
}
}
},
+ "node_modules/on-exit-leak-free": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
+ "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
"node_modules/openai": {
"version": "4.104.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
@@ -1935,6 +2039,91 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pino": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
+ "integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
+ "license": "MIT",
+ "dependencies": {
+ "@pinojs/redact": "^0.4.0",
+ "atomic-sleep": "^1.0.0",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^2.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^0.2.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^4.0.1",
+ "thread-stream": "^3.0.0"
+ },
+ "bin": {
+ "pino": "bin.js"
+ }
+ },
+ "node_modules/pino-abstract-transport": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
+ "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/pino-pretty": {
+ "version": "13.1.3",
+ "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
+ "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "colorette": "^2.0.7",
+ "dateformat": "^4.6.3",
+ "fast-copy": "^4.0.0",
+ "fast-safe-stringify": "^2.1.1",
+ "help-me": "^5.0.0",
+ "joycon": "^3.1.1",
+ "minimist": "^1.2.6",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^3.0.0",
+ "pump": "^3.0.0",
+ "secure-json-parse": "^4.0.0",
+ "sonic-boom": "^4.0.1",
+ "strip-json-comments": "^5.0.2"
+ },
+ "bin": {
+ "pino-pretty": "bin.js"
+ }
+ },
+ "node_modules/pino-pretty/node_modules/pino-abstract-transport": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
+ "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/pino-pretty/node_modules/strip-json-comments": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
+ "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pino-std-serializers": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz",
+ "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==",
+ "license": "MIT"
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -1961,6 +2150,33 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/process-warning": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
+ "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/pump": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
+ "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -1971,6 +2187,21 @@
"node": ">=6"
}
},
+ "node_modules/quick-format-unescaped": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
+ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
+ "license": "MIT"
+ },
+ "node_modules/real-require": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
+ "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -1981,6 +2212,32 @@
"node": ">=4"
}
},
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/secure-json-parse": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
+ "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -2017,6 +2274,24 @@
"node": ">=8"
}
},
+ "node_modules/sonic-boom": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
+ "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
+ "license": "MIT",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -2043,6 +2318,15 @@
"node": ">=8"
}
},
+ "node_modules/thread-stream": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
+ "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
+ "license": "MIT",
+ "dependencies": {
+ "real-require": "^0.2.0"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2218,6 +2502,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
diff --git a/package.json b/package.json
index c08066dd..e77d5680 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,8 @@
"discord.js": "^14.14.1",
"dotenv": "^16.4.5",
"husky": "^9.1.7",
- "openai": "^4.0.0"
+ "openai": "^4.0.0",
+ "pino": "^10.1.0"
},
"devDependencies": {
"@eslint/js": "^9.12.0",
@@ -25,6 +26,7 @@
"eslint": "^9.12.0",
"eslint-config-prettier": "^9.1.0",
"globals": "^15.11.0",
+ "pino-pretty": "^13.1.3",
"prettier": "^3.3.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.0.0"
diff --git a/src/bot.ts b/src/bot.ts
index b9cf1d01..fb90d728 100644
--- a/src/bot.ts
+++ b/src/bot.ts
@@ -5,6 +5,7 @@ import { loadCommands, type CommandClient } from "./services/discord/commandLoad
import { handleInteraction } from "./services/discord/interactionHandler.js";
import { pollPullRequestsOnce } from "./services/github/prPoller.js";
import { env } from "./config/env.js";
+import { logger } from "./utils/logger.js";
/**
* Create the Discord client with only the intents required for slash commands.
@@ -44,22 +45,27 @@ const githubPollingEnabled =
* Log a confirmation once the bot successfully connects.
*/
client.once("clientReady", () => {
- console.log("OmegaBot is online");
+ logger.info("OmegaBot is online");
if (githubPollingEnabled) {
- console.log(
- `GitHub PR polling enabled for ${env.githubOwner}/${env.githubRepo} -> channel ${env.githubAnnounceChannelId}`,
+ logger.info(
+ {
+ owner: env.githubOwner,
+ repo: env.githubRepo,
+ channelId: env.githubAnnounceChannelId,
+ intervalMs: env.githubPollIntervalMs,
+ },
+ "GitHub PR polling enabled",
);
- console.log(`GitHub PR polling interval: ${env.githubPollIntervalMs}ms`);
} else {
- console.log("GitHub PR polling disabled (missing env config)");
+ logger.info("GitHub PR polling disabled (missing env config)");
}
});
/**
* Start the bot session using the configured token.
*/
-client.login(env.token);
+void client.login(env.token);
/**
* Schedule PR polling (if enabled).
diff --git a/src/commands/general/ping.ts b/src/commands/general/ping.ts
index f262d455..f4d27571 100644
--- a/src/commands/general/ping.ts
+++ b/src/commands/general/ping.ts
@@ -1,4 +1,5 @@
import { SlashCommandBuilder, type ChatInputCommandInteraction } from "discord.js";
+import { logger } from "../../utils/logger.js";
/**
* Defines the /ping command.
@@ -9,34 +10,59 @@ export const data = new SlashCommandBuilder()
.setDescription("Ping test with latency");
export async function execute(interaction: ChatInputCommandInteraction): Promise {
- /**
- * Send the initial reply.
- * We avoid deprecated fetchReply option and instead fetch the reply after.
- */
- await interaction.reply("Pinging...");
-
- /**
- * Fetch the bot's reply message so we can compute latency.
- */
- const sent = await interaction.fetchReply();
-
- /**
- * Measure latency:
- * - interaction.createdTimestamp is when Discord received the slash command
- * - sent.createdTimestamp is when Discord created the bot's response
- */
- const latency = sent.createdTimestamp - interaction.createdTimestamp;
-
- /**
- * WebSocket heartbeat is the current ping between the bot and Discord's gateway.
- * Lower numbers mean a healthier connection.
- */
- const wsPing = interaction.client.ws.ping;
-
- /**
- * Edit the original message to show actual latency numbers.
- */
- await interaction.editReply(
- `Pong. Round trip latency is ${latency}ms. WebSocket heartbeat is ${wsPing}ms.`,
- );
+ try {
+ /**
+ * Send the initial reply.
+ * We avoid deprecated fetchReply option and instead fetch the reply after.
+ */
+ await interaction.reply("Pinging...");
+
+ /**
+ * Fetch the bot's reply message so we can compute latency.
+ */
+ const sent = await interaction.fetchReply();
+
+ /**
+ * Measure latency:
+ * - interaction.createdTimestamp is when Discord received the slash command
+ * - sent.createdTimestamp is when Discord created the bot's response
+ */
+ const latency = sent.createdTimestamp - interaction.createdTimestamp;
+
+ /**
+ * WebSocket heartbeat is the current ping between the bot and Discord's gateway.
+ * Lower numbers mean a healthier connection.
+ */
+ const wsPing = interaction.client.ws.ping;
+
+ /**
+ * Debug-level logging so we can observe bot health without spamming logs.
+ */
+ logger.debug(
+ {
+ latency,
+ wsPing,
+ userId: interaction.user.id,
+ },
+ "[ping] latency check",
+ );
+
+ /**
+ * Edit the original message to show actual latency numbers.
+ */
+ await interaction.editReply(
+ `Pong. Round trip latency is ${latency}ms. WebSocket heartbeat is ${wsPing}ms.`,
+ );
+ } catch (err) {
+ /**
+ * Extremely unlikely path, but included for consistency with other commands.
+ */
+ logger.error({ err }, "[ping] command failed");
+
+ if (interaction.replied || interaction.deferred) {
+ await interaction.editReply("Ping failed unexpectedly.");
+ } else {
+ await interaction.reply("Ping failed unexpectedly.");
+ }
+ }
}
diff --git a/src/commands/github/gh.ts b/src/commands/github/gh.ts
index 219d1b42..3f0d1db6 100644
--- a/src/commands/github/gh.ts
+++ b/src/commands/github/gh.ts
@@ -11,6 +11,7 @@ import {
listIssues,
listPullRequests,
} from "../../services/github/githubApi.js";
+import { logger } from "../../utils/logger.js";
/**
* /gh command
@@ -131,13 +132,24 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise
);
return;
}
+
+ // Should be unreachable because subcommand is required, but keep it safe.
+ await interaction.editReply("Unknown subcommand.");
} catch (err) {
if (err instanceof GitHubApiError) {
+ if (err.status === 404) {
+ await interaction.editReply("Not found. Check owner/repo and the number.");
+ return;
+ }
+
+ logger.warn({ err, owner, repo, sub }, "[gh] GitHub API error");
+
await interaction.editReply(`GitHub error (${err.status}): ${err.message}`);
return;
}
- console.error("[gh] command failed", err);
+ logger.error({ err, owner, repo, sub }, "[gh] command failed");
+
await interaction.editReply("Something went wrong talking to GitHub.");
}
}
diff --git a/src/commands/github/pr.ts b/src/commands/github/pr.ts
index 595c5e4b..d3aea2a0 100644
--- a/src/commands/github/pr.ts
+++ b/src/commands/github/pr.ts
@@ -7,6 +7,7 @@ import {
} from "discord.js";
import { GitHubApiError } from "../../services/github/githubClient.js";
import { getPullRequest } from "../../services/github/githubApi.js";
+import { logger } from "../../utils/logger.js";
/**
* /pr command
@@ -32,7 +33,7 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise
const mergedLabel = pr.merged ? "merged" : "not merged";
const body = [
`**${owner}/${repo} PR #${pr.number}**`,
- `${pr.title}`,
+ pr.title,
`State: ${pr.state} (${mergedLabel})`,
`Author: ${pr.user?.login ?? "unknown"}`,
`URL: ${pr.html_url}`,
@@ -45,11 +46,15 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise
await interaction.editReply("PR not found. Check owner/repo and PR number.");
return;
}
+
+ logger.warn({ err, owner, repo, number }, "[pr] GitHub API error");
+
await interaction.editReply(`GitHub API error (${err.status}): ${err.message}`);
return;
}
- console.error("[pr] command failed", err);
+ logger.error({ err, owner, repo, number }, "[pr] command failed");
+
await interaction.editReply("Something went wrong while talking to GitHub.");
}
}
diff --git a/src/commands/history/history.ts b/src/commands/history/history.ts
index 0431d551..3967a1a7 100644
--- a/src/commands/history/history.ts
+++ b/src/commands/history/history.ts
@@ -12,6 +12,7 @@ import {
} from "../../services/transcript/buildTranscript.js";
import { HISTORY_DEFAULTS } from "../../services/transcript/defaults.js";
import { getUserTimezone } from "../../services/timezone/timezoneStore.js";
+import { logger } from "../../utils/logger.js";
/**
* /history command
@@ -31,75 +32,98 @@ export const data = new SlashCommandBuilder()
export async function execute(interaction: ChatInputCommandInteraction): Promise {
const count = interaction.options.getInteger("count") ?? 50;
- // Ephemeral ack so only the caller sees status.
- await interaction.deferReply({ flags: MessageFlags.Ephemeral });
+ try {
+ // Ephemeral ack so only the caller sees status.
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
- // Guard: must be text-based to fetch messages.
- if (!interaction.channel || !interaction.channel.isTextBased()) {
- await interaction.editReply("This channel does not support message history.");
- return;
- }
+ // Guard: must be text-based to fetch messages.
+ if (!interaction.channel || !interaction.channel.isTextBased()) {
+ await interaction.editReply("This channel does not support message history.");
+ return;
+ }
- // Fetch recent messages
- const messages = await interaction.channel.messages.fetch({ limit: count });
+ // Fetch recent messages
+ const messages = await interaction.channel.messages.fetch({ limit: count });
- // Filter non-bot + non-empty content, then sort oldest -> newest
- const userMessages = messages
- .filter((m) => !m.author.bot && m.content)
- .sort((a, b) => a.createdTimestamp - b.createdTimestamp);
+ // Filter non-bot + non-empty content, then sort oldest -> newest
+ const userMessages = messages
+ .filter((m) => !m.author.bot && m.content)
+ .sort((a, b) => a.createdTimestamp - b.createdTimestamp);
- if (userMessages.size === 0) {
- await interaction.editReply("No history found.");
- return;
- }
+ if (userMessages.size === 0) {
+ await interaction.editReply("No history found.");
+ return;
+ }
- // Convert Discord Collection -> array in the minimal shape buildTranscript needs
- const transcriptMessages: TranscriptMessage[] = userMessages.map((m) => ({
- createdTimestamp: m.createdTimestamp,
- content: m.content,
- author: { username: m.author.username },
- }));
+ // Convert Discord Collection -> array in the minimal shape buildTranscript needs
+ const transcriptMessages: TranscriptMessage[] = userMessages.map((m) => ({
+ createdTimestamp: m.createdTimestamp,
+ content: m.content,
+ author: { username: m.author.username },
+ }));
- // Optional per-user timezone override (fallback to defaults)
- const userTz = getUserTimezone(interaction.user.id);
+ // Optional per-user timezone override (fallback to defaults)
+ const userTz = getUserTimezone(interaction.user.id);
- const result = buildTranscript(transcriptMessages, {
- ...HISTORY_DEFAULTS,
- timeZone: userTz ?? HISTORY_DEFAULTS.timeZone,
- });
+ const result = buildTranscript(transcriptMessages, {
+ ...HISTORY_DEFAULTS,
+ timeZone: userTz ?? HISTORY_DEFAULTS.timeZone,
+ });
- const text = result.text;
+ const text = result.text;
- // If too large for a normal DM, send as a file
- if (result.tooLong || text.length > 2000) {
- const file = new AttachmentBuilder(Buffer.from(text, "utf8"), {
- name: "history.txt",
- });
+ // If too large for a normal DM, send as a file
+ if (result.tooLong || text.length > 2000) {
+ const file = new AttachmentBuilder(Buffer.from(text, "utf8"), {
+ name: "history.txt",
+ });
+
+ try {
+ await interaction.user.send({
+ content: "Here is your recent chat history:",
+ files: [file],
+ });
+ await interaction.editReply("History sent to your DMs.");
+ } catch (err) {
+ logger.warn(
+ { err, userId: interaction.user.id },
+ "[history] DM file send failed",
+ );
+ await interaction.editReply(
+ "I generated the history, but your DMs appear to be closed.",
+ );
+ }
+
+ return;
+ }
+ // Normal-length transcript -> DM as text
try {
- await interaction.user.send({
- content: "Here is your recent chat history:",
- files: [file],
- });
+ await interaction.user.send(text);
await interaction.editReply("History sent to your DMs.");
} catch (err) {
- console.error("[history] DM file send failed", err);
+ logger.warn({ err, userId: interaction.user.id }, "[history] DM text send failed");
await interaction.editReply(
- "I generated the history, but your DMs appear to be closed.",
+ "I generated the history, but could not DM you. Your DMs may be closed.",
);
}
-
- return;
- }
-
- // Normal-length transcript -> DM as text
- try {
- await interaction.user.send(text);
- await interaction.editReply("History sent to your DMs.");
} catch (err) {
- console.error("[history] DM text send failed", err);
- await interaction.editReply(
- "I generated the history, but could not DM you. Your DMs may be closed.",
+ logger.error(
+ { err, command: "history", userId: interaction.user.id },
+ "[history] command failed",
);
+
+ try {
+ if (interaction.replied || interaction.deferred) {
+ await interaction.editReply("Something went wrong while fetching history.");
+ } else {
+ await interaction.reply({
+ content: "Something went wrong while fetching history.",
+ flags: MessageFlags.Ephemeral,
+ });
+ }
+ } catch (replyErr) {
+ logger.error({ err: replyErr }, "[history] failed to send fallback error message");
+ }
}
}
diff --git a/src/commands/pagination/pagination.ts b/src/commands/pagination/pagination.ts
index 7fcfbcd5..830140fe 100644
--- a/src/commands/pagination/pagination.ts
+++ b/src/commands/pagination/pagination.ts
@@ -1,4 +1,5 @@
// src/commands/pagination/pagination.ts
+
import {
SlashCommandBuilder,
ActionRowBuilder,
@@ -7,6 +8,7 @@ import {
ComponentType,
type ChatInputCommandInteraction,
} from "discord.js";
+import { logger } from "../../utils/logger.js";
/**
* /pagination command
@@ -87,79 +89,111 @@ function renderPage(pages: string[], pageIndex: number) {
export async function execute(interaction: ChatInputCommandInteraction): Promise {
const count = interaction.options.getInteger("count") ?? 50;
- // Not ephemeral: buttons + paging is easier as a normal message.
- await interaction.deferReply();
-
- if (!interaction.channel || !interaction.channel.isTextBased()) {
- await interaction.editReply("This channel does not support pagination.");
- return;
- }
-
- const messages = await interaction.channel.messages.fetch({ limit: count });
-
- const userMessages = messages
- .filter((m) => !m.author.bot && m.content)
- .sort((a, b) => a.createdTimestamp - b.createdTimestamp);
+ try {
+ // Not ephemeral: buttons + paging is easier as a normal message.
+ await interaction.deferReply();
- if (userMessages.size === 0) {
- await interaction.editReply("No usable messages found.");
- return;
- }
+ if (!interaction.channel || !interaction.channel.isTextBased()) {
+ await interaction.editReply("This channel does not support pagination.");
+ return;
+ }
- const transcript = userMessages
- .map((m) => `${m.author.username}: ${m.content}`)
- .join("\n");
+ const messages = await interaction.channel.messages.fetch({ limit: count });
- const pages = chunkByChars(transcript, MAX_PAGE_CHARS);
- let pageIndex = 0;
+ const userMessages = messages
+ .filter((m) => !m.author.bot && m.content)
+ .sort((a, b) => a.createdTimestamp - b.createdTimestamp);
- const replyMessage = await interaction.editReply({
- content: renderPage(pages, pageIndex),
- components: [buildRow(pageIndex, pages.length)],
- });
+ if (userMessages.size === 0) {
+ await interaction.editReply("No usable messages found.");
+ return;
+ }
- const collector = replyMessage.createMessageComponentCollector({
- componentType: ComponentType.Button,
- time: COLLECTOR_MS,
- filter: (i) => i.user.id === interaction.user.id,
- });
+ const transcript = userMessages
+ .map((m) => `${m.author.username}: ${m.content}`)
+ .join("\n");
+
+ const pages = chunkByChars(transcript, MAX_PAGE_CHARS);
+ let pageIndex = 0;
+
+ const replyMessage = await interaction.editReply({
+ content: renderPage(pages, pageIndex),
+ components: [buildRow(pageIndex, pages.length)],
+ });
+
+ const collector = replyMessage.createMessageComponentCollector({
+ componentType: ComponentType.Button,
+ time: COLLECTOR_MS,
+ filter: (i) => i.user.id === interaction.user.id,
+ });
+
+ collector.on("collect", async (i) => {
+ try {
+ if (i.customId === "pagination_stop") {
+ collector.stop("stopped");
+ await i.update({
+ content: renderPage(pages, pageIndex),
+ components: [buildRow(pageIndex, pages.length, true)],
+ });
+ return;
+ }
+
+ if (i.customId === "pagination_prev") {
+ pageIndex = Math.max(0, pageIndex - 1);
+ }
+
+ if (i.customId === "pagination_next") {
+ pageIndex = Math.min(pages.length - 1, pageIndex + 1);
+ }
- collector.on("collect", async (i) => {
- try {
- if (i.customId === "pagination_stop") {
- collector.stop("stopped");
await i.update({
content: renderPage(pages, pageIndex),
- components: [buildRow(pageIndex, pages.length, true)],
+ components: [buildRow(pageIndex, pages.length)],
});
- return;
+ } catch (err) {
+ logger.warn(
+ { err, userId: interaction.user.id },
+ "[pagination] button update failed",
+ );
}
+ });
- if (i.customId === "pagination_prev") {
- pageIndex = Math.max(0, pageIndex - 1);
- }
+ collector.on("end", async (collected, reason) => {
+ try {
+ await replyMessage.edit({
+ content: renderPage(pages, pageIndex),
+ components: [buildRow(pageIndex, pages.length, true)],
+ });
- if (i.customId === "pagination_next") {
- pageIndex = Math.min(pages.length - 1, pageIndex + 1);
+ logger.debug(
+ {
+ reason,
+ clicks: collected.size,
+ userId: interaction.user.id,
+ },
+ "[pagination] collector ended",
+ );
+ } catch (err) {
+ logger.warn({ err }, "[pagination] failed to disable buttons");
}
+ });
+ } catch (err) {
+ logger.error(
+ { err, command: "pagination", userId: interaction.user.id },
+ "[pagination] command failed",
+ );
- await i.update({
- content: renderPage(pages, pageIndex),
- components: [buildRow(pageIndex, pages.length)],
- });
- } catch (err) {
- console.error("[pagination] button update failed", err);
- }
- });
-
- collector.on("end", async () => {
try {
- await replyMessage.edit({
- content: renderPage(pages, pageIndex),
- components: [buildRow(pageIndex, pages.length, true)],
- });
- } catch (err) {
- console.error("[pagination] failed to disable buttons", err);
+ if (interaction.replied || interaction.deferred) {
+ await interaction.editReply("Something went wrong during pagination.");
+ } else {
+ await interaction.reply("Something went wrong during pagination.");
+ }
+ } catch (replyErr) {
+ logger.error(
+ { err: replyErr },
+ "[pagination] failed to send fallback error message",
+ );
}
- });
+ }
}
diff --git a/src/commands/playback/playback.ts b/src/commands/playback/playback.ts
index 986a9208..f1aef268 100644
--- a/src/commands/playback/playback.ts
+++ b/src/commands/playback/playback.ts
@@ -14,6 +14,7 @@ import {
HISTORY_DEFAULTS,
DISCORD_SAFE_TEXT_LIMIT,
} from "../../services/transcript/defaults.js";
+import { logger } from "../../utils/logger.js";
function chunkText(text: string, maxChars: number): string[] {
if (text.length <= maxChars) return [text];
@@ -64,91 +65,115 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise
const before = interaction.options.getString("before") ?? undefined;
const after = interaction.options.getString("after") ?? undefined;
- await interaction.deferReply({ flags: MessageFlags.Ephemeral });
+ try {
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
- if (!interaction.channel || !interaction.channel.isTextBased()) {
- await interaction.editReply("This channel does not support playback.");
- return;
- }
+ if (!interaction.channel || !interaction.channel.isTextBased()) {
+ await interaction.editReply("This channel does not support playback.");
+ return;
+ }
- const msgs = await fetchChannelMessages(interaction.channel, {
- count,
- before,
- after,
- });
+ const msgs = await fetchChannelMessages(interaction.channel, {
+ count,
+ before,
+ after,
+ });
- if (msgs.length === 0) {
- await interaction.editReply("No usable messages found for playback.");
- return;
- }
+ if (msgs.length === 0) {
+ await interaction.editReply("No usable messages found for playback.");
+ return;
+ }
- const transcript = buildTranscript(msgs, {
- ...HISTORY_DEFAULTS,
- // For pagination, do not truncate by maxChars here, we chunk instead.
- maxChars: undefined,
- maxLines: undefined,
- });
-
- const pages = chunkText(transcript.text, DISCORD_SAFE_TEXT_LIMIT);
-
- let index = 0;
-
- const makeRow = (i: number) =>
- new ActionRowBuilder().addComponents(
- new ButtonBuilder()
- .setCustomId("pb_prev")
- .setStyle(ButtonStyle.Secondary)
- .setLabel("Prev")
- .setDisabled(i <= 0),
- new ButtonBuilder()
- .setCustomId("pb_next")
- .setStyle(ButtonStyle.Secondary)
- .setLabel("Next")
- .setDisabled(i >= pages.length - 1),
- new ButtonBuilder()
- .setCustomId("pb_close")
- .setStyle(ButtonStyle.Danger)
- .setLabel("Close"),
+ const transcript = buildTranscript(msgs, {
+ ...HISTORY_DEFAULTS,
+ // For pagination, do not truncate by maxChars here, we chunk instead.
+ maxChars: undefined,
+ maxLines: undefined,
+ });
+
+ const pages = chunkText(transcript.text, DISCORD_SAFE_TEXT_LIMIT);
+
+ let index = 0;
+
+ const makeRow = (i: number) =>
+ new ActionRowBuilder().addComponents(
+ new ButtonBuilder()
+ .setCustomId("pb_prev")
+ .setStyle(ButtonStyle.Secondary)
+ .setLabel("Prev")
+ .setDisabled(i <= 0),
+ new ButtonBuilder()
+ .setCustomId("pb_next")
+ .setStyle(ButtonStyle.Secondary)
+ .setLabel("Next")
+ .setDisabled(i >= pages.length - 1),
+ new ButtonBuilder()
+ .setCustomId("pb_close")
+ .setStyle(ButtonStyle.Danger)
+ .setLabel("Close"),
+ );
+
+ await interaction.editReply({
+ content: `Page ${index + 1}/${pages.length}\n\n${pages[index]}`,
+ components: [makeRow(index)],
+ });
+
+ const msg = await interaction.fetchReply();
+
+ const collector = msg.createMessageComponentCollector({
+ time: 5 * 60_000,
+ filter: (i) => i.user.id === interaction.user.id,
+ });
+
+ collector.on("collect", async (btn) => {
+ try {
+ if (btn.customId === "pb_close") {
+ collector.stop("closed");
+ await btn.update({ content: "Playback closed.", components: [] });
+ return;
+ }
+
+ if (btn.customId === "pb_prev") index = Math.max(0, index - 1);
+ if (btn.customId === "pb_next") index = Math.min(pages.length - 1, index + 1);
+
+ await btn.update({
+ content: `Page ${index + 1}/${pages.length}\n\n${pages[index]}`,
+ components: [makeRow(index)],
+ });
+ } catch (err) {
+ logger.warn(
+ { err, userId: interaction.user.id },
+ "[playback] button update failed",
+ );
+ }
+ });
+
+ collector.on("end", async () => {
+ try {
+ // disable buttons after timeout
+ await interaction.editReply({ components: [] });
+ } catch (err) {
+ // ignore
+ logger.debug({ err }, "[playback] cleanup after collector end failed");
+ }
+ });
+ } catch (err) {
+ logger.error(
+ { err, command: "playback", userId: interaction.user.id },
+ "[playback] command failed",
);
- await interaction.editReply({
- content: `Page ${index + 1}/${pages.length}\n\n${pages[index]}`,
- components: [makeRow(index)],
- });
-
- const msg = await interaction.fetchReply();
-
- const collector = msg.createMessageComponentCollector({
- time: 5 * 60_000,
- filter: (i) => i.user.id === interaction.user.id,
- });
-
- collector.on("collect", async (btn) => {
try {
- if (btn.customId === "pb_close") {
- collector.stop("closed");
- await btn.update({ content: "Playback closed.", components: [] });
- return;
+ if (interaction.replied || interaction.deferred) {
+ await interaction.editReply("Something went wrong during playback.");
+ } else {
+ await interaction.reply({
+ content: "Something went wrong during playback.",
+ flags: MessageFlags.Ephemeral,
+ });
}
-
- if (btn.customId === "pb_prev") index = Math.max(0, index - 1);
- if (btn.customId === "pb_next") index = Math.min(pages.length - 1, index + 1);
-
- await btn.update({
- content: `Page ${index + 1}/${pages.length}\n\n${pages[index]}`,
- components: [makeRow(index)],
- });
- } catch (err) {
- console.error("[playback] button update failed", err);
+ } catch (replyErr) {
+ logger.error({ err: replyErr }, "[playback] failed to send fallback error message");
}
- });
-
- collector.on("end", async () => {
- try {
- // disable buttons after timeout
- await interaction.editReply({ components: [] });
- } catch {
- // ignore
- }
- });
+ }
}
diff --git a/src/commands/summary/summary.ts b/src/commands/summary/summary.ts
index e8971571..55b5cf6e 100644
--- a/src/commands/summary/summary.ts
+++ b/src/commands/summary/summary.ts
@@ -1,3 +1,5 @@
+// src/commands/summary/summary.ts
+
import {
SlashCommandBuilder,
AttachmentBuilder,
@@ -5,10 +7,13 @@ import {
type ChatInputCommandInteraction,
} from "discord.js";
import { summarize } from "../../services/summary/summarizer.js";
+import { logger } from "../../utils/logger.js";
/**
* Defines the /summary command.
- * Summarizes recent messages from the current channel and sends the result via DM.
+ *
+ * Summarizes recent messages from the current channel
+ * and delivers the result privately via DM.
*/
export const data = new SlashCommandBuilder()
.setName("summary")
@@ -25,74 +30,63 @@ export const data = new SlashCommandBuilder()
* Handler for the /summary command.
*
* Flow:
- * 1. Defer an ephemeral reply so the user sees “working…” without cluttering the channel.
- * 2. Validate the channel can provide messages.
- * 3. Fetch the last N messages, filter out bots and empty content.
- * 4. Build a plain-text transcript.
- * 5. Run the transcript through the summarizer.
- * 6. Try to DM the summary to the user (text or file).
- * 7. Handle and report errors gracefully.
+ * 1. Defer an ephemeral reply so the user sees feedback immediately.
+ * 2. Validate the channel supports messages.
+ * 3. Fetch and filter recent user messages.
+ * 4. Build a transcript.
+ * 5. Generate a summary (local or LLM).
+ * 6. Deliver the result via DM (text or file).
+ * 7. Handle failures gracefully.
*/
export async function execute(interaction: ChatInputCommandInteraction): Promise {
- // Use the requested count or default to 50 messages.
const count = interaction.options.getInteger("count") ?? 50;
try {
- /**
- * Use flags instead of the deprecated `ephemeral: true`.
- * This keeps the response visible only to the user while work happens.
- */
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
/**
- * Guard: make sure the interaction channel supports text messages.
- * Some interaction contexts (like certain system channels) cannot be summarized.
+ * Guard: only text-capable channels can be summarized.
*/
if (!interaction.channel || !interaction.channel.isTextBased()) {
await interaction.editReply("This channel does not support summarizing messages.");
return;
}
- // Fetch the most recent messages up to the requested limit.
const messages = await interaction.channel.messages.fetch({ limit: count });
- // Nothing in the channel at all.
if (messages.size === 0) {
await interaction.editReply("No messages found to summarize.");
return;
}
/**
- * Filter out bot messages and anything without content,
- * then sort oldest → newest so the transcript reads in conversation order.
+ * Filter out bot messages and empty content,
+ * then sort oldest → newest for readability.
*/
const userMessages = messages
.filter((m) => !m.author.bot && m.content)
.sort((a, b) => a.createdTimestamp - b.createdTimestamp);
- // If everything filtered out (all bots or empty content), there is nothing useful to summarize.
if (userMessages.size === 0) {
await interaction.editReply("No usable messages found to summarize.");
return;
}
/**
- * Build a simple “username: content” transcript that the summarizer can consume.
+ * Build a simple transcript the summarizer can consume.
*/
const text = userMessages.map((m) => `${m.author.username}: ${m.content}`).join("\n");
- // Generate the summary using either local or LLM mode, depending on configuration.
const output = await summarize(text);
- // Defensive guard: handle a blank or missing summary result.
if (!output || output.trim().length === 0) {
await interaction.editReply("Summary came back empty.");
return;
}
/**
- * DM-only delivery mode:
- * If the summary is too long for a normal Discord message, send it as a file attachment instead.
+ * If the summary exceeds Discord message limits,
+ * send it as a file attachment instead.
*/
if (output.length > 2000) {
const file = new AttachmentBuilder(Buffer.from(output, "utf8"), {
@@ -100,16 +94,18 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise
});
try {
- // Attempt to DM the file to the user.
await interaction.user.send({
content: "Here is your summary (too long to send as a message):",
files: [file],
});
- // Confirm in the ephemeral reply that the summary was sent.
await interaction.editReply("Summary sent to your DMs.");
} catch (err) {
- console.error("[summary] DM file send failed", err);
+ logger.warn(
+ { err, userId: interaction.user.id },
+ "[summary] DM file send failed",
+ );
+
await interaction.editReply(
"I generated the summary, but your DMs appear to be closed.",
);
@@ -119,24 +115,27 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise
}
/**
- * Normal-sized summary:
- * Send it as a plain DM message, then confirm in the ephemeral reply.
+ * Normal-sized summary: send as plain DM text.
*/
try {
await interaction.user.send(output);
await interaction.editReply("Summary sent to your DMs.");
} catch (err) {
- console.error("[summary] DM text send failed", err);
+ logger.warn({ err, userId: interaction.user.id }, "[summary] DM text send failed");
+
await interaction.editReply(
"I generated the summary, but could not DM you. Your DMs may be closed.",
);
}
} catch (err) {
/**
- * Top-level catch: if anything in the summarization pipeline fails,
- * log the error and try to send a generic failure message back to the user.
+ * Top-level failure handler.
+ * We log the error and attempt to notify the user once.
*/
- console.error("[summary] Summary generation failed", err);
+ logger.error(
+ { err, command: "summary", userId: interaction.user.id },
+ "[summary] Summary generation failed",
+ );
try {
if (interaction.replied || interaction.deferred) {
@@ -148,8 +147,7 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise
});
}
} catch (replyErr) {
- // Final fallback if even the error reply fails.
- console.error("[summary] Failed to send fallback error message", replyErr);
+ logger.error({ err: replyErr }, "[summary] Failed to send fallback error message");
}
}
}
diff --git a/src/config/env.ts b/src/config/env.ts
index c665f9f6..af6dbb56 100644
--- a/src/config/env.ts
+++ b/src/config/env.ts
@@ -15,6 +15,23 @@ function requireEnv(name: string): string {
return value;
}
+/**
+ * Parse an integer env var safely.
+ * Falls back to `defaultValue` if missing/invalid.
+ */
+function envInt(name: string, defaultValue: number): number {
+ const raw = process.env[name];
+ if (!raw) return defaultValue;
+
+ const n = Number(raw);
+ if (!Number.isFinite(n) || !Number.isInteger(n)) return defaultValue;
+ if (n <= 0) return defaultValue;
+
+ return n;
+}
+
+type SummaryMode = "local" | "llm";
+
/**
* Centralized environment configuration for OmegaBot.
*
@@ -74,6 +91,12 @@ function requireEnv(name: string): string {
* All GitHub announcement fields are optional so the bot can run
* without polling enabled.
*/
+const summaryMode = (process.env.SUMMARY_MODE ?? "local") as SummaryMode;
+
+if (summaryMode === "llm" && !process.env.OPENAI_API_KEY) {
+ throw new Error("OPENAI_API_KEY is required when SUMMARY_MODE=llm");
+}
+
export const env = {
/* ---------------------------------------------------------------- */
/* Discord (required) */
@@ -88,13 +111,13 @@ export const env = {
/* ---------------------------------------------------------------- */
// Defaults to local so the bot runs without AI keys.
- summaryMode: process.env.SUMMARY_MODE ?? "local",
+ summaryMode,
// Only required when summaryMode === "llm"
openAIKey: process.env.OPENAI_API_KEY ?? null,
/* ---------------------------------------------------------------- */
- /* GitHub */
+ /* GitHub */
/* ---------------------------------------------------------------- */
// Auth token for GitHub REST API
@@ -108,5 +131,5 @@ export const env = {
githubAnnounceChannelId: process.env.GITHUB_ANNOUNCE_CHANNEL_ID ?? null,
// Polling interval (ms). Defaults to 60s.
- githubPollIntervalMs: Number(process.env.GITHUB_POLL_INTERVAL_MS ?? "60000"),
+ githubPollIntervalMs: envInt("GITHUB_POLL_INTERVAL_MS", 60_000),
};
diff --git a/src/registerCommands.ts b/src/registerCommands.ts
index 49fe0b7a..d73f0ad5 100644
--- a/src/registerCommands.ts
+++ b/src/registerCommands.ts
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { env } from "./config/env.js";
import type { RESTPostAPIChatInputApplicationCommandsJSONBody } from "discord-api-types/v10";
+import { logger } from "./utils/logger.js";
/*
* Read all compiled command definitions and prepare them for registration
@@ -69,13 +70,13 @@ async function register() {
body: commands,
});
- console.log("Commands registered.");
+ logger.info("Commands registered.");
}
/*
* Wrapper so errors throw clearly and stop the script immediately.
*/
register().catch((err) => {
- console.error("Failed to register commands:", err);
+ logger.error(err, "Failed to register commands");
process.exit(1);
});
diff --git a/src/services/discord/commandLoader.ts b/src/services/discord/commandLoader.ts
index 225e3785..d30cc9bc 100644
--- a/src/services/discord/commandLoader.ts
+++ b/src/services/discord/commandLoader.ts
@@ -1,17 +1,19 @@
+// src/services/discord/commandLoader.ts
+
import fs from "fs";
import path from "path";
import {
Client,
+ SlashCommandBuilder,
type ChatInputCommandInteraction,
- type SlashCommandBuilder,
} from "discord.js";
+import { logger } from "../../utils/logger.js";
/**
* Contract that every slash command module must follow.
*
* - `data` describes the command to Discord (name, description, options)
- * using SlashCommandBuilder.
- * - `execute` is the handler that runs when a user invokes the command.
+ * - `execute` runs when a user invokes the command
*/
export interface SlashCommand {
data: SlashCommandBuilder;
@@ -19,58 +21,84 @@ export interface SlashCommand {
}
/**
- * Extension of the Discord Client that includes a `commands` map.
- *
- * This lets us:
- * - dynamically load commands at startup
- * - look up the correct handler at runtime by command name
- * (e.g. "ping" -> ping command module)
+ * Discord Client extended with a command registry.
*/
export type CommandClient = Client & {
commands: Map;
};
/**
- * Dynamically loads all compiled command modules and registers them on the client.
- *
- * How it works:
- * - Looks under dist/commands for grouped command folders (e.g. general/, summary/)
- * - Imports every .js file in those folders at runtime
- * - Expects each module to export `data` and `execute` matching the SlashCommand interface
- * - Registers each command into `client.commands` keyed by its name
+ * Load all compiled command modules from dist/commands and register them into client.commands.
*
- * This allows new commands to be added simply by dropping a new file in src/commands,
- * then rebuilding the project.
+ * Notes:
+ * - We load from dist/ because the bot runs compiled JS.
+ * - A single broken command should not crash the entire bot.
*/
export async function loadCommands(client: CommandClient): Promise {
- // We run the bot from compiled JS, so commands are discovered in dist/commands,
- // not src/commands.
const basePath = path.join(process.cwd(), "dist/commands");
+
+ if (!fs.existsSync(basePath)) {
+ logger.error(
+ { basePath },
+ "Command loader base path not found. Did you build the project?",
+ );
+ return;
+ }
+
const groups = fs.readdirSync(basePath);
+ let loadedCount = 0;
- // Each "group" is a subfolder under dist/commands (e.g. general/, summary/).
for (const group of groups) {
const groupPath = path.join(basePath, group);
if (!fs.statSync(groupPath).isDirectory()) continue;
const files = fs.readdirSync(groupPath);
+
for (const file of files) {
- // Only consider compiled .js files. Ignore maps, d.ts, etc.
if (!file.endsWith(".js")) continue;
const fullPath = path.join(groupPath, file);
- // Dynamically import the command module. We treat it as Partial here
- // and validate that it actually has the expected shape before using it.
- const mod = (await import(fullPath)) as Partial;
+ try {
+ const mod = (await import(fullPath)) as Partial;
+
+ if (!mod.data || !mod.execute) {
+ logger.warn(
+ { file: `${group}/${file}` },
+ "Skipping command module (missing data or execute)",
+ );
+ continue;
+ }
+
+ const name = mod.data.name;
- // Only register modules that provide both `data` and `execute`.
- // This prevents half-configured or broken command files from crashing the bot.
- if (mod.data && mod.execute) {
- // The command name is defined in `data` (SlashCommandBuilder).
- // We use that as the key in the commands map.
- client.commands.set(mod.data.name, mod as SlashCommand);
+ if (!name || typeof name !== "string") {
+ logger.warn(
+ { file: `${group}/${file}` },
+ "Skipping command module (invalid command name)",
+ );
+ continue;
+ }
+
+ // Avoid silent overwrites if two commands share the same name
+ if (client.commands.has(name)) {
+ logger.warn(
+ { name, file: `${group}/${file}` },
+ "Duplicate command name detected. Skipping this module.",
+ );
+ continue;
+ }
+
+ client.commands.set(name, mod as SlashCommand);
+ loadedCount += 1;
+ } catch (err) {
+ logger.error(
+ { err, file: `${group}/${file}`, fullPath },
+ "Failed to import command module",
+ );
}
}
}
+
+ logger.info({ loadedCount }, "Commands loaded");
}
diff --git a/src/services/discord/fetchChannelMessages.ts b/src/services/discord/fetchChannelMessages.ts
index 182b9a50..0ea00aaf 100644
--- a/src/services/discord/fetchChannelMessages.ts
+++ b/src/services/discord/fetchChannelMessages.ts
@@ -1,4 +1,7 @@
+// src/services/discord/fetchChannelMessages.ts
+
import type { TextBasedChannel, Message } from "discord.js";
+import { logger } from "../../utils/logger.js";
export type FetchMessagesOptions = {
count?: number;
@@ -7,10 +10,18 @@ export type FetchMessagesOptions = {
};
/**
- * Fetch messages from a text-based channel with optional
- * count / before / after filters.
+ * Fetch messages from a text-based Discord channel.
+ *
+ * Supports optional:
+ * - count → max number of messages to fetch
+ * - before → message ID cursor
+ * - after → message ID cursor
*
* Returns messages sorted oldest → newest.
+ *
+ * This function performs network I/O and therefore:
+ * - Wraps fetch in try/catch
+ * - Logs failures with context
*/
export async function fetchChannelMessages(
channel: TextBasedChannel,
@@ -29,10 +40,30 @@ export async function fetchChannelMessages(
if (before) fetchOptions.before = before;
if (after) fetchOptions.after = after;
- const collection = await channel.messages.fetch(fetchOptions);
+ try {
+ const collection = await channel.messages.fetch(fetchOptions);
+
+ // Convert Collection → Array and sort chronologically
+ return Array.from(collection.values()).sort(
+ (a, b) => a.createdTimestamp - b.createdTimestamp,
+ );
+ } catch (err) {
+ logger.error(
+ {
+ err,
+ channelId: channel.id,
+ count,
+ before,
+ after,
+ },
+ "Failed to fetch channel messages",
+ );
- // Convert Collection → Array and sort chronologically
- return Array.from(collection.values()).sort(
- (a, b) => a.createdTimestamp - b.createdTimestamp,
- );
+ /**
+ * Fail safe:
+ * - Return empty list instead of crashing caller
+ * - Callers can decide how to handle missing messages
+ */
+ return [];
+ }
}
diff --git a/src/services/discord/interactionHandler.ts b/src/services/discord/interactionHandler.ts
index 2084cd60..9872fd10 100644
--- a/src/services/discord/interactionHandler.ts
+++ b/src/services/discord/interactionHandler.ts
@@ -1,39 +1,79 @@
+// src/services/discord/interactionHandler.ts
+
import type { Interaction, ChatInputCommandInteraction } from "discord.js";
import type { CommandClient } from "./commandLoader.js";
+import { logger } from "../../utils/logger.js";
/**
- * Handles incoming interactions and dispatches slash commands to their registered executors.
+ * Handles incoming Discord interactions and dispatches slash commands
+ * to their registered executors.
+ *
+ * Responsibilities:
+ * - Ignore non-slash interactions
+ * - Look up the command handler
+ * - Execute safely with error handling
+ * - Ensure the user always receives a response
*/
export async function handleInteraction(
interaction: Interaction,
client: CommandClient,
): Promise {
/**
- * Ignore any interaction that is not a slash command.
+ * We only care about slash commands.
+ * Other interaction types (buttons, modals, etc.) are ignored here.
*/
if (!interaction.isChatInputCommand()) return;
const command = client.commands.get(interaction.commandName);
+
/**
- * If we have no handler for this command, reply to the user and stop.
+ * No command registered for this name.
+ * This usually indicates a stale command or partial deploy.
*/
if (!command) {
- await interaction.reply("Command not found");
+ logger.warn(
+ {
+ commandName: interaction.commandName,
+ user: interaction.user?.username,
+ },
+ "Received interaction for unknown command",
+ );
+
+ await interaction.reply({
+ content: "Command not found.",
+ ephemeral: true,
+ });
return;
}
/**
- * Execute the command safely. If it throws, log the error and reply with a generic failure message.
- * If the interaction already has a response, use editReply instead.
+ * Execute the command safely.
+ *
+ * Any thrown error is:
+ * - Logged with context
+ * - Converted into a generic user-facing message
+ *
+ * We avoid leaking internal errors to Discord users.
*/
try {
await command.execute(interaction as ChatInputCommandInteraction);
} catch (err) {
- console.error(err);
+ logger.error(
+ {
+ err,
+ commandName: interaction.commandName,
+ user: interaction.user?.username,
+ guildId: interaction.guildId,
+ },
+ "Slash command execution failed",
+ );
+
+ const message = "Something went wrong while running this command.";
+
if (interaction.replied || interaction.deferred) {
- await interaction.editReply("Something went wrong");
+ await interaction.editReply(message);
} else {
- await interaction.reply("Something went wrong");
+ await interaction.reply({ content: message, ephemeral: true });
}
}
}
diff --git a/src/services/github/githubApi.ts b/src/services/github/githubApi.ts
index 230385a5..d38a50d0 100644
--- a/src/services/github/githubApi.ts
+++ b/src/services/github/githubApi.ts
@@ -1,6 +1,7 @@
// src/services/github/githubApi.ts
import { githubRequest, GitHubApiError } from "./githubClient.js";
+import { logger } from "../../utils/logger.js";
import type {
GitHubIssue,
GitHubPullRequest,
@@ -12,6 +13,9 @@ import type {
/* Path helpers */
/* ------------------------------------------------------------------ */
+/**
+ * Build the base repo API path.
+ */
function repoBase(owner: string, repo: string): string {
return `/repos/${owner}/${repo}`;
}
@@ -37,10 +41,16 @@ function listIssuesPath(
): string {
const params = new URLSearchParams();
+ // GitHub default is "open", but keep explicit if the caller sets it.
if (options?.state) params.set("state", options.state);
+
+ // GitHub uses per_page for page size.
if (options?.limit) params.set("per_page", String(options.limit));
+
+ // Comma-separated labels.
if (options?.labels?.length) params.set("labels", options.labels.join(","));
+ // Default sorting: most recently updated first.
params.set("sort", "updated");
params.set("direction", "desc");
@@ -59,6 +69,7 @@ function listPullRequestsPath(
options?: ListPrOptions,
): string {
const params = new URLSearchParams();
+
params.set("state", options?.state ?? "open");
params.set("per_page", String(options?.limit ?? 20));
@@ -73,6 +84,10 @@ function listPullRequestsPath(
/* Error helpers */
/* ------------------------------------------------------------------ */
+/**
+ * Narrow unknown errors into a GitHubApiError with status info.
+ * This lets callers do 404 fallback safely.
+ */
function isGitHubApiError(err: unknown): err is GitHubApiError {
return err instanceof GitHubApiError;
}
@@ -85,22 +100,55 @@ function is404(err: unknown): err is GitHubApiError {
/* Single item fetchers */
/* ------------------------------------------------------------------ */
+/**
+ * Fetch a single issue by number.
+ */
export async function getIssue(
owner: string,
repo: string,
number: number,
): Promise {
- return githubRequest(issuePath(owner, repo, number));
+ try {
+ return await githubRequest(issuePath(owner, repo, number));
+ } catch (err) {
+ if (isGitHubApiError(err)) {
+ logger.warn(
+ { owner, repo, number, status: err.status, url: err.url },
+ "getIssue failed",
+ );
+ } else {
+ logger.error({ err, owner, repo, number }, "getIssue threw");
+ }
+ throw err;
+ }
}
+/**
+ * Fetch a single pull request by number.
+ */
export async function getPullRequest(
owner: string,
repo: string,
number: number,
): Promise {
- return githubRequest(prPath(owner, repo, number));
+ try {
+ return await githubRequest(prPath(owner, repo, number));
+ } catch (err) {
+ if (isGitHubApiError(err)) {
+ logger.warn(
+ { owner, repo, number, status: err.status, url: err.url },
+ "getPullRequest failed",
+ );
+ } else {
+ logger.error({ err, owner, repo, number }, "getPullRequest threw");
+ }
+ throw err;
+ }
}
+/**
+ * Try PR first, fallback to issue if PR endpoint returns 404.
+ */
export async function getIssueOrPr(
owner: string,
repo: string,
@@ -109,7 +157,9 @@ export async function getIssueOrPr(
try {
return await getPullRequest(owner, repo, number);
} catch (err) {
- if (is404(err)) return await getIssue(owner, repo, number);
+ if (is404(err)) {
+ return await getIssue(owner, repo, number);
+ }
throw err;
}
}
@@ -130,18 +180,30 @@ export async function listIssues(
repo: string,
options?: ListIssuesOptions,
): Promise {
- const data = await githubRequest(listIssuesPath(owner, repo, options));
-
- return data
- .filter((i) => !i.pull_request)
- .map((i) => ({
- number: i.number,
- title: i.title,
- html_url: i.html_url,
- state: i.state,
- user: { login: i.user.login },
- comments: i.comments,
- }));
+ try {
+ const data = await githubRequest(listIssuesPath(owner, repo, options));
+
+ return data
+ .filter((i) => !i.pull_request)
+ .map((i) => ({
+ number: i.number,
+ title: i.title,
+ html_url: i.html_url,
+ state: i.state,
+ user: { login: i.user.login },
+ comments: i.comments,
+ }));
+ } catch (err) {
+ if (isGitHubApiError(err)) {
+ logger.warn(
+ { owner, repo, status: err.status, url: err.url, options },
+ "listIssues failed",
+ );
+ } else {
+ logger.error({ err, owner, repo, options }, "listIssues threw");
+ }
+ throw err;
+ }
}
/**
@@ -156,19 +218,31 @@ export async function listPullRequests(
repo: string,
options?: ListPrOptions,
): Promise {
- const data = await githubRequest(
- listPullRequestsPath(owner, repo, options),
- );
-
- return data.map((pr) => ({
- number: pr.number,
- title: pr.title,
- html_url: pr.html_url,
- state: pr.state,
- merged_at: pr.merged_at,
- updated_at: pr.updated_at,
- user: { login: pr.user.login },
- comments: pr.comments,
- review_comments: pr.review_comments,
- }));
+ try {
+ const data = await githubRequest(
+ listPullRequestsPath(owner, repo, options),
+ );
+
+ return data.map((pr) => ({
+ number: pr.number,
+ title: pr.title,
+ html_url: pr.html_url,
+ state: pr.state,
+ merged_at: pr.merged_at,
+ updated_at: pr.updated_at,
+ user: { login: pr.user.login },
+ comments: pr.comments,
+ review_comments: pr.review_comments,
+ }));
+ } catch (err) {
+ if (isGitHubApiError(err)) {
+ logger.warn(
+ { owner, repo, status: err.status, url: err.url, options },
+ "listPullRequests failed",
+ );
+ } else {
+ logger.error({ err, owner, repo, options }, "listPullRequests threw");
+ }
+ throw err;
+ }
}
diff --git a/src/services/github/githubClient.ts b/src/services/github/githubClient.ts
index 9a907f20..5738f897 100644
--- a/src/services/github/githubClient.ts
+++ b/src/services/github/githubClient.ts
@@ -1,6 +1,7 @@
// src/services/github/githubClient.ts
import { env } from "../../config/env.js";
+import { logger } from "../../utils/logger.js";
/**
* Base URL for GitHub REST API v3
@@ -27,6 +28,7 @@ export class GitHubApiError extends Error {
* Low-level GitHub request helper.
*
* Responsibilities:
+ * - Validate auth is configured
* - Add auth headers
* - Perform fetch
* - Handle non-OK responses
@@ -38,31 +40,48 @@ export async function githubRequest(path: string): Promise {
const url = `${GITHUB_API_BASE}${path}`;
if (!env.githubToken) {
+ // This should be caught at startup, but keep a defensive check here too.
throw new Error("GITHUB_TOKEN is not set in environment");
}
- const res = await fetch(url, {
- headers: {
- Accept: "application/vnd.github+json",
- Authorization: `Bearer ${env.githubToken}`,
- "X-GitHub-Api-Version": "2022-11-28",
- "User-Agent": "OmegaBot",
- },
- });
+ try {
+ const res = await fetch(url, {
+ headers: {
+ Accept: "application/vnd.github+json",
+ Authorization: `Bearer ${env.githubToken}`,
+ "X-GitHub-Api-Version": "2022-11-28",
+ "User-Agent": "OmegaBot",
+ },
+ });
- if (!res.ok) {
- let message = res.statusText;
+ if (!res.ok) {
+ let message = res.statusText;
- // GitHub usually returns JSON with { message: "...", ... }
- try {
- const body = (await res.json()) as { message?: string };
- if (body?.message) message = body.message;
- } catch {
- // Ignore parse failures, keep statusText
+ // GitHub usually returns JSON with { message: "...", ... }
+ try {
+ const body = (await res.json()) as { message?: string };
+ if (body?.message) message = body.message;
+ } catch {
+ // Ignore parse failures, keep statusText
+ }
+
+ logger.warn(
+ { status: res.status, url, path, message },
+ "GitHub API request failed",
+ );
+
+ throw new GitHubApiError(message, res.status, url);
}
- throw new GitHubApiError(message, res.status, url);
- }
+ return (await res.json()) as T;
+ } catch (err) {
+ // If it's already our rich error, bubble it up unchanged.
+ if (err instanceof GitHubApiError) {
+ throw err;
+ }
- return (await res.json()) as T;
+ // Network errors, DNS failures, timeouts, etc.
+ logger.error({ err, url, path }, "GitHub API request threw");
+ throw err;
+ }
}
diff --git a/src/services/github/lastSeenStore.ts b/src/services/github/lastSeenStore.ts
index c9f3b3e2..c6e99cdf 100644
--- a/src/services/github/lastSeenStore.ts
+++ b/src/services/github/lastSeenStore.ts
@@ -1,3 +1,5 @@
+// src/services/github/lastSeenStore.ts
+
import fs from "fs";
import path from "path";
@@ -15,6 +17,14 @@ import path from "path";
*/
const STORE_PATH = path.join(process.cwd(), "data", "last-seen.json");
+/**
+ * Internal storage shape.
+ *
+ * Key → "owner/repo"
+ * Value → Unix timestamp in milliseconds (Number)
+ *
+ * We store numbers so comparisons are cheap and timezone-agnostic.
+ */
type Store = Record;
/**
@@ -34,6 +44,8 @@ export function getLastSeen(owner: string, repo: string): number | null {
* Save the "last seen" timestamp for PR announcements.
*
* We store unix millis (Number) to make comparisons cheap and timezone-agnostic.
+ *
+ * @param updatedAtIso ISO timestamp string from GitHub API (ex: pr.updated_at)
*/
export function setLastSeenPr(owner: string, repo: string, updatedAtIso: string): void {
const store = loadStore();
@@ -50,7 +62,9 @@ export function setLastSeenPr(owner: string, repo: string, updatedAtIso: string)
/**
* Remove any stored "last seen" value for a repo.
- * Safe even if key does not exist.
+ *
+ * Safe to call even if the repo was never stored.
+ * Useful for development resets and testing.
*/
export function clearLastSeen(owner: string, repo: string): void {
const store = loadStore();
@@ -62,24 +76,55 @@ export function clearLastSeen(owner: string, repo: string): void {
}
}
+/**
+ * Build a stable storage key for a repo.
+ *
+ * We intentionally normalize on "owner/repo" because:
+ * - It is human-readable
+ * - Matches GitHub's canonical naming
+ * - Avoids nested JSON structures
+ */
function makeRepoKey(owner: string, repo: string): string {
return `${owner}/${repo}`;
}
+/**
+ * Load the last-seen store from disk.
+ *
+ * Behavior:
+ * - If the file does not exist → return empty store
+ * - If the file exists → parse JSON
+ *
+ * This function is synchronous by design:
+ * - It runs rarely (poll job / command invocation)
+ * - Simpler failure modes
+ * - Fewer edge cases in a single-process bot
+ */
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.
+ // If JSON is corrupted, fail safe to empty.
+ // Worst case: PRs may be re-announced once.
return {};
}
}
+/**
+ * Persist the last-seen store to disk.
+ *
+ * Guarantees:
+ * - Parent directory exists
+ * - JSON is pretty-printed for easy debugging
+ *
+ * We overwrite the file entirely to keep writes simple.
+ */
function saveStore(store: Store): void {
const dir = path.dirname(STORE_PATH);
diff --git a/src/services/github/prPoller.ts b/src/services/github/prPoller.ts
index 05925740..6223f49b 100644
--- a/src/services/github/prPoller.ts
+++ b/src/services/github/prPoller.ts
@@ -4,22 +4,23 @@ import type { Client } from "discord.js";
import { listPullRequests } from "./githubApi.js";
import { getLastSeen, setLastSeenPr } from "./lastSeenStore.js";
import { formatPullRequest } from "./prFormatter.js";
+import { logger } from "../../utils/logger.js";
/**
* Minimal shape we need from the GitHub PR list for polling announcements.
- * Your githubApi.listPullRequests MUST return objects that include `updated_at`.
+ * githubApi.listPullRequests must return objects with `updated_at`.
*/
type PrForPolling = {
- updated_at: string; // ISO timestamp from GitHub
+ updated_at: string;
};
/**
* 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
+ * Design goals:
+ * - Never throw (background job safety)
+ * - Log failures once with context
+ * - Skip quietly when nothing to do
*/
export async function pollPullRequestsOnce(args: {
client: Client;
@@ -30,41 +31,47 @@ export async function pollPullRequestsOnce(args: {
}): 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])[];
+ try {
+ // Pull newest-updated first (based on githubApi query params)
+ const prs = (await listPullRequests(owner, repo, {
+ state: "open",
+ limit,
+ })) as unknown as (PrForPolling & Parameters[0])[];
- if (prs.length === 0) return;
+ if (prs.length === 0) return;
- const lastSeen = getLastSeen(owner, repo) ?? 0;
+ 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;
- });
+ // Keep only PRs updated after our stored timestamp
+ const fresh = prs.filter((pr) => {
+ const ms = Date.parse(pr.updated_at);
+ return !Number.isNaN(ms) && ms > lastSeen;
+ });
- if (fresh.length === 0) return;
+ 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;
+ const fetched = await client.channels.fetch(announceChannelId);
+ if (!fetched || !fetched.isTextBased()) {
+ logger.warn({ announceChannelId }, "PR poller could not resolve announce channel");
+ 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 runtime guard
+ if (!("send" in fetched) || typeof fetched.send !== "function") return;
- // Extra TS guard: only proceed if this thing actually has a send() function.
- if (!("send" in fetched) || typeof fetched.send !== "function") return;
+ // Post oldest-first so announcements read naturally
+ const oldestFirst = [...fresh].sort(
+ (a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at),
+ );
- for (const pr of oldestFirst) {
- await fetched.send(formatPullRequest(pr));
- }
+ 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);
+ // Update last-seen to newest PR we announced
+ const newest = oldestFirst[oldestFirst.length - 1];
+ setLastSeenPr(owner, repo, newest.updated_at);
+ } catch (err) {
+ logger.error({ err, owner, repo, announceChannelId }, "GitHub PR polling failed");
+ }
}
diff --git a/src/services/summary/llmSummary.ts b/src/services/summary/llmSummary.ts
index d362cf3e..c054a3e8 100644
--- a/src/services/summary/llmSummary.ts
+++ b/src/services/summary/llmSummary.ts
@@ -1,17 +1,27 @@
-// src/services/summary/llmSummary.ts
-
import OpenAI from "openai";
import { env } from "../../config/env.js";
+import { logger } from "../../utils/logger.js";
let client: OpenAI | null = null;
+/**
+ * Initialize OpenAI client only if an API key is present.
+ * This allows the bot to run in "local" summary mode without crashing.
+ */
if (env.openAIKey) {
client = new OpenAI({ apiKey: env.openAIKey });
}
/**
- * Generate a structured summary (Markdown) from the transcript.
- * Safe fallback if LLM mode is requested without a key.
+ * Generate a structured summary (Markdown) from a transcript using an LLM.
+ *
+ * Behavior:
+ * - If LLM mode is requested but no API key is configured, return a safe message
+ * - If the OpenAI request fails, log the error and return a user-friendly fallback
+ *
+ * We intentionally:
+ * - Do NOT log the transcript or prompt (privacy + noise)
+ * - Log only the failure signal for observability
*/
export async function llmSummary(text: string): Promise {
if (!client) {
@@ -35,15 +45,20 @@ export async function llmSummary(text: string): Promise {
text,
].join("\n");
- const response = await client.chat.completions.create({
- model: "gpt-4o-mini",
- messages: [{ role: "user", content: prompt }],
- });
+ try {
+ const response = await client.chat.completions.create({
+ model: "gpt-4o-mini",
+ messages: [{ role: "user", content: prompt }],
+ });
- /**
- * Extract the model output, defaulting to a fallback message if no content is returned.
- */
- const content = response.choices?.[0]?.message?.content ?? "LLM returned no content.";
+ /**
+ * Extract model output, falling back safely if no content is returned.
+ */
+ const content = response.choices?.[0]?.message?.content ?? "LLM returned no content.";
- return content.trim();
+ return content.trim();
+ } catch (err) {
+ logger.error({ err }, "LLM summary generation failed");
+ return "Failed to generate summary via LLM.";
+ }
}
diff --git a/src/services/time/validateTimezone.ts b/src/services/time/validateTimezone.ts
index 6e9d02ce..3a5d7658 100644
--- a/src/services/time/validateTimezone.ts
+++ b/src/services/time/validateTimezone.ts
@@ -1,3 +1,5 @@
+// src/services/time/validateTimeZone.ts
+
/**
* Validate whether a string is a valid IANA timezone.
*
@@ -8,12 +10,16 @@
*
* This does NOT guess timezones.
* It only validates exact IANA identifiers.
+ *
+ * Note:
+ * We intentionally do NOT log here. Invalid timezone input is normal user input,
+ * not an operational error. Callers can decide how to message the user.
*/
export function validateTimeZone(tz: string): boolean {
if (!tz || typeof tz !== "string") return false;
try {
- // Intl.DateTimeFormat will throw if the timezone is invalid
+ // Intl.DateTimeFormat will throw if the timezone is invalid.
Intl.DateTimeFormat("en-US", { timeZone: tz });
return true;
} catch {
diff --git a/src/services/timezone/timezone.ts b/src/services/timezone/timezone.ts
index ced30211..c02d4f22 100644
--- a/src/services/timezone/timezone.ts
+++ b/src/services/timezone/timezone.ts
@@ -1,3 +1,4 @@
+//src/services/timezone/timezone.ts
import {
SlashCommandBuilder,
MessageFlags,
@@ -9,6 +10,7 @@ import {
getUserTimezone,
setUserTimezone,
} from "../../services/timezone/timezoneStore.js";
+import { logger } from "../../utils/logger.js";
/**
* /timezone command
@@ -41,32 +43,41 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise
const sub = interaction.options.getSubcommand();
const userId = interaction.user.id;
- if (sub === "show") {
- const current = getUserTimezone(userId);
- await interaction.editReply(
- current ? `Your timezone is set to: ${current}` : "You have no timezone set.",
- );
- return;
- }
+ try {
+ if (sub === "show") {
+ const current = getUserTimezone(userId);
+ await interaction.editReply(
+ current ? `Your timezone is set to: ${current}` : "You have no timezone set.",
+ );
+ return;
+ }
- if (sub === "clear") {
- clearUserTimezone(userId);
- await interaction.editReply("Timezone cleared.");
- return;
- }
+ if (sub === "clear") {
+ clearUserTimezone(userId);
+ logger.info({ userId }, "Timezone cleared");
+ await interaction.editReply("Timezone cleared.");
+ return;
+ }
- // sub === "set"
- const tz = interaction.options.getString("tz", true).trim();
+ // sub === "set"
+ const tz = interaction.options.getString("tz", true).trim();
- try {
- assertValidTimeZone(tz);
- } catch {
- await interaction.editReply(
- `That timezone is not valid. Use an IANA value like "America/New_York" or "Europe/London".`,
- );
- return;
- }
+ try {
+ assertValidTimeZone(tz);
+ } catch {
+ logger.warn({ userId, tz }, "Invalid timezone provided");
+ await interaction.editReply(
+ `That timezone is not valid. Use an IANA value like "America/New_York" or "Europe/London".`,
+ );
+ return;
+ }
+
+ setUserTimezone(userId, tz);
+ logger.info({ userId, tz }, "Timezone set");
+ await interaction.editReply(`Timezone saved: ${tz}`);
+ } catch (err) {
+ logger.error({ err, userId, sub }, "Timezone command failed unexpectedly");
- setUserTimezone(userId, tz);
- await interaction.editReply(`Timezone saved: ${tz}`);
+ await interaction.editReply("Something went wrong while updating your timezone.");
+ }
}
diff --git a/src/services/timezone/timezoneStore.ts b/src/services/timezone/timezoneStore.ts
index f85e7f37..6db19de4 100644
--- a/src/services/timezone/timezoneStore.ts
+++ b/src/services/timezone/timezoneStore.ts
@@ -1,5 +1,7 @@
+//src/services/timezone/timezoneStore.ts
import fs from "fs";
import path from "path";
+import { logger } from "../../utils/logger.js";
type TimezoneStore = Record;
@@ -11,29 +13,49 @@ const DATA_DIR = path.join(process.cwd(), "data");
const STORE_PATH = path.join(DATA_DIR, "timezones.json");
function ensureStoreFile(): void {
- if (!fs.existsSync(DATA_DIR)) {
- fs.mkdirSync(DATA_DIR, { recursive: true });
- }
- if (!fs.existsSync(STORE_PATH)) {
- fs.writeFileSync(STORE_PATH, JSON.stringify({}, null, 2), "utf8");
+ try {
+ if (!fs.existsSync(DATA_DIR)) {
+ fs.mkdirSync(DATA_DIR, { recursive: true });
+ }
+
+ if (!fs.existsSync(STORE_PATH)) {
+ fs.writeFileSync(STORE_PATH, JSON.stringify({}, null, 2), "utf8");
+ logger.info("Created timezone store file");
+ }
+ } catch (err) {
+ logger.error({ err }, "Failed to initialize timezone store");
+ throw err;
}
}
function loadStore(): TimezoneStore {
ensureStoreFile();
+
try {
const raw = fs.readFileSync(STORE_PATH, "utf8");
const parsed = JSON.parse(raw) as unknown;
- if (parsed && typeof parsed === "object") return parsed as TimezoneStore;
+
+ if (parsed && typeof parsed === "object") {
+ return parsed as TimezoneStore;
+ }
+
+ logger.warn("Timezone store contained invalid data shape, resetting");
return {};
- } catch {
+ } catch (err) {
+ logger.error({ err }, "Failed to load timezone store, using empty fallback");
return {};
}
}
function saveStore(store: TimezoneStore): void {
ensureStoreFile();
- fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8");
+
+ try {
+ fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8");
+ } catch (err) {
+ logger.error({ err }, "Failed to save timezone store");
+ throw err;
+ }
}
/**
@@ -58,9 +80,13 @@ export function getUserTimezone(userId: string): string | null {
*/
export function setUserTimezone(userId: string, tz: string): void {
assertValidTimeZone(tz);
+
const store = loadStore();
store[userId] = tz;
+
saveStore(store);
+
+ logger.info({ userId, tz }, "User timezone saved");
}
/**
@@ -68,8 +94,10 @@ export function setUserTimezone(userId: string, tz: string): void {
*/
export function clearUserTimezone(userId: string): void {
const store = loadStore();
+
if (store[userId] !== undefined) {
delete store[userId];
saveStore(store);
+ logger.info({ userId }, "User timezone cleared");
}
}
diff --git a/src/services/transcript/buildTranscript.ts b/src/services/transcript/buildTranscript.ts
index 7c332bc3..dd306a8b 100644
--- a/src/services/transcript/buildTranscript.ts
+++ b/src/services/transcript/buildTranscript.ts
@@ -31,6 +31,13 @@ export type TranscriptResult = {
/**
* Builds a readable transcript from a list of messages.
* Responsible ONLY for formatting + truncation rules.
+ *
+ * This function intentionally:
+ * - Does not log
+ * - Does not throw
+ * - Does not mutate external state
+ *
+ * Callers decide how to handle results and failures.
*/
export function buildTranscript(
messages: TranscriptMessage[],
@@ -77,6 +84,7 @@ export function buildTranscript(
if (maxChars) {
const joinedLen =
lines.reduce((acc, l) => acc + l.length, 0) + Math.max(0, lines.length - 1);
+
if (joinedLen >= maxChars) {
tooLong = true;
break;
diff --git a/src/utils/logger.ts b/src/utils/logger.ts
new file mode 100644
index 00000000..59955bf8
--- /dev/null
+++ b/src/utils/logger.ts
@@ -0,0 +1,86 @@
+// src/utils/logger.ts
+
+import pino, { type Logger } from "pino";
+
+/**
+ * Central logger for OmegaBot.
+ *
+ * Why a single logger module?
+ * - Consistent log format across the whole app
+ * - One place to change log level / destinations later
+ * - Easy to swap to JSON logs in prod and pretty logs in dev
+ *
+ * Notes:
+ * - We use `pino-pretty` only for local development readability.
+ * - If `pino-pretty` is not installed, we gracefully fall back to normal JSON logs
+ * instead of crashing the bot at startup.
+ */
+
+/**
+ * Determine if we should try to use pino-pretty.
+ *
+ * Default behavior:
+ * - Development: pretty logs (if pino-pretty is installed)
+ * - Production: JSON logs
+ *
+ * Override:
+ * - Set LOG_PRETTY="false" to force JSON logs locally if you want.
+ */
+const isProd = process.env.NODE_ENV === "production";
+const prettyEnabled =
+ !isProd && (process.env.LOG_PRETTY ?? "true").toLowerCase() === "true";
+
+/**
+ * Build a configured Pino logger.
+ *
+ * We do this in a function so we can safely try the transport and fall back.
+ */
+function createLogger(): Logger {
+ const level = process.env.LOG_LEVEL ?? (isProd ? "info" : "debug");
+
+ // Normal JSON logger (safe default everywhere).
+ const base = pino({ level });
+
+ if (!prettyEnabled) {
+ return base;
+ }
+
+ /**
+ * Dev pretty mode:
+ * If `pino-pretty` is missing or cannot be resolved, Pino throws:
+ * "unable to determine transport target for 'pino-pretty'"
+ *
+ * We catch that and keep running with JSON logs.
+ */
+ try {
+ const transport = pino.transport({
+ target: "pino-pretty",
+ options: {
+ colorize: true,
+ translateTime: "SYS:standard",
+ ignore: "pid,hostname",
+ },
+ });
+
+ return pino({ level }, transport);
+ } catch (err) {
+ // Do not crash the bot because a dev-only dependency is missing.
+ // Keep output readable enough to diagnose it.
+ // eslint-disable-next-line no-console
+ console.warn(
+ "[logger] pino-pretty not available, falling back to JSON logs. Install with: npm i -D pino-pretty",
+ err,
+ );
+ return base;
+ }
+}
+
+/**
+ * Export a single logger instance for the whole project.
+ *
+ * Usage:
+ * import { logger } from "../utils/logger.js";
+ * logger.info("something happened");
+ * logger.error({ err }, "something failed");
+ */
+export const logger = createLogger();