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.
- package/README.md +128 -0
- package/SPEC.md +179 -0
- package/assets/pokemon-sprites/1.png +0 -0
- package/assets/pokemon-sprites/10.png +0 -0
- package/assets/pokemon-sprites/100.png +0 -0
- package/assets/pokemon-sprites/101.png +0 -0
- package/assets/pokemon-sprites/102.png +0 -0
- package/assets/pokemon-sprites/103.png +0 -0
- package/assets/pokemon-sprites/104.png +0 -0
- package/assets/pokemon-sprites/105.png +0 -0
- package/assets/pokemon-sprites/106.png +0 -0
- package/assets/pokemon-sprites/107.png +0 -0
- package/assets/pokemon-sprites/108.png +0 -0
- package/assets/pokemon-sprites/109.png +0 -0
- package/assets/pokemon-sprites/11.png +0 -0
- package/assets/pokemon-sprites/110.png +0 -0
- package/assets/pokemon-sprites/111.png +0 -0
- package/assets/pokemon-sprites/112.png +0 -0
- package/assets/pokemon-sprites/113.png +0 -0
- package/assets/pokemon-sprites/114.png +0 -0
- package/assets/pokemon-sprites/115.png +0 -0
- package/assets/pokemon-sprites/116.png +0 -0
- package/assets/pokemon-sprites/117.png +0 -0
- package/assets/pokemon-sprites/118.png +0 -0
- package/assets/pokemon-sprites/119.png +0 -0
- package/assets/pokemon-sprites/12.png +0 -0
- package/assets/pokemon-sprites/120.png +0 -0
- package/assets/pokemon-sprites/121.png +0 -0
- package/assets/pokemon-sprites/122.png +0 -0
- package/assets/pokemon-sprites/123.png +0 -0
- package/assets/pokemon-sprites/124.png +0 -0
- package/assets/pokemon-sprites/125.png +0 -0
- package/assets/pokemon-sprites/126.png +0 -0
- package/assets/pokemon-sprites/127.png +0 -0
- package/assets/pokemon-sprites/128.png +0 -0
- package/assets/pokemon-sprites/129.png +0 -0
- package/assets/pokemon-sprites/13.png +0 -0
- package/assets/pokemon-sprites/130.png +0 -0
- package/assets/pokemon-sprites/131.png +0 -0
- package/assets/pokemon-sprites/132.png +0 -0
- package/assets/pokemon-sprites/133.png +0 -0
- package/assets/pokemon-sprites/134.png +0 -0
- package/assets/pokemon-sprites/135.png +0 -0
- package/assets/pokemon-sprites/136.png +0 -0
- package/assets/pokemon-sprites/137.png +0 -0
- package/assets/pokemon-sprites/138.png +0 -0
- package/assets/pokemon-sprites/139.png +0 -0
- package/assets/pokemon-sprites/14.png +0 -0
- package/assets/pokemon-sprites/140.png +0 -0
- package/assets/pokemon-sprites/141.png +0 -0
- package/assets/pokemon-sprites/142.png +0 -0
- package/assets/pokemon-sprites/143.png +0 -0
- package/assets/pokemon-sprites/144.png +0 -0
- package/assets/pokemon-sprites/145.png +0 -0
- package/assets/pokemon-sprites/146.png +0 -0
- package/assets/pokemon-sprites/147.png +0 -0
- package/assets/pokemon-sprites/148.png +0 -0
- package/assets/pokemon-sprites/149.png +0 -0
- package/assets/pokemon-sprites/15.png +0 -0
- package/assets/pokemon-sprites/150.png +0 -0
- package/assets/pokemon-sprites/151.png +0 -0
- package/assets/pokemon-sprites/16.png +0 -0
- package/assets/pokemon-sprites/17.png +0 -0
- package/assets/pokemon-sprites/18.png +0 -0
- package/assets/pokemon-sprites/19.png +0 -0
- package/assets/pokemon-sprites/2.png +0 -0
- package/assets/pokemon-sprites/20.png +0 -0
- package/assets/pokemon-sprites/21.png +0 -0
- package/assets/pokemon-sprites/22.png +0 -0
- package/assets/pokemon-sprites/23.png +0 -0
- package/assets/pokemon-sprites/24.png +0 -0
- package/assets/pokemon-sprites/25.png +0 -0
- package/assets/pokemon-sprites/26.png +0 -0
- package/assets/pokemon-sprites/27.png +0 -0
- package/assets/pokemon-sprites/28.png +0 -0
- package/assets/pokemon-sprites/29.png +0 -0
- package/assets/pokemon-sprites/3.png +0 -0
- package/assets/pokemon-sprites/30.png +0 -0
- package/assets/pokemon-sprites/31.png +0 -0
- package/assets/pokemon-sprites/32.png +0 -0
- package/assets/pokemon-sprites/33.png +0 -0
- package/assets/pokemon-sprites/34.png +0 -0
- package/assets/pokemon-sprites/35.png +0 -0
- package/assets/pokemon-sprites/36.png +0 -0
- package/assets/pokemon-sprites/37.png +0 -0
- package/assets/pokemon-sprites/38.png +0 -0
- package/assets/pokemon-sprites/39.png +0 -0
- package/assets/pokemon-sprites/4.png +0 -0
- package/assets/pokemon-sprites/40.png +0 -0
- package/assets/pokemon-sprites/41.png +0 -0
- package/assets/pokemon-sprites/42.png +0 -0
- package/assets/pokemon-sprites/43.png +0 -0
- package/assets/pokemon-sprites/44.png +0 -0
- package/assets/pokemon-sprites/45.png +0 -0
- package/assets/pokemon-sprites/46.png +0 -0
- package/assets/pokemon-sprites/47.png +0 -0
- package/assets/pokemon-sprites/48.png +0 -0
- package/assets/pokemon-sprites/49.png +0 -0
- package/assets/pokemon-sprites/5.png +0 -0
- package/assets/pokemon-sprites/50.png +0 -0
- package/assets/pokemon-sprites/51.png +0 -0
- package/assets/pokemon-sprites/52.png +0 -0
- package/assets/pokemon-sprites/53.png +0 -0
- package/assets/pokemon-sprites/54.png +0 -0
- package/assets/pokemon-sprites/55.png +0 -0
- package/assets/pokemon-sprites/56.png +0 -0
- package/assets/pokemon-sprites/57.png +0 -0
- package/assets/pokemon-sprites/58.png +0 -0
- package/assets/pokemon-sprites/59.png +0 -0
- package/assets/pokemon-sprites/6.png +0 -0
- package/assets/pokemon-sprites/60.png +0 -0
- package/assets/pokemon-sprites/61.png +0 -0
- package/assets/pokemon-sprites/62.png +0 -0
- package/assets/pokemon-sprites/63.png +0 -0
- package/assets/pokemon-sprites/64.png +0 -0
- package/assets/pokemon-sprites/65.png +0 -0
- package/assets/pokemon-sprites/66.png +0 -0
- package/assets/pokemon-sprites/67.png +0 -0
- package/assets/pokemon-sprites/68.png +0 -0
- package/assets/pokemon-sprites/69.png +0 -0
- package/assets/pokemon-sprites/7.png +0 -0
- package/assets/pokemon-sprites/70.png +0 -0
- package/assets/pokemon-sprites/71.png +0 -0
- package/assets/pokemon-sprites/72.png +0 -0
- package/assets/pokemon-sprites/73.png +0 -0
- package/assets/pokemon-sprites/74.png +0 -0
- package/assets/pokemon-sprites/75.png +0 -0
- package/assets/pokemon-sprites/76.png +0 -0
- package/assets/pokemon-sprites/77.png +0 -0
- package/assets/pokemon-sprites/78.png +0 -0
- package/assets/pokemon-sprites/79.png +0 -0
- package/assets/pokemon-sprites/8.png +0 -0
- package/assets/pokemon-sprites/80.png +0 -0
- package/assets/pokemon-sprites/81.png +0 -0
- package/assets/pokemon-sprites/82.png +0 -0
- package/assets/pokemon-sprites/83.png +0 -0
- package/assets/pokemon-sprites/84.png +0 -0
- package/assets/pokemon-sprites/85.png +0 -0
- package/assets/pokemon-sprites/86.png +0 -0
- package/assets/pokemon-sprites/87.png +0 -0
- package/assets/pokemon-sprites/88.png +0 -0
- package/assets/pokemon-sprites/89.png +0 -0
- package/assets/pokemon-sprites/9.png +0 -0
- package/assets/pokemon-sprites/90.png +0 -0
- package/assets/pokemon-sprites/91.png +0 -0
- package/assets/pokemon-sprites/92.png +0 -0
- package/assets/pokemon-sprites/93.png +0 -0
- package/assets/pokemon-sprites/94.png +0 -0
- package/assets/pokemon-sprites/95.png +0 -0
- package/assets/pokemon-sprites/96.png +0 -0
- package/assets/pokemon-sprites/97.png +0 -0
- package/assets/pokemon-sprites/98.png +0 -0
- package/assets/pokemon-sprites/99.png +0 -0
- package/dist/ai.js +193 -0
- package/dist/bot.d.ts +22 -0
- package/dist/bot.d.ts.map +1 -0
- package/dist/bot.js +547 -0
- package/dist/bot.js.map +1 -0
- package/dist/commands/debug-fail.js +8 -0
- package/dist/commands/hello-world.js +12 -0
- package/dist/commands/hello.d.ts +3 -0
- package/dist/commands/hello.d.ts.map +1 -0
- package/dist/commands/hello.js +8 -0
- package/dist/commands/hello.js.map +1 -0
- package/dist/commands/index.d.ts +3 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +12 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/pokemon.js +132 -0
- package/dist/commands/print.d.ts +9 -0
- package/dist/commands/print.d.ts.map +1 -0
- package/dist/commands/print.js +46 -0
- package/dist/commands/print.js.map +1 -0
- package/dist/commands/sudoku.js +79 -0
- package/dist/commands/util.js +13 -0
- package/dist/commands/wordsearch.js +109 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +149 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +85 -0
- package/dist/index.js.map +1 -0
- package/dist/printer.js +245 -0
- package/dist/printing/cups.js +89 -0
- package/dist/printing/escp.js +147 -0
- package/dist/printing/imagefeed.js +212 -0
- package/dist/printing/index.js +54 -0
- package/dist/reactions.js +5 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +312 -0
- package/dist/server.js.map +1 -0
- package/dist/tests/config.test.js +78 -0
- package/dist/tests/urls.test.js +54 -0
- package/dist/types.d.ts +55 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/dist/web/app.js +325 -0
- package/package.json +45 -0
- package/scripts/download-pokemon-sprites.mjs +38 -0
- package/src/ai.ts +214 -0
- package/src/bot.ts +657 -0
- package/src/commands/debug-fail.ts +11 -0
- package/src/commands/hello-world.ts +15 -0
- package/src/commands/index.ts +35 -0
- package/src/commands/pokemon.ts +146 -0
- package/src/commands/sudoku.ts +94 -0
- package/src/commands/util.ts +14 -0
- package/src/commands/wordsearch.ts +118 -0
- package/src/config.ts +166 -0
- package/src/index.ts +98 -0
- package/src/printer.ts +268 -0
- package/src/printing/cups.ts +104 -0
- package/src/printing/escp.ts +158 -0
- package/src/printing/imagefeed.ts +246 -0
- package/src/printing/index.ts +63 -0
- package/src/reactions.ts +6 -0
- package/src/server.ts +355 -0
- package/src/tests/config.test.ts +88 -0
- package/src/tests/urls.test.ts +64 -0
- package/src/types.ts +120 -0
- package/src/web/app.tsx +690 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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';
|
package/src/reactions.ts
ADDED
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
|
+
}
|