Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <type> <id>` — sign yourself up (self-service).
- `/eqnotify unregister` — remove yourself.
- `/eqnotify add-tag <tag>` / `remove-tag <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 <member> <type> <id>` / `remove-user <member>` — 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

Expand Down
2 changes: 2 additions & 0 deletions src/features/eqnotify/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -22,5 +23,6 @@ export const eqnotifyCommand = new Command(
testSubcommand,
addUserSubcommand,
removeUserSubcommand,
listUsersSubcommand,
]
);
100 changes: 100 additions & 0 deletions src/features/eqnotify/commands/list-users-subcommand.ts
Original file line number Diff line number Diff line change
@@ -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<CacheType>) {
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<CacheType>
) {
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."
);
8 changes: 6 additions & 2 deletions src/features/eqnotify/commands/register-subcommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
);
}

Expand Down
5 changes: 5 additions & 0 deletions src/features/eqnotify/eqnotify.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

/**
Expand Down
39 changes: 32 additions & 7 deletions src/features/eqnotify/notifiers/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,44 @@ 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) {
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}`,
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}` : "."}`
);
}
};
Loading