windsor-bot 0.1.16 → 0.2.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.
package/dist/bot.js CHANGED
@@ -1,5 +1,5 @@
1
- import { ChannelType, Client, GatewayIntentBits, } from 'discord.js';
2
- import { formatScheduleDate, generateIcon, getNextOccurrence, parseRecurringSchedule } from './ai.js';
1
+ import { ChannelType, Client, GatewayIntentBits, Partials, } from 'discord.js';
2
+ import { generateIcon, isIconCached } from './ai.js';
3
3
  import { Commands } from './commands/index.js';
4
4
  import { getCurrentConfig, reconcileChannels } from './config.js';
5
5
  import { formatTimestamp } from './printer.js';
@@ -31,6 +31,15 @@ export function extractUrls(text) {
31
31
  export function stripUrls(text) {
32
32
  return text.replace(URL_REGEX, '').replace(/\s+/g, ' ').trim();
33
33
  }
34
+ async function removeOwnReactionSafe(reaction) {
35
+ try {
36
+ if (reaction.me)
37
+ await reaction.users.remove();
38
+ }
39
+ catch {
40
+ // ignore
41
+ }
42
+ }
34
43
  export function replaceUrlsInText(text, urls) {
35
44
  if (urls.length === 0)
36
45
  return text;
@@ -83,10 +92,10 @@ async function removeReactionSafe(message, emoji) {
83
92
  // ignore
84
93
  }
85
94
  }
86
- async function reactSafe(message, emoji) {
95
+ async function reactSafe(message, emoji, clearThinking = true) {
87
96
  try {
88
97
  await message.react(emoji);
89
- if (emoji !== Reaction.thinking) {
98
+ if (emoji !== Reaction.thinking && clearThinking) {
90
99
  await removeReactionSafe(message, Reaction.thinking);
91
100
  }
92
101
  if (emoji === Reaction.ok) {
@@ -127,6 +136,7 @@ export function parseCommand(content) {
127
136
  }
128
137
  export function createWindsorBot() {
129
138
  const client = new Client({
139
+ partials: [Partials.Message, Partials.Reaction, Partials.User],
130
140
  intents: [
131
141
  GatewayIntentBits.Guilds,
132
142
  GatewayIntentBits.GuildMessages,
@@ -136,18 +146,18 @@ export function createWindsorBot() {
136
146
  });
137
147
  const pendingPrints = new Map();
138
148
  const processingPending = new Set();
149
+ const reusableStates = new Map();
139
150
  let pendingPoller = null;
140
151
  client.on('clientReady', () => void onReady());
141
152
  client.on('messageCreate', (msg) => void onMessage(msg));
153
+ client.on('messageReactionAdd', (reaction, user) => void onReactionAdd(reaction, user));
154
+ client.on('messageReactionRemove', (reaction, user) => void onReactionRemove(reaction, user));
142
155
  const bot = {
143
156
  start,
144
157
  destroy,
145
158
  getClient,
146
159
  handleImmediatePrint,
147
160
  handleAccumulatingPrint,
148
- handleRecurringSetup,
149
- executeRecurringTask,
150
- advanceRecurring,
151
161
  handleOnDemand,
152
162
  };
153
163
  async function start(token) {
@@ -163,6 +173,74 @@ export function createWindsorBot() {
163
173
  function getClient() {
164
174
  return client;
165
175
  }
176
+ function getReusableConfig(message) {
177
+ const config = getCurrentConfig();
178
+ const mapping = config.channels.find(m => m.channelId === message.channelId);
179
+ if (!mapping || mapping.config.type !== 'reusable-list')
180
+ return null;
181
+ if (config.serverId && message.guildId !== config.serverId)
182
+ return null;
183
+ if (message.author.bot || message.reference)
184
+ return null;
185
+ return mapping.config;
186
+ }
187
+ async function hydrateReaction(reaction) {
188
+ const fullReaction = reaction.partial ? await reaction.fetch() : reaction;
189
+ if (fullReaction.message.partial)
190
+ await fullReaction.message.fetch();
191
+ return { reaction: fullReaction, message: fullReaction.message };
192
+ }
193
+ async function findStartupReusableReaction(message) {
194
+ if ([...message.reactions.cache.values()].some(reaction => reaction.me))
195
+ return null;
196
+ for (const reaction of message.reactions.cache.values()) {
197
+ const users = await reaction.users.fetch();
198
+ if ([...users.values()].some(user => !user.bot))
199
+ return reaction;
200
+ }
201
+ return null;
202
+ }
203
+ async function onReactionAdd(reactionEvent, user) {
204
+ if (user.bot)
205
+ return;
206
+ try {
207
+ const { reaction, message } = await hydrateReaction(reactionEvent);
208
+ const config = getReusableConfig(message);
209
+ if (!config)
210
+ return;
211
+ if ([...message.reactions.cache.values()].some(candidate => candidate.me))
212
+ return;
213
+ if (reusableStates.has(message.id))
214
+ return;
215
+ const state = { reaction, cancelled: false, status: 'printing' };
216
+ reusableStates.set(message.id, state);
217
+ await handleReusablePrint(message, config, state);
218
+ }
219
+ catch (err) {
220
+ logEvent('error', `Reusable List reaction handling failed: ${err}`);
221
+ }
222
+ }
223
+ async function onReactionRemove(reactionEvent, user) {
224
+ if (user.bot)
225
+ return;
226
+ try {
227
+ const { reaction, message } = await hydrateReaction(reactionEvent);
228
+ if (!getReusableConfig(message))
229
+ return;
230
+ const state = reusableStates.get(message.id);
231
+ if (state) {
232
+ state.cancelled = true;
233
+ pendingPrints.delete(message.id);
234
+ await removeReactionSafe(message, Reaction.waiting);
235
+ if (state.status !== 'printing')
236
+ reusableStates.delete(message.id);
237
+ }
238
+ await removeOwnReactionSafe(reaction);
239
+ }
240
+ catch (err) {
241
+ logEvent('error', `Reusable List reaction removal failed: ${err}`);
242
+ }
243
+ }
166
244
  function isTextOnlyChannel(ch) {
167
245
  return ch.isTextBased()
168
246
  && ch.type !== ChannelType.GuildVoice
@@ -244,16 +322,7 @@ export function createWindsorBot() {
244
322
  if (!mapping)
245
323
  continue;
246
324
  try {
247
- let fetchedMessages;
248
- if (mapping.config.type === 'recurring-print') {
249
- const page1 = await textCh.messages.fetch({ limit: 100 });
250
- const oldest = [...page1.values()].reduce((a, b) => BigInt(a.id) < BigInt(b.id) ? a : b);
251
- const page2 = await textCh.messages.fetch({ limit: 100, before: oldest.id });
252
- fetchedMessages = new Map([...page1, ...page2]);
253
- }
254
- else {
255
- fetchedMessages = await textCh.messages.fetch({ limit: 100 });
256
- }
325
+ const fetchedMessages = await textCh.messages.fetch({ limit: 100 });
257
326
  const sorted = [...fetchedMessages.values()].sort((a, b) => Number(BigInt(a.id) - BigInt(b.id)));
258
327
  await processBehaviorStartup(textCh, mapping.config, sorted);
259
328
  }
@@ -309,8 +378,17 @@ export function createWindsorBot() {
309
378
  }
310
379
  break;
311
380
  }
312
- case 'recurring-print': {
313
- await startupRecurring(channel, config, messages);
381
+ case 'reusable-list': {
382
+ for (const msg of messages) {
383
+ if (msg.author.bot || msg.reference || msg.author.id === botId)
384
+ continue;
385
+ const reaction = await findStartupReusableReaction(msg);
386
+ if (!reaction || reusableStates.has(msg.id))
387
+ continue;
388
+ const state = { reaction, cancelled: false, status: 'printing' };
389
+ reusableStates.set(msg.id, state);
390
+ await handleReusablePrint(msg, config, state);
391
+ }
314
392
  break;
315
393
  }
316
394
  case 'on-demand': {
@@ -333,35 +411,6 @@ export function createWindsorBot() {
333
411
  }
334
412
  }
335
413
  }
336
- async function startupRecurring(channel, config, messages) {
337
- const botId = client.user?.id;
338
- const scheduleReplies = messages.filter(m => m.author.id === botId && m.content.startsWith('Got it. I will print ⟪'));
339
- for (const scheduleReply of scheduleReplies) {
340
- const parentId = scheduleReply.reference?.messageId;
341
- if (!parentId)
342
- continue;
343
- const statusReplies = messages.filter(m => m.author.id === botId &&
344
- m.reference?.messageId === scheduleReply.id &&
345
- (m.content.startsWith('Printed at') || m.content.includes('has expired')));
346
- const latestStatus = statusReplies[statusReplies.length - 1];
347
- if (latestStatus?.content.includes('has expired')) {
348
- continue;
349
- }
350
- if (latestStatus && latestStatus.content.startsWith('Printed at') && !await hasReaction(latestStatus, Reaction.ok)) {
351
- await advanceRecurring(scheduleReply, latestStatus, config);
352
- continue;
353
- }
354
- if (!latestStatus) {
355
- const match = /at (.+)$/.exec(scheduleReply.content);
356
- if (!match?.[1])
357
- continue;
358
- const nextTime = new Date(match[1]);
359
- if (isNaN(nextTime.getTime()))
360
- continue;
361
- scheduleRecurringTask(scheduleReply, nextTime, config, bot);
362
- }
363
- }
364
- }
365
414
  async function onMessage(message) {
366
415
  if (message.author.bot)
367
416
  return;
@@ -394,8 +443,7 @@ export function createWindsorBot() {
394
443
  }
395
444
  break;
396
445
  }
397
- case 'recurring-print':
398
- await handleRecurringSetup(message, mapping.config);
446
+ case 'reusable-list':
399
447
  break;
400
448
  case 'on-demand':
401
449
  await handleOnDemand(message);
@@ -424,9 +472,12 @@ export function createWindsorBot() {
424
472
  `${message.author.username} · ${formatTimestamp(message.createdAt)}`,
425
473
  ];
426
474
  }
475
+ let thinkingReacted = false;
427
476
  if (config.includeIcon) {
428
- await reactSafe(message, Reaction.thinking);
429
477
  const iconCacheDir = getCurrentConfig().iconCacheDir ?? './icon-cache';
478
+ thinkingReacted = !await isIconCached(strippedText, iconCacheDir);
479
+ if (thinkingReacted)
480
+ await reactSafe(message, Reaction.thinking);
430
481
  const iconPath = await generateIcon(strippedText, iconCacheDir);
431
482
  if (iconPath)
432
483
  job.iconPath = iconPath;
@@ -435,7 +486,8 @@ export function createWindsorBot() {
435
486
  if (await hasAnyReaction(message)) {
436
487
  const waiting = message.reactions.cache.get(Reaction.waiting)?.me;
437
488
  if (!retryPending && (!retryFailed || !message.reactions.cache.get(Reaction.fail)?.me)) {
438
- await removeReactionSafe(message, Reaction.thinking);
489
+ if (thinkingReacted)
490
+ await removeReactionSafe(message, Reaction.thinking);
439
491
  return;
440
492
  }
441
493
  if (retryPending && !waiting)
@@ -444,21 +496,22 @@ export function createWindsorBot() {
444
496
  try {
445
497
  await printJob(job);
446
498
  pendingPrints.delete(message.id);
447
- await reactSafe(message, Reaction.ok);
499
+ await reactSafe(message, Reaction.ok, thinkingReacted);
448
500
  logEvent('print', `Printed immediate message from ${message.author.username}`);
449
501
  }
450
502
  catch (err) {
451
503
  if (isPrinterUnavailableError(err)) {
452
- await reactSafe(message, Reaction.waiting);
504
+ await reactSafe(message, Reaction.waiting, thinkingReacted);
453
505
  enqueuePendingPrint(message, () => handleImmediatePrint(message, config, false, true));
454
506
  logEvent('info', `Printer unavailable for immediate message from ${message.author.username}`);
455
507
  return;
456
508
  }
457
- await reactSafe(message, Reaction.fail);
509
+ await reactSafe(message, Reaction.fail, thinkingReacted);
458
510
  await replySafe(message, `⏸️ Print failed: ${err instanceof Error ? err.message : String(err)}`);
459
511
  logEvent('error', `Print failed: ${err}`);
460
512
  }
461
- await removeReactionSafe(message, Reaction.thinking);
513
+ if (thinkingReacted)
514
+ await removeReactionSafe(message, Reaction.thinking);
462
515
  }
463
516
  async function handleAccumulatingPrint(triggerMessage, config, allMessages, retryPending = false) {
464
517
  const botId = client.user?.id;
@@ -529,99 +582,70 @@ export function createWindsorBot() {
529
582
  logEvent('error', `Accumulating print failed: ${err}`);
530
583
  }
531
584
  }
532
- async function handleRecurringSetup(message, config) {
533
- const rawContent = message.content;
585
+ async function handleReusablePrint(message, config, state) {
586
+ if (state.cancelled) {
587
+ reusableStates.delete(message.id);
588
+ return;
589
+ }
534
590
  try {
535
- const parsed = await parseRecurringSchedule(rawContent, new Date());
536
- if (!parsed) {
537
- await reactSafe(message, Reaction.what);
538
- await replySafe(message, '⁉️ Could not parse a schedule from your message. Please try again with a clearer schedule.');
539
- return;
540
- }
541
- const nextStr = formatScheduleDate(parsed.nextOccurrence);
542
- await reactSafe(message, Reaction.ok);
543
- const reply = await replySafe(message, `Got it. I will print ⟪${parsed.message}⟫ at ${nextStr}`);
544
- if (reply) {
545
- scheduleRecurringTask(reply, parsed.nextOccurrence, config, bot);
546
- }
591
+ await message.fetch();
547
592
  }
548
593
  catch (err) {
549
- await reactSafe(message, Reaction.fail);
550
- await replySafe(message, `⁉️ Failed to parse schedule: ${err instanceof Error ? err.message : String(err)}`);
594
+ reusableStates.delete(message.id);
595
+ pendingPrints.delete(message.id);
596
+ logEvent('info', `Cancelled reusable print for unavailable message ${message.id}: ${err}`);
597
+ return;
551
598
  }
552
- }
553
- async function executeRecurringTask(scheduleReply, config) {
554
- const match = /⟪(.+?)⟫/.exec(scheduleReply.content);
555
- if (!match?.[1])
599
+ const rawContent = message.content;
600
+ const urls = extractUrls(rawContent);
601
+ const text = replaceUrlsInText(rawContent, urls);
602
+ const strippedText = stripUrls(rawContent);
603
+ if (strippedText.length > MAX_MESSAGE_CHARS) {
604
+ reusableStates.delete(message.id);
605
+ await removeReactionSafe(message, Reaction.waiting);
606
+ await replySafe(message, '⁉️ This message is too long to print.');
556
607
  return;
557
- const text = match[1];
558
- const urls = extractUrls(text);
559
- const textWithLinks = replaceUrlsInText(text, urls);
560
- const strippedText = stripUrls(text);
561
- const job = {
562
- lines: [textWithLinks],
563
- urls,
564
- };
608
+ }
609
+ const job = { lines: [text], urls };
565
610
  if (config.header)
566
611
  job.header = config.header;
567
612
  if (config.footer)
568
613
  job.footer = config.footer;
569
614
  if (config.includeMetadata) {
570
- job.metadataLines = [`Recurring · ${formatTimestamp(new Date())}`];
571
- }
572
- if (config.includeIcon) {
573
- const iconCacheDir = getCurrentConfig().iconCacheDir ?? './icon-cache';
574
- const iconPath = await generateIcon(strippedText, iconCacheDir);
575
- if (iconPath)
576
- job.iconPath = iconPath;
615
+ job.metadataLines = [
616
+ `${message.author.username} · ${formatTimestamp(message.createdAt)}`,
617
+ ];
577
618
  }
578
- await refreshMessage(scheduleReply);
579
- if (await hasAnyReaction(scheduleReply)) {
580
- await removeReactionSafe(scheduleReply, Reaction.thinking);
619
+ if (state.cancelled) {
620
+ reusableStates.delete(message.id);
581
621
  return;
582
622
  }
583
- const printedAt = formatTimestamp(new Date());
584
623
  try {
585
624
  await printJob(job);
586
625
  }
587
626
  catch (err) {
588
- await removeReactionSafe(scheduleReply, Reaction.thinking);
589
- logEvent('error', `Recurring print failed: ${err}`);
627
+ if (isPrinterUnavailableError(err)) {
628
+ state.status = 'pending';
629
+ await reactSafe(message, Reaction.waiting);
630
+ enqueuePendingPrint(message, () => handleReusablePrint(message, config, state));
631
+ logEvent('info', `Printer unavailable for reusable message in #${message.channelId}`);
632
+ return;
633
+ }
634
+ reusableStates.delete(message.id);
635
+ await removeReactionSafe(message, Reaction.waiting);
636
+ await replySafe(message, `⏸️ Print failed: ${err instanceof Error ? err.message : String(err)}`);
637
+ logEvent('error', `Reusable List print failed: ${err}`);
590
638
  return;
591
639
  }
592
- logEvent('print', `Printed recurring task: ${text}`);
593
- const originalUserMessage = scheduleReply.content.replace(/^Got it\. I will print ⟪.+?⟫ at /, '');
594
- const nextOccurrence = await getNextOccurrence(originalUserMessage, new Date());
595
- let statusReply;
596
- if (!nextOccurrence) {
597
- statusReply = await replySafe(scheduleReply, 'This occurrence has expired and no more prints are scheduled');
598
- }
599
- else {
600
- const nextStr = formatScheduleDate(nextOccurrence);
601
- statusReply = await replySafe(scheduleReply, `Printed at ${printedAt}. The next print will be at ${nextStr}.`);
602
- }
603
- if (statusReply) {
604
- await reactSafe(statusReply, Reaction.ok);
605
- }
606
- await removeReactionSafe(scheduleReply, Reaction.thinking);
607
- if (nextOccurrence && statusReply) {
608
- scheduleRecurringTask(scheduleReply, nextOccurrence, config, bot);
609
- }
610
- }
611
- async function advanceRecurring(scheduleReply, latestStatusReply, config) {
612
- const match = /⟪(.+?)⟫/.exec(scheduleReply.content);
613
- if (!match?.[1])
614
- return;
615
- const nextOccurrence = await getNextOccurrence(match[1], new Date());
616
- if (!nextOccurrence) {
617
- await replySafe(latestStatusReply, 'This occurrence has expired and no more prints are scheduled');
618
- await removeReactionSafe(latestStatusReply, Reaction.thinking);
640
+ pendingPrints.delete(message.id);
641
+ await removeReactionSafe(message, Reaction.waiting);
642
+ if (state.cancelled) {
643
+ reusableStates.delete(message.id);
619
644
  return;
620
645
  }
621
- const nextStr = formatScheduleDate(nextOccurrence);
622
- await replySafe(latestStatusReply, `The next print will be at ${nextStr}.`);
623
- await reactSafe(latestStatusReply, Reaction.ok);
624
- scheduleRecurringTask(scheduleReply, nextOccurrence, config, bot);
646
+ await message.react(state.reaction.emoji);
647
+ state.status = 'printed';
648
+ logEvent('print', `Printed reusable message from ${message.author.username}`);
625
649
  }
626
650
  async function retryFailedMessages(message) {
627
651
  const mapping = getCurrentConfig().channels.find(m => m.channelId === message.channelId);
@@ -690,26 +714,3 @@ export function createWindsorBot() {
690
714
  }
691
715
  return bot;
692
716
  }
693
- const scheduledTasks = new Map();
694
- export function scheduleRecurringTask(scheduleReply, nextOccurrence, config, bot) {
695
- const key = scheduleReply.id;
696
- const existing = scheduledTasks.get(key);
697
- if (existing)
698
- clearTimeout(existing.timerId);
699
- const delay = Math.max(0, nextOccurrence.getTime() - Date.now());
700
- const timerId = setTimeout(() => {
701
- scheduledTasks.delete(key);
702
- void bot.executeRecurringTask(scheduleReply, config);
703
- }, delay);
704
- scheduledTasks.set(key, { scheduleReply, nextOccurrence, config, bot, timerId });
705
- }
706
- export function checkScheduledTasks() {
707
- const now = new Date();
708
- for (const [key, task] of scheduledTasks) {
709
- if (task.nextOccurrence <= now) {
710
- clearTimeout(task.timerId);
711
- scheduledTasks.delete(key);
712
- void task.bot.executeRecurringTask(task.scheduleReply, task.config);
713
- }
714
- }
715
- }
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { getCurrentConfig, loadConfig } from './config.js';
3
3
  import { logEvent, startDiagnosticsServer, setDiscordChannels, setRestartHandler, setRefreshChannelsHandler } from './server.js';
4
- import { createWindsorBot, checkScheduledTasks } from './bot.js';
4
+ import { createWindsorBot } from './bot.js';
5
5
  import { ChannelType } from 'discord.js';
6
6
  const { config } = await loadConfig();
7
7
  startDiagnosticsServer(config.diagnosticsPort);
@@ -39,10 +39,6 @@ export async function restartBot() {
39
39
  const { config } = await loadConfig();
40
40
  await startBot(config);
41
41
  }
42
- // Recurring task checker
43
- setInterval(() => {
44
- checkScheduledTasks();
45
- }, 30_000);
46
42
  setRestartHandler(restartBot);
47
43
  async function refreshChannels() {
48
44
  if (!bot) {
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createServer } from 'http';
2
2
  import { execFile } from 'child_process';
3
3
  import { build } from 'esbuild';
4
- import { readFile, stat } from 'fs/promises';
4
+ import { access, readFile, stat } from 'fs/promises';
5
5
  import { promisify } from 'util';
6
6
  import { spawn } from 'child_process';
7
7
  import { dirname, join } from 'path';
@@ -65,13 +65,29 @@ async function readPackageVersion() {
65
65
  return packageJson.version;
66
66
  }
67
67
  async function updateGlobalBot() {
68
- // GUI-launched processes may not inherit the shell PATH where npm is installed.
69
- const npmEnv = {
70
- ...process.env,
71
- PATH: [dirname(process.execPath), process.env.PATH].filter(Boolean).join(process.platform === 'win32' ? ';' : ':'),
72
- };
73
- await execFileAsync('npm', ['install', '-g', 'windsor-bot@latest'], { env: npmEnv });
74
- const { stdout } = await execFileAsync('npm', ['root', '-g'], { env: npmEnv });
68
+ const npmCliCandidates = [
69
+ process.env['npm_execpath'],
70
+ join(dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
71
+ '/usr/local/lib/node_modules/npm/bin/npm-cli.js',
72
+ '/opt/homebrew/lib/node_modules/npm/bin/npm-cli.js',
73
+ ].filter((candidate) => Boolean(candidate));
74
+ let npmCliPath;
75
+ for (const candidate of npmCliCandidates) {
76
+ try {
77
+ await access(candidate);
78
+ npmCliPath = candidate;
79
+ break;
80
+ }
81
+ catch {
82
+ // Try the next known npm installation location.
83
+ }
84
+ }
85
+ if (!npmCliPath)
86
+ throw new Error('Unable to locate npm');
87
+ const npmCli = npmCliPath;
88
+ const runNpm = (args) => execFileAsync(process.execPath, [npmCli, ...args]);
89
+ await runNpm(['install', '-g', 'windsor-bot@latest']);
90
+ const { stdout } = await runNpm(['root', '-g']);
75
91
  const packageJsonPath = join(stdout.trim(), 'windsor-bot', 'package.json');
76
92
  const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
77
93
  if (typeof packageJson.version !== 'string')