From b49c6e8fd4a51900fc70da9dce30cadf6971e448 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 8 Oct 2026 02:19:26 +1300 Subject: [PATCH] Add //time command for m->d scheduling --- docs/developer-orientation.md | 4 +- package-lock.json | 20 ++++ package.json | 2 + src/matrix/matrix-command-handler.js | 137 +++++++++++++++++++++++++-- start.js | 5 + 5 files changed, 159 insertions(+), 9 deletions(-) diff --git a/docs/developer-orientation.md b/docs/developer-orientation.md index 839b4af..27ddb7d 100644 --- a/docs/developer-orientation.md +++ b/docs/developer-orientation.md @@ -89,7 +89,7 @@ Whether you read those or not, I'm more than happy to help you 1-on-1 with codin # Dependency justification -Total transitive production dependencies: 112 +Total transitive production dependencies: 114 ### 🦕 @@ -108,6 +108,7 @@ Total transitive production dependencies: 112 * (0) @cloudrac3r/mixin-deep: This is my fork. (It fixes a bug in regular mixin-deep.) * (0) @cloudrac3r/pngjs: Lottie stickers are converted to bitmaps with the vendored Rlottie WASM build, then the bitmaps are converted to PNG with pngjs. * (0) @cloudrac3r/stream-type: Determine type of Matrix files that don't specify it in info. Switched from stream-mime-type to this. +* (0) @cloudrac3r/tell-me-when: Parse English natural language dates and times for the m->d //time command. * (0) @cloudrac3r/turndown: This HTML-to-Markdown converter looked the most suitable. I forked it to change the escaping logic to match the way Discord works. * (3) @stackoverflow/stacks: Stack Overflow design language and icons. * (0) ansi-colors: Helps with interactive prompting for the initial setup, and it's already pulled in by enquirer. @@ -125,6 +126,7 @@ Total transitive production dependencies: 112 * (1) mime-types: List of mime type mappings. Needed to serve static files. * (0) prettier-bytes: It does what I want and has no dependencies. * (0) snowtransfer: Discord API library with bring-your-own-caching that I trust. +* (0) temporal-polyfill-lite: Needed for the //time command. The Temporal API has only been stabilised as of Node.js v26. * (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well. * (0) uqr: QR code SVG generator. Used on the website to scan in an invite link. * (0) xxhash-wasm: Used where cryptographically secure hashing is not required. diff --git a/package-lock.json b/package-lock.json index 32e7e88..5a60f1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/stream-type": "^1.0.0", + "@cloudrac3r/tell-me-when": "^2.0.0", "@cloudrac3r/turndown": "^7.1.5", "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", @@ -39,6 +40,7 @@ "prettier-bytes": "^1.0.4", "sharp": "^0.35.3", "snowtransfer": "^0.19.0", + "temporal-polyfill-lite": "^0.4.3", "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", @@ -280,6 +282,18 @@ "node": ">=8.0.0" } }, + "node_modules/@cloudrac3r/tell-me-when": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@cloudrac3r/tell-me-when/-/tell-me-when-2.0.0.tgz", + "integrity": "sha512-U+TEOTe6f/vSIeD/QP0VPrkLrhnFtQs2euxBOAaqC8rzQKu8nNoKSFuhipT6DyuCZH48B58+qrPMmH2w2IgMhA==", + "license": "MIT", + "bin": { + "tell-me-when": "dist/src/cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@cloudrac3r/turndown": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.5.tgz", @@ -2516,6 +2530,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/temporal-polyfill-lite": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/temporal-polyfill-lite/-/temporal-polyfill-lite-0.4.3.tgz", + "integrity": "sha512-Lw/de9tydKmrU0icQy6Z+abX5mzDk4iqyt6Xp2iDDxY6SGISiuhuP3TcfWlYveM1FN1I7FjT5nG+Kh9eCiSnVg==", + "license": "MIT" + }, "node_modules/test-exclude": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", diff --git a/package.json b/package.json index 72004cf..4c9de0e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@cloudrac3r/pngjs": "^7.0.3", "@cloudrac3r/pug": "^4.0.4", "@cloudrac3r/stream-type": "^1.0.0", + "@cloudrac3r/tell-me-when": "^2.0.0", "@cloudrac3r/turndown": "^7.1.5", "@stackoverflow/stacks": "^2.5.4", "@stackoverflow/stacks-icons": "^6.0.2", @@ -48,6 +49,7 @@ "prettier-bytes": "^1.0.4", "sharp": "^0.35.3", "snowtransfer": "^0.19.0", + "temporal-polyfill-lite": "^0.4.3", "try-to-catch": "^4.0.5", "uqr": "^0.1.2", "xxhash-wasm": "^1.0.2", diff --git a/src/matrix/matrix-command-handler.js b/src/matrix/matrix-command-handler.js index aa80b9c..de81899 100644 --- a/src/matrix/matrix-command-handler.js +++ b/src/matrix/matrix-command-handler.js @@ -3,8 +3,9 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") const Ty = require("../types") -const {pipeline} = require("stream").promises const sharp = require("sharp") +const tw = require("@cloudrac3r/tell-me-when") +const {tag} = require("@cloudrac3r/html-template-tag") const {discord, sync, db, select} = require("../passthrough") /** @type {import("./api")}) */ @@ -13,8 +14,9 @@ const api = sync.require("./api") const mxUtils = sync.require("./utils") /** @type {import("../discord/utils")} */ const dUtils = sync.require("../discord/utils") -/** @type {import("./kstate")} */ -const ks = sync.require("./kstate") +/** @type {import("../d2m/actions/retrigger")} */ +const retrigger = sync.require("../d2m/actions/retrigger") + const {reg} = require("./read-registration") const PREFIXES = ["//", "/"] @@ -33,7 +35,17 @@ function getSlotCount(tier) { return TIER_EMOJI_SLOTS.get(tier) || 50 } -let buttons = [] +/** + * @typedef Button + * @property {string} roomID + * @property {string} eventID + * @property {string} mxid + * @property {string} key + * @property {(any) => any} resolve + * @property {number} created + */ + +const buttonStore = sync.remember(() => /** @type {{buttons: Button[]}} */ ({buttons: []})) /** * @param {string} roomID where to add the button @@ -51,22 +63,23 @@ async function addButton(roomID, eventID, key, mxid) { } }) return new Promise(resolve => { - buttons.push({roomID, eventID, mxid, key, resolve, created: Date.now()}) + buttonStore.buttons.push({roomID, eventID, mxid, key, resolve, created: Date.now()}) }) } // Clear out old buttons every so often to free memory setInterval(() => { const now = Date.now() - buttons = buttons.filter(b => now - b.created < 2*60*60*1000) + buttonStore.buttons = buttonStore.buttons.filter(b => now - b.created < 2*60*60*1000) }, 10*60*1000).unref() /** @param {Ty.Event.Outer} event */ function onReactionAdd(event) { - const button = buttons.find(b => b.roomID === event.room_id && b.mxid === event.sender && b.eventID === event.content["m.relates_to"]?.event_id && b.key === event.content["m.relates_to"]?.key) + const button = buttonStore.buttons.find(b => b.roomID === event.room_id && b.mxid === event.sender && b.eventID === event.content["m.relates_to"]?.event_id && b.key === event.content["m.relates_to"]?.key) if (button) { - buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again + buttonStore.buttons = buttonStore.buttons.filter(b => b !== button) // remove button data so it can't be clicked again button.resolve(event) + return true } } @@ -318,6 +331,114 @@ const commands = [{ }) } ) +}, { + aliases: ["time"], + execute: replyctx( + async (event, realBody, words, ctx) => { + // Guard + /** @type {string} */ // @ts-ignore + const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() + const channel = discord.channels.get(channelID) + const guildID = channel?.["guild_id"] + if (!guildID) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "This room isn't bridged to the other side." + }) + } + + // Get user timezone + const profile = await api.getProfile(event.sender) + const senderTimeZone = profile?.["m.tz"] + if (!senderTimeZone) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "This command converts a time in your local time zone for everybody else. But I don't know your time zone. Please set your time zone (hopefully possible in your client's settings/account menu) and try again." + }) + } + + try { + var now = Temporal.Now.zonedDateTimeISO(senderTimeZone) + } catch (e) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + ...new mxUtils.MatrixStringBuilder().add( + `The time zone on your account, "${senderTimeZone}", is not valid. It needs to be an IANA time zone value, with a slash. See yours here: https://time.is/your_time_zone`, + tag`The time zone on your account, ${senderTimeZone}, is not valid. It needs to be an IANA time zone value with a slash. See yours here.` + ).get() + }) + } + + const input = words.slice(1).join(" ").trim() + if (!input) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "This command converts a time in your local time zone for everybody else. Use this command with a time or date (English natural language)." + }) + } + + try { + var parsed = tw.parse(input) + var when = tw.tellMeWhen(input, {now}) + } catch (e) { + return api.sendEvent(event.room_id, "m.room.message", { + ...ctx, + msgtype: "m.text", + body: "I didn't understand your input. If you think your input was reasonable, please report this as a bug." + }) + } + + const messageID = select("event_message", "message_id", {event_id: event.event_id}).pluck().get() + + const hasDate = parsed.some(x => Array.isArray(x) && x[0].match(/year|month|week|date|day/i)) + const hasTime = parsed.some(x => Array.isArray(x) && x[0].match(/hour|minute|second/i)) + const colonStyle = + ( hasDate && hasTime ? ":F" + : hasDate ? ":D" + : ":t") + + /** @param {Temporal.ZonedDateTime} z */ + function convertOne(z) { + return "" + } + + if (Array.isArray(when)) { // convert range + var content = `From ${convertOne(when[0])} to ${convertOne(when[1])}` + } else { // convert single time + var content = `${convertOne(when)}` + } + + /** @type {DiscordTypes.RESTPostAPIChannelMessageJSONBody} */ + const message = { + flags: DiscordTypes.MessageFlags.IsComponentsV2, + components: [{ + type: DiscordTypes.ComponentType.Container, + components: [{ + type: DiscordTypes.ComponentType.TextDisplay, + content: `## ${content}\n-# Wrong? 🗑️ to delete` + }] + }] + } + if (messageID) { + Object.assign(message, { + message_reference: { + type: DiscordTypes.MessageReferenceType.Default, + message_id: messageID, + } + }) + } + const sent = await discord.snow.channel.createMessage(channelID, message) + if (!await retrigger.waitForMessage(sent.id)) return + const sentEventID = select("event_message", "event_id", {message_id: sent.id}, "ORDER BY reaction_part").pluck().get() + assert(sentEventID) + addButton(event.room_id, sentEventID, "🗑️", event.sender).then(() => { + discord.snow.channel.deleteMessage(channelID, sent.id).catch(() => {}) + }) + } + ) }] diff --git a/start.js b/start.js index 2ab79cb..728c8af 100755 --- a/start.js +++ b/start.js @@ -1,6 +1,11 @@ #!/usr/bin/env node // @ts-check +// Temporal API stabilised in Node.js 26. Previous versions can use `--harmony-temporal`, but that's jank, so have a polyfill. +if (!("Temporal" in globalThis)) { + require("temporal-polyfill-lite/global") +} + const fs = require("fs") const sqlite = require("better-sqlite3") const migrate = require("./src/db/migrate")