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
@@ -0,0 +1,35 @@
1
+ import { debugFail } from "./debug-fail.ts";
2
+ import { helloWorld } from "./hello-world.ts";
3
+ import { printPokemon } from "./pokemon.ts";
4
+ import { printSudoku } from "./sudoku.ts";
5
+ import { printWordsearch } from "./wordsearch.ts";
6
+ import type { PrintJob } from "../types.ts";
7
+
8
+ export interface CommandRunContext {
9
+ printJob: (job: PrintJob) => Promise<void>;
10
+ }
11
+
12
+ export type Command = {
13
+ aliases: string[];
14
+ invoke: (msg: string, ctx: CommandRunContext) => Promise<CommandResult>;
15
+ };
16
+
17
+ export type CommandResultPass = {
18
+ kind: "pass";
19
+ reply?: string;
20
+ };
21
+
22
+ export type CommandResultFail = {
23
+ kind: "fail";
24
+ reason: string;
25
+ };
26
+
27
+ export type CommandResult = CommandResultPass | CommandResultFail;
28
+
29
+ export const Commands = [
30
+ helloWorld,
31
+ printSudoku,
32
+ printWordsearch,
33
+ printPokemon,
34
+ debugFail
35
+ ] as const satisfies ReadonlyArray<Command>;
@@ -0,0 +1,146 @@
1
+ import { join, dirname } from 'path';
2
+ import { fileURLToPath } from 'url';
3
+ import type { Command, CommandResult, CommandRunContext } from './index.ts';
4
+ import type { PrintJob } from '../types.ts';
5
+ import { tryExecCommandFunction } from './util.ts';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const SPRITE_DIR = join(__dirname, '../assets/pokemon-sprites');
9
+
10
+ // Kanto Pokédex — index 0 = Bulbasaur (#1)
11
+ const KANTO: string[] = [
12
+ 'Bulbasaur', 'Ivysaur', 'Venusaur',
13
+ 'Charmander', 'Charmeleon', 'Charizard',
14
+ 'Squirtle', 'Wartortle', 'Blastoise',
15
+ 'Caterpie', 'Metapod', 'Butterfree',
16
+ 'Weedle', 'Kakuna', 'Beedrill',
17
+ 'Pidgey', 'Pidgeotto', 'Pidgeot',
18
+ 'Rattata', 'Raticate',
19
+ 'Spearow', 'Fearow',
20
+ 'Ekans', 'Arbok',
21
+ 'Pikachu', 'Raichu',
22
+ 'Sandshrew', 'Sandslash',
23
+ 'Nidoran♀', 'Nidorina', 'Nidoqueen',
24
+ 'Nidoran♂', 'Nidorino', 'Nidoking',
25
+ 'Clefairy', 'Clefable',
26
+ 'Vulpix', 'Ninetales',
27
+ 'Jigglypuff', 'Wigglytuff',
28
+ 'Zubat', 'Golbat',
29
+ 'Oddish', 'Gloom', 'Vileplume',
30
+ 'Paras', 'Parasect',
31
+ 'Venonat', 'Venomoth',
32
+ 'Diglett', 'Dugtrio',
33
+ 'Meowth', 'Persian',
34
+ 'Psyduck', 'Golduck',
35
+ 'Mankey', 'Primeape',
36
+ 'Growlithe', 'Arcanine',
37
+ 'Poliwag', 'Poliwhirl', 'Poliwrath',
38
+ 'Abra', 'Kadabra', 'Alakazam',
39
+ 'Machop', 'Machoke', 'Machamp',
40
+ 'Bellsprout', 'Weepinbell', 'Victreebel',
41
+ 'Tentacool', 'Tentacruel',
42
+ 'Geodude', 'Graveler', 'Golem',
43
+ 'Ponyta', 'Rapidash',
44
+ 'Slowpoke', 'Slowbro',
45
+ 'Magnemite', 'Magneton',
46
+ "Farfetch'd",
47
+ 'Doduo', 'Dodrio',
48
+ 'Seel', 'Dewgong',
49
+ 'Grimer', 'Muk',
50
+ 'Shellder', 'Cloyster',
51
+ 'Gastly', 'Haunter', 'Gengar',
52
+ 'Onix',
53
+ 'Drowzee', 'Hypno',
54
+ 'Krabby', 'Kingler',
55
+ 'Voltorb', 'Electrode',
56
+ 'Exeggcute', 'Exeggutor',
57
+ 'Cubone', 'Marowak',
58
+ 'Hitmonlee', 'Hitmonchan',
59
+ 'Lickitung',
60
+ 'Koffing', 'Weezing',
61
+ 'Rhyhorn', 'Rhydon',
62
+ 'Chansey',
63
+ 'Tangela',
64
+ 'Kangaskhan',
65
+ 'Horsea', 'Seadra',
66
+ 'Goldeen', 'Seaking',
67
+ 'Staryu', 'Starmie',
68
+ 'Mr. Mime',
69
+ 'Scyther',
70
+ 'Jynx',
71
+ 'Electabuzz',
72
+ 'Magmar',
73
+ 'Pinsir',
74
+ 'Tauros',
75
+ 'Magikarp', 'Gyarados',
76
+ 'Lapras',
77
+ 'Ditto',
78
+ 'Eevee', 'Vaporeon', 'Jolteon', 'Flareon',
79
+ 'Porygon',
80
+ 'Omanyte', 'Omastar',
81
+ 'Kabuto', 'Kabutops',
82
+ 'Aerodactyl',
83
+ 'Snorlax',
84
+ 'Articuno', 'Zapdos', 'Moltres',
85
+ 'Dratini', 'Dragonair', 'Dragonite',
86
+ 'Mewtwo',
87
+ 'Mew',
88
+ ];
89
+
90
+ function findPokemon(arg: string): { id: number; name: string } | null {
91
+ const trimmed = arg.trim();
92
+
93
+ // Try by number
94
+ const num = parseInt(trimmed, 10);
95
+ if (!isNaN(num) && num >= 1 && num <= 151) {
96
+ return { id: num, name: KANTO[num - 1]! };
97
+ }
98
+
99
+ // Try by name (case-insensitive)
100
+ const lower = trimmed.toLowerCase();
101
+ const idx = KANTO.findIndex(n => n.toLowerCase() === lower);
102
+ if (idx !== -1) {
103
+ return { id: idx + 1, name: KANTO[idx]! };
104
+ }
105
+
106
+ return null;
107
+ }
108
+
109
+ async function pokemonWorker(args: string, ctx: CommandRunContext): Promise<CommandResult> {
110
+ let id: number;
111
+ let name: string;
112
+
113
+ if (!args.trim()) {
114
+ // Random
115
+ id = Math.floor(Math.random() * KANTO.length) + 1;
116
+ name = KANTO[id - 1]!;
117
+ } else {
118
+ const found = findPokemon(args);
119
+ if (!found) {
120
+ return {
121
+ kind: 'fail',
122
+ reason: `Unknown Pokémon "${args.trim()}". Use a name or number 1-151.`,
123
+ };
124
+ }
125
+ ({ id, name } = found);
126
+ }
127
+
128
+ const spritePath = join(SPRITE_DIR, `${id}.png`);
129
+ const numStr = String(id).padStart(3, '0');
130
+
131
+ const job: PrintJob = {
132
+ header: `#${numStr} ${name}`,
133
+ lines: [],
134
+ urls: [],
135
+ iconPath: spritePath,
136
+ };
137
+
138
+ await ctx.printJob(job);
139
+
140
+ return { kind: 'pass', reply: `Printing #${numStr} ${name}!` };
141
+ }
142
+
143
+ export const printPokemon: Command = {
144
+ aliases: ['pokemon', 'kanto'],
145
+ invoke: tryExecCommandFunction(pokemonWorker),
146
+ };
@@ -0,0 +1,94 @@
1
+ import type { PrintJob } from '../types.ts';
2
+ import type { Command, CommandResult, CommandRunContext } from './index.ts';
3
+ import { tryExecCommandFunction } from './util.ts';
4
+
5
+ type Difficulty = 'kid' | 'easy' | 'medium' | 'hard' | '';
6
+
7
+ type Grid = (number | 0)[][];
8
+
9
+ function generateSudoku(difficulty: Difficulty): Grid {
10
+ // Start with a solved grid
11
+ const base: Grid = [
12
+ [5, 3, 4, 6, 7, 8, 9, 1, 2],
13
+ [6, 7, 2, 1, 9, 5, 3, 4, 8],
14
+ [1, 9, 8, 3, 4, 2, 5, 6, 7],
15
+ [8, 5, 9, 7, 6, 1, 4, 2, 3],
16
+ [4, 2, 6, 8, 5, 3, 7, 9, 1],
17
+ [7, 1, 3, 9, 2, 4, 8, 5, 6],
18
+ [9, 6, 1, 5, 3, 7, 2, 8, 4],
19
+ [2, 8, 7, 4, 1, 9, 6, 3, 5],
20
+ [3, 4, 5, 2, 8, 6, 1, 7, 9],
21
+ ];
22
+
23
+ // Number of cells to remove based on difficulty
24
+ const removes: Record<NonNullable<Difficulty>, number> = {
25
+ '': 35,
26
+ kid: 20,
27
+ easy: 35,
28
+ medium: 45,
29
+ hard: 55,
30
+ };
31
+ const toRemove = removes[difficulty] ?? 35;
32
+
33
+ // Shuffle positions and remove
34
+ const positions = Array.from({ length: 81 }, (_, i) => i);
35
+ for (let i = positions.length - 1; i > 0; i--) {
36
+ const j = Math.floor(Math.random() * (i + 1));
37
+ const tmp = positions[i]!;
38
+ positions[i] = positions[j]!;
39
+ positions[j] = tmp;
40
+ }
41
+
42
+ const grid: Grid = base.map(row => [...row] as (number | 0)[]);
43
+ for (let i = 0; i < toRemove; i++) {
44
+ const pos = positions[i];
45
+ if (pos === undefined) break;
46
+ const row = Math.floor(pos / 9);
47
+ const col = pos % 9;
48
+ if (grid[row]) grid[row]![col] = 0;
49
+ }
50
+ return grid;
51
+ }
52
+
53
+ function renderSudokuGrid(grid: Grid): string[] {
54
+ const lines: string[] = [];
55
+ lines.push('┌───────┬───────┬───────┐');
56
+ for (let row = 0; row < 9; row++) {
57
+ const cells = grid[row] ?? [];
58
+ const r1 = cells.slice(0, 3).map(c => c === 0 ? '·' : String(c)).join(' ');
59
+ const r2 = cells.slice(3, 6).map(c => c === 0 ? '·' : String(c)).join(' ');
60
+ const r3 = cells.slice(6, 9).map(c => c === 0 ? '·' : String(c)).join(' ');
61
+ lines.push(`│ ${r1} │ ${r2} │ ${r3} │`);
62
+ if (row === 2 || row === 5) {
63
+ lines.push('├───────┼───────┼───────┤');
64
+ }
65
+ }
66
+ lines.push('└───────┴───────┴───────┘');
67
+ return lines;
68
+ }
69
+
70
+ async function printSudokuWorker(args: string, ctx: CommandRunContext): Promise<CommandResult> {
71
+ // !TODO: Implement good command parsing
72
+ const difficulty = args as Difficulty;
73
+
74
+ const diff = difficulty || 'easy';
75
+ const grid = generateSudoku(diff as Difficulty);
76
+ const gridLines = renderSudokuGrid(grid);
77
+
78
+ const job: PrintJob = {
79
+ urls: [],
80
+ header: `Sudoku (${diff})`,
81
+ lines: gridLines,
82
+ };
83
+
84
+ await ctx.printJob(job);
85
+
86
+ return {
87
+ kind: "pass"
88
+ };
89
+ }
90
+
91
+ export const printSudoku: Command = {
92
+ aliases: ["sudoku"],
93
+ invoke: tryExecCommandFunction(printSudokuWorker)
94
+ };
@@ -0,0 +1,14 @@
1
+ import type { CommandResult, CommandRunContext } from "./index.ts";
2
+
3
+ export function tryExecCommandFunction(func: (cmd: string, ctx: CommandRunContext) => Promise<CommandResult>) {
4
+ return async function(cmd: string, ctx: CommandRunContext): Promise<CommandResult> {
5
+ try {
6
+ return await func(cmd, ctx);
7
+ } catch(err) {
8
+ return {
9
+ kind: "fail",
10
+ reason: `${err}`
11
+ };
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,118 @@
1
+ import type { PrintJob } from '../types.ts';
2
+ import type { Command, CommandResultPass, CommandRunContext } from './index.ts';
3
+ import { tryExecCommandFunction } from './util.ts';
4
+
5
+ const THEMES: Array<{ name: string; words: string[] }> = [
6
+ {
7
+ name: 'Animals',
8
+ words: ['CAT', 'DOG', 'BIRD', 'FISH', 'LION', 'BEAR', 'FROG', 'DUCK', 'WOLF', 'DEER'],
9
+ },
10
+ {
11
+ name: 'Fruits',
12
+ words: ['APPLE', 'MANGO', 'GRAPE', 'PEACH', 'PLUM', 'PEAR', 'LIME', 'KIWI', 'FIG', 'DATE'],
13
+ },
14
+ {
15
+ name: 'Colors',
16
+ words: ['RED', 'BLUE', 'GREEN', 'PINK', 'GOLD', 'TEAL', 'CORAL', 'TAN', 'IVORY', 'NAVY'],
17
+ },
18
+ {
19
+ name: 'Space',
20
+ words: ['STAR', 'MOON', 'SUN', 'MARS', 'EARTH', 'VENUS', 'COMET', 'ORBIT', 'NOVA', 'RING'],
21
+ },
22
+ {
23
+ name: 'Food',
24
+ words: ['PIZZA', 'TACO', 'SOUP', 'CAKE', 'RICE', 'BREAD', 'PASTA', 'SALAD', 'CHIP', 'PIE'],
25
+ },
26
+ ];
27
+
28
+ const SIZE = 15;
29
+
30
+ function makeGrid(): string[][] {
31
+ return Array.from({ length: SIZE }, () => Array(SIZE).fill('') as string[]);
32
+ }
33
+
34
+ type Direction = [number, number];
35
+
36
+ const DIRECTIONS: Direction[] = [
37
+ [0, 1], [1, 0], [1, 1], [1, -1],
38
+ [0, -1], [-1, 0], [-1, -1], [-1, 1],
39
+ ];
40
+
41
+ function placeWord(grid: string[][], word: string): boolean {
42
+ const shuffledDirs = [...DIRECTIONS].sort(() => Math.random() - 0.5);
43
+ for (const [dr, dc] of shuffledDirs) {
44
+ for (let attempt = 0; attempt < 30; attempt++) {
45
+ const row = Math.floor(Math.random() * SIZE);
46
+ const col = Math.floor(Math.random() * SIZE);
47
+ let fits = true;
48
+ for (let i = 0; i < word.length; i++) {
49
+ const r = row + dr * i;
50
+ const c = col + dc * i;
51
+ if (r < 0 || r >= SIZE || c < 0 || c >= SIZE) { fits = false; break; }
52
+ const cell = grid[r]?.[c];
53
+ if (cell && cell !== word[i]) { fits = false; break; }
54
+ }
55
+ if (fits) {
56
+ for (let i = 0; i < word.length; i++) {
57
+ const r = row + dr * i;
58
+ const c = col + dc * i;
59
+ if (grid[r]) grid[r]![c] = word[i]!;
60
+ }
61
+ return true;
62
+ }
63
+ }
64
+ }
65
+ return false;
66
+ }
67
+
68
+ const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
69
+
70
+ function fillGrid(grid: string[][]): void {
71
+ for (let r = 0; r < SIZE; r++) {
72
+ for (let c = 0; c < SIZE; c++) {
73
+ if (!grid[r]?.[c]) {
74
+ if (grid[r]) grid[r]![c] = LETTERS[Math.floor(Math.random() * 26)] ?? 'A';
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ function renderGrid(grid: string[][]): string[] {
81
+ return grid.map(row => row.join(' '));
82
+ }
83
+
84
+ async function printWordsearchWorker(_args: string, ctx: CommandRunContext): Promise<CommandResultPass> {
85
+ const theme = THEMES[Math.floor(Math.random() * THEMES.length)];
86
+ if (!theme) throw new Error('No themes available');
87
+
88
+ const grid = makeGrid();
89
+ const placed: string[] = [];
90
+ for (const word of theme.words) {
91
+ if (placeWord(grid, word)) placed.push(word);
92
+ }
93
+ fillGrid(grid);
94
+
95
+ const lines: string[] = [
96
+ ...renderGrid(grid),
97
+ '',
98
+ 'Find these words:',
99
+ placed.join(' '),
100
+ ];
101
+
102
+ const job: PrintJob = {
103
+ urls: [],
104
+ header: `Word Search: ${theme.name}`,
105
+ lines,
106
+ };
107
+
108
+ await ctx.printJob(job);
109
+
110
+ return {
111
+ kind: "pass"
112
+ };
113
+ }
114
+
115
+ export const printWordsearch: Command = {
116
+ aliases: ["wordsearch"],
117
+ invoke: tryExecCommandFunction(printWordsearchWorker)
118
+ };
package/src/config.ts ADDED
@@ -0,0 +1,166 @@
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import { createHash } from 'crypto';
3
+ import { join } from 'path';
4
+ import type { WindsorConfig, ChannelMapping, TokenUsageEntry } from './types.ts';
5
+
6
+ export const DEFAULT_CONFIG_FILE = 'windsor.config.json';
7
+
8
+ export function getConfigPath(): string {
9
+ if (process.env['WINDSOR_CONFIG_PATH']) return process.env['WINDSOR_CONFIG_PATH'];
10
+ return join(process.cwd(), DEFAULT_CONFIG_FILE);
11
+ }
12
+
13
+
14
+ let _config: WindsorConfig = {
15
+ diagnosticsPort: 8080,
16
+ channels: [],
17
+ tokenUsage: [],
18
+ };
19
+ let _configPath: string = getConfigPath();
20
+
21
+ export function getCurrentConfig(): WindsorConfig {
22
+ return _config;
23
+ }
24
+
25
+ export function getConfigFilePath(): string {
26
+ return _configPath;
27
+ }
28
+
29
+
30
+ function defaultConfig(): WindsorConfig {
31
+ const envPort = Number.parseInt(process.env['DIAGNOSTICS_PORT'] ?? '8080', 10);
32
+ const diagnosticsPort = Number.isFinite(envPort) && envPort >= 1 && envPort <= 65535 ? envPort : 8080;
33
+ const result: WindsorConfig = {
34
+ diagnosticsPort,
35
+ channels: [],
36
+ tokenUsage: [],
37
+ };
38
+ const token = process.env['DISCORD_TOKEN']?.trim();
39
+ if (token) result.discordToken = token;
40
+ const serverId = process.env['SERVER_ID']?.trim();
41
+ if (serverId) result.serverId = serverId;
42
+ const openaiKey = process.env['OPENAI_API_KEY']?.trim();
43
+ if (openaiKey) result.openaiKey = openaiKey;
44
+ return result;
45
+ }
46
+
47
+ function parseConfig(raw: unknown): WindsorConfig {
48
+ if (typeof raw !== 'object' || raw === null) {
49
+ return defaultConfig();
50
+ }
51
+ const obj = raw as Record<string, unknown>;
52
+ const base = defaultConfig();
53
+ const result: WindsorConfig = {
54
+ diagnosticsPort: typeof obj['diagnosticsPort'] === 'number' ? obj['diagnosticsPort'] : base.diagnosticsPort,
55
+ channels: Array.isArray(obj['channels']) ? (obj['channels'] as unknown[]).flatMap(c => {
56
+ if (typeof c === 'object' && c !== null) return [c as ChannelMapping];
57
+ return [];
58
+ }) : [],
59
+ tokenUsage: Array.isArray(obj['tokenUsage']) ? (obj['tokenUsage'] as unknown[]).flatMap(e => {
60
+ if (typeof e === 'object' && e !== null) return [e as TokenUsageEntry];
61
+ return [];
62
+ }) : [],
63
+ };
64
+ if (typeof obj['discordToken'] === 'string' && obj['discordToken'].trim()) {
65
+ result.discordToken = obj['discordToken'].trim();
66
+ } else if (base.discordToken) {
67
+ result.discordToken = base.discordToken;
68
+ }
69
+ if (typeof obj['serverId'] === 'string' && obj['serverId'].trim()) {
70
+ result.serverId = obj['serverId'].trim();
71
+ } else if (base.serverId) {
72
+ result.serverId = base.serverId;
73
+ }
74
+ if (typeof obj['openaiKey'] === 'string' && obj['openaiKey'].trim()) {
75
+ result.openaiKey = obj['openaiKey'].trim();
76
+ } else if (base.openaiKey) {
77
+ result.openaiKey = base.openaiKey;
78
+ }
79
+ if (typeof obj['passwordHash'] === 'string') result.passwordHash = obj['passwordHash'];
80
+ if (typeof obj['iconCacheDir'] === 'string') result.iconCacheDir = obj['iconCacheDir'];
81
+ if (typeof obj['printConfig'] === 'object' && obj['printConfig'] !== null) {
82
+ result.printConfig = obj['printConfig'] as import('./types.ts').PrintConfig;
83
+ }
84
+ return result;
85
+ }
86
+
87
+ export async function loadConfig(): Promise<{ config: WindsorConfig; configPath: string }> {
88
+ _configPath = getConfigPath();
89
+
90
+ try {
91
+ const raw = await readFile(_configPath, 'utf8');
92
+ _config = parseConfig(JSON.parse(raw));
93
+ return { config: _config, configPath: _configPath };
94
+ } catch (err) {
95
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
96
+ throw err;
97
+ }
98
+ }
99
+
100
+ _config = defaultConfig();
101
+ return { config: _config, configPath: _configPath };
102
+ }
103
+
104
+ export async function saveCurrentConfig(): Promise<void> {
105
+ await writeFile(_configPath, `${JSON.stringify(_config, null, 2)}\n`, 'utf8');
106
+ }
107
+
108
+ export async function updateConfig(patch: Partial<WindsorConfig>): Promise<WindsorConfig> {
109
+ const updated: WindsorConfig = { ..._config, ...patch };
110
+ _config = updated;
111
+ await saveCurrentConfig();
112
+ return _config;
113
+ }
114
+
115
+
116
+ export function hashPassword(password: string): string {
117
+ return createHash('sha256').update(password).digest('hex');
118
+ }
119
+
120
+ export function checkPassword(password: string): boolean {
121
+ if (!_config.passwordHash) return true; // no password set
122
+ return hashPassword(password) === _config.passwordHash;
123
+ }
124
+
125
+
126
+ export interface DiscordChannelInfo {
127
+ id: string;
128
+ name: string;
129
+ }
130
+
131
+ /**
132
+ * Reconcile channel configs against the current set of Discord channels.
133
+ * Match by ID first, then by name. Delete unmatched UNLESS all channels are missing (server blip).
134
+ * Mutates config.channels in place and saves.
135
+ */
136
+ export async function reconcileChannels(discordChannels: DiscordChannelInfo[]): Promise<void> {
137
+ if (discordChannels.length === 0) {
138
+ // Server blip - don't delete anything
139
+ return;
140
+ }
141
+
142
+ const channelById = new Map(discordChannels.map(c => [c.id, c]));
143
+ const channelByName = new Map(discordChannels.map(c => [c.name, c]));
144
+
145
+ const reconciled: ChannelMapping[] = [];
146
+ for (const mapping of _config.channels) {
147
+ const byId = channelById.get(mapping.channelId);
148
+ if (byId) {
149
+ // ID match — update name if changed
150
+ reconciled.push({ ...mapping, channelName: byId.name });
151
+ continue;
152
+ }
153
+ const byName = channelByName.get(mapping.channelName);
154
+ if (byName) {
155
+ // Name match — update ID
156
+ reconciled.push({ ...mapping, channelId: byName.id });
157
+ continue;
158
+ }
159
+ // No match — delete this mapping
160
+ }
161
+
162
+ _config.channels = reconciled;
163
+ await saveCurrentConfig();
164
+ }
165
+
166
+
package/src/index.ts ADDED
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import { loadConfig, getCurrentConfig } from './config.ts';
3
+ import { logEvent, startDiagnosticsServer, setDiscordChannels, setRestartHandler, setRefreshChannelsHandler } from './server.ts';
4
+ import { createWindsorBot, checkScheduledTasks } from './bot.ts';
5
+ import { ChannelType } from 'discord.js';
6
+
7
+ const config = getCurrentConfig();
8
+
9
+ startDiagnosticsServer(config.diagnosticsPort);
10
+
11
+ let bot: ReturnType<typeof createWindsorBot> | null = null;
12
+ let startInFlight = false;
13
+
14
+ async function startBot(): Promise<void> {
15
+ if (startInFlight) return;
16
+ const cfg = getCurrentConfig();
17
+ if (!cfg.discordToken) {
18
+ logEvent('info', 'No Discord token configured. Visit the control panel to set up.');
19
+ return;
20
+ }
21
+
22
+ startInFlight = true;
23
+ const nextBot = createWindsorBot();
24
+ try {
25
+ await nextBot.start(cfg.discordToken);
26
+ bot = nextBot;
27
+
28
+ // Register discord channels in server for the web UI
29
+ const guilds = [...nextBot.getClient().guilds.cache.values()];
30
+ const channels: Array<{ id: string; name: string }> = [];
31
+ for (const guild of guilds) {
32
+ const fetched = await guild.channels.fetch();
33
+ for (const ch of fetched.values()) {
34
+ if (
35
+ ch?.isTextBased() &&
36
+ 'name' in ch &&
37
+ ch.type !== ChannelType.GuildVoice &&
38
+ ch.type !== ChannelType.GuildStageVoice
39
+ ) {
40
+ channels.push({ id: ch.id, name: (ch as { name: string }).name });
41
+ }
42
+ }
43
+ }
44
+ setDiscordChannels(channels);
45
+
46
+ logEvent('startup', 'Bot started successfully');
47
+ } catch (err) {
48
+ logEvent('error', `Failed to start bot: ${err instanceof Error ? err.message : String(err)}`);
49
+ } finally {
50
+ startInFlight = false;
51
+ }
52
+ }
53
+
54
+ export async function restartBot(): Promise<void> {
55
+ logEvent('info', 'Restarting bot…');
56
+ if (bot) {
57
+ bot.destroy();
58
+ bot = null;
59
+ }
60
+ setDiscordChannels([]);
61
+ await loadConfig();
62
+ await startBot();
63
+ }
64
+
65
+ // Recurring task checker
66
+ setInterval(() => {
67
+ checkScheduledTasks();
68
+ }, 30_000);
69
+
70
+ setRestartHandler(restartBot);
71
+
72
+ async function refreshChannels(): Promise<void> {
73
+ if (!bot) {
74
+ logEvent('info', 'Cannot refresh channels: bot not connected');
75
+ return;
76
+ }
77
+ logEvent('info', 'Refreshing Discord channels…');
78
+ const guilds = [...bot.getClient().guilds.cache.values()];
79
+ const channels: Array<{ id: string; name: string }> = [];
80
+ for (const guild of guilds) {
81
+ const fetched = await guild.channels.fetch();
82
+ for (const ch of fetched.values()) {
83
+ if (
84
+ ch?.isTextBased() &&
85
+ 'name' in ch &&
86
+ ch.type !== ChannelType.GuildVoice &&
87
+ ch.type !== ChannelType.GuildStageVoice
88
+ ) {
89
+ channels.push({ id: ch.id, name: (ch as { name: string }).name });
90
+ }
91
+ }
92
+ }
93
+ setDiscordChannels(channels);
94
+ logEvent('info', `Refreshed ${channels.length} Discord channels`);
95
+ }
96
+
97
+ setRefreshChannelsHandler(refreshChannels);
98
+ await startBot();