posts "question of the day" to a discord webhook

Update README.md

+233 -113
+34 -3
README.md
··· 1 - # Discord Question of the Day delivered via webhook 1 + # Discord Question of the Day 2 + 3 + A Deno cron job that fetches questions from a Google Sheets endpoint and posts them sequentially to Discord via webhook. Uses Deno KV for state management to track question progression. 4 + 5 + ## Setup 6 + 7 + 0. Install [Deno](https://deno.com/), `git clone` the project and run `deno install` to install dependencies. 8 + 1. Create `.env` file for local development and fill in your values (see below) 9 + 2. Deploy to Deno Deploy or run locally 10 + 11 + ## Commands 12 + 13 + ```bash 14 + # Local testing (with optional date/index override) 15 + deno task dev 16 + deno task dev --d="2025-10-25" --i="12" 17 + 18 + # Run locally with cron 19 + deno task run 20 + 21 + # Deploy to Deno Deploy 22 + deno task deploy 23 + ``` 2 24 3 - This project fetches a JSON-ified list of questions from an endpoint, a Google Sheet in our case, and runs a cron job that will successively post the questions to a Discord webhook. 25 + ## Necessary Environment Variables 4 26 5 - Idea by @meylemonade.bsky.social 27 + - `SHEET_ENDPOINT` - JSON endpoint URL (Google Sheets, etc.) 28 + - `CRON_STRING` - Cron schedule expression 29 + - `DISCORD_WEBHOOK_URL` - Discord webhook URL 30 + - `SUGGESTION_FORM_URL` - Form to suggest new questions 31 + - `DENO_KV_DATABASE_ID` - Deno Deploy KV database ID (for local testing) 32 + - `DENO_KV_ACCESS_TOKEN` - Deno Deploy KV access token (for local testing) 33 + 34 + --- 35 + 36 + *Idea by [@meylemonade.bsky.social](https://bsky.app/profile/meylemonade.bsky.social)*
+15 -11
deno.json
··· 1 1 { 2 - "tasks": { 3 - "dev": "deno run --watch main.ts", 4 - "run": "deno run --unstable-cron --unstable-kv --allow-all main.ts" 5 - }, 6 - "imports": { 7 - "@std/assert": "jsr:@std/assert@1" 8 - }, 9 - "fmt": { 10 - "useTabs": true, 11 - "indentWidth": 4 12 - } 2 + "tasks": { 3 + "dev": "deno run --unstable-kv --allow-net --allow-read --allow-env dev.ts", 4 + "run": "deno run --unstable-cron --unstable-kv --allow-net --allow-read --allow-env main.ts", 5 + "deploy": "deployctl deploy --project=discord-qotd-webhook --entrypoint=main.ts --prod" 6 + }, 7 + "imports": { 8 + "@std/assert": "jsr:@std/assert@1", 9 + "@std/cli": "jsr:@std/cli@^1.0.22", 10 + "@std/dotenv": "jsr:@std/dotenv@^0.225.5" 11 + }, 12 + "fmt": { 13 + "useTabs": true, 14 + "indentWidth": 4 15 + }, 16 + "unstable": ["temporal"] 13 17 }
+24 -3
deno.lock
··· 1 1 { 2 - "version": "4", 2 + "version": "5", 3 3 "specifiers": { 4 - "jsr:@std/dotenv@*": "0.225.2" 4 + "jsr:@std/assert@1": "1.0.14", 5 + "jsr:@std/cli@^1.0.22": "1.0.22", 6 + "jsr:@std/dotenv@*": "0.225.2", 7 + "jsr:@std/dotenv@~0.225.5": "0.225.5", 8 + "jsr:@std/internal@^1.0.10": "1.0.10" 5 9 }, 6 10 "jsr": { 11 + "@std/assert@1.0.14": { 12 + "integrity": "68d0d4a43b365abc927f45a9b85c639ea18a9fab96ad92281e493e4ed84abaa4", 13 + "dependencies": [ 14 + "jsr:@std/internal" 15 + ] 16 + }, 17 + "@std/cli@1.0.22": { 18 + "integrity": "50d1e4f87887cb8a8afa29b88505ab5081188f5cad3985460c3b471fa49ff21a" 19 + }, 7 20 "@std/dotenv@0.225.2": { 8 21 "integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23" 22 + }, 23 + "@std/dotenv@0.225.5": { 24 + "integrity": "9ce6f9d0ec3311f74a32535aa1b8c62ed88b1ab91b7f0815797d77a6f60c922f" 25 + }, 26 + "@std/internal@1.0.10": { 27 + "integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7" 9 28 } 10 29 }, 11 30 "workspace": { 12 31 "dependencies": [ 13 - "jsr:@std/assert@1" 32 + "jsr:@std/assert@1", 33 + "jsr:@std/cli@^1.0.22", 34 + "jsr:@std/dotenv@~0.225.5" 14 35 ] 15 36 } 16 37 }
+79
dev.ts
··· 1 + /* 2 + This file is for local testing. Run `deno task dev` to use. 3 + The command will accept optional the input parameters `date` 4 + and `index`. Example usage: 5 + 6 + deno task dev --i="12" 7 + (will fetch today's theme, but question of index 12) 8 + 9 + deno task dev --d="2025-10-25" 10 + (will fetch the theme for October 25, but the current index) 11 + 12 + You will need to set the DENO_KV_DATABASE_ID and 13 + DENO_KV_ACCESS_TOKEN environment variables if you are using 14 + Deno Deploy. 15 + */ 16 + 17 + import { sendDiscordNotification } from "./utils/discordUtils.ts"; 18 + import { getTheme } from "./utils/themes.ts"; 19 + import { parseArgs } from "@std/cli"; 20 + import { load } from "@std/dotenv"; 21 + 22 + await load({ export: true }); 23 + 24 + const args = parseArgs(Deno.args, { 25 + string: ["date", "index"], 26 + alias: { 27 + d: "date", 28 + i: "index", 29 + }, 30 + default: { 31 + date: undefined, 32 + index: undefined, 33 + }, 34 + }); 35 + 36 + const endpoint = Deno.env.get("SHEET_ENDPOINT"); 37 + if (!endpoint) { 38 + throw new Error("SHEET_ENDPOINT environment variable is not set"); 39 + } 40 + 41 + let date: Temporal.PlainDate | undefined; 42 + if (args.date) { 43 + try { 44 + date = Temporal.PlainDate.from(args.date); 45 + } catch (_error) { 46 + throw new Error( 47 + `Invalid date format: ${args.date}. Use format "YYYY-MM-DD"`, 48 + ); 49 + } 50 + } 51 + 52 + const { themeName, theme } = getTheme(date); 53 + 54 + const response = await fetch(endpoint); 55 + const data = await response.json(); 56 + const deck = data[themeName]; 57 + 58 + let index: number; 59 + if (args.index) { 60 + index = +args.index; 61 + } else { 62 + // @ts-ignore Deno.openKv is unstable, run with --unstable-kv flag 63 + const kv = await Deno.openKv( 64 + `https://api.deno.com/databases/${ 65 + Deno.env.get("DENO_KV_DATABASE_ID") 66 + }/connect`, 67 + ); 68 + const current = await kv.get<number>(theme.kvKey); 69 + index = current.value ?? 0; 70 + } 71 + 72 + await sendDiscordNotification( 73 + deck[index].question, 74 + deck[index].by, 75 + themeName, 76 + ); 77 + console.log( 78 + `Dev test: posted ${themeName} question of index ${index} to Discord.`, 79 + );
+9 -57
main.ts
··· 1 1 import { sendDiscordNotification } from "./utils/discordUtils.ts"; 2 - import { load } from "jsr:@std/dotenv"; 2 + import { getTheme } from "./utils/themes.ts"; 3 + import { load } from "@std/dotenv"; 3 4 4 5 await load({ export: true }); 5 6 ··· 8 9 throw new Error("SHEET_ENDPOINT environment variable is not set"); 9 10 } 10 11 11 - function isDateInRange( 12 - date: Date, 13 - startMonth: number, 14 - startDay: number, 15 - endMonth: number, 16 - endDay: number, 17 - ): boolean { 18 - const month = date.getMonth() + 1; // getMonth() returns 0-11 19 - const day = date.getDate(); 20 - 21 - // Handle ranges that cross year boundary (e.g., Dec 15 - Jan 15) 22 - if (startMonth > endMonth) { 23 - return (month > startMonth || 24 - (month === startMonth && day >= startDay)) || 25 - (month < endMonth || (month === endMonth && day <= endDay)); 26 - } 27 - 28 - // Handle ranges within same year 29 - if (month < startMonth || month > endMonth) return false; 30 - if (month === startMonth && day < startDay) return false; 31 - if (month === endMonth && day > endDay) return false; 32 - 33 - return true; 34 - } 35 - 36 - function getCurrentSeason(): { 37 - name: string; 38 - kvKey: string[]; 39 - } { 40 - const now = new Date(); 41 - 42 - // Halloween: October 1-31 43 - if (isDateInRange(now, 10, 1, 10, 31)) { 44 - return { 45 - name: "halloween", 46 - kvKey: ["halloweenIndex"], 47 - }; 48 - } 49 - 50 - return { 51 - name: "default", 52 - kvKey: ["lastIndex"], 53 - }; 54 - } 55 - 56 12 // @ts-ignore Deno.cron is unstable, run with --unstable-cron flag 57 13 Deno.cron("QOTD", Deno.env.get("CRON_STRING"), async () => { 58 - const season = getCurrentSeason(); 14 + const { themeName, theme } = getTheme(); 59 15 60 16 // Fetch data 61 17 const response = await fetch(endpoint); 62 18 const data = await response.json(); 63 - const deck = data[season.name]; 19 + const deck = data[themeName]; 64 20 65 21 // Open KV and fetch last index 66 22 // @ts-ignore Deno.openKv is unstable, run with --unstable-kv flag 67 - const kv = await Deno.openKv( 68 - // `https://api.deno.com/databases/${ 69 - // Deno.env.get("DENO_KV_DATABASE_ID") 70 - // }/connect`, 71 - ); 72 - const current = await kv.get<number>(season.kvKey); 23 + const kv = await Deno.openKv(); 24 + const current = await kv.get<number>(theme.kvKey); 73 25 const index = current.value ?? 0; 74 26 const nextIndex = (index + 1) % deck.length; 75 27 76 28 // Perform atomic operation to update index 77 29 const result = await kv.atomic() 78 30 .check(current) // Ensure the value hasn't changed 79 - .set(season.kvKey, nextIndex) 31 + .set(theme.kvKey, nextIndex) 80 32 .commit(); 81 33 82 34 if (!result.ok) { ··· 88 40 await sendDiscordNotification( 89 41 deck[index].question, 90 42 deck[index].by, 91 - season.name, 43 + themeName, 92 44 ); 93 45 console.log( 94 - `Cron: posted ${season.name} question of index ${index} to Discord.`, 46 + `Cron: posted ${themeName} question of index ${index} to Discord.`, 95 47 ); 96 48 });
+3 -12
utils/discordUtils.ts
··· 1 - import { defaultTheme, halloweenTheme } from "./themes.ts"; 2 - import { load } from "jsr:@std/dotenv"; 1 + import { themes } from "./themes.ts"; 2 + import { load } from "@std/dotenv"; 3 3 4 4 await load({ export: true }); 5 5 6 - function getThemeForSeason(seasonName: string) { 7 - switch (seasonName) { 8 - case "halloween": 9 - return halloweenTheme; 10 - default: 11 - return defaultTheme; 12 - } 13 - } 14 - 15 6 export async function sendDiscordNotification( 16 7 question: string, 17 8 by?: string, 18 9 season: string = "default", 19 10 ) { 20 - const theme = getThemeForSeason(season); 11 + const theme = themes[season] ?? themes.default; 21 12 22 13 const embed1 = { 23 14 color: theme.color,
+69 -27
utils/themes.ts
··· 1 - import { load } from "jsr:@std/dotenv"; 2 - 3 - await load({ export: true }); 4 - 5 1 export interface Theme { 2 + // Styling, how the embed looks 6 3 color?: number; 7 4 image?: { url: string }; 8 5 thumbnail?: { url: string }; 9 6 title?: string; 10 7 author?: { name: string; url: string }; 11 8 footer?: { text: string }; 9 + // Date range for seasonal themes 10 + start?: Temporal.PlainMonthDay; 11 + end?: Temporal.PlainMonthDay; 12 + // Deno KV storage key for the theme 13 + kvKey: string[]; 12 14 } 13 15 14 - export const defaultTheme: Theme = { 15 - color: 0xFEF250, // lemon yellow 16 - image: { url: Deno.env.get("IMAGE_URL")! }, 17 - thumbnail: { url: Deno.env.get("THUMBNAIL_URL")! }, 18 - title: `· · ─ · 𝒒𝒖𝒆𝒔𝒕𝒊𝒐𝒏 𝒐𝒇 𝒕𝒉𝒆 𝒅𝒂𝒚 · ─ · ·`, 19 - author: { 20 - name: ".•° ✿ °•. punchy .•° ✿ °•.", 21 - url: "https://tangled.sh/@timtinkers.online/discord-qotd-webhook", 16 + export const themes: Record<string, Theme> = { 17 + default: { 18 + color: 0xFEF250, // lemon yellow 19 + image: { 20 + url: "https://i.pinimg.com/736x/86/43/cf/8643cf6d02b03cf426bf5c0020cd215b.jpg", 21 + }, 22 + thumbnail: { 23 + url: "https://media.discordapp.net/stickers/1399014806014398616.webp", 24 + }, 25 + title: `· · ─ · 𝒒𝒖𝒆𝒔𝒕𝒊𝒐𝒏 𝒐𝒇 𝒕𝒉𝒆 𝒅𝒂𝒚 · ─ · ·`, 26 + author: { 27 + name: ".•° ✿ °•. punchy .•° ✿ °•.", 28 + url: "https://tangled.sh/@timtinkers.online/discord-qotd-webhook", 29 + }, 30 + footer: { text: `𐔌՞. .՞𐦯` }, 31 + kvKey: ["defaultIndex"], 22 32 }, 23 - footer: { text: `𐔌՞. .՞𐦯` }, 24 - }; 25 33 26 - export const halloweenTheme: Theme = { 27 - color: 0xF25C05, // pumpkin orange 28 - image: { 29 - url: "https://media.discordapp.net/attachments/1279476809653686324/1420214040667361321/tenor.gif?ex=68d494e5&is=68d34365&hm=c2cea6d29d7a58afbe732b7a11775b18f156940bc127aff817bbe005c6bbde38&=", 30 - }, 31 - thumbnail: { 32 - url: "https://cdn.discordapp.com/attachments/1279476809653686324/1420213543961366618/jack_o_lantern__png_by_doloresminette_d5g6dbe-375w-2x.png?ex=68d4946f&is=68d342ef&hm=9d88e5dda9a0ae2ad5f4837ab3a519ad6904c1612f623e327dc4aaf1cc392317", 33 - }, 34 - title: `₊˚🕯️♱‧₊˚. 𝖖𝖚𝖊𝖘𝖙𝖎𝖔𝖓 𝖔𝖋 𝖙𝖍𝖊 𝖉𝖆𝖞 .˚₊‧♱🕯️˚₊`, 35 - author: { 36 - name: ".˚⊹. ࣪𓉸 ࣪⊹˚. 𝔭𝔲𝔫𝔠𝔥𝔶 .˚⊹. ࣪𓉸 ࣪⊹˚.", 37 - url: "https://tangled.sh/@timtinkers.online/discord-qotd-webhook", 34 + halloween: { 35 + color: 0xF25C05, // pumpkin orange 36 + image: { 37 + url: "https://media.discordapp.net/attachments/1279476809653686324/1420214040667361321/tenor.gif?ex=68d494e5&is=68d34365&hm=c2cea6d29d7a58afbe732b7a11775b18f156940bc127aff817bbe005c6bbde38&=", 38 + }, 39 + thumbnail: { 40 + url: "https://cdn.discordapp.com/attachments/1279476809653686324/1420213543961366618/jack_o_lantern__png_by_doloresminette_d5g6dbe-375w-2x.png?ex=68d4946f&is=68d342ef&hm=9d88e5dda9a0ae2ad5f4837ab3a519ad6904c1612f623e327dc4aaf1cc392317", 41 + }, 42 + title: `₊˚🕯️♱‧₊˚. 𝖖𝖚𝖊𝖘𝖙𝖎𝖔𝖓 𝖔𝖋 𝖙𝖍𝖊 𝖉𝖆𝖞 .˚₊‧♱🕯️˚₊`, 43 + author: { 44 + name: ".˚⊹. ࣪𓉸 ࣪⊹˚. 𝔭𝔲𝔫𝔠𝔥𝔶 .˚⊹. ࣪𓉸 ࣪⊹˚.", 45 + url: "https://tangled.sh/@timtinkers.online/discord-qotd-webhook", 46 + }, 47 + footer: { text: `⛧°。 ⋆༺♱༻⋆。 °⛧` }, 48 + start: Temporal.PlainMonthDay.from({ month: 10, day: 1 }), 49 + end: Temporal.PlainMonthDay.from({ month: 10, day: 31 }), 50 + kvKey: ["halloweenIndex"], 38 51 }, 39 - footer: { text: `⛧°。 ⋆༺♱༻⋆。 °⛧` }, 52 + // Expand as needed 40 53 }; 54 + 55 + export function getTheme( 56 + date = Temporal.Now.plainDateISO(), 57 + ): { themeName: string; theme: Theme } { 58 + const currentYear = date.year; 59 + 60 + // Check all seasonal themes 61 + for (const [themeName, theme] of Object.entries(themes)) { 62 + if (themeName === "default" || !theme.start || !theme.end) continue; 63 + 64 + // Temporal voodoo to check if the given date is within the interval 65 + const startDate = theme.start.toPlainDate({ year: currentYear }); 66 + const endDate = theme.end.toPlainDate({ year: currentYear }); 67 + 68 + const inRange = Temporal.PlainDate.compare(startDate, endDate) > 0 69 + ? Temporal.PlainDate.compare(date, startDate) >= 0 || 70 + Temporal.PlainDate.compare( 71 + date, 72 + theme.end.toPlainDate({ year: currentYear + 1 }), 73 + ) <= 0 74 + : Temporal.PlainDate.compare(date, startDate) >= 0 && 75 + Temporal.PlainDate.compare(date, endDate) <= 0; 76 + 77 + if (inRange) return { themeName, theme }; 78 + } 79 + 80 + // Default fallback 81 + return { themeName: "default", theme: themes.default }; 82 + }