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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <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.

### ⏺️ Local

Expand Down
Original file line number Diff line number Diff line change
@@ -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")
);
15 changes: 15 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export const {
SHOW_FUTURE_WINDOW,
CONDENSE_FUTURE_WINDOW,
RTE_REQUIRE_OPEN_TARGET,
eqnotifyChannelId,
TELEGRAM_BOT_TOKEN,

} = process.env as {
/**
Expand Down Expand Up @@ -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";
export const rteRequireOpenTarget = RTE_REQUIRE_OPEN_TARGET !== "false";

/**
* Channel EQNotify watches for batphones. Falls back to the batphone channel.
*/
export const eqnotifyWatchChannelId = eqnotifyChannelId || batphoneChannelId;
26 changes: 26 additions & 0 deletions src/features/eqnotify/command.ts
Original file line number Diff line number Diff line change
@@ -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,
]
);
44 changes: 44 additions & 0 deletions src/features/eqnotify/commands/add-tag-subcommand.ts
Original file line number Diff line number Diff line change
@@ -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<CacheType>) {
const rawTag = this.getRequiredOptionValue<string>(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<CacheType>
) {
return undefined;
}
}

export const addTagSubcommand = new AddTagSubcommand(
"add-tag",
"Add a keyword you want to be notified for."
);
107 changes: 107 additions & 0 deletions src/features/eqnotify/commands/add-user-subcommand.ts
Original file line number Diff line number Diff line change
@@ -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<CacheType>) {
authorizeByMemberRoles(
[officerRoleId, modRoleId, knightRoleId],
interaction
);

const memberId = this.getRequiredOptionValue<string>(
Option.Member,
interaction
);
const type = this.getRequiredOptionValue<eqnotify_type>(
Option.Type,
interaction
);
const contact = String(
this.getRequiredOptionValue<string>(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<CacheType>
) {
return undefined;
}
}

export const addUserSubcommand = new AddUserSubcommand(
"add-user",
"(Officer) Enroll another member in EQNotify."
);
28 changes: 28 additions & 0 deletions src/features/eqnotify/commands/clear-tags-subcommand.ts
Original file line number Diff line number Diff line change
@@ -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<CacheType>) {
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<CacheType>
) {
return undefined;
}
}

export const clearTagsSubcommand = new ClearTagsSubcommand(
"clear-tags",
"Remove all of your notification tags."
);
39 changes: 39 additions & 0 deletions src/features/eqnotify/commands/list-tags-subcommand.ts
Original file line number Diff line number Diff line change
@@ -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<CacheType>) {
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<CacheType>
) {
return undefined;
}
}

export const listTagsSubcommand = new ListTagsSubcommand(
"list-tags",
"List the keywords you're currently notified for."
);
Loading
Loading