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,325 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ /** @jsxImportSource preact */
3
+ import { render } from 'preact';
4
+ import { useEffect, useState } from 'preact/hooks';
5
+ const S = {
6
+ box: { background: '#151922', border: '1px solid #2b3340', borderRadius: '10px', padding: '16px', marginBottom: '16px' },
7
+ input: { background: '#0f1115', color: '#f5f7fa', border: '1px solid #354051', borderRadius: '6px', padding: '8px 10px', width: '100%', boxSizing: 'border-box' },
8
+ btn: { background: '#2e7dff', color: '#fff', border: 'none', borderRadius: '6px', padding: '8px 14px', cursor: 'pointer', marginRight: '8px' },
9
+ btnDanger: { background: '#c0392b', color: '#fff', border: 'none', borderRadius: '6px', padding: '4px 10px', cursor: 'pointer' },
10
+ label: { display: 'block', marginBottom: '12px' },
11
+ labelText: { display: 'block', marginBottom: '4px', color: '#b4bdc8' },
12
+ select: { background: '#0f1115', color: '#f5f7fa', border: '1px solid #354051', borderRadius: '6px', padding: '8px 10px' },
13
+ th: { textAlign: 'left', padding: '8px', borderBottom: '1px solid #2b3340' },
14
+ td: { padding: '8px', borderBottom: '1px solid #1a2030', verticalAlign: 'top', wordBreak: 'break-word' },
15
+ row: { cursor: 'pointer', background: 'transparent' },
16
+ rowHover: { background: '#1a2030' },
17
+ grid2: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' },
18
+ grid3: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '8px', alignItems: 'end' },
19
+ err: { color: '#ff8080', margin: '8px 0' },
20
+ ok: { color: '#8ff0a4', margin: '8px 0' },
21
+ mono: { fontFamily: 'monospace', fontSize: '0.85em' },
22
+ };
23
+ async function apiFetch(url, options) {
24
+ const res = await fetch(url, options);
25
+ const json = await res.json();
26
+ if (!res.ok)
27
+ throw new Error(json.error ?? 'Request failed');
28
+ return json;
29
+ }
30
+ function jsonPost(url, body) {
31
+ return apiFetch(url, {
32
+ method: 'POST',
33
+ headers: { 'Content-Type': 'application/json' },
34
+ body: JSON.stringify(body),
35
+ });
36
+ }
37
+ function jsonPut(url, body) {
38
+ return apiFetch(url, {
39
+ method: 'PUT',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify(body),
42
+ });
43
+ }
44
+ async function jsonDelete(url) {
45
+ return apiFetch(url, { method: 'DELETE' });
46
+ }
47
+ function Field({ label, type = 'text', value, onChange, placeholder }) {
48
+ return (_jsxs("label", { style: S.label, children: [_jsx("span", { style: S.labelText, children: label }), _jsx("input", { type: type, value: value, placeholder: placeholder ?? '', onInput: e => onChange(e.currentTarget.value), style: S.input })] }));
49
+ }
50
+ function CheckField({ label, checked, onChange }) {
51
+ return (_jsxs("label", { style: { ...S.label, display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }, children: [_jsx("input", { type: "checkbox", checked: checked, onChange: e => onChange(e.currentTarget.checked) }), label] }));
52
+ }
53
+ function BehaviorConfig({ config, onChange }) {
54
+ const t = config.type;
55
+ return (_jsxs("div", { style: { paddingTop: '12px' }, children: [(t === 'immediate-print' || t === 'accumulating-list' || t === 'recurring-print') && (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Header", value: config.header ?? '', onChange: v => { const c = { ...config }; if (v) {
56
+ c.header = v;
57
+ }
58
+ else {
59
+ delete c.header;
60
+ } onChange(c); }, placeholder: "Optional header text" }), _jsx(Field, { label: "Footer", value: config.footer ?? '', onChange: v => { const c = { ...config }; if (v) {
61
+ c.footer = v;
62
+ }
63
+ else {
64
+ delete c.footer;
65
+ } onChange(c); }, placeholder: "Optional footer text" }), _jsx(CheckField, { label: "Include metadata footer", checked: config.includeMetadata ?? false, onChange: v => onChange({ ...config, includeMetadata: v }) })] })), (t === 'immediate-print' || t === 'recurring-print') && (_jsx(CheckField, { label: "Include AI-generated icon (requires OpenAI key)", checked: config.includeIcon ?? false, onChange: v => onChange({ ...config, includeIcon: v }) })), t === 'accumulating-list' && (_jsx(CheckField, { label: "Include checklist boxes", checked: config.includeChecklist ?? false, onChange: v => onChange({ ...config, includeChecklist: v }) }))] }));
66
+ }
67
+ function ChannelRow({ mapping, onDelete, onUpdate }) {
68
+ const [expanded, setExpanded] = useState(false);
69
+ const [hovered, setHovered] = useState(false);
70
+ return (_jsxs("tbody", { children: [_jsxs("tr", { style: hovered ? S.rowHover : S.row, onClick: () => setExpanded(!expanded), onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), children: [_jsxs("td", { style: S.td, children: ["# ", mapping.channelName] }), _jsx("td", { style: S.td, children: mapping.config.type }), _jsx("td", { style: { ...S.td, textAlign: 'right' }, children: _jsx("button", { style: S.btnDanger, onClick: e => { e.stopPropagation(); onDelete(); }, children: "Delete" }) })] }), expanded && (_jsx("tr", { children: _jsx("td", { colSpan: 3, style: { ...S.td, background: '#0f1115', padding: '16px' }, children: _jsx(BehaviorConfig, { config: mapping.config, onChange: c => onUpdate(c) }) }) }))] }));
71
+ }
72
+ function BotSetup({ config, onSave }) {
73
+ const [discordToken, setDiscordToken] = useState(config?.discordToken ?? '');
74
+ const [serverId, setServerId] = useState(config?.serverId ?? '');
75
+ const [openaiKey, setOpenaiKey] = useState(config?.openaiKey ?? '');
76
+ const [diagnosticsPort, setDiagnosticsPort] = useState(String(config?.diagnosticsPort ?? 8080));
77
+ const [msg, setMsg] = useState('');
78
+ const [err, setErr] = useState('');
79
+ const [saving, setSaving] = useState(false);
80
+ useEffect(() => {
81
+ setDiscordToken(config?.discordToken ?? '');
82
+ setServerId(config?.serverId ?? '');
83
+ setOpenaiKey(config?.openaiKey ?? '');
84
+ setDiagnosticsPort(String(config?.diagnosticsPort ?? 8080));
85
+ }, [config?.discordToken, config?.openaiKey, config?.serverId, config?.diagnosticsPort]);
86
+ async function save() {
87
+ setSaving(true);
88
+ setErr('');
89
+ setMsg('');
90
+ try {
91
+ const body = {
92
+ discordToken: discordToken.trim(),
93
+ openaiKey: openaiKey.trim(),
94
+ serverId: serverId.trim() || null,
95
+ diagnosticsPort: Number(diagnosticsPort),
96
+ };
97
+ await jsonPost('/api/config', body);
98
+ setMsg('Saved.');
99
+ onSave();
100
+ }
101
+ catch (e) {
102
+ setErr(e instanceof Error ? e.message : String(e));
103
+ }
104
+ finally {
105
+ setSaving(false);
106
+ }
107
+ }
108
+ return (_jsxs("section", { style: S.box, children: [_jsx("h2", { style: { marginTop: 0 }, children: "Bot Setup" }), config && _jsxs("p", { style: { color: '#b4bdc8', marginTop: 0 }, children: ["Config: ", config.configPath] }), err && _jsx("p", { style: S.err, children: err }), msg && _jsx("p", { style: S.ok, children: msg }), _jsx(Field, { label: "Discord Token", value: discordToken, onChange: setDiscordToken, placeholder: "Discord bot token" }), _jsx(Field, { label: "Server ID (required)", value: serverId, onChange: setServerId, placeholder: "Required \u2014 Discord server ID" }), _jsx(Field, { label: "OpenAI Key", value: openaiKey, onChange: setOpenaiKey, placeholder: "OpenAI API key" }), _jsx(Field, { label: "Diagnostics Port", type: "number", value: diagnosticsPort, onChange: setDiagnosticsPort, placeholder: "8080" }), _jsx("button", { style: S.btn, onClick: () => void save(), disabled: saving, children: saving ? 'Saving…' : 'Save' })] }));
109
+ }
110
+ function ChannelBehaviors({ refreshKey }) {
111
+ const [data, setData] = useState(null);
112
+ const [err, setErr] = useState('');
113
+ const [addChannelId, setAddChannelId] = useState('');
114
+ const [addBehavior, setAddBehavior] = useState('immediate-print');
115
+ async function load() {
116
+ try {
117
+ const d = await apiFetch('/api/channels');
118
+ setData(d);
119
+ }
120
+ catch (e) {
121
+ setErr(e instanceof Error ? e.message : String(e));
122
+ }
123
+ }
124
+ useEffect(() => { void load(); }, [refreshKey]);
125
+ async function addMapping() {
126
+ if (!addChannelId)
127
+ return;
128
+ const dc = data?.discordChannels.find(c => c.id === addChannelId);
129
+ if (!dc)
130
+ return;
131
+ const defaultConfig = addBehavior === 'immediate-print'
132
+ ? { type: 'immediate-print', includeIcon: false, includeMetadata: false }
133
+ : addBehavior === 'accumulating-list'
134
+ ? { type: 'accumulating-list', includeChecklist: false, includeMetadata: false }
135
+ : addBehavior === 'recurring-print'
136
+ ? { type: 'recurring-print', includeIcon: false, includeMetadata: false }
137
+ : { type: 'on-demand' };
138
+ await jsonPost('/api/channels', { channelId: dc.id, channelName: dc.name, config: defaultConfig });
139
+ setAddChannelId('');
140
+ await load();
141
+ }
142
+ async function deleteChannel(id) {
143
+ await jsonDelete(`/api/channels/${id}`);
144
+ await load();
145
+ }
146
+ async function updateChannel(id, config) {
147
+ await jsonPut(`/api/channels/${id}`, { config });
148
+ await load();
149
+ }
150
+ async function refreshFromDiscord() {
151
+ try {
152
+ const d = await apiFetch('/api/channels/refresh', { method: 'POST' });
153
+ setData(d);
154
+ }
155
+ catch (e) {
156
+ setErr(e instanceof Error ? e.message : String(e));
157
+ }
158
+ }
159
+ return (_jsxs("section", { style: S.box, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }, children: [_jsx("h2", { style: { marginTop: 0, marginBottom: 0 }, children: "Channel Behaviors" }), _jsx("button", { style: { ...S.btn, marginRight: 0 }, onClick: () => void refreshFromDiscord(), children: "Refresh Channels" })] }), err && _jsx("p", { style: S.err, children: err }), _jsxs("table", { style: { width: '100%', borderCollapse: 'collapse', marginBottom: '16px' }, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: S.th, children: "Channel" }), _jsx("th", { style: S.th, children: "Behavior" }), _jsx("th", { style: { ...S.th, textAlign: 'right' }, children: "Actions" })] }) }), data?.channels.map(m => (_jsx(ChannelRow, { mapping: m, onDelete: () => void deleteChannel(m.channelId), onUpdate: c => void updateChannel(m.channelId, c) }, m.channelId))), (!data || data.channels.length === 0) && (_jsx("tbody", { children: _jsx("tr", { children: _jsx("td", { colSpan: 3, style: { ...S.td, color: '#b4bdc8' }, children: "No channel mappings yet." }) }) }))] }), _jsxs("div", { style: S.grid3, children: [_jsxs("label", { style: S.label, children: [_jsx("span", { style: S.labelText, children: "Channel" }), _jsxs("select", { style: S.select, value: addChannelId, onChange: e => setAddChannelId(e.currentTarget.value), children: [_jsx("option", { value: "", children: "Select channel\u2026" }), data?.unmapped.map(c => _jsxs("option", { value: c.id, children: ["#", c.name] }, c.id))] })] }), _jsxs("label", { style: S.label, children: [_jsx("span", { style: S.labelText, children: "Behavior" }), _jsxs("select", { style: S.select, value: addBehavior, onChange: e => setAddBehavior(e.currentTarget.value), children: [_jsx("option", { value: "immediate-print", children: "\uD83D\uDDA8\uFE0F Immediate Print" }), _jsx("option", { value: "accumulating-list", children: "\uD83D\uDED2 Accumulating List" }), _jsx("option", { value: "recurring-print", children: "\uD83D\uDD04 Recurring Print" }), _jsx("option", { value: "on-demand", children: "\uD83D\uDCAC On-Demand" })] })] }), _jsx("button", { style: S.btn, onClick: () => void addMapping(), disabled: !addChannelId, children: "Add" })] })] }));
160
+ }
161
+ function PrintModeSection() {
162
+ const [printConfig, setPrintConfig] = useState(null);
163
+ const [mode, setMode] = useState('escp');
164
+ // escp fields
165
+ const [serialPort, setSerialPort] = useState('/dev/ttyUSB0');
166
+ // cups fields
167
+ const [printerName, setPrinterName] = useState('');
168
+ const [paperSize, setPaperSize] = useState('Letter');
169
+ const [paperName, setPaperName] = useState('');
170
+ const [msg, setMsg] = useState('');
171
+ const [err, setErr] = useState('');
172
+ const [saving, setSaving] = useState(false);
173
+ const [testing, setTesting] = useState(false);
174
+ async function load() {
175
+ try {
176
+ const d = await apiFetch('/api/print-config');
177
+ if (d.printConfig) {
178
+ setPrintConfig(d.printConfig);
179
+ setMode(d.printConfig.mode);
180
+ if (d.printConfig.mode === 'escp')
181
+ setSerialPort(d.printConfig.serialPort);
182
+ if (d.printConfig.mode === 'cups') {
183
+ setPrinterName(d.printConfig.printerName);
184
+ setPaperSize(d.printConfig.paperSize);
185
+ setPaperName(d.printConfig.paperName);
186
+ }
187
+ }
188
+ }
189
+ catch (e) {
190
+ setErr(e instanceof Error ? e.message : String(e));
191
+ }
192
+ }
193
+ useEffect(() => { void load(); }, []);
194
+ async function save() {
195
+ setSaving(true);
196
+ setErr('');
197
+ setMsg('');
198
+ try {
199
+ let cfg;
200
+ if (mode === 'escp') {
201
+ cfg = { mode: 'escp', serialPort: serialPort.trim() };
202
+ }
203
+ else if (mode === 'cups') {
204
+ cfg = { mode: 'cups', printerName: printerName.trim(), paperSize: paperSize.trim(), paperName: paperName.trim() };
205
+ }
206
+ else {
207
+ cfg = { mode: 'imagefeed' };
208
+ }
209
+ await jsonPost('/api/print-config', { printConfig: cfg });
210
+ setPrintConfig(cfg);
211
+ setMsg('Print mode saved.');
212
+ }
213
+ catch (e) {
214
+ setErr(e instanceof Error ? e.message : String(e));
215
+ }
216
+ finally {
217
+ setSaving(false);
218
+ }
219
+ }
220
+ async function testPrint() {
221
+ setTesting(true);
222
+ setErr('');
223
+ setMsg('');
224
+ try {
225
+ await jsonPost('/api/printer/test', {});
226
+ setMsg(mode === 'imagefeed' ? 'Test job added to image feed.' : 'Test page sent to printer.');
227
+ }
228
+ catch (e) {
229
+ setErr(e instanceof Error ? e.message : String(e));
230
+ }
231
+ finally {
232
+ setTesting(false);
233
+ }
234
+ }
235
+ const modeLabel = {
236
+ escp: '🔌 ESC/P via Serial Port',
237
+ cups: '📄 PDF via CUPS',
238
+ imagefeed: '🖼️ Image Feed (testing)',
239
+ };
240
+ return (_jsxs("section", { style: S.box, children: [_jsx("h2", { style: { marginTop: 0 }, children: "Print Mode" }), err && _jsx("p", { style: S.err, children: err }), msg && _jsx("p", { style: S.ok, children: msg }), _jsxs("label", { style: S.label, children: [_jsx("span", { style: S.labelText, children: "Output Mode" }), _jsx("select", { style: S.select, value: mode, onChange: e => setMode(e.currentTarget.value), children: ['escp', 'cups', 'imagefeed'].map(m => (_jsx("option", { value: m, children: modeLabel[m] }, m))) })] }), mode === 'escp' && (_jsx(Field, { label: "Serial Port", value: serialPort, onChange: setSerialPort, placeholder: "e.g. /dev/ttyUSB0 or COM3" })), mode === 'cups' && _jsxs(_Fragment, { children: [_jsx(Field, { label: "CUPS Printer Name", value: printerName, onChange: setPrinterName, placeholder: "e.g. TM-T88V" }), _jsx(Field, { label: "Paper Size", value: paperSize, onChange: setPaperSize, placeholder: "e.g. Letter, A4, 80x297mm" }), _jsx(Field, { label: "Paper Name (display label)", value: paperName, onChange: setPaperName, placeholder: "e.g. Receipt Roll" })] }), mode === 'imagefeed' && (_jsxs("p", { style: { color: '#b4bdc8', marginTop: 0 }, children: ["Print jobs will be rendered as PNGs and shown at", ' ', _jsx("a", { href: "/feed", target: "_blank", rel: "noopener noreferrer", children: "/feed" }), "."] })), _jsxs("div", { style: { display: 'flex', gap: '8px', marginTop: '8px' }, children: [_jsx("button", { style: S.btn, onClick: () => void save(), disabled: saving, children: saving ? 'Saving…' : 'Save' }), _jsx("button", { style: { ...S.btn, background: '#1a7a3a' }, onClick: () => void testPrint(), disabled: testing || !printConfig, title: !printConfig ? 'Save a mode first' : '', children: testing ? 'Printing…' : 'Print Test Page' })] })] }));
241
+ }
242
+ function SecuritySection({ onSave }) {
243
+ const [password, setPassword] = useState('');
244
+ const [msg, setMsg] = useState('');
245
+ const [err, setErr] = useState('');
246
+ const [saving, setSaving] = useState(false);
247
+ async function save() {
248
+ setSaving(true);
249
+ setErr('');
250
+ setMsg('');
251
+ try {
252
+ await jsonPost('/api/config', { password });
253
+ setPassword('');
254
+ setMsg(password ? 'Password set.' : 'Password cleared.');
255
+ onSave();
256
+ }
257
+ catch (e) {
258
+ setErr(e instanceof Error ? e.message : String(e));
259
+ }
260
+ finally {
261
+ setSaving(false);
262
+ }
263
+ }
264
+ return (_jsxs("section", { style: S.box, children: [_jsx("h2", { style: { marginTop: 0 }, children: "Security" }), err && _jsx("p", { style: S.err, children: err }), msg && _jsx("p", { style: S.ok, children: msg }), _jsx(Field, { label: "Control Panel Password", type: "password", value: password, onChange: setPassword, placeholder: "Leave blank to remove password" }), _jsx("button", { style: S.btn, onClick: () => void save(), disabled: saving, children: saving ? 'Saving…' : 'Set Password' })] }));
265
+ }
266
+ function LogSection() {
267
+ const [events, setEvents] = useState([]);
268
+ const [status, setStatus] = useState(null);
269
+ async function load() {
270
+ const d = await apiFetch('/api/events');
271
+ setEvents(d.events);
272
+ setStatus(d.status);
273
+ }
274
+ useEffect(() => {
275
+ void load();
276
+ const t = setInterval(() => void load(), 4000);
277
+ return () => clearInterval(t);
278
+ }, []);
279
+ const typeColor = {
280
+ startup: '#8ab4ff',
281
+ success: '#8ff0a4',
282
+ error: '#ff8080',
283
+ print: '#ffd580',
284
+ info: '#b4bdc8',
285
+ command: '#d2a8ff',
286
+ };
287
+ return (_jsxs("section", { style: S.box, children: [_jsx("h2", { style: { marginTop: 0 }, children: "Bot Status & Log" }), status && (_jsxs("dl", { style: { display: 'grid', gridTemplateColumns: '140px 1fr', gap: '4px 12px', marginBottom: '16px' }, children: [_jsx("dt", { style: { color: '#b4bdc8' }, children: "Connected" }), _jsx("dd", { style: { margin: 0 }, children: status.connected ? '✅ yes' : '❌ no' }), _jsx("dt", { style: { color: '#b4bdc8' }, children: "Bot" }), _jsx("dd", { style: { margin: 0 }, children: status.tag ?? '—' }), _jsx("dt", { style: { color: '#b4bdc8' }, children: "Started" }), _jsx("dd", { style: { margin: 0 }, children: status.startedAt ?? '—' }), _jsx("dt", { style: { color: '#b4bdc8' }, children: "Server ID" }), _jsx("dd", { style: { margin: 0 }, children: status.configuredServerId ?? '—' }), _jsx("dt", { style: { color: '#b4bdc8' }, children: "Servers" }), _jsx("dd", { style: { margin: 0 }, children: status.guilds.join(', ') || '—' })] })), _jsx("div", { style: { maxHeight: '400px', overflow: 'auto', border: '1px solid #2b3340', borderRadius: '8px' }, children: events.length === 0 ? (_jsx("p", { style: { padding: '12px', margin: 0, color: '#b4bdc8' }, children: "No events yet." })) : (_jsxs("table", { style: { width: '100%', borderCollapse: 'collapse' }, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: S.th, children: "Time" }), _jsx("th", { style: S.th, children: "Type" }), _jsx("th", { style: S.th, children: "Message" })] }) }), _jsx("tbody", { children: events.map((e, i) => (_jsxs("tr", { children: [_jsx("td", { style: { ...S.td, ...S.mono, whiteSpace: 'nowrap' }, children: e.timestamp }), _jsx("td", { style: { ...S.td, color: typeColor[e.type] ?? '#b4bdc8' }, children: e.type }), _jsx("td", { style: S.td, children: e.message })] }, String(i)))) })] })) })] }));
288
+ }
289
+ function RestartSection() {
290
+ const [msg, setMsg] = useState('');
291
+ const [err, setErr] = useState('');
292
+ const [restarting, setRestarting] = useState(false);
293
+ async function restart() {
294
+ setRestarting(true);
295
+ setErr('');
296
+ setMsg('');
297
+ try {
298
+ await jsonPost('/api/restart', {});
299
+ setMsg('Restart signal sent. The server will restart shortly.');
300
+ }
301
+ catch (e) {
302
+ setErr(e instanceof Error ? e.message : String(e));
303
+ setRestarting(false);
304
+ }
305
+ }
306
+ return (_jsxs("section", { style: S.box, children: [_jsx("h2", { style: { marginTop: 0 }, children: "Server Control" }), err && _jsx("p", { style: S.err, children: err }), msg && _jsx("p", { style: S.ok, children: msg }), _jsx("button", { style: { ...S.btn, background: '#8a6d00' }, onClick: () => void restart(), disabled: restarting, children: restarting ? 'Restarting…' : 'Restart Server' })] }));
307
+ }
308
+ function App() {
309
+ const [config, setConfig] = useState(null);
310
+ const [refreshKey, setRefreshKey] = useState(0);
311
+ async function loadConfig() {
312
+ const d = await apiFetch('/api/config');
313
+ setConfig(d);
314
+ }
315
+ useEffect(() => { void loadConfig(); }, []);
316
+ function handleSave() {
317
+ void loadConfig();
318
+ setRefreshKey(k => k + 1);
319
+ }
320
+ return (_jsxs("main", { style: { maxWidth: '860px', margin: '0 auto', padding: '24px' }, children: [_jsx("h1", { style: { marginTop: 0 }, children: "\uD83D\uDDA8\uFE0F Windsor Control Panel" }), _jsx(BotSetup, { config: config, onSave: handleSave }), _jsx(ChannelBehaviors, { refreshKey: refreshKey }), _jsx(PrintModeSection, {}), _jsx(SecuritySection, { onSave: () => void loadConfig() }), _jsx(LogSection, {}), _jsx(RestartSection, {})] }));
321
+ }
322
+ const root = document.getElementById('app');
323
+ if (!root)
324
+ throw new Error('Missing #app');
325
+ render(_jsx(App, {}), root);
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "windsor-bot",
3
+ "version": "0.1.0",
4
+ "description": "Self-hosted Discord bot automation for household list and todo printing",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "prepare": "tsc",
9
+ "build": "tsc",
10
+ "dev": "node src/index.ts",
11
+ "test": "node --test src/tests/*.test.ts"
12
+ },
13
+ "bin": {
14
+ "windsor-bot": "./dist/index.js"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/SeaRyanC/windsor-bot.git"
19
+ },
20
+ "keywords": [],
21
+ "author": "",
22
+ "license": "Apache-2.0",
23
+ "bugs": {
24
+ "url": "https://github.com/SeaRyanC/windsor-bot/issues"
25
+ },
26
+ "homepage": "https://github.com/SeaRyanC/windsor-bot#readme",
27
+ "devDependencies": {
28
+ "@types/node": "^26.1.2",
29
+ "typescript": "^7.0.2"
30
+ },
31
+ "dependencies": {
32
+ "@types/pdfkit": "^0.17.6",
33
+ "@types/qrcode": "^1.5.6",
34
+ "discord.js": "^14.27.0",
35
+ "esbuild": "^0.28.1",
36
+ "escpos-buffer": "^4.1.0",
37
+ "jimp": "^1.6.1",
38
+ "openai": "^7.3.0",
39
+ "pdfkit": "^0.19.1",
40
+ "preact": "^10.29.7",
41
+ "qrcode": "^1.5.4",
42
+ "serialport": "^13.0.0",
43
+ "zod": "^4.4.3"
44
+ }
45
+ }
@@ -0,0 +1,38 @@
1
+ // Downloads Gen 1 GameBoy (yellow/gray) sprites for all 151 Kanto pokemon.
2
+ // Run once: node scripts/download-pokemon-sprites.mjs
3
+
4
+ import { mkdir, writeFile, access } from 'fs/promises';
5
+ import { join, dirname } from 'path';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const OUT_DIR = join(__dirname, '../src/commands/pokemon-sprites');
10
+
11
+ const BASE_URL = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-i/yellow/gray';
12
+
13
+ await mkdir(OUT_DIR, { recursive: true });
14
+
15
+ let ok = 0, skipped = 0, failed = 0;
16
+
17
+ for (let id = 1; id <= 151; id++) {
18
+ const dest = join(OUT_DIR, `${id}.png`);
19
+ try {
20
+ await access(dest);
21
+ skipped++;
22
+ continue;
23
+ } catch { /* not downloaded yet */ }
24
+
25
+ const url = `${BASE_URL}/${id}.png`;
26
+ const res = await fetch(url);
27
+ if (!res.ok) {
28
+ console.error(`FAIL #${id}: HTTP ${res.status}`);
29
+ failed++;
30
+ continue;
31
+ }
32
+ const buf = Buffer.from(await res.arrayBuffer());
33
+ await writeFile(dest, buf);
34
+ process.stdout.write(`\r${id}/151`);
35
+ ok++;
36
+ }
37
+
38
+ console.log(`\nDone. Downloaded: ${ok}, Skipped (already existed): ${skipped}, Failed: ${failed}`);
package/src/ai.ts ADDED
@@ -0,0 +1,214 @@
1
+ import { createHash } from 'crypto';
2
+ import { mkdir, writeFile, readFile, access } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import OpenAI from 'openai';
5
+ import { zodResponseFormat } from 'openai/helpers/zod.js';
6
+ import { z } from 'zod/v4';
7
+ import { getCurrentConfig, updateConfig } from './config.ts';
8
+ import { logEvent } from './server.ts';
9
+
10
+ const MODEL = 'gpt-5.4-nano';
11
+ const IMAGE_MODEL = 'gpt-image-1-mini';
12
+ const TOKEN_LIMIT_24H = 200_000;
13
+
14
+
15
+ export function trackTokenUsage(tokens: number): void {
16
+ const config = getCurrentConfig();
17
+ const entry = { timestamp: new Date().toISOString(), tokens };
18
+ const usage = [...config.tokenUsage, entry];
19
+ // Keep only entries from the last 24 hours
20
+ const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
21
+ const pruned = usage.filter(e => e.timestamp >= cutoff);
22
+ void updateConfig({ tokenUsage: pruned });
23
+ }
24
+
25
+ export function getTokenUsage24h(): number {
26
+ const config = getCurrentConfig();
27
+ const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
28
+ return config.tokenUsage
29
+ .filter(e => e.timestamp >= cutoff)
30
+ .reduce((sum, e) => sum + e.tokens, 0);
31
+ }
32
+
33
+ export async function isOverTokenLimit(): Promise<boolean> {
34
+ const used = getTokenUsage24h();
35
+ if (used >= TOKEN_LIMIT_24H) {
36
+ logEvent('error', `Token limit exceeded (${used} tokens in last 24h). Deleting OpenAI key.`);
37
+ const config = getCurrentConfig();
38
+ const updated = { ...config };
39
+ delete updated.openaiKey;
40
+ await updateConfig(updated);
41
+ return true;
42
+ }
43
+ return false;
44
+ }
45
+
46
+
47
+ function getClient(): OpenAI | null {
48
+ const key = getCurrentConfig().openaiKey;
49
+ if (!key) return null;
50
+ return new OpenAI({ apiKey: key });
51
+ }
52
+
53
+
54
+ const ScheduleSchema = z.object({
55
+ 'message without schedule': z.string(),
56
+ 'next occurrence': z.string(),
57
+ });
58
+
59
+ type ScheduleResult = z.infer<typeof ScheduleSchema>;
60
+
61
+ export interface ParsedSchedule {
62
+ message: string;
63
+ nextOccurrence: Date;
64
+ }
65
+
66
+ function parseOccurrenceDate(str: string): Date | null {
67
+ try {
68
+ const d = new Date(str);
69
+ if (!isNaN(d.getTime())) return d;
70
+ } catch {
71
+ // ignore
72
+ }
73
+ return null;
74
+ }
75
+
76
+ export async function parseRecurringSchedule(userMessage: string, now: Date): Promise<ParsedSchedule | null> {
77
+ const client = getClient();
78
+ if (!client) return null;
79
+
80
+ if (await isOverTokenLimit()) return null;
81
+
82
+ const nowStr = formatScheduleDate(now);
83
+ const prompt = `The user has asked for a recurring printout. It is currently ${nowStr}. Tell me the next time that I should do this, and what the non-schedule part of the message was (verbatim). If the user didn't specify a time of day, use 8:00 AM. Here's the user's message: ${userMessage}`;
84
+
85
+ const results: ScheduleResult[] = [];
86
+ const ATTEMPTS = 5;
87
+
88
+ for (let i = 0; i < ATTEMPTS; i++) {
89
+ try {
90
+ const completion = await client.chat.completions.create({
91
+ model: MODEL,
92
+ reasoning_effort: 'low',
93
+ messages: [{ role: 'user', content: prompt }],
94
+ response_format: zodResponseFormat(ScheduleSchema, 'schedule'),
95
+ } as Parameters<typeof client.chat.completions.create>[0]) as import("openai/resources/chat/completions/completions.js").ChatCompletion;
96
+ const content = completion.choices[0]?.message?.content;
97
+ if (content) {
98
+ const parsed = ScheduleSchema.parse(JSON.parse(content));
99
+ results.push(parsed);
100
+ const tokens = (completion as { usage?: { total_tokens?: number } }).usage?.total_tokens ?? 0;
101
+ trackTokenUsage(tokens);
102
+ }
103
+ } catch (err) {
104
+ logEvent('error', `Schedule parsing attempt ${i + 1} failed: ${err}`);
105
+ }
106
+ }
107
+
108
+ if (results.length < 3) return null;
109
+
110
+ // Majority voting: need 60% consistency for each field
111
+ const messageCounts = new Map<string, number>();
112
+ const occurrenceCounts = new Map<string, number>();
113
+
114
+ for (const r of results) {
115
+ messageCounts.set(r['message without schedule'], (messageCounts.get(r['message without schedule']) ?? 0) + 1);
116
+ occurrenceCounts.set(r['next occurrence'], (occurrenceCounts.get(r['next occurrence']) ?? 0) + 1);
117
+ }
118
+
119
+ const threshold = ATTEMPTS * 0.6;
120
+
121
+ let bestMessage: string | null = null;
122
+ for (const [msg, count] of messageCounts) {
123
+ if (count >= threshold) { bestMessage = msg; break; }
124
+ }
125
+
126
+ let bestOccurrence: string | null = null;
127
+ for (const [occ, count] of occurrenceCounts) {
128
+ if (count >= threshold) { bestOccurrence = occ; break; }
129
+ }
130
+
131
+ if (!bestMessage || !bestOccurrence) return null;
132
+
133
+ const occDate = parseOccurrenceDate(bestOccurrence);
134
+ if (!occDate) return null;
135
+
136
+ return { message: bestMessage, nextOccurrence: occDate };
137
+ }
138
+
139
+ function formatScheduleDate(date: Date): string {
140
+ const year = date.getFullYear();
141
+ const month = String(date.getMonth() + 1).padStart(2, '0');
142
+ const day = String(date.getDate()).padStart(2, '0');
143
+ let hours = date.getHours();
144
+ const minutes = String(date.getMinutes()).padStart(2, '0');
145
+ const ampm = hours >= 12 ? 'PM' : 'AM';
146
+ hours = hours % 12 || 12;
147
+ return `${year}-${month}-${day} ${hours}:${minutes} ${ampm}`;
148
+ }
149
+
150
+ export { formatScheduleDate };
151
+
152
+ export async function getNextOccurrence(originalMessage: string, now: Date): Promise<Date | null> {
153
+ const result = await parseRecurringSchedule(originalMessage, now);
154
+ return result ? result.nextOccurrence : null;
155
+ }
156
+
157
+ export async function generateIcon(text: string, cacheDir: string): Promise<string | null> {
158
+ const prompt = `Black-on-transparent line drawing icon for the TODO item: "${text}". Do not produce any text. Use big, thick lines. No fine detailing.`;
159
+ const hash = createHash('sha1').update(prompt).digest('hex').slice(0, 9);
160
+ const filename = `${hash}.png`;
161
+ const cachePath = join(cacheDir, filename);
162
+
163
+ // Check cache first
164
+ try {
165
+ await access(cachePath);
166
+ return cachePath;
167
+ } catch {
168
+ // not cached
169
+ }
170
+
171
+ const client = getClient();
172
+ if (!client) return null;
173
+
174
+ if (await isOverTokenLimit()) return null;
175
+
176
+ for (let attempt = 0; attempt < 3; attempt++) {
177
+ try {
178
+ const response = await client.images.generate({
179
+ model: IMAGE_MODEL,
180
+ prompt,
181
+ n: 1,
182
+ size: '1024x1024',
183
+ quality: 'low',
184
+ background: 'transparent',
185
+ output_format: 'png',
186
+ } as Parameters<typeof client.images.generate>[0]) as import("openai/resources/images.js").ImagesResponse;
187
+
188
+ const imageData = response.data?.[0];
189
+ if (!imageData) throw new Error('No image data returned');
190
+
191
+ let imageBuffer: Buffer;
192
+ if ('b64_json' in imageData && imageData.b64_json) {
193
+ imageBuffer = Buffer.from(imageData.b64_json, 'base64');
194
+ } else if ('url' in imageData && imageData.url) {
195
+ const res = await fetch(imageData.url);
196
+ imageBuffer = Buffer.from(await res.arrayBuffer());
197
+ } else {
198
+ throw new Error('No image URL or base64 data');
199
+ }
200
+
201
+ await mkdir(cacheDir, { recursive: true });
202
+ await writeFile(cachePath, imageBuffer);
203
+ return cachePath;
204
+ } catch (err) {
205
+ logEvent('error', `Icon generation attempt ${attempt + 1} failed: ${err}`);
206
+ if (attempt === 2) return null;
207
+ }
208
+ }
209
+
210
+ return null;
211
+ }
212
+
213
+ // suppress unused import warning
214
+ void readFile;