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,690 @@
1
+ /** @jsxImportSource preact */
2
+ import { render } from 'preact';
3
+ import type { JSX } from 'preact';
4
+ import { useEffect, useState } from 'preact/hooks';
5
+
6
+
7
+ type BehaviorType = 'immediate-print' | 'accumulating-list' | 'recurring-print' | 'on-demand';
8
+
9
+ interface ChannelBehaviorConfig {
10
+ type: BehaviorType;
11
+ header?: string;
12
+ footer?: string;
13
+ includeIcon?: boolean;
14
+ includeMetadata?: boolean;
15
+ includeChecklist?: boolean;
16
+ }
17
+
18
+ interface ChannelMapping {
19
+ channelId: string;
20
+ channelName: string;
21
+ config: ChannelBehaviorConfig;
22
+ }
23
+
24
+ interface DiscordChannel {
25
+ id: string;
26
+ name: string;
27
+ }
28
+
29
+ interface DiagnosticEvent {
30
+ timestamp: string;
31
+ type: string;
32
+ message: string;
33
+ }
34
+
35
+ interface StatusData {
36
+ connected: boolean;
37
+ tag: string | null;
38
+ startedAt: string | null;
39
+ guilds: string[];
40
+ configuredServerId: string | null;
41
+ }
42
+
43
+ interface ConfigData {
44
+ discordToken: string;
45
+ openaiKey: string;
46
+ serverId: string | null;
47
+ diagnosticsPort: number;
48
+ channels: ChannelMapping[];
49
+ configPath: string;
50
+ }
51
+
52
+ interface ChannelsData {
53
+ channels: ChannelMapping[];
54
+ discordChannels: DiscordChannel[];
55
+ unmapped: DiscordChannel[];
56
+ }
57
+
58
+
59
+ const S = {
60
+ box: { background: '#151922', border: '1px solid #2b3340', borderRadius: '10px', padding: '16px', marginBottom: '16px' } as JSX.CSSProperties,
61
+ input: { background: '#0f1115', color: '#f5f7fa', border: '1px solid #354051', borderRadius: '6px', padding: '8px 10px', width: '100%', boxSizing: 'border-box' as const } as JSX.CSSProperties,
62
+ btn: { background: '#2e7dff', color: '#fff', border: 'none', borderRadius: '6px', padding: '8px 14px', cursor: 'pointer', marginRight: '8px' } as JSX.CSSProperties,
63
+ btnDanger: { background: '#c0392b', color: '#fff', border: 'none', borderRadius: '6px', padding: '4px 10px', cursor: 'pointer' } as JSX.CSSProperties,
64
+ label: { display: 'block', marginBottom: '12px' } as JSX.CSSProperties,
65
+ labelText: { display: 'block', marginBottom: '4px', color: '#b4bdc8' } as JSX.CSSProperties,
66
+ select: { background: '#0f1115', color: '#f5f7fa', border: '1px solid #354051', borderRadius: '6px', padding: '8px 10px' } as JSX.CSSProperties,
67
+ th: { textAlign: 'left' as const, padding: '8px', borderBottom: '1px solid #2b3340' },
68
+ td: { padding: '8px', borderBottom: '1px solid #1a2030', verticalAlign: 'top' as const, wordBreak: 'break-word' as const },
69
+ row: { cursor: 'pointer', background: 'transparent' } as JSX.CSSProperties,
70
+ rowHover: { background: '#1a2030' } as JSX.CSSProperties,
71
+ grid2: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' } as JSX.CSSProperties,
72
+ grid3: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '8px', alignItems: 'end' } as JSX.CSSProperties,
73
+ err: { color: '#ff8080', margin: '8px 0' } as JSX.CSSProperties,
74
+ ok: { color: '#8ff0a4', margin: '8px 0' } as JSX.CSSProperties,
75
+ mono: { fontFamily: 'monospace', fontSize: '0.85em' } as JSX.CSSProperties,
76
+ };
77
+
78
+
79
+ async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
80
+ const res = await fetch(url, options);
81
+ const json = await res.json() as { error?: string } & T;
82
+ if (!res.ok) throw new Error(json.error ?? 'Request failed');
83
+ return json;
84
+ }
85
+
86
+ function jsonPost<T>(url: string, body: unknown): Promise<T> {
87
+ return apiFetch<T>(url, {
88
+ method: 'POST',
89
+ headers: { 'Content-Type': 'application/json' },
90
+ body: JSON.stringify(body),
91
+ });
92
+ }
93
+
94
+ function jsonPut<T>(url: string, body: unknown): Promise<T> {
95
+ return apiFetch<T>(url, {
96
+ method: 'PUT',
97
+ headers: { 'Content-Type': 'application/json' },
98
+ body: JSON.stringify(body),
99
+ });
100
+ }
101
+
102
+ async function jsonDelete<T>(url: string): Promise<T> {
103
+ return apiFetch<T>(url, { method: 'DELETE' });
104
+ }
105
+
106
+
107
+ function Field({ label, type = 'text', value, onChange, placeholder }: {
108
+ label: string;
109
+ type?: string;
110
+ value: string;
111
+ onChange: (v: string) => void;
112
+ placeholder?: string;
113
+ }): JSX.Element {
114
+ return (
115
+ <label style={S.label}>
116
+ <span style={S.labelText}>{label}</span>
117
+ <input
118
+ type={type}
119
+ value={value}
120
+ placeholder={placeholder ?? ''}
121
+ onInput={e => onChange((e.currentTarget as HTMLInputElement).value)}
122
+ style={S.input}
123
+ />
124
+ </label>
125
+ );
126
+ }
127
+
128
+ function CheckField({ label, checked, onChange }: {
129
+ label: string;
130
+ checked: boolean;
131
+ onChange: (v: boolean) => void;
132
+ }): JSX.Element {
133
+ return (
134
+ <label style={{ ...S.label, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
135
+ <input type="checkbox" checked={checked} onChange={e => onChange((e.currentTarget as HTMLInputElement).checked)} />
136
+ {label}
137
+ </label>
138
+ );
139
+ }
140
+
141
+
142
+ function BehaviorConfig({ config, onChange }: {
143
+ config: ChannelBehaviorConfig;
144
+ onChange: (c: ChannelBehaviorConfig) => void;
145
+ }): JSX.Element {
146
+ const t = config.type;
147
+ return (
148
+ <div style={{ paddingTop: '12px' }}>
149
+ {(t === 'immediate-print' || t === 'accumulating-list' || t === 'recurring-print') && (
150
+ <>
151
+ <Field
152
+ label="Header"
153
+ value={config.header ?? ''}
154
+ onChange={v => { const c = { ...config }; if (v) { c.header = v; } else { delete c.header; } onChange(c); }}
155
+ placeholder="Optional header text"
156
+ />
157
+ <Field
158
+ label="Footer"
159
+ value={config.footer ?? ''}
160
+ onChange={v => { const c = { ...config }; if (v) { c.footer = v; } else { delete c.footer; } onChange(c); }}
161
+ placeholder="Optional footer text"
162
+ />
163
+ <CheckField
164
+ label="Include metadata footer"
165
+ checked={config.includeMetadata ?? false}
166
+ onChange={v => onChange({ ...config, includeMetadata: v })}
167
+ />
168
+ </>
169
+ )}
170
+ {(t === 'immediate-print' || t === 'recurring-print') && (
171
+ <CheckField
172
+ label="Include AI-generated icon (requires OpenAI key)"
173
+ checked={config.includeIcon ?? false}
174
+ onChange={v => onChange({ ...config, includeIcon: v })}
175
+ />
176
+ )}
177
+ {t === 'accumulating-list' && (
178
+ <CheckField
179
+ label="Include checklist boxes"
180
+ checked={config.includeChecklist ?? false}
181
+ onChange={v => onChange({ ...config, includeChecklist: v })}
182
+ />
183
+ )}
184
+ </div>
185
+ );
186
+ }
187
+
188
+
189
+ function ChannelRow({ mapping, onDelete, onUpdate }: {
190
+ mapping: ChannelMapping;
191
+ onDelete: () => void;
192
+ onUpdate: (c: ChannelBehaviorConfig) => void;
193
+ }): JSX.Element {
194
+ const [expanded, setExpanded] = useState(false);
195
+ const [hovered, setHovered] = useState(false);
196
+
197
+ return (
198
+ <tbody>
199
+ <tr
200
+ style={hovered ? S.rowHover : S.row}
201
+ onClick={() => setExpanded(!expanded)}
202
+ onMouseEnter={() => setHovered(true)}
203
+ onMouseLeave={() => setHovered(false)}
204
+ >
205
+ <td style={S.td}># {mapping.channelName}</td>
206
+ <td style={S.td}>{mapping.config.type}</td>
207
+ <td style={{ ...S.td, textAlign: 'right' }}>
208
+ <button
209
+ style={S.btnDanger}
210
+ onClick={e => { e.stopPropagation(); onDelete(); }}
211
+ >
212
+ Delete
213
+ </button>
214
+ </td>
215
+ </tr>
216
+ {expanded && (
217
+ <tr>
218
+ <td colSpan={3} style={{ ...S.td, background: '#0f1115', padding: '16px' }}>
219
+ <BehaviorConfig
220
+ config={mapping.config}
221
+ onChange={c => onUpdate(c)}
222
+ />
223
+ </td>
224
+ </tr>
225
+ )}
226
+ </tbody>
227
+ );
228
+ }
229
+
230
+
231
+ function BotSetup({ config, onSave }: {
232
+ config: ConfigData | null;
233
+ onSave: () => void;
234
+ }): JSX.Element {
235
+ const [discordToken, setDiscordToken] = useState(config?.discordToken ?? '');
236
+ const [serverId, setServerId] = useState(config?.serverId ?? '');
237
+ const [openaiKey, setOpenaiKey] = useState(config?.openaiKey ?? '');
238
+ const [diagnosticsPort, setDiagnosticsPort] = useState(String(config?.diagnosticsPort ?? 8080));
239
+ const [msg, setMsg] = useState('');
240
+ const [err, setErr] = useState('');
241
+ const [saving, setSaving] = useState(false);
242
+
243
+ useEffect(() => {
244
+ setDiscordToken(config?.discordToken ?? '');
245
+ setServerId(config?.serverId ?? '');
246
+ setOpenaiKey(config?.openaiKey ?? '');
247
+ setDiagnosticsPort(String(config?.diagnosticsPort ?? 8080));
248
+ }, [config?.discordToken, config?.openaiKey, config?.serverId, config?.diagnosticsPort]);
249
+
250
+ async function save(): Promise<void> {
251
+ setSaving(true);
252
+ setErr(''); setMsg('');
253
+ try {
254
+ const body: Record<string, unknown> = {
255
+ discordToken: discordToken.trim(),
256
+ openaiKey: openaiKey.trim(),
257
+ serverId: serverId.trim() || null,
258
+ diagnosticsPort: Number(diagnosticsPort),
259
+ };
260
+ await jsonPost('/api/config', body);
261
+ setMsg('Saved.');
262
+ onSave();
263
+ } catch (e) {
264
+ setErr(e instanceof Error ? e.message : String(e));
265
+ } finally {
266
+ setSaving(false);
267
+ }
268
+ }
269
+
270
+ return (
271
+ <section style={S.box}>
272
+ <h2 style={{ marginTop: 0 }}>Bot Setup</h2>
273
+ {config && <p style={{ color: '#b4bdc8', marginTop: 0 }}>Config: {config.configPath}</p>}
274
+ {err && <p style={S.err}>{err}</p>}
275
+ {msg && <p style={S.ok}>{msg}</p>}
276
+ <Field label="Discord Token" value={discordToken} onChange={setDiscordToken} placeholder="Discord bot token" />
277
+ <Field label="Server ID (required)" value={serverId} onChange={setServerId} placeholder="Required — Discord server ID" />
278
+ <Field label="OpenAI Key" value={openaiKey} onChange={setOpenaiKey} placeholder="OpenAI API key" />
279
+ <Field label="Diagnostics Port" type="number" value={diagnosticsPort} onChange={setDiagnosticsPort} placeholder="8080" />
280
+ <button style={S.btn} onClick={() => void save()} disabled={saving}>
281
+ {saving ? 'Saving…' : 'Save'}
282
+ </button>
283
+ </section>
284
+ );
285
+ }
286
+
287
+ function ChannelBehaviors({ refreshKey }: { refreshKey?: number }): JSX.Element {
288
+ const [data, setData] = useState<ChannelsData | null>(null);
289
+ const [err, setErr] = useState('');
290
+ const [addChannelId, setAddChannelId] = useState('');
291
+ const [addBehavior, setAddBehavior] = useState<BehaviorType>('immediate-print');
292
+
293
+ async function load(): Promise<void> {
294
+ try {
295
+ const d = await apiFetch<ChannelsData>('/api/channels');
296
+ setData(d);
297
+ } catch (e) {
298
+ setErr(e instanceof Error ? e.message : String(e));
299
+ }
300
+ }
301
+
302
+ useEffect(() => { void load(); }, [refreshKey]);
303
+
304
+ async function addMapping(): Promise<void> {
305
+ if (!addChannelId) return;
306
+ const dc = data?.discordChannels.find(c => c.id === addChannelId);
307
+ if (!dc) return;
308
+ const defaultConfig: ChannelBehaviorConfig = addBehavior === 'immediate-print'
309
+ ? { type: 'immediate-print', includeIcon: false, includeMetadata: false }
310
+ : addBehavior === 'accumulating-list'
311
+ ? { type: 'accumulating-list', includeChecklist: false, includeMetadata: false }
312
+ : addBehavior === 'recurring-print'
313
+ ? { type: 'recurring-print', includeIcon: false, includeMetadata: false }
314
+ : { type: 'on-demand' };
315
+ await jsonPost('/api/channels', { channelId: dc.id, channelName: dc.name, config: defaultConfig });
316
+ setAddChannelId('');
317
+ await load();
318
+ }
319
+
320
+ async function deleteChannel(id: string): Promise<void> {
321
+ await jsonDelete(`/api/channels/${id}`);
322
+ await load();
323
+ }
324
+
325
+ async function updateChannel(id: string, config: ChannelBehaviorConfig): Promise<void> {
326
+ await jsonPut(`/api/channels/${id}`, { config });
327
+ await load();
328
+ }
329
+
330
+ async function refreshFromDiscord(): Promise<void> {
331
+ try {
332
+ const d = await apiFetch<ChannelsData>('/api/channels/refresh', { method: 'POST' });
333
+ setData(d);
334
+ } catch (e) {
335
+ setErr(e instanceof Error ? e.message : String(e));
336
+ }
337
+ }
338
+
339
+ return (
340
+ <section style={S.box}>
341
+ <div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
342
+ <h2 style={{ marginTop: 0, marginBottom: 0 }}>Channel Behaviors</h2>
343
+ <button style={{ ...S.btn, marginRight: 0 }} onClick={() => void refreshFromDiscord()}>Refresh Channels</button>
344
+ </div>
345
+ {err && <p style={S.err}>{err}</p>}
346
+ <table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '16px' }}>
347
+ <thead>
348
+ <tr>
349
+ <th style={S.th}>Channel</th>
350
+ <th style={S.th}>Behavior</th>
351
+ <th style={{ ...S.th, textAlign: 'right' as const }}>Actions</th>
352
+ </tr>
353
+ </thead>
354
+ {data?.channels.map(m => (
355
+ <ChannelRow
356
+ key={m.channelId}
357
+ mapping={m}
358
+ onDelete={() => void deleteChannel(m.channelId)}
359
+ onUpdate={c => void updateChannel(m.channelId, c)}
360
+ />
361
+ ))}
362
+ {(!data || data.channels.length === 0) && (
363
+ <tbody>
364
+ <tr><td colSpan={3} style={{ ...S.td, color: '#b4bdc8' }}>No channel mappings yet.</td></tr>
365
+ </tbody>
366
+ )}
367
+ </table>
368
+
369
+ <div style={S.grid3}>
370
+ <label style={S.label}>
371
+ <span style={S.labelText}>Channel</span>
372
+ <select style={S.select} value={addChannelId} onChange={e => setAddChannelId((e.currentTarget as HTMLSelectElement).value)}>
373
+ <option value="">Select channel…</option>
374
+ {data?.unmapped.map(c => <option key={c.id} value={c.id}>#{c.name}</option>)}
375
+ </select>
376
+ </label>
377
+ <label style={S.label}>
378
+ <span style={S.labelText}>Behavior</span>
379
+ <select style={S.select} value={addBehavior} onChange={e => setAddBehavior((e.currentTarget as HTMLSelectElement).value as BehaviorType)}>
380
+ <option value="immediate-print">🖨️ Immediate Print</option>
381
+ <option value="accumulating-list">🛒 Accumulating List</option>
382
+ <option value="recurring-print">🔄 Recurring Print</option>
383
+ <option value="on-demand">💬 On-Demand</option>
384
+ </select>
385
+ </label>
386
+ <button style={S.btn} onClick={() => void addMapping()} disabled={!addChannelId}>Add</button>
387
+ </div>
388
+ </section>
389
+ );
390
+ }
391
+
392
+
393
+ type PrintMode = 'escp' | 'cups' | 'imagefeed';
394
+
395
+ interface EscpPrintConfig { mode: 'escp'; serialPort: string; }
396
+ interface CupsPrintConfig { mode: 'cups'; printerName: string; paperSize: string; paperName: string; }
397
+ interface ImageFeedConfig { mode: 'imagefeed'; }
398
+ type PrintConfig = EscpPrintConfig | CupsPrintConfig | ImageFeedConfig;
399
+
400
+
401
+ function PrintModeSection(): JSX.Element {
402
+ const [printConfig, setPrintConfig] = useState<PrintConfig | null>(null);
403
+ const [mode, setMode] = useState<PrintMode>('escp');
404
+ // escp fields
405
+ const [serialPort, setSerialPort] = useState('/dev/ttyUSB0');
406
+ // cups fields
407
+ const [printerName, setPrinterName] = useState('');
408
+ const [paperSize, setPaperSize] = useState('Letter');
409
+ const [paperName, setPaperName] = useState('');
410
+ const [msg, setMsg] = useState('');
411
+ const [err, setErr] = useState('');
412
+ const [saving, setSaving] = useState(false);
413
+ const [testing, setTesting] = useState(false);
414
+
415
+ async function load(): Promise<void> {
416
+ try {
417
+ const d = await apiFetch<{ printConfig: PrintConfig | null }>('/api/print-config');
418
+ if (d.printConfig) {
419
+ setPrintConfig(d.printConfig);
420
+ setMode(d.printConfig.mode);
421
+ if (d.printConfig.mode === 'escp') setSerialPort(d.printConfig.serialPort);
422
+ if (d.printConfig.mode === 'cups') {
423
+ setPrinterName(d.printConfig.printerName);
424
+ setPaperSize(d.printConfig.paperSize);
425
+ setPaperName(d.printConfig.paperName);
426
+ }
427
+ }
428
+ } catch (e) {
429
+ setErr(e instanceof Error ? e.message : String(e));
430
+ }
431
+ }
432
+
433
+ useEffect(() => { void load(); }, []);
434
+
435
+ async function save(): Promise<void> {
436
+ setSaving(true); setErr(''); setMsg('');
437
+ try {
438
+ let cfg: PrintConfig;
439
+ if (mode === 'escp') {
440
+ cfg = { mode: 'escp', serialPort: serialPort.trim() };
441
+ } else if (mode === 'cups') {
442
+ cfg = { mode: 'cups', printerName: printerName.trim(), paperSize: paperSize.trim(), paperName: paperName.trim() };
443
+ } else {
444
+ cfg = { mode: 'imagefeed' };
445
+ }
446
+ await jsonPost('/api/print-config', { printConfig: cfg });
447
+ setPrintConfig(cfg);
448
+ setMsg('Print mode saved.');
449
+ } catch (e) {
450
+ setErr(e instanceof Error ? e.message : String(e));
451
+ } finally {
452
+ setSaving(false);
453
+ }
454
+ }
455
+
456
+ async function testPrint(): Promise<void> {
457
+ setTesting(true); setErr(''); setMsg('');
458
+ try {
459
+ await jsonPost('/api/printer/test', {});
460
+ setMsg(mode === 'imagefeed' ? 'Test job added to image feed.' : 'Test page sent to printer.');
461
+ } catch (e) {
462
+ setErr(e instanceof Error ? e.message : String(e));
463
+ } finally {
464
+ setTesting(false);
465
+ }
466
+ }
467
+
468
+ const modeLabel: Record<PrintMode, string> = {
469
+ escp: '🔌 ESC/P via Serial Port',
470
+ cups: '📄 PDF via CUPS',
471
+ imagefeed: '🖼️ Image Feed (testing)',
472
+ };
473
+
474
+ return (
475
+ <section style={S.box}>
476
+ <h2 style={{ marginTop: 0 }}>Print Mode</h2>
477
+ {err && <p style={S.err}>{err}</p>}
478
+ {msg && <p style={S.ok}>{msg}</p>}
479
+
480
+ <label style={S.label}>
481
+ <span style={S.labelText}>Output Mode</span>
482
+ <select
483
+ style={S.select}
484
+ value={mode}
485
+ onChange={e => setMode((e.currentTarget as HTMLSelectElement).value as PrintMode)}
486
+ >
487
+ {(['escp', 'cups', 'imagefeed'] as PrintMode[]).map(m => (
488
+ <option key={m} value={m}>{modeLabel[m]}</option>
489
+ ))}
490
+ </select>
491
+ </label>
492
+
493
+ {mode === 'escp' && (
494
+ <Field
495
+ label="Serial Port"
496
+ value={serialPort}
497
+ onChange={setSerialPort}
498
+ placeholder="e.g. /dev/ttyUSB0 or COM3"
499
+ />
500
+ )}
501
+
502
+ {mode === 'cups' && <>
503
+ <Field label="CUPS Printer Name" value={printerName} onChange={setPrinterName} placeholder="e.g. TM-T88V" />
504
+ <Field label="Paper Size" value={paperSize} onChange={setPaperSize} placeholder="e.g. Letter, A4, 80x297mm" />
505
+ <Field label="Paper Name (display label)" value={paperName} onChange={setPaperName} placeholder="e.g. Receipt Roll" />
506
+ </>}
507
+
508
+ {mode === 'imagefeed' && (
509
+ <p style={{ color: '#b4bdc8', marginTop: 0 }}>
510
+ Print jobs will be rendered as PNGs and shown at{' '}
511
+ <a href="/feed" target="_blank" rel="noopener noreferrer">/feed</a>.
512
+ </p>
513
+ )}
514
+
515
+ <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
516
+ <button style={S.btn} onClick={() => void save()} disabled={saving}>
517
+ {saving ? 'Saving…' : 'Save'}
518
+ </button>
519
+ <button
520
+ style={{ ...S.btn, background: '#1a7a3a' }}
521
+ onClick={() => void testPrint()}
522
+ disabled={testing || !printConfig}
523
+ title={!printConfig ? 'Save a mode first' : ''}
524
+ >
525
+ {testing ? 'Printing…' : 'Print Test Page'}
526
+ </button>
527
+ </div>
528
+ </section>
529
+ );
530
+ }
531
+
532
+ function SecuritySection({ onSave }: { onSave: () => void }): JSX.Element {
533
+ const [password, setPassword] = useState('');
534
+ const [msg, setMsg] = useState('');
535
+ const [err, setErr] = useState('');
536
+ const [saving, setSaving] = useState(false);
537
+
538
+ async function save(): Promise<void> {
539
+ setSaving(true); setErr(''); setMsg('');
540
+ try {
541
+ await jsonPost('/api/config', { password });
542
+ setPassword('');
543
+ setMsg(password ? 'Password set.' : 'Password cleared.');
544
+ onSave();
545
+ } catch (e) {
546
+ setErr(e instanceof Error ? e.message : String(e));
547
+ } finally {
548
+ setSaving(false);
549
+ }
550
+ }
551
+
552
+ return (
553
+ <section style={S.box}>
554
+ <h2 style={{ marginTop: 0 }}>Security</h2>
555
+ {err && <p style={S.err}>{err}</p>}
556
+ {msg && <p style={S.ok}>{msg}</p>}
557
+ <Field label="Control Panel Password" type="password" value={password} onChange={setPassword} placeholder="Leave blank to remove password" />
558
+ <button style={S.btn} onClick={() => void save()} disabled={saving}>
559
+ {saving ? 'Saving…' : 'Set Password'}
560
+ </button>
561
+ </section>
562
+ );
563
+ }
564
+
565
+ function LogSection(): JSX.Element {
566
+ const [events, setEvents] = useState<DiagnosticEvent[]>([]);
567
+ const [status, setStatus] = useState<StatusData | null>(null);
568
+
569
+ async function load(): Promise<void> {
570
+ const d = await apiFetch<{ events: DiagnosticEvent[]; status: StatusData }>('/api/events');
571
+ setEvents(d.events);
572
+ setStatus(d.status);
573
+ }
574
+
575
+ useEffect(() => {
576
+ void load();
577
+ const t = setInterval(() => void load(), 4000);
578
+ return () => clearInterval(t);
579
+ }, []);
580
+
581
+ const typeColor: Record<string, string> = {
582
+ startup: '#8ab4ff',
583
+ success: '#8ff0a4',
584
+ error: '#ff8080',
585
+ print: '#ffd580',
586
+ info: '#b4bdc8',
587
+ command: '#d2a8ff',
588
+ };
589
+
590
+ return (
591
+ <section style={S.box}>
592
+ <h2 style={{ marginTop: 0 }}>Bot Status & Log</h2>
593
+ {status && (
594
+ <dl style={{ display: 'grid', gridTemplateColumns: '140px 1fr', gap: '4px 12px', marginBottom: '16px' }}>
595
+ <dt style={{ color: '#b4bdc8' }}>Connected</dt><dd style={{ margin: 0 }}>{status.connected ? '✅ yes' : '❌ no'}</dd>
596
+ <dt style={{ color: '#b4bdc8' }}>Bot</dt><dd style={{ margin: 0 }}>{status.tag ?? '—'}</dd>
597
+ <dt style={{ color: '#b4bdc8' }}>Started</dt><dd style={{ margin: 0 }}>{status.startedAt ?? '—'}</dd>
598
+ <dt style={{ color: '#b4bdc8' }}>Server ID</dt><dd style={{ margin: 0 }}>{status.configuredServerId ?? '—'}</dd>
599
+ <dt style={{ color: '#b4bdc8' }}>Servers</dt><dd style={{ margin: 0 }}>{status.guilds.join(', ') || '—'}</dd>
600
+ </dl>
601
+ )}
602
+ <div style={{ maxHeight: '400px', overflow: 'auto', border: '1px solid #2b3340', borderRadius: '8px' }}>
603
+ {events.length === 0 ? (
604
+ <p style={{ padding: '12px', margin: 0, color: '#b4bdc8' }}>No events yet.</p>
605
+ ) : (
606
+ <table style={{ width: '100%', borderCollapse: 'collapse' }}>
607
+ <thead>
608
+ <tr>
609
+ <th style={S.th}>Time</th>
610
+ <th style={S.th}>Type</th>
611
+ <th style={S.th}>Message</th>
612
+ </tr>
613
+ </thead>
614
+ <tbody>
615
+ {events.map((e, i) => (
616
+ <tr key={String(i)}>
617
+ <td style={{ ...S.td, ...S.mono, whiteSpace: 'nowrap' as const }}>{e.timestamp}</td>
618
+ <td style={{ ...S.td, color: typeColor[e.type] ?? '#b4bdc8' }}>{e.type}</td>
619
+ <td style={S.td}>{e.message}</td>
620
+ </tr>
621
+ ))}
622
+ </tbody>
623
+ </table>
624
+ )}
625
+ </div>
626
+ </section>
627
+ );
628
+ }
629
+
630
+ function RestartSection(): JSX.Element {
631
+ const [msg, setMsg] = useState('');
632
+ const [err, setErr] = useState('');
633
+ const [restarting, setRestarting] = useState(false);
634
+
635
+ async function restart(): Promise<void> {
636
+ setRestarting(true); setErr(''); setMsg('');
637
+ try {
638
+ await jsonPost('/api/restart', {});
639
+ setMsg('Restart signal sent. The server will restart shortly.');
640
+ } catch (e) {
641
+ setErr(e instanceof Error ? e.message : String(e));
642
+ setRestarting(false);
643
+ }
644
+ }
645
+
646
+ return (
647
+ <section style={S.box}>
648
+ <h2 style={{ marginTop: 0 }}>Server Control</h2>
649
+ {err && <p style={S.err}>{err}</p>}
650
+ {msg && <p style={S.ok}>{msg}</p>}
651
+ <button style={{ ...S.btn, background: '#8a6d00' }} onClick={() => void restart()} disabled={restarting}>
652
+ {restarting ? 'Restarting…' : 'Restart Server'}
653
+ </button>
654
+ </section>
655
+ );
656
+ }
657
+
658
+
659
+ function App(): JSX.Element {
660
+ const [config, setConfig] = useState<ConfigData | null>(null);
661
+ const [refreshKey, setRefreshKey] = useState(0);
662
+
663
+ async function loadConfig(): Promise<void> {
664
+ const d = await apiFetch<ConfigData>('/api/config');
665
+ setConfig(d);
666
+ }
667
+
668
+ useEffect(() => { void loadConfig(); }, []);
669
+
670
+ function handleSave(): void {
671
+ void loadConfig();
672
+ setRefreshKey(k => k + 1);
673
+ }
674
+
675
+ return (
676
+ <main style={{ maxWidth: '860px', margin: '0 auto', padding: '24px' }}>
677
+ <h1 style={{ marginTop: 0 }}>🖨️ Windsor Control Panel</h1>
678
+ <BotSetup config={config} onSave={handleSave} />
679
+ <ChannelBehaviors refreshKey={refreshKey} />
680
+ <PrintModeSection />
681
+ <SecuritySection onSave={() => void loadConfig()} />
682
+ <LogSection />
683
+ <RestartSection />
684
+ </main>
685
+ );
686
+ }
687
+
688
+ const root = document.getElementById('app');
689
+ if (!root) throw new Error('Missing #app');
690
+ render(<App />, root);