From 1bfd6623536d6c9a201bed1aeaf1eeea6782bad3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 14:47:48 +0000 Subject: [PATCH] Add EQNotify feature under /eqnotify command Ports the standalone EQNotify batphone-notification bot into castle as a native feature. Subscribers register a delivery channel and a set of keyword tags; batphones posted in the batphone channel are matched against each subscriber's tags (buff / last-hour RTE calls filtered out) and an alert is pushed to their phone. - New /eqnotify slash command with subcommands: register, unregister, add-tag, remove-tag, list-tags, clear-tags, test, plus officer-gated add-user / remove-user (Officer/Mod/Knight). - Two delivery backends selected per-subscriber: WirePusher (Android) and Telegram (iOS/Android/desktop), dispatched via a pluggable notifier. Telegram requires TELEGRAM_BOT_TOKEN; when unset it is offered nowhere. - Persists subscribers in Postgres via a new eqnotify_subscriber Prisma model + migration, replacing the original users.json. - Dispatch runs from the message-create listener before the bot-author guard, since castle posts batphones itself via /bp send. Watched channel defaults to the batphone channel, overridable via eqnotifyChannelId. - Pure matching/filtering logic extracted to matcher.ts with unit tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BWXRmPbtEz35wquwFd8Cu7 --- README.md | 16 +++ .../migration.sql | 15 +++ prisma/schema.prisma | 15 +++ src/config.ts | 21 ++- src/features/eqnotify/command.ts | 26 ++++ .../eqnotify/commands/add-tag-subcommand.ts | 44 ++++++ .../eqnotify/commands/add-user-subcommand.ts | 107 +++++++++++++++ .../commands/clear-tags-subcommand.ts | 28 ++++ .../eqnotify/commands/list-tags-subcommand.ts | 39 ++++++ .../eqnotify/commands/register-subcommand.ts | 89 ++++++++++++ .../commands/remove-tag-subcommand.ts | 47 +++++++ .../commands/remove-user-subcommand.ts | 56 ++++++++ .../eqnotify/commands/test-subcommand.ts | 36 +++++ .../commands/unregister-subcommand.ts | 29 ++++ .../eqnotify/eqnotify-message-action.ts | 23 ++++ src/features/eqnotify/eqnotify.service.ts | 127 ++++++++++++++++++ src/features/eqnotify/matcher.test.ts | 53 ++++++++ src/features/eqnotify/matcher.ts | 54 ++++++++ src/features/eqnotify/notifiers/index.ts | 20 +++ src/features/eqnotify/notifiers/telegram.ts | 28 ++++ src/features/eqnotify/notifiers/wirepusher.ts | 22 +++ src/listeners/message-create-listener.ts | 5 + src/listeners/register-commands.ts | 2 + 23 files changed, 901 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260724000000_add_eqnotify_subscribers/migration.sql create mode 100644 src/features/eqnotify/command.ts create mode 100644 src/features/eqnotify/commands/add-tag-subcommand.ts create mode 100644 src/features/eqnotify/commands/add-user-subcommand.ts create mode 100644 src/features/eqnotify/commands/clear-tags-subcommand.ts create mode 100644 src/features/eqnotify/commands/list-tags-subcommand.ts create mode 100644 src/features/eqnotify/commands/register-subcommand.ts create mode 100644 src/features/eqnotify/commands/remove-tag-subcommand.ts create mode 100644 src/features/eqnotify/commands/remove-user-subcommand.ts create mode 100644 src/features/eqnotify/commands/test-subcommand.ts create mode 100644 src/features/eqnotify/commands/unregister-subcommand.ts create mode 100644 src/features/eqnotify/eqnotify-message-action.ts create mode 100644 src/features/eqnotify/eqnotify.service.ts create mode 100644 src/features/eqnotify/matcher.test.ts create mode 100644 src/features/eqnotify/matcher.ts create mode 100644 src/features/eqnotify/notifiers/index.ts create mode 100644 src/features/eqnotify/notifiers/telegram.ts create mode 100644 src/features/eqnotify/notifiers/wirepusher.ts diff --git a/README.md b/README.md index dc04a03f..a21f426a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Provides various features for the Castle EverQuest guild's Discord server. - **Orientation** - welcome instructions and access provisioning - **Invites** - invite list and management tools - **Raid Enlistment** - raid instructions and enlistment tools +- **EQNotify** - per-user phone notifications (WirePusher / Telegram) for the batphones you care about - **Monitoring** - player departures, application requests, etc. - **Utilities** - silently add roles to threads @@ -51,6 +52,21 @@ Some features required secrets, such as to connect to CastleDKP.com or the Castl | `castleDkpTokenRO` | Admin token for making authenticated requests to CastleDKP.com | Discord DKP Auctions, Discord DKP Uploads | Discuss with a Castle Moderator | | `GOOGLE_CLIENT_EMAIL` | The Google Drive account for accessing guild resources | Banking, Shared Characters | Discuss with a Castle Moderator | | `GOOGLE_PRIVATE_KEY` | The Google Drive account key for accessing guild resources | Banking, Shared Characters | Discuss with a Castle Moderator | +| `TELEGRAM_BOT_TOKEN` | Bot token from Telegram's @BotFather | EQNotify Telegram delivery | Create a bot via [@BotFather](https://t.me/BotFather) | +| `eqnotifyChannelId` | [Optional] Channel EQNotify watches; defaults to the batphone channel | EQNotify | Any batphone-style channel ID | + +#### πŸ“£ EQNotify + +`/eqnotify` lets raiders subscribe to phone notifications for the raid targets they care about. It watches the batphone channel and, for each subscriber whose keyword tags match the batphone (buff / last-hour RTE calls are filtered out), pushes an alert to their phone. + +- **Delivery channels**: **WirePusher** (free, Android-only) or **Telegram** (iOS/Android/desktop). Telegram delivery requires `TELEGRAM_BOT_TOKEN`; if unset, only WirePusher is offered. +- **Getting your ID**: WirePusher users use their device ID from the app. Telegram users start a chat with the guild's EQNotify bot and get their numeric chat ID (e.g. from [@userinfobot](https://t.me/userinfobot)). +- **Subcommands**: + - `/eqnotify register ` β€” sign yourself up (self-service). + - `/eqnotify unregister` β€” remove yourself. + - `/eqnotify add-tag ` / `remove-tag ` / `list-tags` / `clear-tags` β€” manage your keywords (use `all` to be notified for every batphone). + - `/eqnotify test` β€” send yourself a test alert to verify delivery. + - `/eqnotify add-user ` / `remove-user ` β€” Officer/Mod/Knight tools to enroll or remove others. ### ⏺️ Local diff --git a/prisma/migrations/20260724000000_add_eqnotify_subscribers/migration.sql b/prisma/migrations/20260724000000_add_eqnotify_subscribers/migration.sql new file mode 100644 index 00000000..feb6a80c --- /dev/null +++ b/prisma/migrations/20260724000000_add_eqnotify_subscribers/migration.sql @@ -0,0 +1,15 @@ +-- CreateEnum +CREATE TYPE "eqnotify_type" AS ENUM ('wire', 'telegram'); + +-- CreateTable +CREATE TABLE "eqnotify_subscriber" ( + "discordId" VARCHAR NOT NULL, + "discordUsername" VARCHAR NOT NULL, + "contact" VARCHAR NOT NULL, + "type" "eqnotify_type" NOT NULL DEFAULT 'wire', + "tags" TEXT[], + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "eqnotify_subscriber_pkey" PRIMARY KEY ("discordId") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f9a410c2..9ee6bea2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -86,6 +86,21 @@ model batphone { message String } +enum eqnotify_type { + wire + telegram +} + +model eqnotify_subscriber { + discordId String @id @db.VarChar + discordUsername String @db.VarChar + contact String @db.VarChar + type eqnotify_type @default(wire) + tags String[] + createdAt DateTime @default(now()) @db.Timestamp(6) + updatedAt DateTime @default(now()) @updatedAt @db.Timestamp(6) +} + enum bank_hour_day_enum { Monday Tuesday diff --git a/src/config.ts b/src/config.ts index e27af270..9f28ec89 100644 --- a/src/config.ts +++ b/src/config.ts @@ -90,6 +90,8 @@ export const { SHOW_FUTURE_WINDOW, CONDENSE_FUTURE_WINDOW, RTE_REQUIRE_OPEN_TARGET, + eqnotifyChannelId, + TELEGRAM_BOT_TOKEN, } = process.env as { /** @@ -286,6 +288,23 @@ export const { * without it needing to be opened first. Defaults to requiring an open target. */ RTE_REQUIRE_OPEN_TARGET?: string; + + /** + * [Optional] Channel EQNotify watches for batphones to match against user + * tags. Defaults to the batphone channel if unset. + */ + eqnotifyChannelId?: string; + + /** + * [Optional] Telegram bot token (from @BotFather) used to deliver EQNotify + * alerts to Telegram subscribers. If unset, Telegram delivery is disabled. + */ + TELEGRAM_BOT_TOKEN?: string; }; -export const rteRequireOpenTarget = RTE_REQUIRE_OPEN_TARGET !== "false"; \ No newline at end of file +export const rteRequireOpenTarget = RTE_REQUIRE_OPEN_TARGET !== "false"; + +/** + * Channel EQNotify watches for batphones. Falls back to the batphone channel. + */ +export const eqnotifyWatchChannelId = eqnotifyChannelId || batphoneChannelId; \ No newline at end of file diff --git a/src/features/eqnotify/command.ts b/src/features/eqnotify/command.ts new file mode 100644 index 00000000..b3b5676d --- /dev/null +++ b/src/features/eqnotify/command.ts @@ -0,0 +1,26 @@ +import { Command } from "../../shared/command/command"; +import { registerSubcommand } from "./commands/register-subcommand"; +import { unregisterSubcommand } from "./commands/unregister-subcommand"; +import { addTagSubcommand } from "./commands/add-tag-subcommand"; +import { removeTagSubcommand } from "./commands/remove-tag-subcommand"; +import { listTagsSubcommand } from "./commands/list-tags-subcommand"; +import { clearTagsSubcommand } from "./commands/clear-tags-subcommand"; +import { testSubcommand } from "./commands/test-subcommand"; +import { addUserSubcommand } from "./commands/add-user-subcommand"; +import { removeUserSubcommand } from "./commands/remove-user-subcommand"; + +export const eqnotifyCommand = new Command( + "eqnotify", + "Get phone notifications for the raid targets you care about.", + [ + registerSubcommand, + unregisterSubcommand, + addTagSubcommand, + removeTagSubcommand, + listTagsSubcommand, + clearTagsSubcommand, + testSubcommand, + addUserSubcommand, + removeUserSubcommand, + ] +); diff --git a/src/features/eqnotify/commands/add-tag-subcommand.ts b/src/features/eqnotify/commands/add-tag-subcommand.ts new file mode 100644 index 00000000..a6af9426 --- /dev/null +++ b/src/features/eqnotify/commands/add-tag-subcommand.ts @@ -0,0 +1,44 @@ +import { + AutocompleteInteraction, + CacheType, + CommandInteraction, +} from "discord.js"; +import { Subcommand } from "../../../shared/command/subcommand"; +import { eqnotifyService } from "../eqnotify.service"; + +export enum Option { + Tag = "tag", +} + +class AddTagSubcommand extends Subcommand { + public async execute(interaction: CommandInteraction) { + const rawTag = this.getRequiredOptionValue(Option.Tag, interaction); + const tag = await eqnotifyService.addTag(interaction.user.id, rawTag); + await interaction.editReply( + `\`${tag}\` has been added to your notification tags.` + ); + } + + public get command() { + return super.command.addStringOption((o) => + o + .setName(Option.Tag) + .setDescription( + "A keyword to match in batphones (e.g. 'vulak', 'kt'). Use 'all' for every batphone." + ) + .setRequired(true) + ); + } + + public async getOptionAutocomplete( + _option: string, + _interaction: AutocompleteInteraction + ) { + return undefined; + } +} + +export const addTagSubcommand = new AddTagSubcommand( + "add-tag", + "Add a keyword you want to be notified for." +); diff --git a/src/features/eqnotify/commands/add-user-subcommand.ts b/src/features/eqnotify/commands/add-user-subcommand.ts new file mode 100644 index 00000000..4e737fa7 --- /dev/null +++ b/src/features/eqnotify/commands/add-user-subcommand.ts @@ -0,0 +1,107 @@ +import { + AutocompleteInteraction, + CacheType, + CommandInteraction, +} from "discord.js"; +import { eqnotify_type } from "@prisma/client"; +import { Subcommand } from "../../../shared/command/subcommand"; +import { authorizeByMemberRoles } from "../../../shared/command/util"; +import { knightRoleId, modRoleId, officerRoleId } from "../../../config"; +import { eqnotifyService, DEFAULT_TAGS } from "../eqnotify.service"; +import { isTelegramConfigured } from "../notifiers/telegram"; +import { getMember } from "../../.."; + +export enum Option { + Member = "member", + Type = "type", + Id = "id", +} + +class AddUserSubcommand extends Subcommand { + public async execute(interaction: CommandInteraction) { + authorizeByMemberRoles( + [officerRoleId, modRoleId, knightRoleId], + interaction + ); + + const memberId = this.getRequiredOptionValue( + Option.Member, + interaction + ); + const type = this.getRequiredOptionValue( + Option.Type, + interaction + ); + const contact = String( + this.getRequiredOptionValue(Option.Id, interaction) + ).trim(); + + if (type === eqnotify_type.telegram && !isTelegramConfigured()) { + throw new Error( + "Telegram delivery is not configured on this bot yet (TELEGRAM_BOT_TOKEN is unset)." + ); + } + if (!contact) { + throw new Error("You must provide a valid ID."); + } + + const member = await getMember(memberId); + const existing = await eqnotifyService.getSubscriber(memberId); + await eqnotifyService.enroll({ + discordId: memberId, + discordUsername: member.user.username, + contact, + type, + }); + + const channel = type === eqnotify_type.telegram ? "Telegram" : "WirePusher"; + await interaction.editReply( + existing + ? `Updated ${member}'s EQNotify delivery to **${channel}**. Their tags are unchanged.` + : `Enrolled ${member} in EQNotify via **${channel}** with default tags: ${DEFAULT_TAGS.join( + ", " + )}.` + ); + } + + public get command() { + return super.command + .addUserOption((o) => + o + .setName(Option.Member) + .setDescription("The member to enroll.") + .setRequired(true) + ) + .addStringOption((o) => + o + .setName(Option.Type) + .setDescription("How they'll receive alerts") + .setRequired(true) + .addChoices( + { name: "WirePusher (Android only)", value: eqnotify_type.wire }, + { + name: "Telegram (iOS/Android/desktop)", + value: eqnotify_type.telegram, + } + ) + ) + .addStringOption((o) => + o + .setName(Option.Id) + .setDescription("Their WirePusher device ID or Telegram chat ID.") + .setRequired(true) + ); + } + + public async getOptionAutocomplete( + _option: string, + _interaction: AutocompleteInteraction + ) { + return undefined; + } +} + +export const addUserSubcommand = new AddUserSubcommand( + "add-user", + "(Officer) Enroll another member in EQNotify." +); diff --git a/src/features/eqnotify/commands/clear-tags-subcommand.ts b/src/features/eqnotify/commands/clear-tags-subcommand.ts new file mode 100644 index 00000000..98d625ce --- /dev/null +++ b/src/features/eqnotify/commands/clear-tags-subcommand.ts @@ -0,0 +1,28 @@ +import { + AutocompleteInteraction, + CacheType, + CommandInteraction, +} from "discord.js"; +import { Subcommand } from "../../../shared/command/subcommand"; +import { eqnotifyService } from "../eqnotify.service"; + +class ClearTagsSubcommand extends Subcommand { + public async execute(interaction: CommandInteraction) { + await eqnotifyService.clearTags(interaction.user.id); + await interaction.editReply( + "Your notification tags have been cleared. You won't be alerted for any batphones until you add tags again." + ); + } + + public async getOptionAutocomplete( + _option: string, + _interaction: AutocompleteInteraction + ) { + return undefined; + } +} + +export const clearTagsSubcommand = new ClearTagsSubcommand( + "clear-tags", + "Remove all of your notification tags." +); diff --git a/src/features/eqnotify/commands/list-tags-subcommand.ts b/src/features/eqnotify/commands/list-tags-subcommand.ts new file mode 100644 index 00000000..1b55f956 --- /dev/null +++ b/src/features/eqnotify/commands/list-tags-subcommand.ts @@ -0,0 +1,39 @@ +import { + AutocompleteInteraction, + CacheType, + CommandInteraction, +} from "discord.js"; +import { Subcommand } from "../../../shared/command/subcommand"; +import { eqnotifyService } from "../eqnotify.service"; + +class ListTagsSubcommand extends Subcommand { + public async execute(interaction: CommandInteraction) { + const subscriber = await eqnotifyService.requireSubscriber( + interaction.user.id + ); + const channel = subscriber.type === "telegram" ? "Telegram" : "WirePusher"; + if (subscriber.tags.length === 0) { + await interaction.editReply( + `You have no notification tags, so you won't be alerted for any batphones. Delivery channel: **${channel}**.` + ); + return; + } + await interaction.editReply( + `Delivery channel: **${channel}**.\nYour current notification tags are: ${subscriber.tags + .map((t) => `\`${t}\``) + .join(", ")}` + ); + } + + public async getOptionAutocomplete( + _option: string, + _interaction: AutocompleteInteraction + ) { + return undefined; + } +} + +export const listTagsSubcommand = new ListTagsSubcommand( + "list-tags", + "List the keywords you're currently notified for." +); diff --git a/src/features/eqnotify/commands/register-subcommand.ts b/src/features/eqnotify/commands/register-subcommand.ts new file mode 100644 index 00000000..0aabb05a --- /dev/null +++ b/src/features/eqnotify/commands/register-subcommand.ts @@ -0,0 +1,89 @@ +import { + AutocompleteInteraction, + CacheType, + CommandInteraction, +} from "discord.js"; +import { eqnotify_type } from "@prisma/client"; +import { Subcommand } from "../../../shared/command/subcommand"; +import { eqnotifyService, DEFAULT_TAGS } from "../eqnotify.service"; +import { isTelegramConfigured } from "../notifiers/telegram"; + +export enum Option { + Type = "type", + Id = "id", +} + +class RegisterSubcommand extends Subcommand { + public async execute(interaction: CommandInteraction) { + const type = this.getRequiredOptionValue( + Option.Type, + interaction + ); + const contact = String( + this.getRequiredOptionValue(Option.Id, interaction) + ).trim(); + + if (type === eqnotify_type.telegram && !isTelegramConfigured()) { + throw new Error( + "Telegram delivery is not configured on this bot yet. Please choose WirePusher or ask an officer." + ); + } + if (!contact) { + throw new Error("You must provide a valid ID."); + } + + const existing = await eqnotifyService.getSubscriber(interaction.user.id); + await eqnotifyService.enroll({ + discordId: interaction.user.id, + discordUsername: interaction.user.username, + contact, + type, + }); + + const channel = type === eqnotify_type.telegram ? "Telegram" : "WirePusher"; + await interaction.editReply( + existing + ? `Updated your EQNotify delivery to **${channel}**. Your notification tags are unchanged.` + : `You're enrolled in EQNotify via **${channel}**! You'll be notified for: ${DEFAULT_TAGS.join( + ", " + )}.\nUse \`/eqnotify add-tag\` / \`/eqnotify remove-tag\` to customize, and \`/eqnotify test\` to verify delivery.` + ); + } + + public get command() { + return super.command + .addStringOption((o) => + o + .setName(Option.Type) + .setDescription("How you want to receive alerts") + .setRequired(true) + .addChoices( + { name: "WirePusher (Android only)", value: eqnotify_type.wire }, + { + name: "Telegram (iOS/Android/desktop)", + value: eqnotify_type.telegram, + } + ) + ) + .addStringOption((o) => + o + .setName(Option.Id) + .setDescription( + "Your WirePusher device ID, or your Telegram chat ID (from @userinfobot)" + ) + .setRequired(true) + ); + } + + public async getOptionAutocomplete( + _option: string, + _interaction: AutocompleteInteraction + ) { + return undefined; + } +} + +export const registerSubcommand = new RegisterSubcommand( + "register", + "Sign yourself up for EQNotify batphone alerts." +); diff --git a/src/features/eqnotify/commands/remove-tag-subcommand.ts b/src/features/eqnotify/commands/remove-tag-subcommand.ts new file mode 100644 index 00000000..a7ba8de7 --- /dev/null +++ b/src/features/eqnotify/commands/remove-tag-subcommand.ts @@ -0,0 +1,47 @@ +import { + AutocompleteInteraction, + CacheType, + CommandInteraction, +} from "discord.js"; +import { Subcommand } from "../../../shared/command/subcommand"; +import { eqnotifyService } from "../eqnotify.service"; + +export enum Option { + Tag = "tag", +} + +class RemoveTagSubcommand extends Subcommand { + public async execute(interaction: CommandInteraction) { + const rawTag = this.getRequiredOptionValue(Option.Tag, interaction); + const tag = await eqnotifyService.removeTag(interaction.user.id, rawTag); + await interaction.editReply( + `\`${tag}\` has been removed from your notification tags.` + ); + } + + public get command() { + return super.command.addStringOption((o) => + o + .setName(Option.Tag) + .setDescription("The keyword to stop being notified for.") + .setRequired(true) + .setAutocomplete(true) + ); + } + + public async getOptionAutocomplete( + option: string, + interaction: AutocompleteInteraction + ) { + if (option !== Option.Tag) { + return undefined; + } + const subscriber = await eqnotifyService.getSubscriber(interaction.user.id); + return subscriber?.tags.map((tag) => ({ name: tag, value: tag })); + } +} + +export const removeTagSubcommand = new RemoveTagSubcommand( + "remove-tag", + "Remove a keyword from your notification tags." +); diff --git a/src/features/eqnotify/commands/remove-user-subcommand.ts b/src/features/eqnotify/commands/remove-user-subcommand.ts new file mode 100644 index 00000000..90c7e7db --- /dev/null +++ b/src/features/eqnotify/commands/remove-user-subcommand.ts @@ -0,0 +1,56 @@ +import { + AutocompleteInteraction, + CacheType, + CommandInteraction, +} from "discord.js"; +import { Subcommand } from "../../../shared/command/subcommand"; +import { authorizeByMemberRoles } from "../../../shared/command/util"; +import { knightRoleId, modRoleId, officerRoleId } from "../../../config"; +import { eqnotifyService } from "../eqnotify.service"; +import { getMember } from "../../.."; + +export enum Option { + Member = "member", +} + +class RemoveUserSubcommand extends Subcommand { + public async execute(interaction: CommandInteraction) { + authorizeByMemberRoles( + [officerRoleId, modRoleId, knightRoleId], + interaction + ); + + const memberId = this.getRequiredOptionValue( + Option.Member, + interaction + ); + const member = await getMember(memberId); + const existing = await eqnotifyService.getSubscriber(memberId); + if (!existing) { + throw new Error(`${member} is not enrolled in EQNotify.`); + } + await eqnotifyService.remove(memberId); + await interaction.editReply(`Removed ${member} from EQNotify.`); + } + + public get command() { + return super.command.addUserOption((o) => + o + .setName(Option.Member) + .setDescription("The member to remove from EQNotify.") + .setRequired(true) + ); + } + + public async getOptionAutocomplete( + _option: string, + _interaction: AutocompleteInteraction + ) { + return undefined; + } +} + +export const removeUserSubcommand = new RemoveUserSubcommand( + "remove-user", + "(Officer) Remove another member from EQNotify." +); diff --git a/src/features/eqnotify/commands/test-subcommand.ts b/src/features/eqnotify/commands/test-subcommand.ts new file mode 100644 index 00000000..3a3da6ee --- /dev/null +++ b/src/features/eqnotify/commands/test-subcommand.ts @@ -0,0 +1,36 @@ +import { + AutocompleteInteraction, + CacheType, + CommandInteraction, +} from "discord.js"; +import { Subcommand } from "../../../shared/command/subcommand"; +import { eqnotifyService } from "../eqnotify.service"; +import { notify } from "../notifiers"; + +class TestSubcommand extends Subcommand { + public async execute(interaction: CommandInteraction) { + const subscriber = await eqnotifyService.requireSubscriber( + interaction.user.id + ); + await notify( + subscriber, + "This is a test EQNotify alert. If you can read this, your notifications are working!" + ); + const channel = subscriber.type === "telegram" ? "Telegram" : "WirePusher"; + await interaction.editReply( + `Sent a test alert to your **${channel}**. If you didn't receive it, double-check your ID with \`/eqnotify register\`.` + ); + } + + public async getOptionAutocomplete( + _option: string, + _interaction: AutocompleteInteraction + ) { + return undefined; + } +} + +export const testSubcommand = new TestSubcommand( + "test", + "Send yourself a test notification to verify delivery." +); diff --git a/src/features/eqnotify/commands/unregister-subcommand.ts b/src/features/eqnotify/commands/unregister-subcommand.ts new file mode 100644 index 00000000..f723336d --- /dev/null +++ b/src/features/eqnotify/commands/unregister-subcommand.ts @@ -0,0 +1,29 @@ +import { + AutocompleteInteraction, + CacheType, + CommandInteraction, +} from "discord.js"; +import { Subcommand } from "../../../shared/command/subcommand"; +import { eqnotifyService } from "../eqnotify.service"; + +class UnregisterSubcommand extends Subcommand { + public async execute(interaction: CommandInteraction) { + await eqnotifyService.requireSubscriber(interaction.user.id); + await eqnotifyService.remove(interaction.user.id); + await interaction.editReply( + "You've been removed from EQNotify and will no longer receive batphone alerts." + ); + } + + public async getOptionAutocomplete( + _option: string, + _interaction: AutocompleteInteraction + ) { + return undefined; + } +} + +export const unregisterSubcommand = new UnregisterSubcommand( + "unregister", + "Remove yourself from EQNotify." +); diff --git a/src/features/eqnotify/eqnotify-message-action.ts b/src/features/eqnotify/eqnotify-message-action.ts new file mode 100644 index 00000000..5b8cc4c1 --- /dev/null +++ b/src/features/eqnotify/eqnotify-message-action.ts @@ -0,0 +1,23 @@ +import { Message } from "discord.js"; +import { eqnotifyWatchChannelId } from "../../config"; +import { eqnotifyService } from "./eqnotify.service"; +import { log } from "../../shared/logger"; + +/** + * Watches the batphone channel for messages (including the bot's own `/bp send` + * posts) and dispatches EQNotify alerts to subscribers whose tags match. + * + * Unlike the other message actions, this must run even for bot-authored + * messages, since castle posts batphones itself. It is therefore invoked before + * the bot-author guard in the message-create listener. + */ +export const tryEqnotifyDispatchAction = async (message: Message) => { + if (message.channelId !== eqnotifyWatchChannelId) { + return; + } + try { + await eqnotifyService.dispatch(message.content); + } catch (error) { + log(`EQNotify dispatch failed: ${error}`); + } +}; diff --git a/src/features/eqnotify/eqnotify.service.ts b/src/features/eqnotify/eqnotify.service.ts new file mode 100644 index 00000000..9f853f79 --- /dev/null +++ b/src/features/eqnotify/eqnotify.service.ts @@ -0,0 +1,127 @@ +import { eqnotify_subscriber, eqnotify_type } from "@prisma/client"; +import { prismaClient } from "../.."; +import { log } from "../../shared/logger"; +import { notify } from "./notifiers"; +import { + DEFAULT_TAGS, + isFiltered, + matchesTags, + normalizeTag, +} from "./matcher"; + +export { DEFAULT_TAGS } from "./matcher"; + +interface EnrollInput { + discordId: string; + discordUsername: string; + contact: string; + type: eqnotify_type; +} + +const requireSubscriber = async (discordId: string) => { + const subscriber = await prismaClient.eqnotify_subscriber.findUnique({ + where: { discordId }, + }); + if (!subscriber) { + throw new Error( + "You are not enrolled in EQNotify. Use `/eqnotify register` to set up notifications." + ); + } + return subscriber; +}; + +export const eqnotifyService = { + getSubscriber: (discordId: string) => + prismaClient.eqnotify_subscriber.findUnique({ where: { discordId } }), + + requireSubscriber, + + /** + * Creates a subscriber, or updates their contact/type if they already exist. + * Existing tags are preserved; new subscribers start with the defaults. + */ + enroll: async ({ discordId, discordUsername, contact, type }: EnrollInput) => + prismaClient.eqnotify_subscriber.upsert({ + where: { discordId }, + update: { discordUsername, contact, type }, + create: { + discordId, + discordUsername, + contact, + type, + tags: DEFAULT_TAGS, + }, + }), + + remove: (discordId: string) => + prismaClient.eqnotify_subscriber.delete({ where: { discordId } }), + + addTag: async (discordId: string, rawTag: string) => { + const subscriber = await requireSubscriber(discordId); + const tag = normalizeTag(rawTag); + if (!tag) { + throw new Error("Tag cannot be empty."); + } + if (subscriber.tags.includes(tag)) { + throw new Error(`\`${tag}\` is already in your notification tags.`); + } + await prismaClient.eqnotify_subscriber.update({ + where: { discordId }, + data: { tags: { push: tag } }, + }); + return tag; + }, + + removeTag: async (discordId: string, rawTag: string) => { + const subscriber = await requireSubscriber(discordId); + const tag = normalizeTag(rawTag); + if (!subscriber.tags.includes(tag)) { + throw new Error(`\`${tag}\` is not in your notification tags.`); + } + await prismaClient.eqnotify_subscriber.update({ + where: { discordId }, + data: { tags: subscriber.tags.filter((t) => t !== tag) }, + }); + return tag; + }, + + clearTags: async (discordId: string) => { + await requireSubscriber(discordId); + await prismaClient.eqnotify_subscriber.update({ + where: { discordId }, + data: { tags: [] }, + }); + }, + + /** + * Matches a batphone against every subscriber's tags and delivers alerts. + * Delivery failures are logged but never interrupt other subscribers. + */ + dispatch: async (content: string) => { + if (!content.trim() || isFiltered(content)) { + return; + } + const subscribers = await prismaClient.eqnotify_subscriber.findMany(); + await Promise.all( + subscribers + .filter((sub) => matchesTags(content, sub.tags)) + .map((sub) => eqnotifyService.notifySubscriber(sub, content)) + ); + }, + + notifySubscriber: async ( + subscriber: eqnotify_subscriber, + message: string + ) => { + try { + log( + `EQNotify alert -> ${subscriber.discordUsername} (${subscriber.type})` + ); + await notify(subscriber, message); + } catch (error) { + log( + `EQNotify delivery failed for ${subscriber.discordUsername}: ${error}` + ); + } + }, +}; diff --git a/src/features/eqnotify/matcher.test.ts b/src/features/eqnotify/matcher.test.ts new file mode 100644 index 00000000..6d26ab2c --- /dev/null +++ b/src/features/eqnotify/matcher.test.ts @@ -0,0 +1,53 @@ +import { isFiltered, matchesTags, normalizeTag } from "./matcher"; + +describe("eqnotify matcher", () => { + describe("isFiltered", () => { + it("filters buff batphones", () => { + expect(isFiltered("@everyone Vindi BUFFS up")).toBe(true); + }); + + it("filters RTE batphones", () => { + expect(isFiltered("Statue RTE bnp and park")).toBe(true); + }); + + it("is case insensitive", () => { + expect(isFiltered("aary Buff")).toBe(true); + }); + + it("does not filter normal pop batphones", () => { + expect(isFiltered("@everyone VULAK VULAK POP POP")).toBe(false); + }); + }); + + describe("matchesTags", () => { + it("matches a tag that is a substring of the batphone", () => { + expect(matchesTags("@everyone VULAK POP", ["vulak", "kt"])).toBe(true); + }); + + it("is case insensitive against the content", () => { + expect(matchesTags("King Tormax is up", ["tormax"])).toBe(true); + }); + + it("does not match when no tag is present", () => { + expect(matchesTags("Dain pop", ["vulak", "kt"])).toBe(false); + }); + + it("matches everything when the 'all' tag is present", () => { + expect(matchesTags("anything at all", ["all"])).toBe(true); + }); + + it("does not match an empty tag list", () => { + expect(matchesTags("Vulak pop", [])).toBe(false); + }); + + it("ignores empty-string tags", () => { + expect(matchesTags("Vulak pop", [""])).toBe(false); + }); + }); + + describe("normalizeTag", () => { + it("trims and lowercases", () => { + expect(normalizeTag(" VuLaK ")).toBe("vulak"); + }); + }); +}); diff --git a/src/features/eqnotify/matcher.ts b/src/features/eqnotify/matcher.ts new file mode 100644 index 00000000..b0db5a68 --- /dev/null +++ b/src/features/eqnotify/matcher.ts @@ -0,0 +1,54 @@ +/** + * Pure batphone-matching logic for EQNotify, kept free of side-effectful + * imports (Discord client, Prisma) so it can be unit tested in isolation. + */ + +/** + * Default raid targets a newly enrolled subscriber is notified for. They can be + * customized afterwards with the add-tag / remove-tag / clear-tags subcommands. + */ +export const DEFAULT_TAGS = [ + "dain", + "quake", + "vulak", + "kt", + "tormax", + "doze", + "statue", + "ct", + "cazic", +]; + +/** + * A batphone whose content includes any of these terms is treated as a + * buff/last-hour-RTE call and never triggers notifications. + */ +export const FILTERED_TERMS = ["buff", "rte"]; + +/** + * Special tag that opts a subscriber into every (unfiltered) batphone. + */ +export const ALL_TAG = "all"; + +export const normalizeTag = (tag: string) => tag.trim().toLowerCase(); + +/** + * Whether a batphone should be suppressed (buff / last-hour RTE calls). + */ +export const isFiltered = (content: string) => { + const lower = content.toLowerCase(); + return FILTERED_TERMS.some((term) => lower.includes(term)); +}; + +/** + * Whether a batphone matches any of a subscriber's tags. The special `all` tag + * matches every (unfiltered) batphone; otherwise tags are substring-matched + * against the lowercased content. + */ +export const matchesTags = (content: string, tags: string[]) => { + if (tags.includes(ALL_TAG)) { + return true; + } + const lower = content.toLowerCase(); + return tags.some((tag) => Boolean(tag) && lower.includes(tag)); +}; diff --git a/src/features/eqnotify/notifiers/index.ts b/src/features/eqnotify/notifiers/index.ts new file mode 100644 index 00000000..873512b9 --- /dev/null +++ b/src/features/eqnotify/notifiers/index.ts @@ -0,0 +1,20 @@ +import { eqnotify_subscriber, eqnotify_type } from "@prisma/client"; +import { wirePush } from "./wirepusher"; +import { telegramPush } from "./telegram"; + +/** + * Delivers an EQNotify alert to a subscriber using their configured channel. + */ +export const notify = async ( + subscriber: Pick, + message: string +) => { + switch (subscriber.type) { + case eqnotify_type.wire: + return wirePush(subscriber.contact, message); + case eqnotify_type.telegram: + return telegramPush(subscriber.contact, message); + default: + throw new Error(`Unknown EQNotify delivery type: ${subscriber.type}`); + } +}; diff --git a/src/features/eqnotify/notifiers/telegram.ts b/src/features/eqnotify/notifiers/telegram.ts new file mode 100644 index 00000000..bcae5c76 --- /dev/null +++ b/src/features/eqnotify/notifiers/telegram.ts @@ -0,0 +1,28 @@ +import axios from "axios"; +import { TELEGRAM_BOT_TOKEN } from "../../../config"; +import { log } from "../../../shared/logger"; + +/** + * Whether Telegram delivery is configured (i.e. a bot token is present). + */ +export const isTelegramConfigured = () => Boolean(TELEGRAM_BOT_TOKEN); + +/** + * Delivers a message to a Telegram chat via the Bot API. `contact` is the + * numeric chat ID of the user's conversation with the EQNotify Telegram bot. + */ +export const telegramPush = async (contact: string, message: string) => { + if (!TELEGRAM_BOT_TOKEN) { + throw new Error( + "Telegram delivery is not configured (TELEGRAM_BOT_TOKEN is unset)." + ); + } + await axios.post( + `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, + { + chat_id: contact, + text: `EQNotify Alert\n${message}`, + } + ); + log(`EQNotify telegram message sent to ${contact}`); +}; diff --git a/src/features/eqnotify/notifiers/wirepusher.ts b/src/features/eqnotify/notifiers/wirepusher.ts new file mode 100644 index 00000000..b5aa69bb --- /dev/null +++ b/src/features/eqnotify/notifiers/wirepusher.ts @@ -0,0 +1,22 @@ +import axios from "axios"; +import { log } from "../../../shared/logger"; + +const WIREPUSHER_URL = "https://wirepusher.com/send"; + +/** + * Delivers a push notification to an Android device via the WirePusher service. + * `contact` is the device's WirePusher ID. + */ +export const wirePush = async (contact: string, message: string) => { + await axios({ + method: "post", + url: WIREPUSHER_URL, + params: { + id: contact, + title: "EQNotify Alert", + message, + type: "EQNotify", + }, + }); + log(`EQNotify wirepush sent to ${contact}`); +}; diff --git a/src/listeners/message-create-listener.ts b/src/listeners/message-create-listener.ts index 27bc3eb7..d2afd7b1 100644 --- a/src/listeners/message-create-listener.ts +++ b/src/listeners/message-create-listener.ts @@ -5,6 +5,7 @@ import { tryRaidReportRevisionMessageAction } from "../features/dkp-records/requ import { tryParseInventoryAction } from "../features/bank-inventory/actions/inventory-file-action"; import { buildTodAdapter } from "../features/spawn-timers/commands/prefix-adapter"; import { todCommand } from "../features/spawn-timers/commands/tod"; +import { tryEqnotifyDispatchAction } from "../features/eqnotify/eqnotify-message-action"; const messageActions = [ tryCreateRaidReportThreadAction, @@ -14,6 +15,10 @@ const messageActions = [ ]; export const messageCreateListener = async (message: Message) => { + // EQNotify must run before the bot-author guard: castle posts batphones + // itself (via /bp send), so the watched messages are bot-authored. + tryEqnotifyDispatchAction(message); + if (message.author.bot) { return; } diff --git a/src/listeners/register-commands.ts b/src/listeners/register-commands.ts index 8839e292..9f896f09 100644 --- a/src/listeners/register-commands.ts +++ b/src/listeners/register-commands.ts @@ -34,6 +34,7 @@ import { requestBotButtonCommand } from "../features/bp/request-bot-button-comma import { parkBotButtonCommand } from "../features/raid-bots/park-bot-button-command" import { odkpCommand } from "../features/odkp/odkp-command"; import { rteCommand } from "../features/rte/command"; +import { eqnotifyCommand } from "../features/eqnotify/command"; import { endRteButtonCommand } from "../features/rte/end-session-button-command"; import { endRtePopButtonCommand } from "../features/rte/end-session-pop-button-command"; import { @@ -72,6 +73,7 @@ const slashCommands = [ wakeupCommand, odkpCommand, rteCommand, + eqnotifyCommand, aliasCommand, autotodCommand, earthquakeCommand,