windsor-bot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +128 -0
  2. package/SPEC.md +179 -0
  3. package/assets/pokemon-sprites/1.png +0 -0
  4. package/assets/pokemon-sprites/10.png +0 -0
  5. package/assets/pokemon-sprites/100.png +0 -0
  6. package/assets/pokemon-sprites/101.png +0 -0
  7. package/assets/pokemon-sprites/102.png +0 -0
  8. package/assets/pokemon-sprites/103.png +0 -0
  9. package/assets/pokemon-sprites/104.png +0 -0
  10. package/assets/pokemon-sprites/105.png +0 -0
  11. package/assets/pokemon-sprites/106.png +0 -0
  12. package/assets/pokemon-sprites/107.png +0 -0
  13. package/assets/pokemon-sprites/108.png +0 -0
  14. package/assets/pokemon-sprites/109.png +0 -0
  15. package/assets/pokemon-sprites/11.png +0 -0
  16. package/assets/pokemon-sprites/110.png +0 -0
  17. package/assets/pokemon-sprites/111.png +0 -0
  18. package/assets/pokemon-sprites/112.png +0 -0
  19. package/assets/pokemon-sprites/113.png +0 -0
  20. package/assets/pokemon-sprites/114.png +0 -0
  21. package/assets/pokemon-sprites/115.png +0 -0
  22. package/assets/pokemon-sprites/116.png +0 -0
  23. package/assets/pokemon-sprites/117.png +0 -0
  24. package/assets/pokemon-sprites/118.png +0 -0
  25. package/assets/pokemon-sprites/119.png +0 -0
  26. package/assets/pokemon-sprites/12.png +0 -0
  27. package/assets/pokemon-sprites/120.png +0 -0
  28. package/assets/pokemon-sprites/121.png +0 -0
  29. package/assets/pokemon-sprites/122.png +0 -0
  30. package/assets/pokemon-sprites/123.png +0 -0
  31. package/assets/pokemon-sprites/124.png +0 -0
  32. package/assets/pokemon-sprites/125.png +0 -0
  33. package/assets/pokemon-sprites/126.png +0 -0
  34. package/assets/pokemon-sprites/127.png +0 -0
  35. package/assets/pokemon-sprites/128.png +0 -0
  36. package/assets/pokemon-sprites/129.png +0 -0
  37. package/assets/pokemon-sprites/13.png +0 -0
  38. package/assets/pokemon-sprites/130.png +0 -0
  39. package/assets/pokemon-sprites/131.png +0 -0
  40. package/assets/pokemon-sprites/132.png +0 -0
  41. package/assets/pokemon-sprites/133.png +0 -0
  42. package/assets/pokemon-sprites/134.png +0 -0
  43. package/assets/pokemon-sprites/135.png +0 -0
  44. package/assets/pokemon-sprites/136.png +0 -0
  45. package/assets/pokemon-sprites/137.png +0 -0
  46. package/assets/pokemon-sprites/138.png +0 -0
  47. package/assets/pokemon-sprites/139.png +0 -0
  48. package/assets/pokemon-sprites/14.png +0 -0
  49. package/assets/pokemon-sprites/140.png +0 -0
  50. package/assets/pokemon-sprites/141.png +0 -0
  51. package/assets/pokemon-sprites/142.png +0 -0
  52. package/assets/pokemon-sprites/143.png +0 -0
  53. package/assets/pokemon-sprites/144.png +0 -0
  54. package/assets/pokemon-sprites/145.png +0 -0
  55. package/assets/pokemon-sprites/146.png +0 -0
  56. package/assets/pokemon-sprites/147.png +0 -0
  57. package/assets/pokemon-sprites/148.png +0 -0
  58. package/assets/pokemon-sprites/149.png +0 -0
  59. package/assets/pokemon-sprites/15.png +0 -0
  60. package/assets/pokemon-sprites/150.png +0 -0
  61. package/assets/pokemon-sprites/151.png +0 -0
  62. package/assets/pokemon-sprites/16.png +0 -0
  63. package/assets/pokemon-sprites/17.png +0 -0
  64. package/assets/pokemon-sprites/18.png +0 -0
  65. package/assets/pokemon-sprites/19.png +0 -0
  66. package/assets/pokemon-sprites/2.png +0 -0
  67. package/assets/pokemon-sprites/20.png +0 -0
  68. package/assets/pokemon-sprites/21.png +0 -0
  69. package/assets/pokemon-sprites/22.png +0 -0
  70. package/assets/pokemon-sprites/23.png +0 -0
  71. package/assets/pokemon-sprites/24.png +0 -0
  72. package/assets/pokemon-sprites/25.png +0 -0
  73. package/assets/pokemon-sprites/26.png +0 -0
  74. package/assets/pokemon-sprites/27.png +0 -0
  75. package/assets/pokemon-sprites/28.png +0 -0
  76. package/assets/pokemon-sprites/29.png +0 -0
  77. package/assets/pokemon-sprites/3.png +0 -0
  78. package/assets/pokemon-sprites/30.png +0 -0
  79. package/assets/pokemon-sprites/31.png +0 -0
  80. package/assets/pokemon-sprites/32.png +0 -0
  81. package/assets/pokemon-sprites/33.png +0 -0
  82. package/assets/pokemon-sprites/34.png +0 -0
  83. package/assets/pokemon-sprites/35.png +0 -0
  84. package/assets/pokemon-sprites/36.png +0 -0
  85. package/assets/pokemon-sprites/37.png +0 -0
  86. package/assets/pokemon-sprites/38.png +0 -0
  87. package/assets/pokemon-sprites/39.png +0 -0
  88. package/assets/pokemon-sprites/4.png +0 -0
  89. package/assets/pokemon-sprites/40.png +0 -0
  90. package/assets/pokemon-sprites/41.png +0 -0
  91. package/assets/pokemon-sprites/42.png +0 -0
  92. package/assets/pokemon-sprites/43.png +0 -0
  93. package/assets/pokemon-sprites/44.png +0 -0
  94. package/assets/pokemon-sprites/45.png +0 -0
  95. package/assets/pokemon-sprites/46.png +0 -0
  96. package/assets/pokemon-sprites/47.png +0 -0
  97. package/assets/pokemon-sprites/48.png +0 -0
  98. package/assets/pokemon-sprites/49.png +0 -0
  99. package/assets/pokemon-sprites/5.png +0 -0
  100. package/assets/pokemon-sprites/50.png +0 -0
  101. package/assets/pokemon-sprites/51.png +0 -0
  102. package/assets/pokemon-sprites/52.png +0 -0
  103. package/assets/pokemon-sprites/53.png +0 -0
  104. package/assets/pokemon-sprites/54.png +0 -0
  105. package/assets/pokemon-sprites/55.png +0 -0
  106. package/assets/pokemon-sprites/56.png +0 -0
  107. package/assets/pokemon-sprites/57.png +0 -0
  108. package/assets/pokemon-sprites/58.png +0 -0
  109. package/assets/pokemon-sprites/59.png +0 -0
  110. package/assets/pokemon-sprites/6.png +0 -0
  111. package/assets/pokemon-sprites/60.png +0 -0
  112. package/assets/pokemon-sprites/61.png +0 -0
  113. package/assets/pokemon-sprites/62.png +0 -0
  114. package/assets/pokemon-sprites/63.png +0 -0
  115. package/assets/pokemon-sprites/64.png +0 -0
  116. package/assets/pokemon-sprites/65.png +0 -0
  117. package/assets/pokemon-sprites/66.png +0 -0
  118. package/assets/pokemon-sprites/67.png +0 -0
  119. package/assets/pokemon-sprites/68.png +0 -0
  120. package/assets/pokemon-sprites/69.png +0 -0
  121. package/assets/pokemon-sprites/7.png +0 -0
  122. package/assets/pokemon-sprites/70.png +0 -0
  123. package/assets/pokemon-sprites/71.png +0 -0
  124. package/assets/pokemon-sprites/72.png +0 -0
  125. package/assets/pokemon-sprites/73.png +0 -0
  126. package/assets/pokemon-sprites/74.png +0 -0
  127. package/assets/pokemon-sprites/75.png +0 -0
  128. package/assets/pokemon-sprites/76.png +0 -0
  129. package/assets/pokemon-sprites/77.png +0 -0
  130. package/assets/pokemon-sprites/78.png +0 -0
  131. package/assets/pokemon-sprites/79.png +0 -0
  132. package/assets/pokemon-sprites/8.png +0 -0
  133. package/assets/pokemon-sprites/80.png +0 -0
  134. package/assets/pokemon-sprites/81.png +0 -0
  135. package/assets/pokemon-sprites/82.png +0 -0
  136. package/assets/pokemon-sprites/83.png +0 -0
  137. package/assets/pokemon-sprites/84.png +0 -0
  138. package/assets/pokemon-sprites/85.png +0 -0
  139. package/assets/pokemon-sprites/86.png +0 -0
  140. package/assets/pokemon-sprites/87.png +0 -0
  141. package/assets/pokemon-sprites/88.png +0 -0
  142. package/assets/pokemon-sprites/89.png +0 -0
  143. package/assets/pokemon-sprites/9.png +0 -0
  144. package/assets/pokemon-sprites/90.png +0 -0
  145. package/assets/pokemon-sprites/91.png +0 -0
  146. package/assets/pokemon-sprites/92.png +0 -0
  147. package/assets/pokemon-sprites/93.png +0 -0
  148. package/assets/pokemon-sprites/94.png +0 -0
  149. package/assets/pokemon-sprites/95.png +0 -0
  150. package/assets/pokemon-sprites/96.png +0 -0
  151. package/assets/pokemon-sprites/97.png +0 -0
  152. package/assets/pokemon-sprites/98.png +0 -0
  153. package/assets/pokemon-sprites/99.png +0 -0
  154. package/dist/ai.js +193 -0
  155. package/dist/bot.d.ts +22 -0
  156. package/dist/bot.d.ts.map +1 -0
  157. package/dist/bot.js +547 -0
  158. package/dist/bot.js.map +1 -0
  159. package/dist/commands/debug-fail.js +8 -0
  160. package/dist/commands/hello-world.js +12 -0
  161. package/dist/commands/hello.d.ts +3 -0
  162. package/dist/commands/hello.d.ts.map +1 -0
  163. package/dist/commands/hello.js +8 -0
  164. package/dist/commands/hello.js.map +1 -0
  165. package/dist/commands/index.d.ts +3 -0
  166. package/dist/commands/index.d.ts.map +1 -0
  167. package/dist/commands/index.js +12 -0
  168. package/dist/commands/index.js.map +1 -0
  169. package/dist/commands/pokemon.js +132 -0
  170. package/dist/commands/print.d.ts +9 -0
  171. package/dist/commands/print.d.ts.map +1 -0
  172. package/dist/commands/print.js +46 -0
  173. package/dist/commands/print.js.map +1 -0
  174. package/dist/commands/sudoku.js +79 -0
  175. package/dist/commands/util.js +13 -0
  176. package/dist/commands/wordsearch.js +109 -0
  177. package/dist/config.d.ts +8 -0
  178. package/dist/config.d.ts.map +1 -0
  179. package/dist/config.js +149 -0
  180. package/dist/config.js.map +1 -0
  181. package/dist/index.d.ts +2 -0
  182. package/dist/index.d.ts.map +1 -0
  183. package/dist/index.js +85 -0
  184. package/dist/index.js.map +1 -0
  185. package/dist/printer.js +245 -0
  186. package/dist/printing/cups.js +89 -0
  187. package/dist/printing/escp.js +147 -0
  188. package/dist/printing/imagefeed.js +212 -0
  189. package/dist/printing/index.js +54 -0
  190. package/dist/reactions.js +5 -0
  191. package/dist/server.d.ts +5 -0
  192. package/dist/server.d.ts.map +1 -0
  193. package/dist/server.js +312 -0
  194. package/dist/server.js.map +1 -0
  195. package/dist/tests/config.test.js +78 -0
  196. package/dist/tests/urls.test.js +54 -0
  197. package/dist/types.d.ts +55 -0
  198. package/dist/types.d.ts.map +1 -0
  199. package/dist/types.js +1 -0
  200. package/dist/types.js.map +1 -0
  201. package/dist/web/app.js +325 -0
  202. package/package.json +45 -0
  203. package/scripts/download-pokemon-sprites.mjs +38 -0
  204. package/src/ai.ts +214 -0
  205. package/src/bot.ts +657 -0
  206. package/src/commands/debug-fail.ts +11 -0
  207. package/src/commands/hello-world.ts +15 -0
  208. package/src/commands/index.ts +35 -0
  209. package/src/commands/pokemon.ts +146 -0
  210. package/src/commands/sudoku.ts +94 -0
  211. package/src/commands/util.ts +14 -0
  212. package/src/commands/wordsearch.ts +118 -0
  213. package/src/config.ts +166 -0
  214. package/src/index.ts +98 -0
  215. package/src/printer.ts +268 -0
  216. package/src/printing/cups.ts +104 -0
  217. package/src/printing/escp.ts +158 -0
  218. package/src/printing/imagefeed.ts +246 -0
  219. package/src/printing/index.ts +63 -0
  220. package/src/reactions.ts +6 -0
  221. package/src/server.ts +355 -0
  222. package/src/tests/config.test.ts +88 -0
  223. package/src/tests/urls.test.ts +64 -0
  224. package/src/types.ts +120 -0
  225. package/src/web/app.tsx +690 -0
  226. package/tsconfig.json +25 -0
package/src/bot.ts ADDED
@@ -0,0 +1,657 @@
1
+ import {
2
+ ChannelType,
3
+ Client,
4
+ GatewayIntentBits,
5
+ type Guild,
6
+ type Message,
7
+ type MessageReaction,
8
+ type TextChannel,
9
+ } from 'discord.js';
10
+ import { formatScheduleDate, generateIcon, getNextOccurrence, parseRecurringSchedule } from './ai.ts';
11
+ import { Commands } from './commands/index.ts';
12
+ import { getCurrentConfig, reconcileChannels } from './config.ts';
13
+ import { formatTimestamp } from './printer.ts';
14
+ import { printJob } from './printing/index.ts';
15
+ import { logEvent, status } from './server.ts';
16
+ import type {
17
+ AccumulatingListConfig,
18
+ ChannelBehaviorConfig,
19
+ ImmediatePrintConfig,
20
+ PrintJob,
21
+ RecurringPrintConfig,
22
+ } from './types.ts';
23
+ import { Reaction } from './reactions.ts';
24
+
25
+ const MAX_MESSAGE_CHARS = 800;
26
+ const MAX_URLS = 5;
27
+
28
+
29
+ const URL_REGEX = /https:\/\/[^\s<>"']+/g;
30
+ const TRAILING_PUNCT = /[.,;:!?)\]}>'"]+$/;
31
+
32
+ export function extractUrls(text: string): string[] {
33
+ const matches = text.match(URL_REGEX) ?? [];
34
+ const result: string[] = [];
35
+ for (const m of matches) {
36
+ try {
37
+ const stripped = m.replace(TRAILING_PUNCT, '');
38
+ new URL(stripped); // validate
39
+ result.push(stripped);
40
+ } catch {
41
+ // not a valid URL - skip
42
+ }
43
+ if (result.length >= MAX_URLS) break;
44
+ }
45
+ return result;
46
+ }
47
+
48
+ export function stripUrls(text: string): string {
49
+ return text.replace(URL_REGEX, '').replace(/\s+/g, ' ').trim();
50
+ }
51
+
52
+ export function replaceUrlsInText(text: string, urls: string[]): string {
53
+ if (urls.length === 0) return text;
54
+ let result = text;
55
+ for (let i = 0; i < urls.length; i++) {
56
+ const url = urls[i];
57
+ if (!url) continue;
58
+ const label = urls.length === 1 ? '[link]' : `[link ${i + 1}]`;
59
+ // Replace first occurrence only (in case of duplicates)
60
+ result = result.replace(url, label);
61
+ }
62
+ return result;
63
+ }
64
+
65
+
66
+ async function hasReaction(message: Message, emoji: Reaction): Promise<boolean> {
67
+ try {
68
+ await message.fetch();
69
+ } catch {
70
+ // ignore
71
+ }
72
+ const reaction: MessageReaction | undefined = message.reactions.cache.get(emoji);
73
+ return Boolean(reaction?.me);
74
+ }
75
+
76
+ async function hasAnyReaction(message: Message) {
77
+ try {
78
+ await message.fetch();
79
+ } catch {
80
+ // ignore
81
+ }
82
+ for (const v of Object.values(Reaction) as Reaction[]) {
83
+ if (message.reactions.cache.get(v)) {
84
+ return true;
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+
90
+ async function reactSafe(message: Message, emoji: Reaction): Promise<void> {
91
+ try {
92
+ await message.react(emoji);
93
+ } catch {
94
+ // ignore
95
+ }
96
+ }
97
+
98
+ async function replySafe(message: Message, content: string): Promise<Message | null> {
99
+ try {
100
+ return await message.reply(content);
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+
107
+ function isPrintTrigger(content: string): boolean {
108
+ return content.replace(/[^a-zA-Z0-9]/g, '').toLowerCase() === 'print';
109
+ }
110
+
111
+
112
+ export interface WindsorBotHandle {
113
+ start(token: string): Promise<void>;
114
+ destroy(): void;
115
+ getClient(): Client;
116
+ handleImmediatePrint(message: Message, config: ImmediatePrintConfig): Promise<void>;
117
+ handleAccumulatingPrint(triggerMessage: Message, config: AccumulatingListConfig, allMessages: Message[]): Promise<void>;
118
+ handleRecurringSetup(message: Message, config: RecurringPrintConfig): Promise<void>;
119
+ executeRecurringTask(scheduleReply: Message, config: RecurringPrintConfig): Promise<void>;
120
+ advanceRecurring(scheduleReply: Message, latestStatusReply: Message, config: RecurringPrintConfig): Promise<void>;
121
+ handleOnDemand(message: Message): Promise<void>;
122
+ }
123
+
124
+
125
+ export function createWindsorBot(): WindsorBotHandle {
126
+ const client = new Client({
127
+ intents: [
128
+ GatewayIntentBits.Guilds,
129
+ GatewayIntentBits.GuildMessages,
130
+ GatewayIntentBits.MessageContent,
131
+ GatewayIntentBits.GuildMessageReactions,
132
+ ],
133
+ });
134
+
135
+ client.on('clientReady', () => void onReady());
136
+ client.on('messageCreate', (msg) => void onMessage(msg));
137
+
138
+ const bot: WindsorBotHandle = {
139
+ start,
140
+ destroy,
141
+ getClient,
142
+ handleImmediatePrint,
143
+ handleAccumulatingPrint,
144
+ handleRecurringSetup,
145
+ executeRecurringTask,
146
+ advanceRecurring,
147
+ handleOnDemand,
148
+ };
149
+
150
+ async function start(token: string): Promise<void> {
151
+ await client.login(token);
152
+ }
153
+
154
+ function destroy(): void {
155
+ client.destroy();
156
+ }
157
+
158
+ function getClient(): Client {
159
+ return client;
160
+ }
161
+
162
+ function isTextOnlyChannel(ch: { isTextBased(): boolean; type: ChannelType }): boolean {
163
+ return ch.isTextBased()
164
+ && ch.type !== ChannelType.GuildVoice
165
+ && ch.type !== ChannelType.GuildStageVoice
166
+ && ch.type !== ChannelType.GuildForum
167
+ && ch.type !== ChannelType.GuildMedia;
168
+ }
169
+
170
+ async function onReady(): Promise<void> {
171
+ const botUser = client.user!;
172
+ status.connected = true;
173
+ status.tag = botUser.tag;
174
+ status.startedAt = new Date().toISOString();
175
+ status.configuredServerId = getCurrentConfig().serverId ?? null;
176
+ status.guilds = [...client.guilds.cache.values()].map(g => g.name);
177
+
178
+ logEvent('startup', `Connected as ${botUser.tag}`);
179
+
180
+ const guilds = getTargetGuilds();
181
+ for (const guild of guilds) {
182
+ const channels = await guild.channels.fetch();
183
+ const textChannels = [...channels.values()]
184
+ .filter((c): c is NonNullable<typeof c> => c !== null && isTextOnlyChannel(c))
185
+ .map(c => ({ id: c.id, name: (c as TextChannel).name }));
186
+ await reconcileChannels(textChannels);
187
+ }
188
+
189
+ await startupScan();
190
+ }
191
+
192
+ function getTargetGuilds(): Guild[] {
193
+ const serverId = getCurrentConfig().serverId;
194
+ if (!serverId) return [...client.guilds.cache.values()];
195
+ const guild = client.guilds.cache.get(serverId);
196
+ if (!guild) {
197
+ logEvent('error', `Configured serverId ${serverId} not found`);
198
+ return [];
199
+ }
200
+ return [guild];
201
+ }
202
+
203
+ async function startupScan(): Promise<void> {
204
+ logEvent('info', 'Starting startup scan...');
205
+ const config = getCurrentConfig();
206
+ const guilds = getTargetGuilds();
207
+
208
+ for (const guild of guilds) {
209
+ const channels = await guild.channels.fetch();
210
+ for (const ch of channels.values()) {
211
+ if (!ch?.isTextBased()) continue;
212
+ const textCh = ch as TextChannel;
213
+ const mapping = config.channels.find(m => m.channelId === textCh.id);
214
+ if (!mapping) continue;
215
+
216
+ try {
217
+ let fetchedMessages;
218
+ if (mapping.config.type === 'recurring-print') {
219
+ const page1 = await textCh.messages.fetch({ limit: 100 });
220
+ const oldest = [...page1.values()].reduce((a, b) =>
221
+ BigInt(a.id) < BigInt(b.id) ? a : b
222
+ );
223
+ const page2 = await textCh.messages.fetch({ limit: 100, before: oldest.id });
224
+ fetchedMessages = new Map([...page1, ...page2]);
225
+ } else {
226
+ fetchedMessages = await textCh.messages.fetch({ limit: 100 });
227
+ }
228
+ const sorted = [...fetchedMessages.values()].sort((a, b) =>
229
+ Number(BigInt(a.id) - BigInt(b.id))
230
+ );
231
+ await processBehaviorStartup(textCh, mapping.config, sorted);
232
+ } catch (err) {
233
+ logEvent('error', `Startup scan failed for #${textCh.name}: ${err}`);
234
+ }
235
+ }
236
+ }
237
+ logEvent('info', 'Startup scan complete');
238
+ }
239
+
240
+ async function processBehaviorStartup(
241
+ channel: TextChannel,
242
+ config: ChannelBehaviorConfig,
243
+ messages: Message[],
244
+ ): Promise<void> {
245
+ const botId = client.user?.id;
246
+
247
+ switch (config.type) {
248
+ case 'immediate-print': {
249
+ for (const msg of messages) {
250
+ if (msg.author.bot) continue;
251
+ if (msg.reference) continue;
252
+ if (msg.author.id === botId) continue;
253
+ if (await hasAnyReaction(msg)) continue;
254
+ await handleImmediatePrint(msg, config);
255
+ }
256
+ break;
257
+ }
258
+ case 'accumulating-list': {
259
+ for (const msg of messages) {
260
+ if (msg.author.bot) continue;
261
+ if (msg.reference) continue;
262
+ if (msg.author.id === botId) continue;
263
+ if (!isPrintTrigger(msg.content)) continue;
264
+ if (await hasAnyReaction(msg)) continue;
265
+ await handleAccumulatingPrint(msg, config, messages);
266
+ }
267
+ break;
268
+ }
269
+ case 'recurring-print': {
270
+ await startupRecurring(channel, config, messages);
271
+ break;
272
+ }
273
+ case 'on-demand': {
274
+ for (const msg of messages) {
275
+ if (msg.author.bot) continue;
276
+ if (msg.reference) continue;
277
+ if (msg.author.id === botId) continue;
278
+ if (await hasAnyReaction(msg)) continue;
279
+ await handleOnDemand(msg);
280
+ }
281
+ break;
282
+ }
283
+ }
284
+ }
285
+
286
+ async function startupRecurring(
287
+ channel: TextChannel,
288
+ config: RecurringPrintConfig,
289
+ messages: Message[],
290
+ ): Promise<void> {
291
+ const botId = client.user?.id;
292
+ const scheduleReplies = messages.filter(m =>
293
+ m.author.id === botId && m.content.startsWith('Got it. I will print ⟪')
294
+ );
295
+
296
+ for (const scheduleReply of scheduleReplies) {
297
+ const parentId = scheduleReply.reference?.messageId;
298
+ if (!parentId) continue;
299
+
300
+ const statusReplies = messages.filter(m =>
301
+ m.author.id === botId &&
302
+ m.reference?.messageId === scheduleReply.id &&
303
+ (m.content.startsWith('Printed at') || m.content.includes('has expired'))
304
+ );
305
+
306
+ const latestStatus = statusReplies[statusReplies.length - 1];
307
+
308
+ if (latestStatus?.content.includes('has expired')) {
309
+ continue;
310
+ }
311
+
312
+ if (latestStatus && latestStatus.content.startsWith('Printed at') && !await hasReaction(latestStatus, Reaction.ok)) {
313
+ await advanceRecurring(scheduleReply, latestStatus, config);
314
+ continue;
315
+ }
316
+
317
+ if (!latestStatus) {
318
+ const match = /at (.+)$/.exec(scheduleReply.content);
319
+ if (!match?.[1]) continue;
320
+ const nextTime = new Date(match[1]);
321
+ if (isNaN(nextTime.getTime())) continue;
322
+ scheduleRecurringTask(scheduleReply, nextTime, config, bot);
323
+ }
324
+ }
325
+ }
326
+
327
+ async function onMessage(message: Message): Promise<void> {
328
+ if (message.author.bot) return;
329
+ if (message.reference) return;
330
+ if (message.author.id === client.user?.id) return;
331
+
332
+ const config = getCurrentConfig();
333
+ const mapping = config.channels.find(m => m.channelId === message.channelId);
334
+ if (!mapping) return;
335
+
336
+ const serverId = config.serverId;
337
+ if (serverId && message.guildId !== serverId) return;
338
+
339
+ switch (mapping.config.type) {
340
+ case 'immediate-print':
341
+ await handleImmediatePrint(message, mapping.config);
342
+ break;
343
+ case 'accumulating-list': {
344
+ if (isPrintTrigger(message.content)) {
345
+ const channel = message.channel as TextChannel;
346
+ const messages = await channel.messages.fetch({ limit: 100 });
347
+ const sorted = [...messages.values()].sort((a, b) =>
348
+ Number(BigInt(a.id) - BigInt(b.id))
349
+ );
350
+ await handleAccumulatingPrint(message, mapping.config, sorted);
351
+ }
352
+ break;
353
+ }
354
+ case 'recurring-print':
355
+ await handleRecurringSetup(message, mapping.config);
356
+ break;
357
+ case 'on-demand':
358
+ await handleOnDemand(message);
359
+ break;
360
+ }
361
+ }
362
+
363
+ async function handleImmediatePrint(message: Message, config: ImmediatePrintConfig): Promise<void> {
364
+ const rawContent = message.content;
365
+ const urls = extractUrls(rawContent);
366
+ const textWithLinkLabels = replaceUrlsInText(rawContent, urls);
367
+ const strippedText = stripUrls(rawContent);
368
+
369
+ if (strippedText.length > MAX_MESSAGE_CHARS) {
370
+ await reactSafe(message, Reaction.fail);
371
+ return;
372
+ }
373
+
374
+ const job: PrintJob = {
375
+ lines: [textWithLinkLabels],
376
+ urls,
377
+ };
378
+
379
+ if (config.header) job.header = config.header;
380
+ if (config.footer) job.footer = config.footer;
381
+ if (config.includeMetadata) {
382
+ job.metadataLines = [
383
+ `${message.author.username} · ${formatTimestamp(message.createdAt)}`,
384
+ ];
385
+ }
386
+
387
+ if (config.includeIcon) {
388
+ const iconCacheDir = getCurrentConfig().iconCacheDir ?? './icon-cache';
389
+ const iconPath = await generateIcon(strippedText, iconCacheDir);
390
+ if (iconPath) job.iconPath = iconPath;
391
+ }
392
+
393
+ try {
394
+ await printJob(job);
395
+ await reactSafe(message, Reaction.ok);
396
+ logEvent('print', `Printed immediate message from ${message.author.username}`);
397
+ } catch (err) {
398
+ await reactSafe(message, Reaction.fail);
399
+ await replySafe(message, `⏸️ Print failed: ${err instanceof Error ? err.message : String(err)}`);
400
+ logEvent('error', `Print failed: ${err}`);
401
+ }
402
+ }
403
+
404
+ async function handleAccumulatingPrint(
405
+ triggerMessage: Message,
406
+ config: AccumulatingListConfig,
407
+ allMessages: Message[],
408
+ ): Promise<void> {
409
+ const botId = client.user?.id;
410
+
411
+ const triggerIdx = allMessages.findIndex(m => m.id === triggerMessage.id);
412
+ const prior = allMessages.slice(0, triggerIdx).reverse();
413
+ const prevPrintIdx = prior.findIndex(m =>
414
+ !m.author.bot &&
415
+ !m.reference &&
416
+ isPrintTrigger(m.content) &&
417
+ m.reactions.cache.get(Reaction.ok)?.me
418
+ );
419
+
420
+ const startIdx = prevPrintIdx === -1 ? 0 : triggerIdx - prevPrintIdx;
421
+
422
+ let items = allMessages.slice(startIdx, triggerIdx).filter(m =>
423
+ !m.author.bot &&
424
+ !m.reference &&
425
+ m.author.id !== botId &&
426
+ !isPrintTrigger(m.content)
427
+ );
428
+
429
+ if (items.length === 0) {
430
+ if (prevPrintIdx === -1) {
431
+ await reactSafe(triggerMessage, Reaction.ok);
432
+ return;
433
+ }
434
+ const prevTriggerMsg = prior[prevPrintIdx];
435
+ if (!prevTriggerMsg) {
436
+ await reactSafe(triggerMessage, Reaction.ok);
437
+ return;
438
+ }
439
+ const prevTriggerIdx = allMessages.findIndex(m => m.id === prevTriggerMsg.id);
440
+ const beforePrev = allMessages.slice(0, prevTriggerIdx);
441
+ const prevPrevIdx = beforePrev.reverse().findIndex(m =>
442
+ !m.author.bot && !m.reference && isPrintTrigger(m.content) && m.reactions.cache.get(Reaction.ok)?.me
443
+ );
444
+ const prevStartIdx = prevPrevIdx === -1 ? 0 : prevTriggerIdx - prevPrevIdx;
445
+ items = allMessages.slice(prevStartIdx, prevTriggerIdx).filter(m =>
446
+ !m.author.bot && !m.reference && m.author.id !== botId && !isPrintTrigger(m.content)
447
+ );
448
+ }
449
+
450
+ if (items.length === 0) {
451
+ await reactSafe(triggerMessage, Reaction.ok);
452
+ return;
453
+ }
454
+
455
+ const lines = items.map(m => {
456
+ const urls = extractUrls(m.content);
457
+ const text = replaceUrlsInText(m.content, urls);
458
+ return config.includeChecklist ? `[_] ${text}` : text;
459
+ });
460
+
461
+ const job: PrintJob = { lines, urls: [] };
462
+ if (config.header) job.header = config.header;
463
+ if (config.footer) job.footer = config.footer;
464
+ if (config.includeMetadata) {
465
+ job.metadataLines = [`Printed at ${formatTimestamp(new Date())}`];
466
+ }
467
+
468
+ try {
469
+ await printJob(job);
470
+ await reactSafe(triggerMessage, Reaction.ok);
471
+ logEvent('print', `Printed accumulating list (${lines.length} items)`);
472
+ } catch (err) {
473
+ await reactSafe(triggerMessage, Reaction.fail);
474
+ await replySafe(triggerMessage, `⏸️ Print failed: ${err instanceof Error ? err.message : String(err)}`);
475
+ logEvent('error', `Accumulating print failed: ${err}`);
476
+ }
477
+ }
478
+
479
+ async function handleRecurringSetup(message: Message, config: RecurringPrintConfig): Promise<void> {
480
+ const rawContent = message.content;
481
+
482
+ try {
483
+ const parsed = await parseRecurringSchedule(rawContent, new Date());
484
+ if (!parsed) {
485
+ await reactSafe(message, Reaction.what);
486
+ await replySafe(message, '⁉️ Could not parse a schedule from your message. Please try again with a clearer schedule.');
487
+ return;
488
+ }
489
+
490
+ const nextStr = formatScheduleDate(parsed.nextOccurrence);
491
+ await reactSafe(message, Reaction.ok);
492
+ const reply = await replySafe(message, `Got it. I will print ⟪${parsed.message}⟫ at ${nextStr}`);
493
+
494
+ if (reply) {
495
+ scheduleRecurringTask(reply, parsed.nextOccurrence, config, bot);
496
+ }
497
+ } catch (err) {
498
+ await reactSafe(message, Reaction.fail);
499
+ await replySafe(message, `⁉️ Failed to parse schedule: ${err instanceof Error ? err.message : String(err)}`);
500
+ }
501
+ }
502
+
503
+ async function executeRecurringTask(
504
+ scheduleReply: Message,
505
+ config: RecurringPrintConfig,
506
+ ): Promise<void> {
507
+ const match = /⟪(.+?)⟫/.exec(scheduleReply.content);
508
+ if (!match?.[1]) return;
509
+ const text = match[1];
510
+
511
+ const urls = extractUrls(text);
512
+ const textWithLinks = replaceUrlsInText(text, urls);
513
+ const strippedText = stripUrls(text);
514
+
515
+ const job: PrintJob = {
516
+ lines: [textWithLinks],
517
+ urls,
518
+ };
519
+ if (config.header) job.header = config.header;
520
+ if (config.footer) job.footer = config.footer;
521
+ if (config.includeMetadata) {
522
+ job.metadataLines = [`Recurring · ${formatTimestamp(new Date())}`];
523
+ }
524
+ if (config.includeIcon) {
525
+ const iconCacheDir = getCurrentConfig().iconCacheDir ?? './icon-cache';
526
+ const iconPath = await generateIcon(strippedText, iconCacheDir);
527
+ if (iconPath) job.iconPath = iconPath;
528
+ }
529
+
530
+ const printedAt = formatTimestamp(new Date());
531
+
532
+ try {
533
+ await printJob(job);
534
+ } catch (err) {
535
+ logEvent('error', `Recurring print failed: ${err}`);
536
+ return;
537
+ }
538
+
539
+ logEvent('print', `Printed recurring task: ${text}`);
540
+
541
+ const originalUserMessage = scheduleReply.content.replace(/^Got it\. I will print ⟪.+?⟫ at /, '');
542
+ const nextOccurrence = await getNextOccurrence(originalUserMessage, new Date());
543
+
544
+ let statusReply: Message | null;
545
+ if (!nextOccurrence) {
546
+ statusReply = await replySafe(scheduleReply, 'This occurrence has expired and no more prints are scheduled');
547
+ } else {
548
+ const nextStr = formatScheduleDate(nextOccurrence);
549
+ statusReply = await replySafe(scheduleReply, `Printed at ${printedAt}. The next print will be at ${nextStr}.`);
550
+ }
551
+
552
+ if (statusReply) {
553
+ await reactSafe(statusReply, Reaction.ok);
554
+ }
555
+
556
+ if (nextOccurrence && statusReply) {
557
+ scheduleRecurringTask(scheduleReply, nextOccurrence, config, bot);
558
+ }
559
+ }
560
+
561
+ async function advanceRecurring(
562
+ scheduleReply: Message,
563
+ latestStatusReply: Message,
564
+ config: RecurringPrintConfig,
565
+ ): Promise<void> {
566
+ const match = /⟪(.+?)⟫/.exec(scheduleReply.content);
567
+ if (!match?.[1]) return;
568
+
569
+ const nextOccurrence = await getNextOccurrence(match[1], new Date());
570
+ if (!nextOccurrence) {
571
+ await replySafe(latestStatusReply, 'This occurrence has expired and no more prints are scheduled');
572
+ return;
573
+ }
574
+
575
+ const nextStr = formatScheduleDate(nextOccurrence);
576
+ await replySafe(latestStatusReply, `The next print will be at ${nextStr}.`);
577
+ await reactSafe(latestStatusReply, Reaction.ok);
578
+ scheduleRecurringTask(scheduleReply, nextOccurrence, config, bot);
579
+ }
580
+
581
+ async function handleOnDemand(message: Message): Promise<void> {
582
+ const content = message.content.trim();
583
+ if (!content.startsWith('!') && !content.startsWith('/')) return;
584
+
585
+ const afterPrefix = content.slice(1).trim();
586
+ const spaceIdx = afterPrefix.indexOf(' ');
587
+ const commandName = (spaceIdx === -1 ? afterPrefix : afterPrefix.slice(0, spaceIdx)).toLowerCase();
588
+ const args = spaceIdx === -1 ? '' : afterPrefix.slice(spaceIdx + 1).trim();
589
+
590
+ let foundCommand = false;
591
+ for (const cmd of Commands) {
592
+ if (cmd.aliases.some(a => commandName.localeCompare(a, undefined, { sensitivity: "base" }) == 0)) {
593
+ foundCommand = true;
594
+ logEvent('command', `Command '${commandName}' invoked by ${message.author.username}`);
595
+ const ctx: import('./commands/index.ts').CommandRunContext = { printJob };
596
+ const result = await cmd.invoke(args, ctx);
597
+ if (result.kind === 'pass') {
598
+ await reactSafe(message, "✅");
599
+ if (result.reply) {
600
+ await replySafe(message, result.reply);
601
+ }
602
+ } else {
603
+ void (result.kind satisfies 'fail');
604
+ await reactSafe(message, Reaction.fail);
605
+ await replySafe(message, result.reason);
606
+ }
607
+ break;
608
+ }
609
+ }
610
+ if (!foundCommand) {
611
+ await reactSafe(message, "❓");
612
+ }
613
+ }
614
+
615
+ return bot;
616
+ }
617
+
618
+
619
+ interface ScheduledTask {
620
+ scheduleReply: Message;
621
+ nextOccurrence: Date;
622
+ config: RecurringPrintConfig;
623
+ bot: WindsorBotHandle;
624
+ timerId: ReturnType<typeof setTimeout>;
625
+ }
626
+
627
+ const scheduledTasks = new Map<string, ScheduledTask>();
628
+
629
+ export function scheduleRecurringTask(
630
+ scheduleReply: Message,
631
+ nextOccurrence: Date,
632
+ config: RecurringPrintConfig,
633
+ bot: WindsorBotHandle,
634
+ ): void {
635
+ const key = scheduleReply.id;
636
+ const existing = scheduledTasks.get(key);
637
+ if (existing) clearTimeout(existing.timerId);
638
+
639
+ const delay = Math.max(0, nextOccurrence.getTime() - Date.now());
640
+ const timerId = setTimeout(() => {
641
+ scheduledTasks.delete(key);
642
+ void bot.executeRecurringTask(scheduleReply, config);
643
+ }, delay);
644
+
645
+ scheduledTasks.set(key, { scheduleReply, nextOccurrence, config, bot, timerId });
646
+ }
647
+
648
+ export function checkScheduledTasks(): void {
649
+ const now = new Date();
650
+ for (const [key, task] of scheduledTasks) {
651
+ if (task.nextOccurrence <= now) {
652
+ clearTimeout(task.timerId);
653
+ scheduledTasks.delete(key);
654
+ void task.bot.executeRecurringTask(task.scheduleReply, task.config);
655
+ }
656
+ }
657
+ }
@@ -0,0 +1,11 @@
1
+ import type { CommandResultPass } from "./index.ts";
2
+ import { tryExecCommandFunction } from "./util.ts";
3
+
4
+ async function failWorker(): Promise<CommandResultPass> {
5
+ throw new Error("this just never seems to work");
6
+ }
7
+
8
+ export const debugFail = {
9
+ aliases: ["__debug_fail"],
10
+ invoke: tryExecCommandFunction(failWorker)
11
+ };
@@ -0,0 +1,15 @@
1
+ import type { CommandResultPass } from "./index.ts";
2
+ import { tryExecCommandFunction } from "./util.ts";
3
+
4
+ async function helloWorldWorker(): Promise<CommandResultPass> {
5
+ console.log("Hello, world!");
6
+ return {
7
+ kind: "pass",
8
+ reply: "Nice to meet you!"
9
+ };
10
+ }
11
+
12
+ export const helloWorld = {
13
+ aliases: ["hello", "helloworld"],
14
+ invoke: tryExecCommandFunction(helloWorldWorker)
15
+ };