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,246 @@
1
+ import { Jimp, loadFont, measureText, JimpMime, ResizeStrategy } from 'jimp';
2
+ import QRCode from 'qrcode';
3
+ import type { PrintJob } from '../types.ts';
4
+
5
+
6
+ const FONT_DIR = new URL('../../node_modules/@jimp/plugin-print/dist/fonts/open-sans/', import.meta.url).pathname;
7
+
8
+ const FONT_16 = `${FONT_DIR}open-sans-16-black/open-sans-16-black.fnt`;
9
+ const FONT_32 = `${FONT_DIR}open-sans-32-black/open-sans-32-black.fnt`;
10
+
11
+
12
+ interface FeedEntry {
13
+ timestamp: Date;
14
+ label: string;
15
+ png: Buffer;
16
+ }
17
+
18
+ const MAX_FEED_ENTRIES = 50;
19
+ const feed: FeedEntry[] = [];
20
+
21
+ export function getImageFeed(): FeedEntry[] {
22
+ return feed;
23
+ }
24
+
25
+ export function clearImageFeed(): void {
26
+ feed.length = 0;
27
+ }
28
+
29
+
30
+ const PAGE_WIDTH = 560; // ~80mm at 180dpi
31
+ const MARGIN = 20;
32
+ const LINE_HEIGHT_NORMAL = 20;
33
+ const LINE_HEIGHT_LARGE = 36;
34
+ const TEXT_WIDTH = PAGE_WIDTH - MARGIN * 2;
35
+
36
+ function chooseLineHeight(totalLines: number): { lineHeight: number; fontPath: string } {
37
+ if (totalLines <= 4) return { lineHeight: LINE_HEIGHT_LARGE, fontPath: FONT_32 };
38
+ return { lineHeight: LINE_HEIGHT_NORMAL, fontPath: FONT_16 };
39
+ }
40
+
41
+ function wrapText(text: string, font: Awaited<ReturnType<typeof loadFont>>, maxWidth: number): string[] {
42
+ if (!text) return [''];
43
+ const words = text.split(' ');
44
+ const lines: string[] = [];
45
+ let current = '';
46
+ for (const word of words) {
47
+ const candidate = current ? `${current} ${word}` : word;
48
+ if (measureText(font, candidate) <= maxWidth) {
49
+ current = candidate;
50
+ } else {
51
+ if (current) lines.push(current);
52
+ current = word;
53
+ }
54
+ }
55
+ if (current) lines.push(current);
56
+ return lines.length > 0 ? lines : [''];
57
+ }
58
+
59
+
60
+ const QR_SIZE = 120; // pixels per QR code
61
+
62
+ async function renderQrCode(url: string) {
63
+ const pngBuf = await QRCode.toBuffer(url, { type: 'png', width: QR_SIZE, margin: 1 });
64
+ return Jimp.fromBuffer(pngBuf);
65
+ }
66
+
67
+
68
+ export async function renderJobToPng(job: PrintJob): Promise<Buffer> {
69
+ const totalLines = job.lines.length <= 2 ? 2 : job.lines.length;
70
+ const { lineHeight, fontPath } = chooseLineHeight(totalLines);
71
+
72
+ const fontNormal = await loadFont(FONT_16);
73
+ const fontLarge = await loadFont(FONT_32);
74
+ const bodyFont = lineHeight === LINE_HEIGHT_LARGE ? fontLarge : fontNormal;
75
+
76
+ // Pre-load and scale the icon so we know its exact dimensions before sizing the canvas
77
+ let scaledSprite: Awaited<ReturnType<typeof Jimp.read>> | null = null;
78
+ if (job.iconPath) {
79
+ try {
80
+ const sprite = await Jimp.read(job.iconPath);
81
+ // For small pixel-art sprites: scale up by integer factor for crisp pixels.
82
+ // For large icons: scale down to fit within TEXT_WIDTH.
83
+ const scale = sprite.width <= TEXT_WIDTH
84
+ ? Math.floor(TEXT_WIDTH / sprite.width)
85
+ : TEXT_WIDTH / sprite.width;
86
+ sprite.resize({ w: Math.round(sprite.width * scale), h: Math.round(sprite.height * scale), mode: ResizeStrategy.NEAREST_NEIGHBOR });
87
+ scaledSprite = sprite;
88
+ } catch {
89
+ // silently skip
90
+ }
91
+ }
92
+
93
+ // Measure total height now that icon dimensions are known
94
+ let estimatedHeight = MARGIN * 2;
95
+ if (job.header) estimatedHeight += LINE_HEIGHT_LARGE * 2 + 12;
96
+ if (scaledSprite) estimatedHeight += scaledSprite.height + 8;
97
+ for (const line of job.lines) {
98
+ const wrapped = wrapText(line || ' ', bodyFont, TEXT_WIDTH);
99
+ estimatedHeight += wrapped.length * lineHeight;
100
+ }
101
+ if (job.urls.length > 0) estimatedHeight += job.urls.length * (QR_SIZE + LINE_HEIGHT_NORMAL + 8) + 8;
102
+ if (job.footer) estimatedHeight += LINE_HEIGHT_NORMAL * 2 + 8;
103
+ if (job.metadataLines) estimatedHeight += job.metadataLines.length * LINE_HEIGHT_NORMAL + 8;
104
+ estimatedHeight += 20; // bottom padding
105
+
106
+ const img = new Jimp({ width: PAGE_WIDTH, height: Math.max(estimatedHeight, 100), color: 0xffffffff });
107
+
108
+ let y = MARGIN;
109
+
110
+ const printLine = (text: string, font: Awaited<ReturnType<typeof loadFont>>, lh: number): void => {
111
+ img.print({ font, x: MARGIN, y, text, maxWidth: TEXT_WIDTH });
112
+ y += lh;
113
+ };
114
+
115
+ // Header
116
+ if (job.header) {
117
+ const headerLines = wrapText(job.header, fontLarge, TEXT_WIDTH);
118
+ for (const line of headerLines) {
119
+ img.print({ font: fontLarge, x: MARGIN, y, text: line, maxWidth: TEXT_WIDTH });
120
+ y += LINE_HEIGHT_LARGE;
121
+ }
122
+ y += 12;
123
+ }
124
+
125
+ // Icon / sprite image
126
+ if (scaledSprite) {
127
+ img.composite(scaledSprite, MARGIN, y);
128
+ y += scaledSprite.height + 8;
129
+ }
130
+
131
+ // Primary text
132
+ for (const line of job.lines) {
133
+ const wrapped = wrapText(line || ' ', bodyFont, TEXT_WIDTH);
134
+ for (const l of wrapped) {
135
+ printLine(l, bodyFont, lineHeight);
136
+ }
137
+ }
138
+ y += 8;
139
+
140
+ // URLs — render each as a QR code with a label beneath it
141
+ if (job.urls.length > 0) {
142
+ y += 8;
143
+ for (let i = 0; i < job.urls.length; i++) {
144
+ const url = job.urls[i];
145
+ if (!url) continue;
146
+ const label = job.urls.length === 1 ? '🔗 Scan to open link' : `🔗 Link ${i + 1}`;
147
+ printLine(label, fontNormal, LINE_HEIGHT_NORMAL);
148
+
149
+ const qrImg = await renderQrCode(url);
150
+ img.composite(qrImg, MARGIN, y);
151
+ y += QR_SIZE + 4;
152
+
153
+ // Print the URL text in small font next to/below the QR
154
+ const urlLines = wrapText(url, fontNormal, TEXT_WIDTH);
155
+ for (const l of urlLines) {
156
+ printLine(l, fontNormal, LINE_HEIGHT_NORMAL);
157
+ }
158
+ y += 4;
159
+ }
160
+ }
161
+
162
+ // Footer
163
+ if (job.footer) {
164
+ y += 8;
165
+ const footerLines = wrapText(job.footer, fontNormal, TEXT_WIDTH);
166
+ for (const line of footerLines) {
167
+ printLine(line, fontNormal, LINE_HEIGHT_NORMAL);
168
+ }
169
+ }
170
+
171
+ // Metadata
172
+ if (job.metadataLines && job.metadataLines.length > 0) {
173
+ y += 8;
174
+ for (const line of job.metadataLines) {
175
+ printLine(line, fontNormal, LINE_HEIGHT_NORMAL);
176
+ }
177
+ }
178
+
179
+ // Draw a dashed separator line at the bottom to simulate a receipt cut
180
+ const cutY = y + 10;
181
+ if (cutY < img.height) {
182
+ for (let x = MARGIN; x < PAGE_WIDTH - MARGIN; x += 8) {
183
+ img.setPixelColor(0x888888ff, x, cutY);
184
+ img.setPixelColor(0x888888ff, x + 1, cutY);
185
+ }
186
+ }
187
+
188
+ return img.getBuffer(JimpMime.png);
189
+ }
190
+
191
+
192
+ export async function printJobImageFeed(job: PrintJob): Promise<void> {
193
+ const png = await renderJobToPng(job);
194
+ const label = job.header ?? job.lines[0] ?? 'print';
195
+ feed.unshift({ timestamp: new Date(), label, png });
196
+ if (feed.length > MAX_FEED_ENTRIES) feed.length = MAX_FEED_ENTRIES;
197
+ }
198
+
199
+ export async function printTestJobImageFeed(job: PrintJob): Promise<void> {
200
+ return printJobImageFeed(job);
201
+ }
202
+
203
+
204
+ export function renderFeedHtml(): string {
205
+ const items = feed.map((entry, i) => {
206
+ const b64 = entry.png.toString('base64');
207
+ const ts = entry.timestamp.toLocaleString();
208
+ return `
209
+ <div style="margin-bottom:32px; border-bottom: 1px solid #333; padding-bottom:24px;">
210
+ <p style="color:#888; margin:0 0 8px;">#${i + 1} — ${ts} — <em>${escHtml(entry.label)}</em></p>
211
+ <img src="data:image/png;base64,${b64}" style="max-width:100%; border:1px solid #444;" alt="print job ${i + 1}"/>
212
+ </div>`;
213
+ }).join('\n');
214
+
215
+ const emptyMsg = feed.length === 0
216
+ ? '<p style="color:#888">No print jobs yet. Switch to "Image Feed" mode and trigger a print.</p>'
217
+ : '';
218
+
219
+ return `<!DOCTYPE html>
220
+ <html>
221
+ <head>
222
+ <meta charset="utf-8"/>
223
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
224
+ <title>Windsor Image Feed</title>
225
+ <style>
226
+ :root { color-scheme: dark; }
227
+ body { margin: 0; padding: 24px; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background: #0f1115; color: #f5f7fa; max-width: 700px; }
228
+ h1 { margin-top: 0; }
229
+ button { background: #2e7dff; color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; margin-bottom: 24px; }
230
+ </style>
231
+ </head>
232
+ <body>
233
+ <h1>🖨️ Image Feed</h1>
234
+ <form method="POST" action="/feed/clear" style="display:inline">
235
+ <button type="submit">Clear Feed</button>
236
+ </form>
237
+ <p style="color:#888; margin-top:0;">Newest first. Refresh the page to see new jobs. ${feed.length} job(s).</p>
238
+ ${emptyMsg}
239
+ ${items}
240
+ </body>
241
+ </html>`;
242
+ }
243
+
244
+ function escHtml(str: string): string {
245
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
246
+ }
@@ -0,0 +1,63 @@
1
+ import type { PrintJob } from '../types.ts';
2
+ import { getCurrentConfig } from '../config.ts';
3
+ import { printJobEscp } from './escp.ts';
4
+ import { printJobCups } from './cups.ts';
5
+ import { printJobImageFeed, printTestJobImageFeed } from './imagefeed.ts';
6
+
7
+ export async function printJob(job: PrintJob): Promise<void> {
8
+ const cfg = getCurrentConfig();
9
+ const printConfig = cfg.printConfig;
10
+
11
+ if (!printConfig) {
12
+ throw new Error('No print mode configured. Set a print mode in the Control Panel.');
13
+ }
14
+
15
+ switch (printConfig.mode) {
16
+ case 'escp':
17
+ await printJobEscp(job, printConfig.serialPort);
18
+ break;
19
+ case 'cups':
20
+ await printJobCups(job, printConfig);
21
+ break;
22
+ case 'imagefeed':
23
+ await printJobImageFeed(job);
24
+ break;
25
+ default: {
26
+ const never: never = printConfig;
27
+ throw new Error(`Unknown print mode: ${(never as { mode: string }).mode}`);
28
+ }
29
+ }
30
+ }
31
+
32
+ export async function printTestPage(): Promise<void> {
33
+ const cfg = getCurrentConfig();
34
+ const printConfig = cfg.printConfig;
35
+
36
+ if (!printConfig) {
37
+ throw new Error('No print mode configured.');
38
+ }
39
+
40
+ const testJob: PrintJob = {
41
+ header: 'Windsor Test Page',
42
+ lines: [
43
+ 'Normal text — The quick brown fox jumps over the lazy dog.',
44
+ ],
45
+ urls: ['https://github.com/SeaRyanC/d2p'],
46
+ footer: 'Footer text here',
47
+ metadataLines: [`Printed at: ${new Date().toLocaleString()}`, `Mode: ${printConfig.mode}`],
48
+ };
49
+
50
+ switch (printConfig.mode) {
51
+ case 'escp':
52
+ await printJobEscp(testJob, printConfig.serialPort);
53
+ break;
54
+ case 'cups':
55
+ await printJobCups(testJob, printConfig);
56
+ break;
57
+ case 'imagefeed':
58
+ await printTestJobImageFeed(testJob);
59
+ break;
60
+ }
61
+ }
62
+
63
+ export { getImageFeed, clearImageFeed, renderFeedHtml } from './imagefeed.ts';
@@ -0,0 +1,6 @@
1
+ export const Reaction = {
2
+ ok: "✅",
3
+ what: "❓",
4
+ fail: "❌"
5
+ } as const;
6
+ export type Reaction = (typeof Reaction)[keyof typeof Reaction];
package/src/server.ts ADDED
@@ -0,0 +1,355 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from 'http';
2
+ import { build } from 'esbuild';
3
+ import { stat } from 'fs/promises';
4
+ import { join } from 'path';
5
+ import {
6
+ getCurrentConfig,
7
+ updateConfig,
8
+ checkPassword,
9
+ hashPassword,
10
+ getConfigFilePath,
11
+ } from './config.ts';
12
+ import { printTestPage, getImageFeed, clearImageFeed, renderFeedHtml } from './printing/index.ts';
13
+ import type { DiagnosticEvent, BotStatus, PrintConfig } from './types.ts';
14
+
15
+ const MAX_EVENTS = 200;
16
+ const events: DiagnosticEvent[] = [];
17
+
18
+ export const status: BotStatus = {
19
+ connected: false,
20
+ tag: null,
21
+ startedAt: null,
22
+ guilds: [],
23
+ configuredServerId: null,
24
+ };
25
+
26
+ export function logEvent(type: DiagnosticEvent['type'], message: string): void {
27
+ const event: DiagnosticEvent = { timestamp: new Date().toISOString(), type, message };
28
+ events.push(event);
29
+ if (events.length > MAX_EVENTS) events.shift();
30
+ console.log(`[${event.timestamp}] [${type.toUpperCase()}] ${message}`);
31
+ }
32
+
33
+
34
+ function checkAuth(req: IncomingMessage): boolean {
35
+ const config = getCurrentConfig();
36
+ if (!config.passwordHash) return true;
37
+
38
+ const authHeader = req.headers['authorization'];
39
+ if (!authHeader || !authHeader.startsWith('Basic ')) return false;
40
+
41
+ const base64 = authHeader.slice(6);
42
+ const decoded = Buffer.from(base64, 'base64').toString('utf8');
43
+ const colonIdx = decoded.indexOf(':');
44
+ const password = colonIdx === -1 ? decoded : decoded.slice(colonIdx + 1);
45
+
46
+ return checkPassword(password);
47
+ }
48
+
49
+ function requireAuth(req: IncomingMessage, res: ServerResponse): boolean {
50
+ if (!checkAuth(req)) {
51
+ res.writeHead(401, {
52
+ 'WWW-Authenticate': 'Basic realm="Windsor"',
53
+ 'Content-Type': 'application/json',
54
+ });
55
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
56
+ return false;
57
+ }
58
+ return true;
59
+ }
60
+
61
+
62
+ function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
63
+ const body = JSON.stringify(payload, null, 2);
64
+ res.writeHead(statusCode, {
65
+ 'Content-Type': 'application/json; charset=utf-8',
66
+ 'Content-Length': Buffer.byteLength(body),
67
+ });
68
+ res.end(body);
69
+ }
70
+
71
+ async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
72
+ const chunks: Buffer[] = [];
73
+ let total = 0;
74
+ for await (const chunk of req) {
75
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Buffer);
76
+ total += buf.byteLength;
77
+ if (total > 1_000_000) throw new Error('Request body too large');
78
+ chunks.push(buf);
79
+ }
80
+ if (chunks.length === 0) return {};
81
+ const raw = Buffer.concat(chunks).toString('utf8');
82
+ const parsed = JSON.parse(raw);
83
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
84
+ throw new Error('JSON body must be an object');
85
+ }
86
+ return parsed as Record<string, unknown>;
87
+ }
88
+
89
+
90
+ let appBundleCache: { script: string; mtimeMs: number } | null = null;
91
+
92
+ async function loadAppScript(): Promise<string> {
93
+ const entryPoint = join(process.cwd(), 'src', 'web', 'app.tsx');
94
+ const sourceStat = await stat(entryPoint);
95
+ if (appBundleCache && appBundleCache.mtimeMs === sourceStat.mtimeMs) {
96
+ return appBundleCache.script;
97
+ }
98
+
99
+ const result = await build({
100
+ entryPoints: [entryPoint],
101
+ bundle: true,
102
+ write: false,
103
+ platform: 'browser',
104
+ format: 'esm',
105
+ target: ['es2022'],
106
+ jsx: 'automatic',
107
+ jsxImportSource: 'preact',
108
+ sourcemap: 'inline',
109
+ });
110
+
111
+ const firstFile = result.outputFiles[0];
112
+ if (!firstFile) throw new Error('Failed to generate app bundle');
113
+ appBundleCache = { script: firstFile.text, mtimeMs: sourceStat.mtimeMs };
114
+ return firstFile.text;
115
+ }
116
+
117
+ function renderHtmlShell(): string {
118
+ return `<!DOCTYPE html>
119
+ <html>
120
+ <head>
121
+ <meta charset="utf-8"/>
122
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
123
+ <title>Windsor Control Panel</title>
124
+ <style>
125
+ :root { color-scheme: dark; }
126
+ body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background: #0f1115; color: #f5f7fa; }
127
+ a { color: #8ab4ff; }
128
+ </style>
129
+ </head>
130
+ <body>
131
+ <div id="app"></div>
132
+ <script type="module" src="/app.js"></script>
133
+ </body>
134
+ </html>`;
135
+ }
136
+
137
+
138
+ /** Registered Discord channels (set by bot on startup) */
139
+ let discordChannels: Array<{ id: string; name: string }> = [];
140
+
141
+ export function setDiscordChannels(channels: Array<{ id: string; name: string }>): void {
142
+ discordChannels = channels;
143
+ }
144
+
145
+ let _restartHandler: (() => Promise<void>) | null = null;
146
+
147
+ export function setRestartHandler(fn: () => Promise<void>): void {
148
+ _restartHandler = fn;
149
+ }
150
+
151
+ let _refreshChannelsHandler: (() => Promise<void>) | null = null;
152
+
153
+ export function setRefreshChannelsHandler(fn: () => Promise<void>): void {
154
+ _refreshChannelsHandler = fn;
155
+ }
156
+
157
+ export function startDiagnosticsServer(port: number): void {
158
+ const server = createServer(async (req, res) => {
159
+ try {
160
+ const method = req.method ?? 'GET';
161
+ const rawUrl = req.url ?? '/';
162
+ const urlPath = rawUrl.split('?')[0] ?? '/';
163
+
164
+ // Static assets (no auth required)
165
+ if (method === 'GET' && urlPath === '/app.js') {
166
+ const script = await loadAppScript();
167
+ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
168
+ res.end(script);
169
+ return;
170
+ }
171
+
172
+ if (method === 'GET' && urlPath === '/') {
173
+ const html = renderHtmlShell();
174
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
175
+ res.end(html);
176
+ return;
177
+ }
178
+ if (method === 'GET' && urlPath === '/feed') {
179
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
180
+ res.end(renderFeedHtml());
181
+ return;
182
+ }
183
+
184
+ if (method === 'POST' && urlPath === '/feed/clear') {
185
+ clearImageFeed();
186
+ res.writeHead(303, { 'Location': '/feed' });
187
+ res.end();
188
+ return;
189
+ }
190
+
191
+ // All API routes require auth
192
+ if (!requireAuth(req, res)) return;
193
+ if (method === 'GET' && urlPath === '/api/events') {
194
+ sendJson(res, 200, { status, events: [...events].reverse() });
195
+ return;
196
+ }
197
+
198
+ if (method === 'GET' && urlPath === '/api/status') {
199
+ sendJson(res, 200, status);
200
+ return;
201
+ }
202
+ if (method === 'GET' && urlPath === '/api/config') {
203
+ const cfg = getCurrentConfig();
204
+ sendJson(res, 200, {
205
+ discordToken: cfg.discordToken ?? '',
206
+ openaiKey: cfg.openaiKey ?? '',
207
+ serverId: cfg.serverId ?? null,
208
+ diagnosticsPort: cfg.diagnosticsPort,
209
+ channels: cfg.channels,
210
+ configPath: getConfigFilePath(),
211
+ });
212
+ return;
213
+ }
214
+
215
+ if ((method === 'POST' || method === 'PUT') && urlPath === '/api/config') {
216
+ const body = await readJsonBody(req);
217
+ const patch: Record<string, unknown> = {};
218
+
219
+ if (Object.hasOwn(body, 'discordToken') && typeof body['discordToken'] === 'string') {
220
+ patch['discordToken'] = body['discordToken'].trim() || undefined;
221
+ }
222
+ if (Object.hasOwn(body, 'serverId')) {
223
+ const v = body['serverId'];
224
+ patch['serverId'] = typeof v === 'string' && v.trim() ? v.trim() : undefined;
225
+ }
226
+ if (Object.hasOwn(body, 'openaiKey') && typeof body['openaiKey'] === 'string') {
227
+ patch['openaiKey'] = body['openaiKey'].trim() || undefined;
228
+ }
229
+ if (Object.hasOwn(body, 'diagnosticsPort') && typeof body['diagnosticsPort'] === 'number') {
230
+ patch['diagnosticsPort'] = body['diagnosticsPort'];
231
+ }
232
+ if (Object.hasOwn(body, 'password') && typeof body['password'] === 'string') {
233
+ const pw = body['password'].trim();
234
+ if (pw) {
235
+ patch['passwordHash'] = hashPassword(pw);
236
+ } else {
237
+ patch['passwordHash'] = undefined;
238
+ }
239
+ }
240
+
241
+ await updateConfig(patch as Parameters<typeof updateConfig>[0]);
242
+ const updated = getCurrentConfig();
243
+ sendJson(res, 200, {
244
+ discordToken: updated.discordToken ?? '',
245
+ openaiKey: updated.openaiKey ?? '',
246
+ serverId: updated.serverId ?? null,
247
+ diagnosticsPort: updated.diagnosticsPort,
248
+ channels: updated.channels,
249
+ configPath: getConfigFilePath(),
250
+ });
251
+ return;
252
+ }
253
+ if (method === 'GET' && urlPath === '/api/channels') {
254
+ const config = getCurrentConfig();
255
+ sendJson(res, 200, {
256
+ channels: config.channels,
257
+ discordChannels,
258
+ unmapped: discordChannels.filter(dc =>
259
+ !config.channels.find(m => m.channelId === dc.id)
260
+ ),
261
+ });
262
+ return;
263
+ }
264
+
265
+ if (method === 'POST' && urlPath === '/api/channels') {
266
+ const body = await readJsonBody(req);
267
+ const config = getCurrentConfig();
268
+ const newMapping = body as unknown as import('./types.ts').ChannelMapping;
269
+ const channels = [...config.channels, newMapping];
270
+ await updateConfig({ channels });
271
+ sendJson(res, 200, { channels: getCurrentConfig().channels });
272
+ return;
273
+ }
274
+
275
+ const channelDeleteMatch = /^\/api\/channels\/([^/]+)$/.exec(urlPath);
276
+ if (channelDeleteMatch) {
277
+ const channelId = channelDeleteMatch[1]!;
278
+ if (method === 'DELETE') {
279
+ const config = getCurrentConfig();
280
+ const channels = config.channels.filter(m => m.channelId !== channelId);
281
+ await updateConfig({ channels });
282
+ sendJson(res, 200, { channels: getCurrentConfig().channels });
283
+ return;
284
+ }
285
+ if (method === 'PUT') {
286
+ const body = await readJsonBody(req);
287
+ const config = getCurrentConfig();
288
+ const channels = config.channels.map(m =>
289
+ m.channelId === channelId ? { ...m, ...body } : m
290
+ ) as typeof config.channels;
291
+ await updateConfig({ channels });
292
+ sendJson(res, 200, { channels: getCurrentConfig().channels });
293
+ return;
294
+ }
295
+ }
296
+ if (method === 'GET' && urlPath === '/api/print-config') {
297
+ const cfg = getCurrentConfig();
298
+ sendJson(res, 200, { printConfig: cfg.printConfig ?? null });
299
+ return;
300
+ }
301
+
302
+ if ((method === 'POST' || method === 'PUT') && urlPath === '/api/print-config') {
303
+ const body = await readJsonBody(req);
304
+ const printConfig = body['printConfig'] as PrintConfig | null;
305
+ const patch: Partial<import('./types.ts').WindsorConfig> = {};
306
+ if (printConfig != null) patch.printConfig = printConfig;
307
+ await updateConfig(patch);
308
+ sendJson(res, 200, { printConfig: getCurrentConfig().printConfig ?? null });
309
+ return;
310
+ }
311
+
312
+ if (method === 'POST' && urlPath === '/api/printer/test') {
313
+ await printTestPage();
314
+ logEvent('print', 'Test page printed');
315
+ sendJson(res, 200, { ok: true });
316
+ return;
317
+ }
318
+ if (method === 'POST' && urlPath === '/api/restart') {
319
+ logEvent('info', 'Restart requested via API');
320
+ sendJson(res, 200, { ok: true });
321
+ if (_restartHandler) {
322
+ void _restartHandler();
323
+ }
324
+ return;
325
+ }
326
+
327
+ if (method === 'POST' && urlPath === '/api/channels/refresh') {
328
+ if (_refreshChannelsHandler) {
329
+ await _refreshChannelsHandler();
330
+ const config = getCurrentConfig();
331
+ sendJson(res, 200, {
332
+ channels: config.channels,
333
+ discordChannels,
334
+ unmapped: discordChannels.filter(dc =>
335
+ !config.channels.find(m => m.channelId === dc.id)
336
+ ),
337
+ });
338
+ } else {
339
+ sendJson(res, 503, { error: 'Bot not connected' });
340
+ }
341
+ return;
342
+ }
343
+
344
+ sendJson(res, 404, { error: 'Not found' });
345
+ } catch (err) {
346
+ const message = err instanceof Error ? err.message : String(err);
347
+ sendJson(res, 400, { error: message });
348
+ logEvent('error', `API error: ${message}`);
349
+ }
350
+ });
351
+
352
+ server.listen(port, '0.0.0.0', () => {
353
+ logEvent('startup', `Diagnostics server listening on http://0.0.0.0:${port}`);
354
+ });
355
+ }