Skip to content

Commit c4017b5

Browse files
authored
Fixes for timer bot - not announcing, skip not working (#270)
1 parent c48c7ea commit c4017b5

6 files changed

Lines changed: 150 additions & 17 deletions

File tree

src/features/spawn-timers/commands/helpers/channel-update.ts

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ import {
1010
inWindow,
1111
displayWindow,
1212
} from "./timer";
13-
import { formatTimeDistance } from "./duration";
13+
import { formatTimeDistance, formatMinutesSecondsAgo } from "./duration";
1414
import { getSettingByKey, saveSettingByKey } from "./settings";
1515
import { TIMER_CHANNEL_ID, SHOW_FUTURE_WINDOW } from "../../../../config";
1616
import { timerPrismaClient } from "../../../../db/timer-client";
1717

1818
const MAX_DESCRIPTION_LENGTH = 4096;
1919
const MAX_EMBEDS_PER_MESSAGE = 10;
20+
const ENDED_RECENTLY_WINDOW_MS = 60 * 60 * 1000;
2021

2122
interface TableRow {
2223
name: string;
@@ -25,6 +26,12 @@ interface TableRow {
2526
remainingMs: number;
2627
}
2728

29+
interface EndedRow {
30+
name: string;
31+
time: string;
32+
endedMsAgo: number;
33+
}
34+
2835
function pad(str: string, len: number): string {
2936
if (str.length > len) return str.slice(0, len);
3037
return str.padEnd(len, " ");
@@ -65,33 +72,53 @@ function renderTable(
6572
return "```\n" + lines.join("\n") + "\n```";
6673
}
6774

68-
function chunkTable(
69-
rows: TableRow[],
70-
maxLen: number,
71-
widths: { nameWidth: number; timeWidth: number; windowWidth: number },
72-
thirdColumnLabel?: string
73-
): string[] {
75+
function chunkRows<T>(rows: T[], maxLen: number, render: (rows: T[]) => string): string[] {
7476
const chunks: string[] = [];
75-
let currentRows: TableRow[] = [];
77+
let currentRows: T[] = [];
7678

7779
for (const row of rows) {
7880
const testRows = [...currentRows, row];
79-
const rendered = renderTable(testRows, widths, thirdColumnLabel);
81+
const rendered = render(testRows);
8082
if (rendered.length > maxLen && currentRows.length > 0) {
81-
chunks.push(renderTable(currentRows, widths, thirdColumnLabel));
83+
chunks.push(render(currentRows));
8284
currentRows = [row];
8385
} else {
8486
currentRows = testRows;
8587
}
8688
}
8789

8890
if (currentRows.length > 0) {
89-
chunks.push(renderTable(currentRows, widths, thirdColumnLabel));
91+
chunks.push(render(currentRows));
9092
}
9193

9294
return chunks;
9395
}
9496

97+
function getEndedColumnWidths(rows: EndedRow[]): { nameWidth: number; timeWidth: number } {
98+
return {
99+
nameWidth: Math.max(5, ...rows.map((r) => r.name.length)),
100+
timeWidth: Math.max(5, ...rows.map((r) => r.time.length)),
101+
};
102+
}
103+
104+
function renderEndedTable(
105+
rows: EndedRow[],
106+
widths: { nameWidth: number; timeWidth: number }
107+
): string {
108+
const { nameWidth, timeWidth } = widths;
109+
const sep = " | ";
110+
const header = `${pad("Timer", nameWidth)}${sep}${pad("Ended", timeWidth)}`;
111+
const divider = "-".repeat(header.length);
112+
113+
const lines = [
114+
header,
115+
divider,
116+
...rows.map((r) => `${pad(r.name, nameWidth)}${sep}${pad(r.time, timeWidth)}`),
117+
];
118+
119+
return "```\n" + lines.join("\n") + "\n```";
120+
}
121+
95122
/**
96123
* Update the timer channel with current timer status using standard message embeds.
97124
*/
@@ -116,6 +143,7 @@ export async function updateTimersChannel(client: Client): Promise<void> {
116143
const futureRows: TableRow[] = [];
117144
const upcomingRows: TableRow[] = [];
118145
const inWindowRows: TableRow[] = [];
146+
const endedRows: EndedRow[] = [];
119147

120148
for (const timer of sortedTimers) {
121149
if (!timer.lastTod) continue;
@@ -142,6 +170,16 @@ export async function updateTimersChannel(client: Client): Promise<void> {
142170
remainingMs,
143171
});
144172
}
173+
} else if (
174+
endsAt.getTime() < now.getTime() &&
175+
now.getTime() - endsAt.getTime() <= ENDED_RECENTLY_WINDOW_MS
176+
) {
177+
const endedMsAgo = now.getTime() - endsAt.getTime();
178+
endedRows.push({
179+
name: getDisplayName(timer.name, timer.skipCount),
180+
time: formatMinutesSecondsAgo(endsAt, now),
181+
endedMsAgo,
182+
});
145183
} else if (startsAt.getTime() <= now.getTime() + 24 * 60 * 60 * 1000) {
146184
const remainingMs = startsAt.getTime() - now.getTime();
147185
upcomingRows.push({
@@ -165,6 +203,8 @@ export async function updateTimersChannel(client: Client): Promise<void> {
165203
futureRows.sort((a, b) => b.remainingMs - a.remainingMs);
166204
upcomingRows.sort((a, b) => b.remainingMs - a.remainingMs);
167205
inWindowRows.sort((a, b) => b.remainingMs - a.remainingMs);
206+
// Most recently ended at the bottom, oldest at the top
207+
endedRows.sort((a, b) => b.endedMsAgo - a.endedMsAgo);
168208

169209
const embeds: EmbedBuilder[] = [];
170210

@@ -177,7 +217,7 @@ export async function updateTimersChannel(client: Client): Promise<void> {
177217
// Future window embed(s)
178218
if (SHOW_FUTURE_WINDOW?.toLowerCase() === "true" && futureRows.length > 0) {
179219
const widths = getColumnWidths(futureRows);
180-
const descChunks = chunkTable(futureRows, MAX_DESCRIPTION_LENGTH, widths);
220+
const descChunks = chunkRows(futureRows, MAX_DESCRIPTION_LENGTH, (rs) => renderTable(rs, widths));
181221
for (let i = 0; i < descChunks.length; i++) {
182222
const embed = new EmbedBuilder().setDescription(descChunks[i]);
183223
if (i === 0) embed.setTitle("Future Windows");
@@ -188,7 +228,7 @@ export async function updateTimersChannel(client: Client): Promise<void> {
188228
// Upcoming embed(s)
189229
if (upcomingRows.length > 0) {
190230
const widths = getColumnWidths(upcomingRows);
191-
const descChunks = chunkTable(upcomingRows, MAX_DESCRIPTION_LENGTH, widths);
231+
const descChunks = chunkRows(upcomingRows, MAX_DESCRIPTION_LENGTH, (rs) => renderTable(rs, widths));
192232
for (let i = 0; i < descChunks.length; i++) {
193233
const embed = new EmbedBuilder().setDescription(descChunks[i]);
194234
if (i === 0) embed.setTitle("Mobs Entering Window In The Next 24 Hours");
@@ -199,7 +239,7 @@ export async function updateTimersChannel(client: Client): Promise<void> {
199239
// In-window embed(s)
200240
if (anyInWindow) {
201241
const widths = getColumnWidths(inWindowRows);
202-
const descChunks = chunkTable(inWindowRows, MAX_DESCRIPTION_LENGTH, widths, "%");
242+
const descChunks = chunkRows(inWindowRows, MAX_DESCRIPTION_LENGTH, (rs) => renderTable(rs, widths, "%"));
203243
for (let i = 0; i < descChunks.length; i++) {
204244
const embed = new EmbedBuilder().setColor(0xe67e22).setDescription(descChunks[i]);
205245
if (i === 0) embed.setTitle("Mobs In Window");
@@ -215,6 +255,17 @@ export async function updateTimersChannel(client: Client): Promise<void> {
215255
);
216256
}
217257

258+
// Ended recently embed(s)
259+
if (endedRows.length > 0) {
260+
const widths = getEndedColumnWidths(endedRows);
261+
const descChunks = chunkRows(endedRows, MAX_DESCRIPTION_LENGTH, (rs) => renderEndedTable(rs, widths));
262+
for (let i = 0; i < descChunks.length; i++) {
263+
const embed = new EmbedBuilder().setColor(0x95a5a6).setDescription(descChunks[i]);
264+
if (i === 0) embed.setTitle("Ended Recently");
265+
embeds.push(embed);
266+
}
267+
}
268+
218269
// Discord allows at most 10 embeds per message
219270
embeds.splice(MAX_EMBEDS_PER_MESSAGE);
220271

src/features/spawn-timers/commands/helpers/duration.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { parseDuration, formatDuration, formatTimeDistance } from "./duration";
1+
import {
2+
parseDuration,
3+
formatDuration,
4+
formatTimeDistance,
5+
formatMinutesSecondsAgo,
6+
} from "./duration";
27

38
describe("parseDuration", () => {
49
it("should parse various duration formats", () => {
@@ -68,3 +73,17 @@ describe("formatTimeDistance", () => {
6873
expect(formatTimeDistance(past, now)).toBe("2h");
6974
});
7075
});
76+
77+
describe("formatMinutesSecondsAgo", () => {
78+
it("should always include both minutes and seconds", () => {
79+
const now = new Date("2021-05-27T05:57:45Z");
80+
expect(formatMinutesSecondsAgo(new Date("2021-05-27T05:57:00Z"), now)).toBe("0m 45s ago");
81+
expect(formatMinutesSecondsAgo(new Date("2021-05-27T05:52:45Z"), now)).toBe("5m 0s ago");
82+
expect(formatMinutesSecondsAgo(new Date("2021-05-27T05:53:12Z"), now)).toBe("4m 33s ago");
83+
});
84+
85+
it("should clamp to zero for times not in the past", () => {
86+
const now = new Date("2021-05-27T05:57:00Z");
87+
expect(formatMinutesSecondsAgo(new Date("2021-05-27T05:58:00Z"), now)).toBe("0m 0s ago");
88+
});
89+
});

src/features/spawn-timers/commands/helpers/duration.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,16 @@ export function formatTimeAgo(time: Date, now: Date = new Date()): string {
129129
return formatTimeDistance(time, now) + " ago";
130130
}
131131

132+
/**
133+
* Format how long ago a time was as "Xm Ys ago", always including both units.
134+
*/
135+
export function formatMinutesSecondsAgo(time: Date, now: Date = new Date()): string {
136+
const diffSeconds = Math.floor(Math.max(0, now.getTime() - time.getTime()) / 1000);
137+
const minutes = Math.floor(diffSeconds / 60);
138+
const seconds = diffSeconds % 60;
139+
return `${minutes}m ${seconds}s ago`;
140+
}
141+
132142
/**
133143
* Format a Date as a Discord Hammertime timestamp.
134144
* Default style is relative (R), which renders dynamically in Discord
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { buildTodAdapter } from "./prefix-adapter";
2+
3+
function fakeMessage(content: string): any {
4+
return { content, author: { id: "1", username: "tester" } };
5+
}
6+
7+
describe("buildTodAdapter", () => {
8+
it("should extract a trailing #<n> as skip_count", () => {
9+
const adapter = buildTodAdapter(fakeMessage("!tod lodi 7/26 04:04:10#1"));
10+
expect(adapter.options.getString("mob")).toBe("lodi");
11+
expect(adapter.options.getString("time")).toBe("7/26 04:04:10");
12+
expect(adapter.options.getInteger("skip_count")).toBe(1);
13+
});
14+
15+
it("should extract #<n> when separated by a space", () => {
16+
const adapter = buildTodAdapter(fakeMessage("!tod lodi 7/26 04:04:10 #2"));
17+
expect(adapter.options.getString("mob")).toBe("lodi");
18+
expect(adapter.options.getString("time")).toBe("7/26 04:04:10");
19+
expect(adapter.options.getInteger("skip_count")).toBe(2);
20+
});
21+
22+
it("should extract #<n> from the negative-minutes shorthand", () => {
23+
const adapter = buildTodAdapter(fakeMessage("!tod lodi -20#1"));
24+
expect(adapter.options.getString("mob")).toBe("lodi");
25+
expect(adapter.options.getString("time")).toBe("-20");
26+
expect(adapter.options.getInteger("skip_count")).toBe(1);
27+
});
28+
29+
it("should extract #<n> when no time is given", () => {
30+
const adapter = buildTodAdapter(fakeMessage("!tod lodi #1"));
31+
expect(adapter.options.getString("mob")).toBe("lodi");
32+
expect(adapter.options.getString("time")).toBe(null);
33+
expect(adapter.options.getInteger("skip_count")).toBe(1);
34+
});
35+
36+
it("should default skip_count to null when no #<n> is present", () => {
37+
const adapter = buildTodAdapter(fakeMessage("!tod lodi 7/26 04:04:10"));
38+
expect(adapter.options.getString("mob")).toBe("lodi");
39+
expect(adapter.options.getString("time")).toBe("7/26 04:04:10");
40+
expect(adapter.options.getInteger("skip_count")).toBe(null);
41+
});
42+
});

src/features/spawn-timers/commands/prefix-adapter.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,21 @@ export class PrefixInteractionAdapter {
4848
}
4949

5050
export function buildTodAdapter(message: Message): PrefixInteractionAdapter {
51-
const raw = message.content.slice(4).trim(); // strip "!tod"
51+
let raw = message.content.slice(4).trim(); // strip "!tod"
52+
53+
// A trailing "#<n>" indicates a skip count, e.g. "lodi 7/26 04:04:10#1"
54+
let skipCount: number | null = null;
55+
const skipMatch = raw.match(/#(\d+)\s*$/);
56+
if (skipMatch) {
57+
skipCount = parseInt(skipMatch[1], 10);
58+
raw = raw.slice(0, skipMatch.index).trim();
59+
}
60+
5261
const [mob, time] = parseArguments(raw);
5362
const args = new Map<string, string | number | null>([
5463
["mob", mob],
5564
["time", time],
56-
["skip_count", null],
65+
["skip_count", skipCount],
5766
]);
5867
return new PrefixInteractionAdapter(message, args);
5968
}

src/features/spawn-timers/commands/register.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ class RegisterCommand extends SimpleCommand {
7575
windowEnd,
7676
variance,
7777
skipCount: 0,
78+
alerted: null,
79+
alertingSoon: false,
7880
},
7981
});
8082
} else {

0 commit comments

Comments
 (0)