Add //time command for m->d scheduling
This commit is contained in:
@@ -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
|
# Dependency justification
|
||||||
|
|
||||||
Total transitive production dependencies: 112
|
Total transitive production dependencies: 114
|
||||||
|
|
||||||
### <font size="+2">🦕</font>
|
### <font size="+2">🦕</font>
|
||||||
|
|
||||||
@@ -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/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/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/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.
|
* (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.
|
* (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.
|
* (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.
|
* (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) 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) 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) 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) 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.
|
* (0) xxhash-wasm: Used where cryptographically secure hashing is not required.
|
||||||
|
|||||||
Generated
+20
@@ -18,6 +18,7 @@
|
|||||||
"@cloudrac3r/pngjs": "^7.0.3",
|
"@cloudrac3r/pngjs": "^7.0.3",
|
||||||
"@cloudrac3r/pug": "^4.0.4",
|
"@cloudrac3r/pug": "^4.0.4",
|
||||||
"@cloudrac3r/stream-type": "^1.0.0",
|
"@cloudrac3r/stream-type": "^1.0.0",
|
||||||
|
"@cloudrac3r/tell-me-when": "^2.0.0",
|
||||||
"@cloudrac3r/turndown": "^7.1.5",
|
"@cloudrac3r/turndown": "^7.1.5",
|
||||||
"@stackoverflow/stacks": "^2.5.4",
|
"@stackoverflow/stacks": "^2.5.4",
|
||||||
"@stackoverflow/stacks-icons": "^6.0.2",
|
"@stackoverflow/stacks-icons": "^6.0.2",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"prettier-bytes": "^1.0.4",
|
"prettier-bytes": "^1.0.4",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"snowtransfer": "^0.19.0",
|
"snowtransfer": "^0.19.0",
|
||||||
|
"temporal-polyfill-lite": "^0.4.3",
|
||||||
"try-to-catch": "^4.0.5",
|
"try-to-catch": "^4.0.5",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "^0.1.2",
|
||||||
"xxhash-wasm": "^1.0.2",
|
"xxhash-wasm": "^1.0.2",
|
||||||
@@ -280,6 +282,18 @@
|
|||||||
"node": ">=8.0.0"
|
"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": {
|
"node_modules/@cloudrac3r/turndown": {
|
||||||
"version": "7.1.5",
|
"version": "7.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@cloudrac3r/turndown/-/turndown-7.1.5.tgz",
|
||||||
@@ -2516,6 +2530,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/test-exclude": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"@cloudrac3r/pngjs": "^7.0.3",
|
"@cloudrac3r/pngjs": "^7.0.3",
|
||||||
"@cloudrac3r/pug": "^4.0.4",
|
"@cloudrac3r/pug": "^4.0.4",
|
||||||
"@cloudrac3r/stream-type": "^1.0.0",
|
"@cloudrac3r/stream-type": "^1.0.0",
|
||||||
|
"@cloudrac3r/tell-me-when": "^2.0.0",
|
||||||
"@cloudrac3r/turndown": "^7.1.5",
|
"@cloudrac3r/turndown": "^7.1.5",
|
||||||
"@stackoverflow/stacks": "^2.5.4",
|
"@stackoverflow/stacks": "^2.5.4",
|
||||||
"@stackoverflow/stacks-icons": "^6.0.2",
|
"@stackoverflow/stacks-icons": "^6.0.2",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"prettier-bytes": "^1.0.4",
|
"prettier-bytes": "^1.0.4",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"snowtransfer": "^0.19.0",
|
"snowtransfer": "^0.19.0",
|
||||||
|
"temporal-polyfill-lite": "^0.4.3",
|
||||||
"try-to-catch": "^4.0.5",
|
"try-to-catch": "^4.0.5",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "^0.1.2",
|
||||||
"xxhash-wasm": "^1.0.2",
|
"xxhash-wasm": "^1.0.2",
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
const assert = require("assert").strict
|
const assert = require("assert").strict
|
||||||
const DiscordTypes = require("discord-api-types/v10")
|
const DiscordTypes = require("discord-api-types/v10")
|
||||||
const Ty = require("../types")
|
const Ty = require("../types")
|
||||||
const {pipeline} = require("stream").promises
|
|
||||||
const sharp = require("sharp")
|
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")
|
const {discord, sync, db, select} = require("../passthrough")
|
||||||
/** @type {import("./api")}) */
|
/** @type {import("./api")}) */
|
||||||
@@ -13,8 +14,9 @@ const api = sync.require("./api")
|
|||||||
const mxUtils = sync.require("./utils")
|
const mxUtils = sync.require("./utils")
|
||||||
/** @type {import("../discord/utils")} */
|
/** @type {import("../discord/utils")} */
|
||||||
const dUtils = sync.require("../discord/utils")
|
const dUtils = sync.require("../discord/utils")
|
||||||
/** @type {import("./kstate")} */
|
/** @type {import("../d2m/actions/retrigger")} */
|
||||||
const ks = sync.require("./kstate")
|
const retrigger = sync.require("../d2m/actions/retrigger")
|
||||||
|
|
||||||
const {reg} = require("./read-registration")
|
const {reg} = require("./read-registration")
|
||||||
|
|
||||||
const PREFIXES = ["//", "/"]
|
const PREFIXES = ["//", "/"]
|
||||||
@@ -33,7 +35,17 @@ function getSlotCount(tier) {
|
|||||||
return TIER_EMOJI_SLOTS.get(tier) || 50
|
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
|
* @param {string} roomID where to add the button
|
||||||
@@ -51,22 +63,23 @@ async function addButton(roomID, eventID, key, mxid) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
return new Promise(resolve => {
|
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
|
// Clear out old buttons every so often to free memory
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now()
|
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()
|
}, 10*60*1000).unref()
|
||||||
|
|
||||||
/** @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event */
|
/** @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event */
|
||||||
function onReactionAdd(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) {
|
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)
|
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, <code>${senderTimeZone}</code>, is not valid. It needs to be an IANA time zone value with a slash. <a href="https://time.is/your_time_zone">See yours here.</a>`
|
||||||
|
).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 "<t:" + Math.floor(z.toInstant().epochMilliseconds / 1000) + colonStyle + ">"
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// @ts-check
|
// @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 fs = require("fs")
|
||||||
const sqlite = require("better-sqlite3")
|
const sqlite = require("better-sqlite3")
|
||||||
const migrate = require("./src/db/migrate")
|
const migrate = require("./src/db/migrate")
|
||||||
|
|||||||
Reference in New Issue
Block a user