windsor-bot 0.1.12 → 0.1.13

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.
@@ -0,0 +1,6 @@
1
+ These suit SVGs are CC0 assets from Wikimedia Commons:
2
+
3
+ - https://commons.wikimedia.org/wiki/File:Playing_Card_Suit_-_Swiss-French_Club.svg
4
+ - https://commons.wikimedia.org/wiki/File:Playing_Card_Suit_-_Swiss-French_Diamond.svg
5
+ - https://commons.wikimedia.org/wiki/File:Playing_Card_Suit_-_Swiss-French_Heart.svg
6
+ - https://commons.wikimedia.org/wiki/File:Playing_Card_Suit_-_Swiss-French_Spade.svg
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+ <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
5
+ width="72px" height="72px" viewBox="0 0 72 72" enable-background="new 0 0 72 72" xml:space="preserve">
6
+ <path fill="#0A0B0B" d="M19.21,28.22c-12.744,0-12.976,16.22,0,16.22c7.647,0,11.818-7.491,14.367-4.865
7
+ c2.395,2.472-1.391,15.911-1.699,18.073c-0.464,2.936,2.008,5.253,4.712,5.253c2.857,0,4.865-2.317,4.325-5.253
8
+ c-0.387-2.162-3.785-15.524-1.313-18.073c2.395-2.472,7.029,4.942,15.217,4.865c11.662-0.076,11.662-16.22,0-16.22
9
+ c-8.033,0-12.745,7.415-15.217,4.943c-2.317-2.317,4.943-5.021,4.943-12.899c0-11.432-16.065-11.432-16.065-0.077
10
+ c0,7.956,7.492,10.736,5.098,12.976C30.951,35.635,26.857,28.22,19.21,28.22z"/>
11
+ </svg>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+ <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
5
+ width="72px" height="72px" viewBox="0 0 72 72" enable-background="new 0 0 72 72" xml:space="preserve">
6
+ <path fill="#ED2224" d="M36.143,9.601c-5.631,9.257-16.662,20.982-25.688,27.154c9.026,5.708,20.057,17.896,25.688,26.846
7
+ c5.632-8.949,17.28-21.138,25.998-27.077C53.423,30.583,41.774,18.857,36.143,9.601z"/>
8
+ </svg>
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+ <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
5
+ width="72px" height="72px" viewBox="0 0 72 72" enable-background="new 0 0 72 72" xml:space="preserve">
6
+ <path fill="#ED2224" d="M35.84,21.358c-11.368-11.92-27-6-27,9c0,14.684,14.842,18.631,21.158,24.947
7
+ c0.947,0.947,3.711,3.474,5.921,7.895c2.289-4.421,4.974-6.947,5.921-7.895c6-6.316,21-10.264,21-24.632
8
+ C62.84,15.358,47.051,9.438,35.84,21.358z"/>
9
+ </svg>
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+ <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
5
+ width="72px" height="72px" viewBox="0 0 72 72" enable-background="new 0 0 72 72" xml:space="preserve">
6
+ <path fill="#010101" d="M36.067,10.646C29.462,25.327,11.332,27.309,9.35,39.199C7.441,51.017,22.636,58.356,33.572,49.988
7
+ c1.468,3.084-0.367,8.001-3.377,12.111c1.541-0.513,4.624-0.733,5.872-0.733s4.33,0.221,5.872,0.733
8
+ c-3.01-4.11-4.771-9.027-3.377-12.111c12.772,7.928,26.352,0.882,24.443-10.789C60.95,26.648,42.674,25.327,36.067,10.646z"/>
9
+ </svg>
package/dist/bot.js CHANGED
@@ -3,11 +3,12 @@ import { formatScheduleDate, generateIcon, getNextOccurrence, parseRecurringSche
3
3
  import { Commands } from './commands/index.js';
4
4
  import { getCurrentConfig, reconcileChannels } from './config.js';
5
5
  import { formatTimestamp } from './printer.js';
6
- import { printJob } from './printing/index.js';
6
+ import { printJob, PrinterUnavailableError } from './printing/index.js';
7
7
  import { logEvent, status } from './server.js';
8
8
  import { Reaction } from './reactions.js';
9
9
  const MAX_MESSAGE_CHARS = 800;
10
10
  const MAX_URLS = 5;
11
+ const PRINTER_POLL_INTERVAL_MS = 3_000;
11
12
  const URL_REGEX = /https:\/\/[^\s<>"']+/g;
12
13
  const TRAILING_PUNCT = /[.,;:!?)\]}>'"]+$/;
13
14
  export function extractUrls(text) {
@@ -90,6 +91,10 @@ async function reactSafe(message, emoji) {
90
91
  }
91
92
  if (emoji === Reaction.ok) {
92
93
  await removeReactionSafe(message, Reaction.fail);
94
+ await removeReactionSafe(message, Reaction.waiting);
95
+ }
96
+ if (emoji === Reaction.waiting) {
97
+ await removeReactionSafe(message, Reaction.fail);
93
98
  }
94
99
  }
95
100
  catch {
@@ -111,6 +116,15 @@ function isCommandMessage(content) {
111
116
  const trimmed = content.trim();
112
117
  return trimmed.startsWith('!') || trimmed.startsWith('/');
113
118
  }
119
+ export function parseCommand(content) {
120
+ const match = content.trim().match(/^[!/]\s*(\S+)(?:\s+(.*))?$/);
121
+ if (!match?.[1])
122
+ return null;
123
+ return {
124
+ name: match[1].toLowerCase(),
125
+ args: match[2]?.trim() ?? '',
126
+ };
127
+ }
114
128
  export function createWindsorBot() {
115
129
  const client = new Client({
116
130
  intents: [
@@ -120,6 +134,9 @@ export function createWindsorBot() {
120
134
  GatewayIntentBits.GuildMessageReactions,
121
135
  ],
122
136
  });
137
+ const pendingPrints = new Map();
138
+ const processingPending = new Set();
139
+ let pendingPoller = null;
123
140
  client.on('clientReady', () => void onReady());
124
141
  client.on('messageCreate', (msg) => void onMessage(msg));
125
142
  const bot = {
@@ -137,6 +154,10 @@ export function createWindsorBot() {
137
154
  await client.login(token);
138
155
  }
139
156
  function destroy() {
157
+ if (pendingPoller) {
158
+ clearInterval(pendingPoller);
159
+ pendingPoller = null;
160
+ }
140
161
  client.destroy();
141
162
  }
142
163
  function getClient() {
@@ -149,6 +170,37 @@ export function createWindsorBot() {
149
170
  && ch.type !== ChannelType.GuildForum
150
171
  && ch.type !== ChannelType.GuildMedia;
151
172
  }
173
+ function enqueuePendingPrint(message, retry) {
174
+ pendingPrints.set(message.id, { message, retry });
175
+ if (pendingPoller)
176
+ return;
177
+ pendingPoller = setInterval(() => void pollPendingPrints(), PRINTER_POLL_INTERVAL_MS);
178
+ logEvent('info', `Printer unavailable; polling for ${pendingPrints.size} pending print(s)`);
179
+ }
180
+ async function pollPendingPrints() {
181
+ for (const [messageId, pending] of pendingPrints) {
182
+ if (processingPending.has(messageId))
183
+ continue;
184
+ processingPending.add(messageId);
185
+ try {
186
+ await pending.retry();
187
+ await refreshMessage(pending.message);
188
+ if (!pending.message.reactions.cache.get(Reaction.waiting)?.me) {
189
+ pendingPrints.delete(messageId);
190
+ }
191
+ }
192
+ catch (err) {
193
+ logEvent('error', `Pending print retry failed: ${err}`);
194
+ }
195
+ finally {
196
+ processingPending.delete(messageId);
197
+ }
198
+ }
199
+ if (pendingPrints.size === 0 && pendingPoller) {
200
+ clearInterval(pendingPoller);
201
+ pendingPoller = null;
202
+ }
203
+ }
152
204
  async function onReady() {
153
205
  const botUser = client.user;
154
206
  status.connected = true;
@@ -224,7 +276,11 @@ export function createWindsorBot() {
224
276
  if (msg.author.id === botId)
225
277
  continue;
226
278
  if (isCommandMessage(msg.content)) {
227
- await handleOnDemand(msg);
279
+ await handleOnDemand(msg, await hasReaction(msg, Reaction.waiting));
280
+ continue;
281
+ }
282
+ if (await hasReaction(msg, Reaction.waiting)) {
283
+ await handleImmediatePrint(msg, config, false, true);
228
284
  continue;
229
285
  }
230
286
  if (await hasAnyReaction(msg))
@@ -243,6 +299,10 @@ export function createWindsorBot() {
243
299
  continue;
244
300
  if (!isPrintTrigger(msg.content))
245
301
  continue;
302
+ if (await hasReaction(msg, Reaction.waiting)) {
303
+ await handleAccumulatingPrint(msg, config, messages, true);
304
+ continue;
305
+ }
246
306
  if (await hasAnyReaction(msg))
247
307
  continue;
248
308
  await handleAccumulatingPrint(msg, config, messages);
@@ -261,6 +321,10 @@ export function createWindsorBot() {
261
321
  continue;
262
322
  if (msg.author.id === botId)
263
323
  continue;
324
+ if (await hasReaction(msg, Reaction.waiting)) {
325
+ await handleOnDemand(msg, true);
326
+ continue;
327
+ }
264
328
  if (await hasAnyReaction(msg))
265
329
  continue;
266
330
  await handleOnDemand(msg);
@@ -338,7 +402,7 @@ export function createWindsorBot() {
338
402
  break;
339
403
  }
340
404
  }
341
- async function handleImmediatePrint(message, config, retryFailed = false) {
405
+ async function handleImmediatePrint(message, config, retryFailed = false, retryPending = false) {
342
406
  const rawContent = message.content;
343
407
  const urls = extractUrls(rawContent);
344
408
  const textWithLinkLabels = replaceUrlsInText(rawContent, urls);
@@ -369,24 +433,34 @@ export function createWindsorBot() {
369
433
  }
370
434
  await refreshMessage(message);
371
435
  if (await hasAnyReaction(message)) {
372
- if (!retryFailed || !message.reactions.cache.get(Reaction.fail)?.me) {
436
+ const waiting = message.reactions.cache.get(Reaction.waiting)?.me;
437
+ if (!retryPending && (!retryFailed || !message.reactions.cache.get(Reaction.fail)?.me)) {
373
438
  await removeReactionSafe(message, Reaction.thinking);
374
439
  return;
375
440
  }
441
+ if (retryPending && !waiting)
442
+ return;
376
443
  }
377
444
  try {
378
445
  await printJob(job);
446
+ pendingPrints.delete(message.id);
379
447
  await reactSafe(message, Reaction.ok);
380
448
  logEvent('print', `Printed immediate message from ${message.author.username}`);
381
449
  }
382
450
  catch (err) {
451
+ if (err instanceof PrinterUnavailableError) {
452
+ await reactSafe(message, Reaction.waiting);
453
+ enqueuePendingPrint(message, () => handleImmediatePrint(message, config, false, true));
454
+ logEvent('info', `Printer unavailable for immediate message from ${message.author.username}`);
455
+ return;
456
+ }
383
457
  await reactSafe(message, Reaction.fail);
384
458
  await replySafe(message, `⏸️ Print failed: ${err instanceof Error ? err.message : String(err)}`);
385
459
  logEvent('error', `Print failed: ${err}`);
386
460
  }
387
461
  await removeReactionSafe(message, Reaction.thinking);
388
462
  }
389
- async function handleAccumulatingPrint(triggerMessage, config, allMessages) {
463
+ async function handleAccumulatingPrint(triggerMessage, config, allMessages, retryPending = false) {
390
464
  const botId = client.user?.id;
391
465
  const triggerIdx = allMessages.findIndex(m => m.id === triggerMessage.id);
392
466
  const prior = allMessages.slice(0, triggerIdx).reverse();
@@ -433,14 +507,23 @@ export function createWindsorBot() {
433
507
  job.metadataLines = [`Printed at ${formatTimestamp(new Date())}`];
434
508
  }
435
509
  await refreshMessage(triggerMessage);
436
- if (await hasAnyReaction(triggerMessage))
437
- return;
510
+ if (await hasAnyReaction(triggerMessage)) {
511
+ if (!retryPending || !triggerMessage.reactions.cache.get(Reaction.waiting)?.me)
512
+ return;
513
+ }
438
514
  try {
439
515
  await printJob(job);
516
+ pendingPrints.delete(triggerMessage.id);
440
517
  await reactSafe(triggerMessage, Reaction.ok);
441
518
  logEvent('print', `Printed accumulating list (${lines.length} items)`);
442
519
  }
443
520
  catch (err) {
521
+ if (err instanceof PrinterUnavailableError) {
522
+ await reactSafe(triggerMessage, Reaction.waiting);
523
+ enqueuePendingPrint(triggerMessage, () => handleAccumulatingPrint(triggerMessage, config, allMessages, true));
524
+ logEvent('info', `Printer unavailable for accumulating list in #${triggerMessage.channelId}`);
525
+ return;
526
+ }
444
527
  await reactSafe(triggerMessage, Reaction.fail);
445
528
  await replySafe(triggerMessage, `⏸️ Print failed: ${err instanceof Error ? err.message : String(err)}`);
446
529
  logEvent('error', `Accumulating print failed: ${err}`);
@@ -556,28 +639,38 @@ export function createWindsorBot() {
556
639
  }
557
640
  return failed.length;
558
641
  }
559
- async function handleOnDemand(message) {
560
- const content = message.content.trim();
561
- if (!content.startsWith('!') && !content.startsWith('/'))
642
+ async function handleOnDemand(message, retryPending = false) {
643
+ const parsed = parseCommand(message.content);
644
+ if (!parsed)
562
645
  return;
563
- const afterPrefix = content.slice(1).trim();
564
- const spaceIdx = afterPrefix.indexOf(' ');
565
- const commandName = (spaceIdx === -1 ? afterPrefix : afterPrefix.slice(0, spaceIdx)).toLowerCase();
566
- const args = spaceIdx === -1 ? '' : afterPrefix.slice(spaceIdx + 1).trim();
646
+ const { name: commandName, args } = parsed;
567
647
  let foundCommand = false;
568
648
  for (const cmd of Commands) {
569
649
  if (cmd.aliases.some(a => commandName.localeCompare(a, undefined, { sensitivity: "base" }) == 0)) {
570
650
  foundCommand = true;
571
651
  logEvent('command', `Command '${commandName}' invoked by ${message.author.username}`);
572
652
  await refreshMessage(message);
573
- if (await hasAnyReaction(message))
653
+ if (await hasAnyReaction(message) && !retryPending)
574
654
  return;
575
655
  const ctx = {
576
656
  printJob,
577
657
  retryFailedMessages: () => retryFailedMessages(message),
578
658
  };
579
- const result = await cmd.invoke(args, ctx);
659
+ let result;
660
+ try {
661
+ result = await cmd.invoke(args, ctx);
662
+ }
663
+ catch (err) {
664
+ if (err instanceof PrinterUnavailableError) {
665
+ await reactSafe(message, Reaction.waiting);
666
+ enqueuePendingPrint(message, () => handleOnDemand(message, true));
667
+ logEvent('info', `Printer unavailable for command '${commandName}'`);
668
+ return;
669
+ }
670
+ throw err;
671
+ }
580
672
  if (result.kind === 'pass') {
673
+ pendingPrints.delete(message.id);
581
674
  await reactSafe(message, "✅");
582
675
  if (result.reply) {
583
676
  await replySafe(message, result.reply);
@@ -1,6 +1,7 @@
1
1
  import { debugFail } from "./debug-fail.js";
2
2
  import { helloWorld } from "./hello-world.js";
3
3
  import { printPokemon } from "./pokemon.js";
4
+ import { printPoker } from "./poker.js";
4
5
  import { retry } from "./retry.js";
5
6
  import { printSudoku } from "./sudoku.js";
6
7
  import { printWordsearch } from "./wordsearch.js";
@@ -9,6 +10,7 @@ export const Commands = [
9
10
  printSudoku,
10
11
  printWordsearch,
11
12
  printPokemon,
13
+ printPoker,
12
14
  retry,
13
15
  debugFail
14
16
  ];
@@ -0,0 +1,139 @@
1
+ import { readFile, unlink, writeFile } from 'fs/promises';
2
+ import { tmpdir } from 'os';
3
+ import { join } from 'path';
4
+ import { Jimp, JimpMime, loadFont, measureText, ResizeStrategy } from 'jimp';
5
+ import { Resvg } from '@resvg/resvg-js';
6
+ import { tryExecCommandFunction } from './util.js';
7
+ const FONT_DIR = new URL('../../node_modules/@jimp/plugin-print/dist/fonts/open-sans/', import.meta.url).pathname;
8
+ const FONT_64 = `${FONT_DIR}open-sans-64-black/open-sans-64-black.fnt`;
9
+ const FONT_128 = `${FONT_DIR}open-sans-128-black/open-sans-128-black.fnt`;
10
+ const IMAGE_WIDTH = 560;
11
+ const MARGIN = 24;
12
+ const CARD_WIDTH = 88;
13
+ const CARD_HEIGHT = 118;
14
+ const CARD_GAP = 8;
15
+ const HAND_GAP = 12;
16
+ const TITLE_HEIGHT = 48;
17
+ const SUIT_MARGIN = 8;
18
+ const RENDER_SCALE = 3;
19
+ const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
20
+ const SUITS = ['clubs', 'diamonds', 'hearts', 'spades'];
21
+ const SUIT_FILES = {
22
+ clubs: new URL('../../assets/poker/club.svg', import.meta.url),
23
+ diamonds: new URL('../../assets/poker/diamond.svg', import.meta.url),
24
+ hearts: new URL('../../assets/poker/heart.svg', import.meta.url),
25
+ spades: new URL('../../assets/poker/spade.svg', import.meta.url),
26
+ };
27
+ export function dealPokerHand(random = Math.random) {
28
+ const deck = SUITS.flatMap(suit => RANKS.map(rank => ({ rank, suit })));
29
+ for (let i = deck.length - 1; i > 0; i--) {
30
+ const j = Math.floor(random() * (i + 1));
31
+ [deck[i], deck[j]] = [deck[j], deck[i]];
32
+ }
33
+ const board = deck.slice(0, 5);
34
+ const hands = Array.from({ length: 4 }, (_, index) => {
35
+ const offset = 5 + index * 2;
36
+ return [deck[offset], deck[offset + 1]];
37
+ });
38
+ return { board, hands };
39
+ }
40
+ function drawBox(image, left, top) {
41
+ left *= RENDER_SCALE;
42
+ top *= RENDER_SCALE;
43
+ const right = left + CARD_WIDTH * RENDER_SCALE;
44
+ const bottom = top + CARD_HEIGHT * RENDER_SCALE;
45
+ for (let x = left; x <= right; x++) {
46
+ image.setPixelColor(0x000000ff, x, top);
47
+ image.setPixelColor(0x000000ff, x, bottom);
48
+ }
49
+ for (let y = top; y <= bottom; y++) {
50
+ image.setPixelColor(0x000000ff, left, y);
51
+ image.setPixelColor(0x000000ff, right, y);
52
+ }
53
+ }
54
+ async function loadSuitImages() {
55
+ const entries = await Promise.all(Object.entries(SUIT_FILES).map(async ([suit, path]) => {
56
+ const svg = await readFile(path, 'utf8');
57
+ const png = new Resvg(svg, {
58
+ fitTo: { mode: 'width', value: 48 * RENDER_SCALE },
59
+ }).render().asPng();
60
+ return [suit, await Jimp.fromBuffer(png)];
61
+ }));
62
+ return Object.fromEntries(entries);
63
+ }
64
+ function drawCard(image, card, left, top, rankFont, suitImages) {
65
+ drawBox(image, left, top);
66
+ image.print({
67
+ font: rankFont,
68
+ x: (left + 8) * RENDER_SCALE,
69
+ y: (top + 7) * RENDER_SCALE,
70
+ text: card.rank,
71
+ });
72
+ const suit = suitImages[card.suit];
73
+ image.composite(suit, (left + CARD_WIDTH - SUIT_MARGIN) * RENDER_SCALE - suit.width, (top + 58) * RENDER_SCALE);
74
+ }
75
+ async function renderPokerImage(deal) {
76
+ const rankFont = await loadFont(FONT_128);
77
+ const titleFont = await loadFont(FONT_64);
78
+ const suitImages = await loadSuitImages();
79
+ const boardWidth = CARD_WIDTH * 5 + CARD_GAP * 4;
80
+ const handsTop = MARGIN + TITLE_HEIGHT + CARD_HEIGHT + 24;
81
+ const imageHeight = handsTop + CARD_HEIGHT * 4 + HAND_GAP * 3 + MARGIN;
82
+ const image = new Jimp({
83
+ width: IMAGE_WIDTH * RENDER_SCALE,
84
+ height: imageHeight * RENDER_SCALE,
85
+ color: 0xffffffff,
86
+ });
87
+ const title = "Texas Hold 'Em";
88
+ image.print({
89
+ font: titleFont,
90
+ x: (IMAGE_WIDTH * RENDER_SCALE - measureText(titleFont, title)) / 2,
91
+ y: MARGIN * RENDER_SCALE,
92
+ text: title,
93
+ });
94
+ const boardLeft = (IMAGE_WIDTH - boardWidth) / 2;
95
+ for (const [index, card] of deal.board.entries()) {
96
+ drawCard(image, card, boardLeft + index * (CARD_WIDTH + CARD_GAP), MARGIN + TITLE_HEIGHT, rankFont, suitImages);
97
+ }
98
+ const handCardsWidth = CARD_WIDTH * 2 + CARD_GAP;
99
+ const lineLeft = MARGIN + handCardsWidth + 28;
100
+ const lineRight = IMAGE_WIDTH - MARGIN;
101
+ for (const [index, hand] of deal.hands.entries()) {
102
+ const top = handsTop + index * (CARD_HEIGHT + HAND_GAP);
103
+ drawCard(image, hand[0], MARGIN, top, rankFont, suitImages);
104
+ drawCard(image, hand[1], MARGIN + CARD_WIDTH + CARD_GAP, top, rankFont, suitImages);
105
+ const lineY = top + CARD_HEIGHT;
106
+ for (let x = lineLeft * RENDER_SCALE; x <= lineRight * RENDER_SCALE; x++) {
107
+ for (let offset = 0; offset < RENDER_SCALE; offset++) {
108
+ image.setPixelColor(0x000000ff, x, lineY * RENDER_SCALE + offset);
109
+ }
110
+ }
111
+ }
112
+ image.resize({
113
+ w: IMAGE_WIDTH,
114
+ h: imageHeight,
115
+ mode: ResizeStrategy.BICUBIC,
116
+ });
117
+ const imagePath = join(tmpdir(), `windsor-poker-${process.pid}-${Date.now()}.png`);
118
+ await writeFile(imagePath, await image.getBuffer(JimpMime.png));
119
+ return imagePath;
120
+ }
121
+ async function pokerWorker(_args, ctx) {
122
+ const imagePath = await renderPokerImage(dealPokerHand());
123
+ try {
124
+ const job = {
125
+ lines: [],
126
+ urls: [],
127
+ iconPath: imagePath,
128
+ };
129
+ await ctx.printJob(job);
130
+ }
131
+ finally {
132
+ await unlink(imagePath);
133
+ }
134
+ return { kind: 'pass' };
135
+ }
136
+ export const printPoker = {
137
+ aliases: ['poker'],
138
+ invoke: tryExecCommandFunction(pokerWorker),
139
+ };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { loadConfig } from './config.js';
2
+ import { getCurrentConfig, loadConfig } from './config.js';
3
3
  import { logEvent, startDiagnosticsServer, setDiscordChannels, setRestartHandler, setRefreshChannelsHandler } from './server.js';
4
4
  import { createWindsorBot, checkScheduledTasks } from './bot.js';
5
5
  import { ChannelType } from 'discord.js';
@@ -19,21 +19,7 @@ async function startBot(cfg) {
19
19
  try {
20
20
  await nextBot.start(cfg.discordToken);
21
21
  bot = nextBot;
22
- // Register discord channels in server for the web UI
23
- const guilds = [...nextBot.getClient().guilds.cache.values()];
24
- const channels = [];
25
- for (const guild of guilds) {
26
- const fetched = await guild.channels.fetch();
27
- for (const ch of fetched.values()) {
28
- if (ch?.isTextBased() &&
29
- 'name' in ch &&
30
- ch.type !== ChannelType.GuildVoice &&
31
- ch.type !== ChannelType.GuildStageVoice) {
32
- channels.push({ id: ch.id, name: ch.name });
33
- }
34
- }
35
- }
36
- setDiscordChannels(channels);
22
+ await refreshChannels();
37
23
  logEvent('startup', 'Bot started successfully');
38
24
  }
39
25
  catch (err) {
@@ -64,7 +50,13 @@ async function refreshChannels() {
64
50
  return;
65
51
  }
66
52
  logEvent('info', 'Refreshing Discord channels…');
67
- const guilds = [...bot.getClient().guilds.cache.values()];
53
+ const serverId = getCurrentConfig().serverId;
54
+ const guilds = serverId
55
+ ? [bot.getClient().guilds.cache.get(serverId)].filter((guild) => guild !== undefined)
56
+ : [...bot.getClient().guilds.cache.values()];
57
+ if (serverId && guilds.length === 0) {
58
+ throw new Error(`Configured serverId ${serverId} not found`);
59
+ }
68
60
  const channels = [];
69
61
  for (const guild of guilds) {
70
62
  const fetched = await guild.channels.fetch();
@@ -2,26 +2,52 @@ import { getCurrentConfig } from '../config.js';
2
2
  import { printJobEscp } from './escp.js';
3
3
  import { printJobCups } from './cups.js';
4
4
  import { printJobImageFeed, printTestJobImageFeed } from './imagefeed.js';
5
+ export class PrinterUnavailableError extends Error {
6
+ constructor(message = 'Printer is unavailable') {
7
+ super(message);
8
+ this.name = 'PrinterUnavailableError';
9
+ }
10
+ }
11
+ function isUnavailableError(error) {
12
+ if (!(error instanceof Error))
13
+ return false;
14
+ const code = 'code' in error ? error.code : undefined;
15
+ const message = error.message.toLowerCase();
16
+ return code === 'ENOENT' ||
17
+ code === 'ENODEV' ||
18
+ code === 'ENXIO' ||
19
+ message.includes('printer or class does not exist') ||
20
+ message.includes('no printer') ||
21
+ message.includes('device or resource busy');
22
+ }
5
23
  export async function printJob(job) {
6
24
  const cfg = getCurrentConfig();
7
25
  const printConfig = cfg.printConfig;
8
26
  if (!printConfig) {
9
27
  throw new Error('No print mode configured. Set a print mode in the Control Panel.');
10
28
  }
11
- switch (printConfig.mode) {
12
- case 'escp':
13
- await printJobEscp(job, printConfig.serialPort);
14
- break;
15
- case 'cups':
16
- await printJobCups(job, printConfig);
17
- break;
18
- case 'imagefeed':
19
- await printJobImageFeed(job);
20
- break;
21
- default: {
22
- const never = printConfig;
23
- throw new Error(`Unknown print mode: ${never.mode}`);
29
+ try {
30
+ switch (printConfig.mode) {
31
+ case 'escp':
32
+ await printJobEscp(job, printConfig.serialPort);
33
+ break;
34
+ case 'cups':
35
+ await printJobCups(job, printConfig);
36
+ break;
37
+ case 'imagefeed':
38
+ await printJobImageFeed(job);
39
+ break;
40
+ default: {
41
+ const never = printConfig;
42
+ throw new Error(`Unknown print mode: ${never.mode}`);
43
+ }
44
+ }
45
+ }
46
+ catch (error) {
47
+ if (isUnavailableError(error)) {
48
+ throw new PrinterUnavailableError(error instanceof Error ? error.message : String(error));
24
49
  }
50
+ throw error;
25
51
  }
26
52
  }
27
53
  export async function printTestPage() {
package/dist/reactions.js CHANGED
@@ -2,5 +2,6 @@ export const Reaction = {
2
2
  ok: "✅",
3
3
  what: "❓",
4
4
  fail: "❌",
5
- thinking: "🧠"
5
+ thinking: "🧠",
6
+ waiting: "⏳"
6
7
  };
package/dist/server.js CHANGED
@@ -1,12 +1,16 @@
1
1
  import { createServer } from 'http';
2
+ import { execFile } from 'child_process';
2
3
  import { build } from 'esbuild';
3
4
  import { readFile, stat } from 'fs/promises';
5
+ import { promisify } from 'util';
6
+ import { spawn } from 'child_process';
4
7
  import { dirname, join } from 'path';
5
8
  import { fileURLToPath } from 'url';
6
9
  import { getCurrentConfig, updateConfig, checkPassword, hashPassword, getConfigFilePath, } from './config.js';
7
10
  import { printTestPage, getImageFeed, clearImageFeed, renderFeedHtml } from './printing/index.js';
8
11
  const MAX_EVENTS = 200;
9
12
  const events = [];
13
+ const execFileAsync = promisify(execFile);
10
14
  export const status = {
11
15
  connected: false,
12
16
  tag: null,
@@ -53,6 +57,38 @@ function sendJson(res, statusCode, payload) {
53
57
  });
54
58
  res.end(body);
55
59
  }
60
+ async function readPackageVersion() {
61
+ const runtimeDir = dirname(fileURLToPath(import.meta.url));
62
+ const packageJson = JSON.parse(await readFile(join(runtimeDir, '..', 'package.json'), 'utf8'));
63
+ if (typeof packageJson.version !== 'string')
64
+ throw new Error('package.json does not contain a valid version');
65
+ return packageJson.version;
66
+ }
67
+ async function updateGlobalBot() {
68
+ await execFileAsync('npm', ['install', '-g', 'windsor-bot@latest']);
69
+ const { stdout } = await execFileAsync('npm', ['root', '-g']);
70
+ const packageJsonPath = join(stdout.trim(), 'windsor-bot', 'package.json');
71
+ const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
72
+ if (typeof packageJson.version !== 'string')
73
+ throw new Error('Updated package does not contain a valid version');
74
+ return packageJson.version;
75
+ }
76
+ function hardRestart() {
77
+ const child = spawn('/bin/sh', [
78
+ '-c',
79
+ 'sleep 1; exec "$@"',
80
+ 'windsor-hard-restart',
81
+ process.execPath,
82
+ ...process.argv.slice(1),
83
+ ], {
84
+ detached: true,
85
+ stdio: 'inherit',
86
+ cwd: process.cwd(),
87
+ env: process.env,
88
+ });
89
+ child.unref();
90
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), 250);
91
+ }
56
92
  async function readJsonBody(req) {
57
93
  const chunks = [];
58
94
  let total = 0;
@@ -185,6 +221,10 @@ export function startDiagnosticsServer(port) {
185
221
  sendJson(res, 200, status);
186
222
  return;
187
223
  }
224
+ if (method === 'GET' && urlPath === '/api/version') {
225
+ sendJson(res, 200, { version: await readPackageVersion() });
226
+ return;
227
+ }
188
228
  if (method === 'GET' && urlPath === '/api/config') {
189
229
  const cfg = getCurrentConfig();
190
230
  sendJson(res, 200, {
@@ -300,6 +340,18 @@ export function startDiagnosticsServer(port) {
300
340
  }
301
341
  return;
302
342
  }
343
+ if (method === 'POST' && urlPath === '/api/update') {
344
+ const version = await updateGlobalBot();
345
+ logEvent('info', `Updated windsor-bot to version ${version}`);
346
+ sendJson(res, 200, { version });
347
+ return;
348
+ }
349
+ if (method === 'POST' && urlPath === '/api/hard-restart') {
350
+ logEvent('info', 'Hard restart requested via API');
351
+ sendJson(res, 200, { ok: true });
352
+ hardRestart();
353
+ return;
354
+ }
303
355
  if (method === 'POST' && urlPath === '/api/channels/refresh') {
304
356
  if (_refreshChannelsHandler) {
305
357
  await _refreshChannelsHandler();