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: 3 additions & 0 deletions src/application/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"./Tts": {
"import": "./src/Tts.ts"
},
"./Guild": {
"import": "./src/Guild.ts"
},
"./staff": {
"import": "./src/utils/staff.ts"
},
Expand Down
70 changes: 70 additions & 0 deletions src/application/core/src/Guild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,32 @@ import type { Record } from "./Record";
import type { GuildMetaData } from "./types/GuildMetaData";
import type { RootPage } from "./types/RootPage";

const BUMP_NOTICE_KEY_PREFIX = "bump:notice:";

// A pending "bump is available again" notice -- not the live setTimeout itself (that's
// process-local, see presentation-bot's utils/bump/notice.ts), just enough to re-arm it after
// a restart. commandId is captured at schedule time so the restored notice can still render
// the same clickable `/bump` mention as the original.
export type BumpNoticeJob = {
guildId: string;
channelId: string;
content: string | null;
roleId: string | null;
commandId: string | null;
fireAt: string;
};

function isBumpNoticeJob(value: unknown): value is BumpNoticeJob {
const job = value as { guildId?: unknown; channelId?: unknown; fireAt?: unknown } | null;
return (
typeof job === "object" &&
job !== null &&
typeof job.guildId === "string" &&
typeof job.channelId === "string" &&
typeof job.fireAt === "string"
);
}

export class Guild extends Base {
public readonly rootPage: RootPage = {
latestGuilds: [],
Expand Down Expand Up @@ -174,6 +200,50 @@ export class Guild extends Base {
};
}

// Called from presentation-bot's BumpCommand right alongside arming the live setTimeout --
// keeps Redis in sync so a rolling update's restart (which kills that timer) has something
// to re-arm from. TTL mirrors fireAt rather than being a fixed two hours: Redis's own
// expiry is then a safety net, not the primary cleanup path (fireBumpNotice deletes the key
// itself once sent).
public async saveBumpNotice(job: BumpNoticeJob): Promise<void> {
const ttlSeconds = Math.max(1, Math.ceil((new Date(job.fireAt).getTime() - Date.now()) / 1000));
await this.state.redis.set(
`${BUMP_NOTICE_KEY_PREFIX}${job.guildId}`,
JSON.stringify(job),
"EX",
ttlSeconds,
);
}

public async clearBumpNotice(guildId: string): Promise<void> {
await this.state.redis.del(`${BUMP_NOTICE_KEY_PREFIX}${guildId}`);
}

// Read once at startup (see presentation-bot's utils/bump/notice.ts restoreBumpNotices) to
// re-arm every notice that survived a restart. A corrupt entry is skipped rather than
// thrown -- one bad key shouldn't block every other guild's notice from restoring.
public async getAllBumpNotices(): Promise<BumpNoticeJob[]> {
const keys = await this.state.redis.keys(`${BUMP_NOTICE_KEY_PREFIX}*`);
const jobs: BumpNoticeJob[] = [];
for (const key of keys) {
const raw = await this.state.redis.get(key);
if (!raw) {
continue;
}
try {
const parsed: unknown = JSON.parse(raw);
if (!isBumpNoticeJob(parsed)) {
console.error(`[bump] persisted notice for key ${key} has an unexpected shape`);
continue;
}
jobs.push(parsed);
} catch (error) {
console.error(`[bump] failed to parse persisted notice for key ${key}`, error);
}
}
return jobs;
}

public async save(input: GuildUpsertInput) {
if (input.public === false) {
const guild = await this.state.database.guild.upsert(input);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "discord.js";
import { RateLimitError } from "domain-model";

import { scheduleBumpNotice } from "../../../utils/bump/notice";
import { ChatInputCommandBase } from "../Base/ChatInputCommandBase";
import { GuildParseError } from "../Base/Error/GuildParseError";

Expand Down Expand Up @@ -64,21 +65,18 @@ export class BumpCommand extends ChatInputCommandBase<Options> {
const settings = await this.core.guild.getSetting(guild.id);

if (settings && settings.bumpNotice && channel?.isSendable()) {
setTimeout(async () => {
const { bumpNoticeContent, bumpNoticeRole } = settings;
const embed = new EmbedBuilder()
.setColor("Gold")
.setTitle("Bumpが実行できますよ!!")
.setURL(`https://distopia.top/`)
.setDescription(
bumpNoticeContent ??
`只今、前回のBumpから2時間がたちました。\n再度 </bump:${interaction.commandId}> を実行可能です。`,
);
await channel.send({
content: bumpNoticeRole ? `<@&${bumpNoticeRole}>` : undefined,
embeds: [embed],
});
}, twoHours);
const job = {
guildId: guild.id,
channelId: channel.id,
content: settings.bumpNoticeContent,
roleId: settings.bumpNoticeRole,
commandId: interaction.commandId,
fireAt: new Date(Date.now() + twoHours).toISOString(),
};
// Persisted so a rolling update's restart (which kills the setTimeout below) can
// re-arm this from Redis instead of losing the notice -- see utils/bump/notice.ts.
await this.core.guild.saveBumpNotice(job);
scheduleBumpNotice(job, interaction.client, this.core);
}

const { guildBumpCounter, userBumpCounter } = bumped;
Expand Down
7 changes: 7 additions & 0 deletions src/presentation/bot/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { RoleCreateHandler } from "./EventHandler/RoleCreateHandler";
import { RoleDeleteHandler } from "./EventHandler/RoleDeleteHandler";
import { RoleUpdateHandler } from "./EventHandler/RoleUpdateHandler";
import { VoiceStateUpdateHandler } from "./EventHandler/VoiceStateUpdateHandler";
import { restoreBumpNotices } from "./utils/bump/notice";
import { restoreSessions } from "./utils/tts/session";

export function handleClient(client: Client, core: AppCore) {
Expand Down Expand Up @@ -46,6 +47,12 @@ export function handleClient(client: Client, core: AppCore) {
console.error("[tts] failed to restore voice sessions", error),
);

// Fire-and-forget, same reasoning: re-arming pending bump notices must not block command
// registration below.
void restoreBumpNotices(client, core).catch((error) =>
console.error("[bump] failed to restore bump notices", error),
);

const commands = interactionCreateHandler.commands.chatInput
.filter((command) => command.availableGuildId === null)
.map((command) => command.register);
Expand Down
62 changes: 62 additions & 0 deletions src/presentation/bot/src/utils/bump/notice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { AppCore } from "app-core";
import type { BumpNoticeJob } from "app-core/Guild";
import { EmbedBuilder, type Client } from "discord.js";

// Process-local, in-memory only -- mirrors presentation-bot's tts/session.ts: the live
// setTimeout can't survive a restart, so Guild.saveBumpNotice's Redis copy is what actually
// does, and restoreBumpNotices re-arms one of these from it on boot.
const timers = new Map<string, NodeJS.Timeout>();

export function scheduleBumpNotice(job: BumpNoticeJob, client: Client, core: AppCore): void {
const existing = timers.get(job.guildId);
if (existing) {
clearTimeout(existing);
}

const delay = Math.max(0, new Date(job.fireAt).getTime() - Date.now());
timers.set(
job.guildId,
setTimeout(() => void fireBumpNotice(job, client, core), delay),
);
}

async function fireBumpNotice(job: BumpNoticeJob, client: Client, core: AppCore): Promise<void> {
timers.delete(job.guildId);
await core.guild.clearBumpNotice(job.guildId);

try {
const channel = await client.channels.fetch(job.channelId);
if (!channel?.isSendable()) {
return;
}

const embed = new EmbedBuilder()
.setColor("Gold")
.setTitle("Bumpが実行できますよ!!")
.setURL("https://distopia.top/")
.setDescription(
job.content ??
`只今、前回のBumpから2時間がたちました。\n再度 ${
job.commandId ? `</bump:${job.commandId}>` : "/bump"
} を実行可能です。`,
);

await channel.send({
content: job.roleId ? `<@&${job.roleId}>` : undefined,
embeds: [embed],
});
} catch (error) {
console.error(`[bump] failed to send bump notice for guild ${job.guildId}`, error);
}
}

// Called once from client.ts's `clientReady` handler to re-arm every bump notice that
// survived a restart -- a rolling update kills the old pod's process (and with it every
// in-memory setTimeout), but the Redis-backed job (see Guild.saveBumpNotice) tells the new pod
// what's still pending, including one already past due, which fires immediately.
export async function restoreBumpNotices(client: Client<true>, core: AppCore): Promise<void> {
const jobs = await core.guild.getAllBumpNotices();
for (const job of jobs) {
scheduleBumpNotice(job, client, core);
}
}
Loading