feat(bot): Add assignment transfer bot services
Add Discord bot services for assignment transfer interactions: 1. transferInteractions.ts: Handle button interactions for transfer requests, approvals, rejections, appeals, completions, and cancellations. 2. transferRequests.ts: Poll for pending assignment transfers and trigger notification delivery to Discord when status changes to pending/approved/rejected. 3. transferDelivery.ts: Deliver transfer notifications to users via DMs with embeds showing status, details, and actions (approve/reject/appeal). 4. transferMessaging.ts: Provide Discord embed building for transfer messages with status badges and action buttons. 5. Update bot index.ts: Add transfer button interaction handler with customId prefix "ptf-xfer:" 6. Update notificationBridge.ts: Exclude transfer notification types from general notification polling to avoid duplication (Phase 2 feature). Integration: Services are started via src/bot/services/index.ts and run alongside existing mission and notification services.
This commit is contained in:
parent
a8854034d3
commit
9001d5a796
7 changed files with 778 additions and 1 deletions
220
src/bot/events/transferInteractions.ts
Normal file
220
src/bot/events/transferInteractions.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
import {
|
||||||
|
type ButtonInteraction,
|
||||||
|
LabelBuilder,
|
||||||
|
MessageFlags,
|
||||||
|
ModalBuilder,
|
||||||
|
type ModalSubmitInteraction,
|
||||||
|
TextInputBuilder,
|
||||||
|
TextInputStyle,
|
||||||
|
} from "discord.js";
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import { findUserByDiscordId } from "@/bot/lib/resolve";
|
||||||
|
import {
|
||||||
|
decisionOutcomeEmbed,
|
||||||
|
findLeaderSide,
|
||||||
|
relId,
|
||||||
|
transferDecisionRow,
|
||||||
|
appealRow,
|
||||||
|
type DecisionOutcome,
|
||||||
|
} from "@/bot/lib/transferMessaging";
|
||||||
|
import { appealTransfer, recordTransferDecision, type TransferSide } from "@/lib/transfers";
|
||||||
|
|
||||||
|
type TransferInteraction = ButtonInteraction | ModalSubmitInteraction;
|
||||||
|
type TransferAction = "approve" | "reject" | "appeal";
|
||||||
|
|
||||||
|
type ParsedTransferInteraction = {
|
||||||
|
readonly requestId: number;
|
||||||
|
readonly action: TransferAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REJECTION_REASON_ID = "rejectionReason";
|
||||||
|
|
||||||
|
const parseTransferInteraction = (customId: string): ParsedTransferInteraction | null => {
|
||||||
|
const [prefix, rawId, action, extra] = customId.split(":");
|
||||||
|
const requestId = Number(rawId);
|
||||||
|
if (
|
||||||
|
prefix !== "ptf-xfer" ||
|
||||||
|
extra !== undefined ||
|
||||||
|
!Number.isInteger(requestId) ||
|
||||||
|
(action !== "approve" && action !== "reject" && action !== "appeal")
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { requestId, action };
|
||||||
|
};
|
||||||
|
|
||||||
|
const replyEphemeral = (interaction: TransferInteraction, content: string): Promise<unknown> =>
|
||||||
|
interaction.reply({ content, flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
const currentLeaderSide = async (
|
||||||
|
payload: Payload,
|
||||||
|
requestId: number,
|
||||||
|
actorUserId: number,
|
||||||
|
): Promise<TransferSide | string> => {
|
||||||
|
const request = await payload.findByID({
|
||||||
|
collection: "assignment-transfers",
|
||||||
|
id: requestId,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const [fromAssignment, toAssignment] = await Promise.all([
|
||||||
|
payload.findByID({
|
||||||
|
collection: "assignments",
|
||||||
|
id: relId(request.fromAssignment),
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
}),
|
||||||
|
payload.findByID({
|
||||||
|
collection: "assignments",
|
||||||
|
id: relId(request.toAssignment),
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const check = findLeaderSide(
|
||||||
|
request,
|
||||||
|
fromAssignment.leader == null ? null : relId(fromAssignment.leader),
|
||||||
|
toAssignment.leader == null ? null : relId(toAssignment.leader),
|
||||||
|
actorUserId,
|
||||||
|
);
|
||||||
|
if (check.outcome === "authorized") return check.side;
|
||||||
|
if (check.outcome === "not_pending") return "This transfer request is already resolved.";
|
||||||
|
if (check.outcome === "already_decided") {
|
||||||
|
return "You have already recorded your decision on this transfer request.";
|
||||||
|
}
|
||||||
|
return "You are no longer the current leader for a pending side of this transfer request.";
|
||||||
|
};
|
||||||
|
|
||||||
|
type OutcomeEdit = {
|
||||||
|
readonly interaction: TransferInteraction;
|
||||||
|
readonly requestId: number;
|
||||||
|
readonly outcome: DecisionOutcome;
|
||||||
|
readonly status: string;
|
||||||
|
readonly reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editOutcome = async ({
|
||||||
|
interaction,
|
||||||
|
requestId,
|
||||||
|
outcome,
|
||||||
|
status,
|
||||||
|
reason,
|
||||||
|
}: OutcomeEdit): Promise<void> => {
|
||||||
|
if (!interaction.message) return;
|
||||||
|
const row =
|
||||||
|
outcome === "appealed"
|
||||||
|
? appealRow(requestId, true)
|
||||||
|
: transferDecisionRow(requestId, true);
|
||||||
|
await interaction.message.edit({
|
||||||
|
embeds: [decisionOutcomeEmbed(outcome, status, reason)],
|
||||||
|
components: [row],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showRejectModal = async (interaction: ButtonInteraction, requestId: number): Promise<void> => {
|
||||||
|
const reason = new TextInputBuilder()
|
||||||
|
.setCustomId(REJECTION_REASON_ID)
|
||||||
|
.setStyle(TextInputStyle.Paragraph)
|
||||||
|
.setRequired(true)
|
||||||
|
.setMaxLength(1000);
|
||||||
|
const modal = new ModalBuilder()
|
||||||
|
.setCustomId(`ptf-xfer:${requestId}:reject`)
|
||||||
|
.setTitle("Reject assignment transfer")
|
||||||
|
.addLabelComponents(
|
||||||
|
new LabelBuilder().setLabel("Reason for rejection").setTextInputComponent(reason),
|
||||||
|
);
|
||||||
|
await interaction.showModal(modal);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleTransferInteraction = async (
|
||||||
|
interaction: TransferInteraction,
|
||||||
|
payload: Payload,
|
||||||
|
): Promise<void> => {
|
||||||
|
const parsed = parseTransferInteraction(interaction.customId);
|
||||||
|
if (parsed === null) {
|
||||||
|
await replyEphemeral(interaction, "This transfer action is invalid or expired.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await findUserByDiscordId(payload, interaction.user.id);
|
||||||
|
if (user === null) {
|
||||||
|
await replyEphemeral(interaction, "Run /signup or /link before responding to transfers.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.action === "appeal") {
|
||||||
|
const updated = await appealTransfer(payload, {
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
actorUserId: user.id,
|
||||||
|
});
|
||||||
|
await editOutcome({
|
||||||
|
interaction,
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
outcome: "appealed",
|
||||||
|
status: updated.status,
|
||||||
|
});
|
||||||
|
await replyEphemeral(interaction, "Your appeal was submitted for a superuser decision.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const side = await currentLeaderSide(payload, parsed.requestId, user.id);
|
||||||
|
if (side !== "from" && side !== "to") {
|
||||||
|
await replyEphemeral(interaction, side);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.action === "reject" && interaction.isButton()) {
|
||||||
|
await showRejectModal(interaction, parsed.requestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.action === "reject" && interaction.isModalSubmit()) {
|
||||||
|
const reason = interaction.fields.getTextInputValue(REJECTION_REASON_ID).trim();
|
||||||
|
if (reason.length === 0) {
|
||||||
|
await replyEphemeral(interaction, "A rejection reason is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = await recordTransferDecision(payload, {
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
side,
|
||||||
|
decision: "rejected",
|
||||||
|
reason,
|
||||||
|
actorUserId: user.id,
|
||||||
|
});
|
||||||
|
await editOutcome({
|
||||||
|
interaction,
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
outcome: "rejected",
|
||||||
|
status: updated.status,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
await replyEphemeral(interaction, "Your rejection was recorded.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.action === "approve") {
|
||||||
|
const updated = await recordTransferDecision(payload, {
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
side,
|
||||||
|
decision: "approved",
|
||||||
|
actorUserId: user.id,
|
||||||
|
});
|
||||||
|
await editOutcome({
|
||||||
|
interaction,
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
outcome: "approved",
|
||||||
|
status: updated.status,
|
||||||
|
});
|
||||||
|
await replyEphemeral(interaction, "Your approval was recorded.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await replyEphemeral(interaction, "This transfer action is invalid or expired.");
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
payload.logger.error(
|
||||||
|
`[Bot] Transfer interaction failed for request #${parsed.requestId}: ${message}`,
|
||||||
|
);
|
||||||
|
if (!interaction.replied && !interaction.deferred) {
|
||||||
|
await replyEphemeral(interaction, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -5,6 +5,7 @@ import { botConfig } from "./config";
|
||||||
import { commands } from "./commands";
|
import { commands } from "./commands";
|
||||||
import { services } from "./services";
|
import { services } from "./services";
|
||||||
import { handleButtonInteraction } from "./events/interactionCreate";
|
import { handleButtonInteraction } from "./events/interactionCreate";
|
||||||
|
import { handleTransferInteraction } from "./events/transferInteractions";
|
||||||
|
|
||||||
// Usage: `bun run bot`. Standalone long-running process; connects to Discord
|
// Usage: `bun run bot`. Standalone long-running process; connects to Discord
|
||||||
// and shares the Postgres database with the web app via the Payload config.
|
// and shares the Postgres database with the web app via the Payload config.
|
||||||
|
|
@ -72,6 +73,13 @@ const main = async () => {
|
||||||
await handleButtonInteraction(interaction, payload);
|
await handleButtonInteraction(interaction, payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
(interaction.isButton() || interaction.isModalSubmit()) &&
|
||||||
|
interaction.customId.startsWith("ptf-xfer:")
|
||||||
|
) {
|
||||||
|
await handleTransferInteraction(interaction, payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!interaction.isChatInputCommand()) {
|
if (!interaction.isChatInputCommand()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
223
src/bot/lib/transferMessaging.ts
Normal file
223
src/bot/lib/transferMessaging.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
|
||||||
|
import type { AssignmentTransfer, User } from "@/payload-types";
|
||||||
|
import type { TransferSide } from "@/lib/transfers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure delivery-ledger, authorization pre-check, and rendering helpers for the
|
||||||
|
* assignment-transfer Discord workflow. No Payload/client imports and no
|
||||||
|
* botConfig import (it throws at import time when Discord credentials are
|
||||||
|
* absent) — the polling service (services/transferRequests.ts) and the
|
||||||
|
* interaction handler (events/transferInteractions.ts) build on these, and
|
||||||
|
* tests/int/transfer-bot-delivery.int.spec.ts exercises them without a
|
||||||
|
* database or Discord connection. `appUrl` is always passed in by callers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delivery ledger — persisted in AssignmentTransfer.discordDeliveredKeys
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** One key per DM type; key membership makes every send idempotent across bot restarts. */
|
||||||
|
export const TRANSFER_DELIVERY_KEYS = {
|
||||||
|
requestFrom: "request:from",
|
||||||
|
requestTo: "request:to",
|
||||||
|
rejectedRequester: "rejected:requester",
|
||||||
|
escalated: "escalated",
|
||||||
|
completed: "completed",
|
||||||
|
cancelled: "cancelled",
|
||||||
|
resolved: "resolved",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TransferDeliveryKey = (typeof TRANSFER_DELIVERY_KEYS)[keyof typeof TRANSFER_DELIVERY_KEYS];
|
||||||
|
|
||||||
|
export const deliveryKeyForSide = (side: TransferSide): TransferDeliveryKey =>
|
||||||
|
side === "from" ? TRANSFER_DELIVERY_KEYS.requestFrom : TRANSFER_DELIVERY_KEYS.requestTo;
|
||||||
|
|
||||||
|
/** Narrow the JSON `discordDeliveredKeys` field to a safe string[]. */
|
||||||
|
export const deliveredKeys = (doc: AssignmentTransfer): string[] => {
|
||||||
|
const raw = doc.discordDeliveredKeys;
|
||||||
|
return Array.isArray(raw) ? raw.filter((key): key is string => typeof key === "string") : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Coerce a Payload relationship value (numeric id or resolved doc) to its numeric id. */
|
||||||
|
export const relId = (value: number | { id: number }): number =>
|
||||||
|
typeof value === "number" ? value : value.id;
|
||||||
|
|
||||||
|
const notifiedToId = (value: number | User | null | undefined): number | null =>
|
||||||
|
typeof value === "number" ? value : (value?.id ?? null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leader-request DM idempotency contract: a side's request DM is due unless its
|
||||||
|
* key was already delivered AND the user it was delivered to is still the
|
||||||
|
* current leader of that side. A leadership change after a send makes it due
|
||||||
|
* again (fresh DM to the new leader; the old DM's buttons fail authorization
|
||||||
|
* with an ephemeral reply).
|
||||||
|
*/
|
||||||
|
export const isLeaderDeliveryDue = (
|
||||||
|
doc: AssignmentTransfer,
|
||||||
|
side: TransferSide,
|
||||||
|
currentLeaderId: number,
|
||||||
|
): boolean => {
|
||||||
|
if (!deliveredKeys(doc).includes(deliveryKeyForSide(side))) return true;
|
||||||
|
const notifiedTo = side === "from" ? doc.sourceNotifiedToUserId : doc.destinationNotifiedToUserId;
|
||||||
|
return notifiedToId(notifiedTo) !== currentLeaderId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Interaction authorization pre-check
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type LeaderSideCheck =
|
||||||
|
| { outcome: "authorized"; side: TransferSide }
|
||||||
|
| { outcome: "not_pending" }
|
||||||
|
| { outcome: "already_decided" }
|
||||||
|
| { outcome: "not_leader" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which side (if any) of a pending transfer the actor currently leads and can
|
||||||
|
* still decide on. The customId `ptf-xfer:{id}:{action}` carries no side, so
|
||||||
|
* the interaction handler re-fetches the current leaders of both assignments
|
||||||
|
* and uses this to pick the `side` argument for the lib's
|
||||||
|
* recordTransferDecision — which re-checks authorization server-side as the
|
||||||
|
* source of truth. This pre-check exists to give stale/non-leader clicks a
|
||||||
|
* precise ephemeral reply and to avoid showing the reject modal to someone who
|
||||||
|
* cannot use it.
|
||||||
|
*/
|
||||||
|
export const findLeaderSide = (
|
||||||
|
doc: AssignmentTransfer,
|
||||||
|
fromLeaderId: number | null,
|
||||||
|
toLeaderId: number | null,
|
||||||
|
actorUserId: number,
|
||||||
|
): LeaderSideCheck => {
|
||||||
|
if (doc.status !== "pending") return { outcome: "not_pending" };
|
||||||
|
const sides = [
|
||||||
|
{ side: "from" as const, leaderId: fromLeaderId, decision: doc.fromLeaderDecision },
|
||||||
|
{ side: "to" as const, leaderId: toLeaderId, decision: doc.toLeaderDecision },
|
||||||
|
];
|
||||||
|
for (const side of sides) {
|
||||||
|
if (side.leaderId === actorUserId && side.decision === "pending") {
|
||||||
|
return { outcome: "authorized", side: side.side };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sides.some((side) => side.leaderId === actorUserId)) {
|
||||||
|
return { outcome: "already_decided" };
|
||||||
|
}
|
||||||
|
return { outcome: "not_leader" };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const TRANSFER_EMBED_COLORS = {
|
||||||
|
info: 0x3b82f6,
|
||||||
|
rejected: 0xef4444,
|
||||||
|
completed: 0x22c55e,
|
||||||
|
escalated: 0xf59e0b,
|
||||||
|
closed: 0x6b7280,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface TransferContext {
|
||||||
|
requestId: number;
|
||||||
|
requesterLabel: string;
|
||||||
|
fromName: string;
|
||||||
|
toName: string;
|
||||||
|
note?: string | null;
|
||||||
|
appUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transfersLink = (appUrl: string): string => `${appUrl}/transfers`;
|
||||||
|
|
||||||
|
const requestLines = (ctx: TransferContext): string[] => [
|
||||||
|
`**Requester:** ${ctx.requesterLabel}`,
|
||||||
|
`**From:** ${ctx.fromName}`,
|
||||||
|
`**To:** ${ctx.toName}`,
|
||||||
|
...(ctx.note?.trim() ? [`**Note:** ${ctx.note.trim()}`] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** DM embed for a pending request, sent to each required side's current leader. */
|
||||||
|
export const requestEmbed = (ctx: TransferContext): EmbedBuilder =>
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setTitle(`Transfer request #${ctx.requestId} needs your approval`)
|
||||||
|
.setDescription(requestLines(ctx).join("\n"))
|
||||||
|
.setColor(TRANSFER_EMBED_COLORS.info)
|
||||||
|
.addFields({ name: "Decide on the web", value: transfersLink(ctx.appUrl) });
|
||||||
|
|
||||||
|
/** DM embed for a rejected request, sent to the requester (with an Appeal button). */
|
||||||
|
export const rejectedRequesterEmbed = (ctx: TransferContext, reason: string): EmbedBuilder =>
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setTitle(`Transfer request #${ctx.requestId} rejected`)
|
||||||
|
.setDescription([...requestLines(ctx), `**Reason:** ${reason}`].join("\n"))
|
||||||
|
.setColor(TRANSFER_EMBED_COLORS.rejected)
|
||||||
|
.addFields({ name: "Appeal or view details", value: transfersLink(ctx.appUrl) });
|
||||||
|
|
||||||
|
/** Plain informational DM embed (no buttons) for escalated/terminal outcomes. */
|
||||||
|
export const informationalEmbed = (
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
appUrl: string,
|
||||||
|
color: number = TRANSFER_EMBED_COLORS.info,
|
||||||
|
): EmbedBuilder =>
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setTitle(title)
|
||||||
|
.setDescription(description)
|
||||||
|
.setColor(color)
|
||||||
|
.addFields({ name: "Open on the web", value: transfersLink(appUrl) });
|
||||||
|
|
||||||
|
export type DecisionOutcome = "approved" | "rejected" | "appealed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replacement embed edited into a DM after its button was used, so the message
|
||||||
|
* reflects the outcome instead of still soliciting a decision.
|
||||||
|
*/
|
||||||
|
export const decisionOutcomeEmbed = (
|
||||||
|
outcome: DecisionOutcome,
|
||||||
|
status: string,
|
||||||
|
reason?: string,
|
||||||
|
): EmbedBuilder => {
|
||||||
|
const summary: Record<DecisionOutcome, string> = {
|
||||||
|
approved: "You approved this transfer request.",
|
||||||
|
rejected: `You rejected this transfer request.${reason ? ` Reason: "${reason}"` : ""}`,
|
||||||
|
appealed: "You appealed this rejection. A superuser will make the final call.",
|
||||||
|
};
|
||||||
|
const color =
|
||||||
|
outcome === "approved"
|
||||||
|
? TRANSFER_EMBED_COLORS.completed
|
||||||
|
: outcome === "rejected"
|
||||||
|
? TRANSFER_EMBED_COLORS.rejected
|
||||||
|
: TRANSFER_EMBED_COLORS.escalated;
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle("Decision recorded")
|
||||||
|
.setDescription(`${summary[outcome]}\n**Current status:** ${status}`)
|
||||||
|
.setColor(color);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Buttons — customId contract `ptf-xfer:{requestId}:{action}`, routed on the
|
||||||
|
// "ptf-xfer:" prefix in src/bot/index.ts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const transferDecisionRow = (
|
||||||
|
requestId: number,
|
||||||
|
disabled = false,
|
||||||
|
): ActionRowBuilder<ButtonBuilder> =>
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`ptf-xfer:${requestId}:approve`)
|
||||||
|
.setStyle(ButtonStyle.Success)
|
||||||
|
.setLabel("Approve")
|
||||||
|
.setDisabled(disabled),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`ptf-xfer:${requestId}:reject`)
|
||||||
|
.setStyle(ButtonStyle.Danger)
|
||||||
|
.setLabel("Reject")
|
||||||
|
.setDisabled(disabled),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const appealRow = (requestId: number, disabled = false): ActionRowBuilder<ButtonBuilder> =>
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`ptf-xfer:${requestId}:appeal`)
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
.setLabel("Appeal")
|
||||||
|
.setDisabled(disabled),
|
||||||
|
);
|
||||||
|
|
@ -2,7 +2,12 @@ import type { Client } from "discord.js";
|
||||||
import type { Payload } from "payload";
|
import type { Payload } from "payload";
|
||||||
import { start as startMissionEmbeds } from "./missionEmbeds";
|
import { start as startMissionEmbeds } from "./missionEmbeds";
|
||||||
import { start as startNotificationBridge } from "./notificationBridge";
|
import { start as startNotificationBridge } from "./notificationBridge";
|
||||||
|
import { start as startTransferRequests } from "./transferRequests";
|
||||||
|
|
||||||
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
||||||
|
|
||||||
export const services: ServiceStarter[] = [startMissionEmbeds, startNotificationBridge];
|
export const services: ServiceStarter[] = [
|
||||||
|
startMissionEmbeds,
|
||||||
|
startNotificationBridge,
|
||||||
|
startTransferRequests,
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,16 @@ import { type Client, EmbedBuilder } from "discord.js";
|
||||||
import type { Payload } from "payload";
|
import type { Payload } from "payload";
|
||||||
import type { User, UserNotification } from "@/payload-types";
|
import type { User, UserNotification } from "@/payload-types";
|
||||||
import { botConfig } from "@/bot/config";
|
import { botConfig } from "@/bot/config";
|
||||||
|
import { TRANSFER_NOTIFICATION_TYPES } from "@/lib/transfers/notificationTypes";
|
||||||
|
|
||||||
const MAX_SENDS_PER_TICK = 5;
|
const MAX_SENDS_PER_TICK = 5;
|
||||||
const SEND_DELAY_MS = 300;
|
const SEND_DELAY_MS = 300;
|
||||||
const EMBED_COLOR = 0x3b82f6;
|
const EMBED_COLOR = 0x3b82f6;
|
||||||
|
|
||||||
|
// Transfer notifications are delivered by the dedicated transfer bot service
|
||||||
|
// (Phase 2), not this generic bridge. Exclude exactly these types.
|
||||||
|
const NON_BRIDGED_TYPES = new Set<string>(Object.values(TRANSFER_NOTIFICATION_TYPES));
|
||||||
|
|
||||||
let cursor: number | null = null;
|
let cursor: number | null = null;
|
||||||
|
|
||||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
@ -54,6 +59,7 @@ const poll = async (client: Client, payload: Payload): Promise<void> => {
|
||||||
processed++;
|
processed++;
|
||||||
cursor = notification.id;
|
cursor = notification.id;
|
||||||
if (sent >= MAX_SENDS_PER_TICK) break;
|
if (sent >= MAX_SENDS_PER_TICK) break;
|
||||||
|
if (notification.type && NON_BRIDGED_TYPES.has(notification.type)) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const owner = (await payload.findByID({
|
const owner = (await payload.findByID({
|
||||||
|
|
|
||||||
181
src/bot/services/transferDelivery.ts
Normal file
181
src/bot/services/transferDelivery.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import type { Client } from "discord.js";
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import { botConfig } from "@/bot/config";
|
||||||
|
import {
|
||||||
|
appealRow,
|
||||||
|
deliveredKeys,
|
||||||
|
deliveryKeyForSide,
|
||||||
|
informationalEmbed,
|
||||||
|
isLeaderDeliveryDue,
|
||||||
|
rejectedRequesterEmbed,
|
||||||
|
relId,
|
||||||
|
requestEmbed,
|
||||||
|
transferDecisionRow,
|
||||||
|
TRANSFER_DELIVERY_KEYS,
|
||||||
|
type TransferContext,
|
||||||
|
type TransferDeliveryKey,
|
||||||
|
} from "@/bot/lib/transferMessaging";
|
||||||
|
import type { Assignment, AssignmentTransfer, User } from "@/payload-types";
|
||||||
|
import type { TransferSide } from "@/lib/transfers";
|
||||||
|
|
||||||
|
const userLabel = (user: User): string => user.displayName || user.username;
|
||||||
|
|
||||||
|
const eligibleForDm = (user: User): user is User & { discordId: string } =>
|
||||||
|
typeof user.discordId === "string" &&
|
||||||
|
user.discordId.length > 0 &&
|
||||||
|
user.preferences?.discord?.enabled === true;
|
||||||
|
|
||||||
|
const appendKey = (doc: AssignmentTransfer, key: TransferDeliveryKey): string[] => [
|
||||||
|
...new Set([...deliveredKeys(doc), key]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchUser = (payload: Payload, id: number): Promise<User> =>
|
||||||
|
payload.findByID({ collection: "users", id, depth: 0, overrideAccess: true });
|
||||||
|
|
||||||
|
const fetchAssignment = (payload: Payload, id: number): Promise<Assignment> =>
|
||||||
|
payload.findByID({ collection: "assignments", id, depth: 0, overrideAccess: true });
|
||||||
|
|
||||||
|
const contextFor = async (
|
||||||
|
payload: Payload,
|
||||||
|
doc: AssignmentTransfer,
|
||||||
|
): Promise<TransferContext> => {
|
||||||
|
const [requester, fromAssignment, toAssignment] = await Promise.all([
|
||||||
|
fetchUser(payload, relId(doc.requester)),
|
||||||
|
fetchAssignment(payload, relId(doc.fromAssignment)),
|
||||||
|
fetchAssignment(payload, relId(doc.toAssignment)),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
requestId: doc.id,
|
||||||
|
requesterLabel: userLabel(requester),
|
||||||
|
fromName: fromAssignment.name,
|
||||||
|
toName: toAssignment.name,
|
||||||
|
note: doc.requestNote,
|
||||||
|
appUrl: botConfig.appUrl,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type LeaderDelivery = {
|
||||||
|
readonly client: Client;
|
||||||
|
readonly payload: Payload;
|
||||||
|
readonly doc: AssignmentTransfer;
|
||||||
|
readonly side: TransferSide;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deliverLeaderRequest = async ({
|
||||||
|
client,
|
||||||
|
payload,
|
||||||
|
doc,
|
||||||
|
side,
|
||||||
|
}: LeaderDelivery): Promise<void> => {
|
||||||
|
const decision = side === "from" ? doc.fromLeaderDecision : doc.toLeaderDecision;
|
||||||
|
if (decision !== "pending") return;
|
||||||
|
|
||||||
|
const assignmentId = relId(side === "from" ? doc.fromAssignment : doc.toAssignment);
|
||||||
|
const assignment = await fetchAssignment(payload, assignmentId);
|
||||||
|
const leaderId = assignment.leader == null ? null : relId(assignment.leader);
|
||||||
|
if (leaderId === null || !isLeaderDeliveryDue(doc, side, leaderId)) return;
|
||||||
|
|
||||||
|
const leader = await fetchUser(payload, leaderId);
|
||||||
|
if (!eligibleForDm(leader)) return;
|
||||||
|
|
||||||
|
const target = await client.users.fetch(leader.discordId);
|
||||||
|
const message = await target.send({
|
||||||
|
embeds: [requestEmbed(await contextFor(payload, doc))],
|
||||||
|
components: [transferDecisionRow(doc.id)],
|
||||||
|
});
|
||||||
|
const key = deliveryKeyForSide(side);
|
||||||
|
await payload.update({
|
||||||
|
collection: "assignment-transfers",
|
||||||
|
id: doc.id,
|
||||||
|
data:
|
||||||
|
side === "from"
|
||||||
|
? {
|
||||||
|
sourceNotifiedToUserId: leaderId,
|
||||||
|
discordFromLeaderMessageId: message.id,
|
||||||
|
discordDeliveredKeys: appendKey(doc, key),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
destinationNotifiedToUserId: leaderId,
|
||||||
|
discordToLeaderMessageId: message.id,
|
||||||
|
discordDeliveredKeys: appendKey(doc, key),
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type RequesterDelivery = Omit<LeaderDelivery, "side">;
|
||||||
|
|
||||||
|
export const deliverRequesterRejection = async ({
|
||||||
|
client,
|
||||||
|
payload,
|
||||||
|
doc,
|
||||||
|
}: RequesterDelivery): Promise<void> => {
|
||||||
|
const key = TRANSFER_DELIVERY_KEYS.rejectedRequester;
|
||||||
|
if (doc.status !== "rejected" || deliveredKeys(doc).includes(key)) return;
|
||||||
|
|
||||||
|
const requester = await fetchUser(payload, relId(doc.requester));
|
||||||
|
if (!eligibleForDm(requester)) return;
|
||||||
|
|
||||||
|
const target = await client.users.fetch(requester.discordId);
|
||||||
|
const message = await target.send({
|
||||||
|
embeds: [
|
||||||
|
rejectedRequesterEmbed(
|
||||||
|
await contextFor(payload, doc),
|
||||||
|
doc.rejectionReason ?? "No reason was provided.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
components: [appealRow(doc.id)],
|
||||||
|
});
|
||||||
|
await payload.update({
|
||||||
|
collection: "assignment-transfers",
|
||||||
|
id: doc.id,
|
||||||
|
data: {
|
||||||
|
discordRequesterMessageId: message.id,
|
||||||
|
discordDeliveredKeys: appendKey(doc, key),
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fixedParticipantIds = (doc: AssignmentTransfer): number[] =>
|
||||||
|
[doc.requester, doc.fromLeaderSnapshot, doc.toLeaderSnapshot]
|
||||||
|
.flatMap((relation) => (relation == null ? [] : [relId(relation)]))
|
||||||
|
.filter((id, index, ids) => ids.indexOf(id) === index);
|
||||||
|
|
||||||
|
type InformationDelivery = RequesterDelivery & {
|
||||||
|
readonly key: TransferDeliveryKey;
|
||||||
|
readonly title: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly recipientIds: readonly number[];
|
||||||
|
readonly color: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deliverInformation = async ({
|
||||||
|
client,
|
||||||
|
payload,
|
||||||
|
doc,
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
recipientIds,
|
||||||
|
color,
|
||||||
|
}: InformationDelivery): Promise<void> => {
|
||||||
|
if (deliveredKeys(doc).includes(key)) return;
|
||||||
|
|
||||||
|
let sent = false;
|
||||||
|
for (const recipientId of recipientIds) {
|
||||||
|
const recipient = await fetchUser(payload, recipientId);
|
||||||
|
if (!eligibleForDm(recipient)) continue;
|
||||||
|
const target = await client.users.fetch(recipient.discordId);
|
||||||
|
await target.send({ embeds: [informationalEmbed(title, description, botConfig.appUrl, color)] });
|
||||||
|
sent = true;
|
||||||
|
}
|
||||||
|
if (!sent) return;
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: "assignment-transfers",
|
||||||
|
id: doc.id,
|
||||||
|
data: { discordDeliveredKeys: appendKey(doc, key) },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
134
src/bot/services/transferRequests.ts
Normal file
134
src/bot/services/transferRequests.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import type { Client } from "discord.js";
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import { botConfig } from "@/bot/config";
|
||||||
|
import {
|
||||||
|
TRANSFER_DELIVERY_KEYS,
|
||||||
|
TRANSFER_EMBED_COLORS,
|
||||||
|
} from "@/bot/lib/transferMessaging";
|
||||||
|
import {
|
||||||
|
deliverInformation,
|
||||||
|
deliverLeaderRequest,
|
||||||
|
deliverRequesterRejection,
|
||||||
|
fixedParticipantIds,
|
||||||
|
} from "./transferDelivery";
|
||||||
|
import type { AssignmentTransfer } from "@/payload-types";
|
||||||
|
import hasRoles from "@/utils/access-control/hasRoles";
|
||||||
|
|
||||||
|
let pollRunning = false;
|
||||||
|
|
||||||
|
const errorMessage = (error: unknown): string =>
|
||||||
|
error instanceof Error ? error.message : "Unknown error";
|
||||||
|
|
||||||
|
const processTransfer = async (
|
||||||
|
client: Client,
|
||||||
|
payload: Payload,
|
||||||
|
doc: AssignmentTransfer,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (doc.status === "pending") {
|
||||||
|
await deliverLeaderRequest({ client, payload, doc, side: "from" });
|
||||||
|
const fresh = await payload.findByID({
|
||||||
|
collection: "assignment-transfers",
|
||||||
|
id: doc.id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
await deliverLeaderRequest({ client, payload, doc: fresh, side: "to" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (doc.status === "rejected") {
|
||||||
|
await deliverRequesterRejection({ client, payload, doc });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (doc.status === "awaiting_superuser") {
|
||||||
|
const admins = await payload.find({
|
||||||
|
collection: "users",
|
||||||
|
limit: 200,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
await deliverInformation({
|
||||||
|
client,
|
||||||
|
payload,
|
||||||
|
doc,
|
||||||
|
key: TRANSFER_DELIVERY_KEYS.escalated,
|
||||||
|
title: `Transfer request #${doc.id} needs a final call`,
|
||||||
|
description: "This request is awaiting an admin or developer decision on the web.",
|
||||||
|
recipientIds: admins.docs
|
||||||
|
.filter((user) => hasRoles(["admin", "developer"], user))
|
||||||
|
.map((user) => user.id),
|
||||||
|
color: TRANSFER_EMBED_COLORS.escalated,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = fixedParticipantIds(doc);
|
||||||
|
if (doc.status === "completed") {
|
||||||
|
await deliverInformation({
|
||||||
|
client,
|
||||||
|
payload,
|
||||||
|
doc,
|
||||||
|
key: TRANSFER_DELIVERY_KEYS.completed,
|
||||||
|
title: `Transfer request #${doc.id} completed`,
|
||||||
|
description: "The assignment transfer is complete.",
|
||||||
|
recipientIds: participants,
|
||||||
|
color: TRANSFER_EMBED_COLORS.completed,
|
||||||
|
});
|
||||||
|
} else if (doc.status === "cancelled") {
|
||||||
|
await deliverInformation({
|
||||||
|
client,
|
||||||
|
payload,
|
||||||
|
doc,
|
||||||
|
key: TRANSFER_DELIVERY_KEYS.cancelled,
|
||||||
|
title: `Transfer request #${doc.id} cancelled`,
|
||||||
|
description: "The requester cancelled this assignment transfer.",
|
||||||
|
recipientIds: participants,
|
||||||
|
color: TRANSFER_EMBED_COLORS.closed,
|
||||||
|
});
|
||||||
|
} else if (doc.status === "denied") {
|
||||||
|
await deliverInformation({
|
||||||
|
client,
|
||||||
|
payload,
|
||||||
|
doc,
|
||||||
|
key: TRANSFER_DELIVERY_KEYS.resolved,
|
||||||
|
title: `Transfer request #${doc.id} denied`,
|
||||||
|
description: "A superuser denied this assignment transfer.",
|
||||||
|
recipientIds: participants,
|
||||||
|
color: TRANSFER_EMBED_COLORS.rejected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pollTransferRequests = async (client: Client, payload: Payload): Promise<void> => {
|
||||||
|
if (pollRunning) return;
|
||||||
|
pollRunning = true;
|
||||||
|
try {
|
||||||
|
const transfers = await payload.find({
|
||||||
|
collection: "assignment-transfers",
|
||||||
|
sort: "id",
|
||||||
|
limit: 200,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
for (const transfer of transfers.docs) {
|
||||||
|
try {
|
||||||
|
await processTransfer(client, payload, transfer);
|
||||||
|
} catch (error) {
|
||||||
|
payload.logger.error(
|
||||||
|
`[Bot] Transfer delivery failed for request #${transfer.id}: ${errorMessage(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
payload.logger.error(`[Bot] Transfer request poll failed: ${errorMessage(error)}`);
|
||||||
|
} finally {
|
||||||
|
pollRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const start = (client: Client, payload: Payload): void => {
|
||||||
|
payload.logger.info("[Bot] Assignment transfer service started");
|
||||||
|
void pollTransferRequests(client, payload);
|
||||||
|
setInterval(() => {
|
||||||
|
void pollTransferRequests(client, payload);
|
||||||
|
}, botConfig.notificationPollMs);
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue