Discord watcher bot that listens to configured channels and forwards messages to the Hacker Portal announcements ingestion API.
- Watches only configured Discord channel IDs
- Forwards every non-bot message from those channels to the Portal API
- Mirrors message edits in place via
MessageUpdate(usesPartials.Messageso it catches edits to messages sent before the bot started) - Sends each attachment's metadata (URL, filename, content type, size, dimensions) as a structured array — content text and attachments are kept separate
- Archives the full Discord
Message.toJSON()payload so we can backfill later if needed - Archives portal rows when messages are deleted in Discord (
MessageDeleteandMessageBulkDelete→ portalDELETE,is_archived=true) - Includes deterministic idempotency key (
message.id) to support dedupe - Retries transient API failures with capped exponential backoff + jitter
- Supports
--dry-runstartup for safe validation
- Install dependencies:
npm install
- Copy env template:
cp .env.example .env(or create.envmanually on Windows)
- Fill required values in
.env
DISCORD_BOT_TOKEN: Discord bot tokenDISCORD_WATCH_CHANNEL_IDS: comma-separated channel IDs to ingest fromPORTAL_API_URL: Portal endpoint (e.g.https://portal.sfusurge.com/api/webhooks/discord)PORTAL_API_SECRET: shared bearer secret expected by Portal APIPORTAL_API_TIMEOUT_MS: request timeout in msPORTAL_MAX_RETRIES: number of retries after initial requestPORTAL_RETRY_BASE_DELAY_MS: retry base backoff delay in msPORTAL_RETRY_MAX_DELAY_MS: maximum retry delay cap in msLOG_LEVEL:trace|debug|info|warn|error|fatal
- Development:
npm run dev - Build:
npm run build - Start built app:
npm run start - Dry-run:
node dist/index.js --dry-run
Headers:
Authorization: Bearer <PORTAL_API_SECRET>X-Idempotency-Key: <messageId>
Portal responses (consumed by PortalClient):
| Status | Meaning |
|---|---|
| 201 | Created (first delivery) |
| 200 | Duplicate (idempotent replay) or updated (edit applied) |
| 400 | Invalid JSON / fails Zod (e.g. empty content + no attachments) |
| 401 | Bad bearer secret |
| 403 | Portal ingest disabled — bot does not retry |
| 422 | No active channel mapping on the portal — bot does not retry |
| 429 / 5xx | Retried with exponential backoff + jitter |
MessageUpdateis wired up alongsideMessageCreate. When fired, the bot:- Hydrates partial messages via
.fetch()(necessary for messages sent before the bot started — requiresPartials.Messagein the client config, already configured) - Skips updates with no
editedAt(Discord firesMessageUpdatefor embed expansion, pin/unpin, etc.) - Sends the same payload shape as create, with
editedTimestampset
- Hydrates partial messages via
- The portal applies edits in place: updates
content, replaces the entire attachment set, setslast_edited_at. Edit replays with the same or oldereditedTimestampare no-ops.
MessageDelete and MessageBulkDelete are wired up alongside create/edit. When a watched message is deleted, the bot sends:
{
"messageId": "string",
"channelId": "string",
"guildId": "string | undefined"
}Headers:
Authorization: Bearer <PORTAL_API_SECRET>X-Idempotency-Key: delete:<messageId>
Portal delete responses:
| Status | Meaning |
|---|---|
archived |
Matching announcement existed and was soft-hidden with is_archived=true |
duplicate |
Matching announcement was already archived |
not_found |
Portal never ingested that Discord message id; treated as successful no-op |
Delete handlers do not hydrate/fetch the deleted message because Discord usually cannot fetch deleted messages. They rely on partial-safe fields (id, channelId, and guildId when available), and filter by DISCORD_WATCH_CHANNEL_IDS.
idempotencyKeyis alwaysmessage.id(does not change on edit).- Network retries of a create return
200 duplicate. - Network retries of an edit return
200 duplicateonce the portal has stored the sameeditedTimestamponce. - Network retries of a delete use
delete:<messageId>and return200 duplicateor200 not_foundonce the portal has already handled the event.
{ "channelId": "string", "guildId": "string", "messageId": "string", "authorId": "string", "content": "string (may be empty if attachments[] is non-empty)", "timestamp": "ISO-8601 string (Message.createdAt)", "editedTimestamp": "ISO-8601 | null (set on edit deliveries)", "attachments": [ { "url": "string", "filename": "string | null", "contentType": "string | null", "sizeBytes": "integer | null", "width": "integer | null", "height": "integer | null" } ], "rawPayload": { /* Message.toJSON() */ }, "idempotencyKey": "string (= messageId)" }