From 9d869dfb2c4ecf29310ee8e15a765a2a868a67e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 15:27:58 +0000 Subject: [PATCH 1/3] Surface Telegram delivery errors and guide bot /start /eqnotify test (and batphone dispatch) surfaced a bare "status code 400" when Telegram rejected a send. The most common cause is that a Telegram bot cannot message a user who has not contacted it first, so sends to a freshly registered chat ID fail with "chat not found". - telegramPush now inspects Telegram's error `description` and throws an actionable message: "chat not found" tells the user to open the bot and send /start; "blocked" tells them to unblock it; anything else surfaces the raw Telegram description instead of a generic Axios 400. - /eqnotify register now reminds Telegram registrants to message the bot first, and the README documents the requirement. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BWXRmPbtEz35wquwFd8Cu7 --- README.md | 2 +- .../eqnotify/commands/register-subcommand.ts | 8 +++- src/features/eqnotify/notifiers/telegram.ts | 39 +++++++++++++++---- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5110ac2..98749c9 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ 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. 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/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}` : "."}` + ); + } }; From 8dd464291bc1b4586735d653fb34764940bdb377 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 15:34:44 +0000 Subject: [PATCH 2/3] Add /eqnotify list-users (Officer/Knight) subcommand Adds an Officer/Knight-gated subcommand that lists everyone registered for EQNotify, showing each subscriber's delivery channel, current Raider status (resolved live, so officers can see who is actually receiving alerts), and tag count. Output is ephemeral and chunked to stay within Discord's message length limit for large rosters. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BWXRmPbtEz35wquwFd8Cu7 --- README.md | 1 + src/features/eqnotify/command.ts | 2 + .../commands/list-users-subcommand.ts | 92 +++++++++++++++++++ src/features/eqnotify/eqnotify.service.ts | 5 + 4 files changed, 100 insertions(+) create mode 100644 src/features/eqnotify/commands/list-users-subcommand.ts diff --git a/README.md b/README.md index 98749c9..4773fb6 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Some features required secrets, such as to connect to CastleDKP.com or the Castl - `/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/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..5c5b4b8 --- /dev/null +++ b/src/features/eqnotify/commands/list-users-subcommand.ts @@ -0,0 +1,92 @@ +import { + AutocompleteInteraction, + CacheType, + CommandInteraction, +} from "discord.js"; +import { Subcommand } from "../../../shared/command/subcommand"; +import { authorizeByMemberRoles } from "../../../shared/command/util"; +import { knightRoleId, 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, 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/Knight) Show everyone registered for EQNotify." +); 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, /** From 1adaae863d461b6b76fe2b3a0727807a687e7ce6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 15:41:21 +0000 Subject: [PATCH 3/3] Gate /eqnotify list-users on Officer/Mod/Knight Aligns list-users with add-user / remove-user (Officer/Mod/Knight) instead of Officer/Knight only. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BWXRmPbtEz35wquwFd8Cu7 --- README.md | 2 +- .../eqnotify/commands/list-users-subcommand.ts | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4773fb6..ac9dd6e 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Some features required secrets, such as to connect to CastleDKP.com or the Castl - `/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/Knight tool listing everyone registered, their delivery channel, current Raider status, and tag count. + - `/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/commands/list-users-subcommand.ts b/src/features/eqnotify/commands/list-users-subcommand.ts index 5c5b4b8..436081d 100644 --- a/src/features/eqnotify/commands/list-users-subcommand.ts +++ b/src/features/eqnotify/commands/list-users-subcommand.ts @@ -5,7 +5,12 @@ import { } from "discord.js"; import { Subcommand } from "../../../shared/command/subcommand"; import { authorizeByMemberRoles } from "../../../shared/command/util"; -import { knightRoleId, officerRoleId, raiderRoleId } from "../../../config"; +import { + knightRoleId, + modRoleId, + officerRoleId, + raiderRoleId, +} from "../../../config"; import { eqnotifyService } from "../eqnotify.service"; import { getMember } from "../../.."; @@ -30,7 +35,10 @@ const describeMember = async (discordId: string, username: string) => { class ListUsersSubcommand extends Subcommand { public async execute(interaction: CommandInteraction) { - authorizeByMemberRoles([officerRoleId, knightRoleId], interaction); + authorizeByMemberRoles( + [officerRoleId, modRoleId, knightRoleId], + interaction + ); const subscribers = await eqnotifyService.listSubscribers(); if (subscribers.length === 0) { @@ -88,5 +96,5 @@ const chunkLines = (lines: string[]) => { export const listUsersSubcommand = new ListUsersSubcommand( "list-users", - "(Officer/Knight) Show everyone registered for EQNotify." + "(Officer/Mod/Knight) Show everyone registered for EQNotify." );