diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 7e1fa8e..5b446d9 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -1,11 +1,11 @@ -import type { GenericMessageEvent } from "@slack/web-api"; +import type { AppMentionEvent } from "@slack/web-api"; import type { FastifyBaseLogger } from "fastify"; import type { EventsClient } from "../events/events.ts"; import { handleEventsCommand, type EventsCmd } from "./events.ts"; import type { SlackClient } from "../slack/slack.ts"; interface CommandHandler { - handleSlackMessage(msg: GenericMessageEvent, log: FastifyBaseLogger): Promise; + handleBotMention(msg: AppMentionEvent, log: FastifyBaseLogger): Promise; } interface CommandHandlerDeps { @@ -16,8 +16,8 @@ interface CommandHandlerDeps { type Command = EventsCmd; export function newCommandHandler(deps: CommandHandlerDeps): CommandHandler { - function msgToCommand(msg: GenericMessageEvent): Command | null { - const msgSansBot = msg.text?.replace(/^@bot\s+/, ""); + function msgToCommand(msg: AppMentionEvent): Command | null { + const msgSansBot = msg.text?.replace(/^<@.+>\s+/, ""); if (msgSansBot?.startsWith("!events")) { return { type: "events", channel: msg.channel, thread: msg.thread_ts ?? msg.ts }; } @@ -25,10 +25,10 @@ export function newCommandHandler(deps: CommandHandlerDeps): CommandHandler { } return { - async handleSlackMessage(msg: GenericMessageEvent, log: FastifyBaseLogger): Promise { + async handleBotMention(msg: AppMentionEvent, log: FastifyBaseLogger): Promise { const cmd = msgToCommand(msg); if (!cmd) { - log.debug({ text: msg.text }, "command: message did not match any command"); + log.info({ text: msg.text }, "command: message did not match any command"); return; } diff --git a/src/commands/events.ts b/src/commands/events.ts index 27a1d34..cc99786 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -24,7 +24,7 @@ export async function handleEventsCommand({ } log.info({ count: events.length, channel: cmd.channel }, "events: fetched"); - const msg = formatEvents(events); + const msg = formatEvents(trimSeriesEvents(events)); try { await slackClient.sendMessage({ msg, channel: cmd.channel, thread_ts: cmd.thread }); } catch (err) { @@ -34,7 +34,40 @@ export async function handleEventsCommand({ log.info({ channel: cmd.channel }, "events: sent"); } -// TODO: make this formatting better -function formatEvents(events: Event[]): string { - return events.map((e) => e.name).join("\n"); +export function formatEvents(events: Event[]): string { + return events + .map((e) => { + const s = e.startDate.withTimeZone("America/Chicago"); + const en = e.endDate.withTimeZone("America/Chicago"); + return `- ${e.name} (${formatDate(s)}, ${formatTime(s)} - ${formatTime(en)})`; + }) + .join("\n"); +} + +function formatDate(c: Temporal.ZonedDateTime): string { + return `${c.month}/${c.day}`; +} + +function formatTime(c: Temporal.ZonedDateTime): string { + const h = c.hour % 12 || 12; + const ap = c.hour < 12 ? "a" : "p"; + return `${h}:${pad(c.minute)}${ap}`; +} + +function pad(n: number): string { + return String(n).padStart(2, "0"); +} + +export function trimSeriesEvents(events: Event[]): Event[] { + // keep only the first (most recent) event in a given unique series + const seen = new Set(); + return events + .sort((a, b) => Temporal.ZonedDateTime.compare(a.startDate, b.startDate)) + .filter((e) => { + const id = e.series?.id; + if (id === undefined) return true; + if (seen.has(id)) return false; + seen.add(id); + return true; + }); } diff --git a/src/events/events.ts b/src/events/events.ts index f074c06..02f11c1 100644 --- a/src/events/events.ts +++ b/src/events/events.ts @@ -1,17 +1,31 @@ import { getApiUpcoming } from "./openapi/client.ts"; -import { type GetApiUpcomingStatus200 } from "./openapi/types.ts"; -export type Event = GetApiUpcomingStatus200[number]; +export interface Event { + name: string; + startDate: Temporal.ZonedDateTime; + endDate: Temporal.ZonedDateTime; + series?: { + id?: number; + }; +} export interface EventsClient { getUpcomingEvents(): Promise; } export function eventsClientFromOpenAPI(): EventsClient { + const baseURL = "https://events.devict.org"; return { async getUpcomingEvents(): Promise { - const resp = await getApiUpcoming(); - return resp.data; + const resp = await getApiUpcoming({ baseURL }); + + return resp.data.map((e) => { + return { + ...e, + startDate: Temporal.Instant.from(e.startDate).toZonedDateTimeISO("UTC"), + endDate: Temporal.Instant.from(e.endDate).toZonedDateTimeISO("UTC"), + }; + }); }, }; } diff --git a/src/server.ts b/src/server.ts index 18df859..080069b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,4 @@ -import { type SlackEvent, type GenericMessageEvent } from "@slack/web-api"; +import { type SlackEvent } from "@slack/web-api"; import type { JsonSchemaToTsProvider } from "@fastify/type-provider-json-schema-to-ts"; import Fastify, { type FastifyRequest, type FastifyServerOptions } from "fastify"; import type { EventsClient } from "./events/events.ts"; @@ -84,13 +84,11 @@ export function buildServer(deps: ServerDeps, opts?: { logger?: FastifyServerOpt } request.log.info({ eventType: event.type }, "slack webhook: event_callback received"); - if (event.type !== "message") { - request.log.debug({ eventType: event.type }, "slack webhook: ignoring non-message event"); - return reply.code(200).send({}); - } - try { - await commandHandler.handleSlackMessage(event as GenericMessageEvent, request.log); + switch (event.type) { + case "app_mention": + await commandHandler.handleBotMention(event, request.log); + } } catch (err) { request.log.error({ err }, "slack webhook: command handling failed"); return reply.code(200).send({}); diff --git a/src/slack/slack.ts b/src/slack/slack.ts index 8520a1c..55e6779 100644 --- a/src/slack/slack.ts +++ b/src/slack/slack.ts @@ -27,7 +27,7 @@ export function makeSlackClient({ return { async sendMessage({ msg, channel, thread_ts }): Promise { - await client.chat.postMessage({ channel, text: msg, thread_ts }); + await client.chat.postMessage({ channel, markdown_text: msg, thread_ts }); }, // WebClient can't be used here because it's a user-auth'd admin request, not diff --git a/test/events.test.ts b/test/events.test.ts new file mode 100644 index 0000000..0f5bde9 --- /dev/null +++ b/test/events.test.ts @@ -0,0 +1,79 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { formatEvents, trimSeriesEvents } from "../src/commands/events.ts"; +import type { Event } from "../src/events/events.ts"; + +function zdt(iso: string): Temporal.ZonedDateTime { + return Temporal.Instant.from(iso).toZonedDateTimeISO("UTC"); +} + +function ev(name: string, startDate: Temporal.ZonedDateTime, seriesId?: number): Event { + return seriesId === undefined + ? { name, startDate, endDate: startDate } + : { name, startDate, endDate: startDate, series: { id: seriesId } }; +} + +test("trimSeriesEvents: empty input returns empty", () => { + assert.deepEqual(trimSeriesEvents([]), []); +}); + +test("trimSeriesEvents: keeps all events with no series", () => { + const events = [ev("a", zdt("2026-07-11T12:00:00Z")), ev("b", zdt("2026-07-18T12:00:00Z"))]; + assert.deepEqual(trimSeriesEvents(events), events); +}); + +test("trimSeriesEvents: keeps all events when series ids differ", () => { + const events = [ev("a", zdt("2026-07-11T12:00:00Z"), 1), ev("b", zdt("2026-07-18T12:00:00Z"), 2)]; + assert.deepEqual(trimSeriesEvents(events), events); +}); + +test("trimSeriesEvents: keeps only first event of a shared series id", () => { + const first = ev("hack night #1", zdt("2026-07-11T12:00:00Z"), 7); + const second = ev("hack night #2", zdt("2026-07-18T12:00:00Z"), 7); + assert.deepEqual(trimSeriesEvents([first, second]), [first]); +}); + +test("trimSeriesEvents: keeps non-series events alongside trimmed series", () => { + const oneOff = ev("lunch", zdt("2026-07-12T12:00:00Z")); + const first = ev("hack night #1", zdt("2026-07-11T12:00:00Z"), 7); + const second = ev("hack night #2", zdt("2026-07-18T12:00:00Z"), 7); + assert.deepEqual(trimSeriesEvents([first, oneOff, second]), [first, oneOff]); +}); + +test("trimSeriesEvents: events with series but no id are all kept", () => { + const a = { name: "a", startDate: zdt("2026-07-11T12:00:00Z"), endDate: zdt("2026-07-11T12:00:00Z"), series: {} }; + const b = { name: "b", startDate: zdt("2026-07-18T12:00:00Z"), endDate: zdt("2026-07-18T12:00:00Z"), series: {} }; + assert.deepEqual(trimSeriesEvents([a, b]), [a, b]); +}); + +test("trimSeriesEvents: sorts events by startDate ascending", () => { + const later = ev("later", zdt("2026-07-25T12:00:00Z")); + const earlier = ev("earlier", zdt("2026-07-11T12:00:00Z")); + const middle = ev("middle", zdt("2026-07-18T12:00:00Z")); + assert.deepEqual(trimSeriesEvents([later, earlier, middle]), [earlier, middle, later]); +}); + +test("trimSeriesEvents: keeps earliest occurrence of a shared series id", () => { + const later = ev("hack night #2", zdt("2026-07-25T12:00:00Z"), 7); + const earlier = ev("hack night #1", zdt("2026-07-11T12:00:00Z"), 7); + assert.deepEqual(trimSeriesEvents([later, earlier]), [earlier]); +}); + +test("formatEvents: formats name and Chicago-localized date/time", () => { + // 2026-07-16T12:00:00Z..13:00:00Z -> 07/16 07:00a - 08:00a America/Chicago + const events = [{ name: "Code & Coffee", startDate: zdt("2026-07-16T12:00:00Z"), endDate: zdt("2026-07-16T13:00:00Z") }]; + assert.equal(formatEvents(events), "- Code & Coffee (07/16 07:00a - 08:00a)"); +}); + +test("formatEvents: handles midnight and noon in America/Chicago", () => { + // 2026-07-16T00:00:00Z..01:00:00Z -> 07/15 07:00p - 08:00p (previous day, CDT) + // 2026-07-16T18:00:00Z..19:00:00Z -> 07/16 01:00p - 02:00p + const events = [ + { name: "midnight UTC", startDate: zdt("2026-07-16T00:00:00Z"), endDate: zdt("2026-07-16T01:00:00Z") }, + { name: "noon-ish UTC", startDate: zdt("2026-07-16T18:00:00Z"), endDate: zdt("2026-07-16T19:00:00Z") }, + ]; + assert.equal( + formatEvents(events), + "- midnight UTC (07/15 07:00p - 08:00p)\n- noon-ish UTC (07/16 01:00p - 02:00p)", + ); +}); diff --git a/test/server.test.ts b/test/server.test.ts index 1334418..903c61b 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -22,8 +22,8 @@ function fakes(): { eventsClient: EventsClient; slackClient: SlackClient } { name: "devict hack night", slug: "devict-hack-night", location: "devict", - startDate: "2026-07-11T00:00:00.000Z", - endDate: "2026-07-11T01:00:00.000Z", + startDate: Temporal.Instant.from("2026-07-11T00:00:00.000Z").toZonedDateTimeISO("UTC"), + endDate: Temporal.Instant.from("2026-07-11T01:00:00.000Z").toZonedDateTimeISO("UTC"), }, ]; },