diff --git a/README.md b/README.md index 5110ac2..ac9dd6e 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,14 @@ Some features required secrets, such as to connect to CastleDKP.com or the Castl `/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. Alerts are only delivered to subscribers who **currently hold the Raider role**, so anyone who goes inactive or leaves stops receiving them automatically. - **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)). +- **Getting your ID**: WirePusher users use their device ID from the app. Telegram users must **first open the guild's EQNotify bot and send it a message (e.g. `/start`)** — a Telegram bot cannot message a user who hasn't contacted it, so sends fail with `chat not found` until this is done — then register with 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. + - `/eqnotify list-users` — Officer/Mod/Knight tool listing everyone registered, their delivery channel, current Raider status, and tag count. ### ⏺️ Local diff --git a/src/features/eqnotify/command.ts b/src/features/eqnotify/command.ts index b3b5676..394451a 100644 --- a/src/features/eqnotify/command.ts +++ b/src/features/eqnotify/command.ts @@ -8,6 +8,7 @@ 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"; +import { listUsersSubcommand } from "./commands/list-users-subcommand"; export const eqnotifyCommand = new Command( "eqnotify", @@ -22,5 +23,6 @@ export const eqnotifyCommand = new Command( testSubcommand, addUserSubcommand, removeUserSubcommand, + listUsersSubcommand, ] ); diff --git a/src/features/eqnotify/commands/list-users-subcommand.ts b/src/features/eqnotify/commands/list-users-subcommand.ts new file mode 100644 index 0000000..436081d --- /dev/null +++ b/src/features/eqnotify/commands/list-users-subcommand.ts @@ -0,0 +1,100 @@ +import { + AutocompleteInteraction, + CacheType, + CommandInteraction, +} from "discord.js"; +import { Subcommand } from "../../../shared/command/subcommand"; +import { authorizeByMemberRoles } from "../../../shared/command/util"; +import { + knightRoleId, + modRoleId, + officerRoleId, + raiderRoleId, +} from "../../../config"; +import { eqnotifyService } from "../eqnotify.service"; +import { getMember } from "../../.."; + +const MAX_MESSAGE_LENGTH = 1900; + +/** + * Resolves a subscriber's display name and current Raider status. Falls back + * gracefully if the member has left the server. + */ +const describeMember = async (discordId: string, username: string) => { + try { + const member = await getMember(discordId); + const isRaider = member.roles.cache.has(raiderRoleId); + return { + label: `${member} (${member.user.username})`, + status: isRaider ? "Raider ✅" : "not a Raider ⛔", + }; + } catch { + return { label: `${username} (not in server)`, status: "left ❌" }; + } +}; + +class ListUsersSubcommand extends Subcommand { + public async execute(interaction: CommandInteraction) { + authorizeByMemberRoles( + [officerRoleId, modRoleId, knightRoleId], + interaction + ); + + const subscribers = await eqnotifyService.listSubscribers(); + if (subscribers.length === 0) { + await interaction.editReply("No one is registered for EQNotify yet."); + return; + } + + const lines = await Promise.all( + subscribers.map(async (sub) => { + const { label, status } = await describeMember( + sub.discordId, + sub.discordUsername + ); + const channel = sub.type === "telegram" ? "Telegram" : "WirePusher"; + return `• ${label} — ${channel} — ${status} — ${sub.tags.length} tag(s)`; + }) + ); + + const header = `**EQNotify subscribers (${subscribers.length}):**`; + const messages = chunkLines([header, ...lines]); + + await interaction.editReply(messages[0]); + for (let i = 1; i < messages.length; i++) { + await interaction.followUp({ content: messages[i], ephemeral: true }); + } + } + + public async getOptionAutocomplete( + _option: string, + _interaction: AutocompleteInteraction + ) { + return undefined; + } +} + +/** + * Packs lines into as few messages as possible without exceeding Discord's + * message length limit. + */ +const chunkLines = (lines: string[]) => { + const messages: string[] = []; + let current = ""; + for (const line of lines) { + if (current && current.length + line.length + 1 > MAX_MESSAGE_LENGTH) { + messages.push(current); + current = ""; + } + current = current ? `${current}\n${line}` : line; + } + if (current) { + messages.push(current); + } + return messages; +}; + +export const listUsersSubcommand = new ListUsersSubcommand( + "list-users", + "(Officer/Mod/Knight) Show everyone registered for EQNotify." +); diff --git a/src/features/eqnotify/commands/register-subcommand.ts b/src/features/eqnotify/commands/register-subcommand.ts index a4de6e5..6a30d27 100644 --- a/src/features/eqnotify/commands/register-subcommand.ts +++ b/src/features/eqnotify/commands/register-subcommand.ts @@ -41,12 +41,16 @@ class RegisterSubcommand extends Subcommand { }); const channel = type === eqnotify_type.telegram ? "Telegram" : "WirePusher"; + const telegramTip = + type === eqnotify_type.telegram + ? "\n**Important:** open the EQNotify bot in Telegram and send it a message (e.g. `/start`) first — Telegram won't let the bot message you until you do." + : ""; await interaction.editReply( existing - ? `Updated your EQNotify delivery to **${channel}**. Your notification tags are unchanged.` + ? `Updated your EQNotify delivery to **${channel}**. Your notification tags are unchanged.${telegramTip}` : `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.\n_Note: alerts are only sent while you actively hold the Raider role._` + )}.\nUse \`/eqnotify add-tag\` / \`/eqnotify remove-tag\` to customize, and \`/eqnotify test\` to verify delivery.\n_Note: alerts are only sent while you actively hold the Raider role._${telegramTip}` ); } diff --git a/src/features/eqnotify/eqnotify.service.ts b/src/features/eqnotify/eqnotify.service.ts index 7b162c0..9bb4630 100644 --- a/src/features/eqnotify/eqnotify.service.ts +++ b/src/features/eqnotify/eqnotify.service.ts @@ -53,6 +53,11 @@ export const eqnotifyService = { getSubscriber: (discordId: string) => prismaClient.eqnotify_subscriber.findUnique({ where: { discordId } }), + listSubscribers: () => + prismaClient.eqnotify_subscriber.findMany({ + orderBy: { discordUsername: "asc" }, + }), + requireSubscriber, /** diff --git a/src/features/eqnotify/notifiers/telegram.ts b/src/features/eqnotify/notifiers/telegram.ts index bcae5c7..bc67c54 100644 --- a/src/features/eqnotify/notifiers/telegram.ts +++ b/src/features/eqnotify/notifiers/telegram.ts @@ -10,6 +10,10 @@ 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. + * + * Note: a Telegram bot cannot initiate a conversation. The user must send the + * EQNotify bot a message (e.g. `/start`) first, otherwise the API rejects + * sends with `400 Bad Request: chat not found`. */ export const telegramPush = async (contact: string, message: string) => { if (!TELEGRAM_BOT_TOKEN) { @@ -17,12 +21,33 @@ export const telegramPush = async (contact: string, message: string) => { "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}`, + try { + 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}`); + } catch (error) { + const description = axios.isAxiosError(error) + ? (error.response?.data as { description?: string } | undefined) + ?.description + : undefined; + + if (description?.includes("chat not found")) { + throw new Error( + `Telegram couldn't find chat \`${contact}\`. Open the EQNotify bot in Telegram and send it a message (e.g. \`/start\`) first, then confirm the ID is your numeric chat ID from @userinfobot.` + ); + } + if (description?.includes("blocked")) { + throw new Error( + "Telegram delivery was blocked — you've blocked the EQNotify bot. Unblock it and send it a message to resume alerts." + ); } - ); - log(`EQNotify telegram message sent to ${contact}`); + throw new Error( + `Telegram delivery failed${description ? `: ${description}` : "."}` + ); + } };