windsor-bot 0.1.2 → 0.1.4
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/package.json +6 -2
- package/SPEC.md +0 -179
- package/scripts/build-web.mjs +0 -13
- package/scripts/download-pokemon-sprites.mjs +0 -38
- package/src/ai.ts +0 -214
- package/src/bot.ts +0 -657
- package/src/commands/debug-fail.ts +0 -11
- package/src/commands/hello-world.ts +0 -15
- package/src/commands/index.ts +0 -35
- package/src/commands/pokemon.ts +0 -146
- package/src/commands/sudoku.ts +0 -94
- package/src/commands/util.ts +0 -14
- package/src/commands/wordsearch.ts +0 -118
- package/src/config.ts +0 -166
- package/src/index.ts +0 -98
- package/src/printer.ts +0 -268
- package/src/printing/cups.ts +0 -104
- package/src/printing/escp.ts +0 -158
- package/src/printing/imagefeed.ts +0 -246
- package/src/printing/index.ts +0 -63
- package/src/reactions.ts +0 -6
- package/src/server.ts +0 -372
- package/src/tests/config.test.ts +0 -88
- package/src/tests/urls.test.ts +0 -64
- package/src/types.ts +0 -120
- package/src/web/app.tsx +0 -690
- package/tsconfig.json +0 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "windsor-bot",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Self-hosted Discord bot automation for household list and todo printing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -11,8 +11,12 @@
|
|
|
11
11
|
"test": "node --test src/tests/*.test.ts"
|
|
12
12
|
},
|
|
13
13
|
"bin": {
|
|
14
|
-
"windsor-bot": "
|
|
14
|
+
"windsor-bot": "dist/index.js"
|
|
15
15
|
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"assets"
|
|
19
|
+
],
|
|
16
20
|
"repository": {
|
|
17
21
|
"type": "git",
|
|
18
22
|
"url": "git+https://github.com/SeaRyanC/windsor-bot.git"
|
package/SPEC.md
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
# Windsor Specification: Implementation Guide
|
|
2
|
-
|
|
3
|
-
## Channel Behavior Mapping
|
|
4
|
-
|
|
5
|
-
The server UI always immediately saves all config changes.
|
|
6
|
-
|
|
7
|
-
In the server config UI webpage, we see a list of channel->behavior mapping rows. Clicking on a row allows the user to see sub-configuration for that behavior (e.g. footer text). They can also delete a row. There's adjacent UI with a list of unmapped channels and list of behaviors; the user can pick one of each and then click "Add" to create the behavior mapping.
|
|
8
|
-
|
|
9
|
-
Internally, we store configuration in terms of both the channel name and ID, and match based on what we see on server startup or refresh: If the ID matches, use that and update our name. If none of the IDs match, see if a channel with the right name exists, update the ID instead. If neither exist, just delete that configuraiton entry. Be careful to not accidently nuke all config if we're encountering a server availabiliy blip - if *all* channels are missing, probably something has gone wrong, don't delete the config.
|
|
10
|
-
|
|
11
|
-
Channels may only have one behavior.
|
|
12
|
-
|
|
13
|
-
## URL Detection and Transformation
|
|
14
|
-
|
|
15
|
-
URLs are detected as any `https://...` text; we don't need to support other protocols. Trailing punctuation is ignored.
|
|
16
|
-
|
|
17
|
-
If multiple URLs are in the same message, write `[link 1]`, `[link 2]`, etc, instead of `[link]`. Don't try to dedupe URLs. There is a maximum of 5 links per printout. If URL parsing fails, assume it wasn't a URL in the first pace. If a URL is too long to generate a printable QR code for, assume it wasn't a URL in the first place.
|
|
18
|
-
|
|
19
|
-
## Idempotency
|
|
20
|
-
|
|
21
|
-
The bot does not require or assume 24/7 uptime. On startup you should scan prior messages and see what needs acting on. You only need to look at the last 100 messages per channel (500 for recurrences - assume we do not have more than that); messages older than this can be assumed to already be actioned.
|
|
22
|
-
|
|
23
|
-
For non-recurring items, use **reactions** to indicate that you've complied (e.g. printed or whatever) to a request. Use the ✅ reaction upon successfully fulfilling the request.
|
|
24
|
-
|
|
25
|
-
If printing fails, react with ⏸️ and post a reply that explains what went wrong.
|
|
26
|
-
|
|
27
|
-
The intended user workflow on fixing a printer is to power-cycle the Raspberry Pi, so you should attempt to re-process these messages on startup.
|
|
28
|
-
|
|
29
|
-
## Print Layout
|
|
30
|
-
|
|
31
|
-
In general we assume an 80mm printer. Long text should be wrapped. Extremely long messages (800 characters (?) after stripping URLs) should be ignored and reacted with ⁉️.
|
|
32
|
-
|
|
33
|
-
For message content, we generally want to print at quite a large font, for readability. This should scale down for longer messages; our ideal is that we are printing something that is modestly square to rectangular (not a tiny strip, not a CVS receipt). Images should span the full width of the paper minus a small margin.
|
|
34
|
-
|
|
35
|
-
General format is:
|
|
36
|
-
|
|
37
|
-
* User-specified header (large font)
|
|
38
|
-
* Primary text (medium-large font, depending)
|
|
39
|
-
* Icon
|
|
40
|
-
* QR codes
|
|
41
|
-
* User-specified footer (medium font)
|
|
42
|
-
* Metadata footer (small font)
|
|
43
|
-
|
|
44
|
-
Timestamps are in local time, in the format `2026-08-17 7:34 PM`
|
|
45
|
-
|
|
46
|
-
## Message Ingestion
|
|
47
|
-
|
|
48
|
-
Ignore user attachments, images, embeds, etc.. Only look at primary message content.
|
|
49
|
-
Ignore replies.
|
|
50
|
-
Ignore messages from your own account.
|
|
51
|
-
|
|
52
|
-
If parsing fails, react with ⁉️ and reply with something sensible related to the parsing failure that occurred.
|
|
53
|
-
|
|
54
|
-
Editing a message does not re-trigger bot activity.
|
|
55
|
-
|
|
56
|
-
## Accumulating List Behavior
|
|
57
|
-
|
|
58
|
-
The message `print` (any casing, all spacing / punctuation ignored) triggers a print.
|
|
59
|
-
Print all items between this message and the prior print message (or 100 messages, whichever is shorter).
|
|
60
|
-
If this would yield 0 items, instead print the "previous" list (this would normally occur if the printer had run out of paper and the user is retrying).
|
|
61
|
-
|
|
62
|
-
Don't try to deduplicate items.
|
|
63
|
-
Ignore deleted messages in the chat.
|
|
64
|
-
Use the latest version of any edited message.
|
|
65
|
-
|
|
66
|
-
## Recurring Print Behavior
|
|
67
|
-
|
|
68
|
-
We need to extract two main things from the user's message:
|
|
69
|
-
* The *message* part
|
|
70
|
-
* The *schedule* part
|
|
71
|
-
|
|
72
|
-
Use structured output using a zod schema to implement this.
|
|
73
|
-
|
|
74
|
-
Send the AI a message in this form:
|
|
75
|
-
|
|
76
|
-
> The user has asked for a recurring printout. It is currently [the current date and time]. 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: [the user message]
|
|
77
|
-
|
|
78
|
-
You'll want to get back a JSON blob like this:
|
|
79
|
-
```json
|
|
80
|
-
{
|
|
81
|
-
"message without schedule": "take out the trash",
|
|
82
|
-
"next occurrence": "2025-08-02 07:30 PM"
|
|
83
|
-
}
|
|
84
|
-
```
|
|
85
|
-
Be flexible about date parsing.
|
|
86
|
-
|
|
87
|
-
Use majority-reasoning method of asking 5 times. You must get 60% consistency in the message portion and 60% consistency in the next occurrence portion (not necessarily from the same responses); if this doesn't happen, treat this as a failure to parse and react appropriately.
|
|
88
|
-
|
|
89
|
-
React to the message with the standard green checkmark and reply with:
|
|
90
|
-
|
|
91
|
-
> Got it. I will print ⟪take out the trash⟫ at 2025-08-02 07:30 PM
|
|
92
|
-
|
|
93
|
-
You'll ingest this message on re-starts to establish what scheduled tasks there are.
|
|
94
|
-
Enqueue a background task that checks for scheduled items maturing (check every 30s).
|
|
95
|
-
|
|
96
|
-
When a task matures, we'll of course print it as requested.
|
|
97
|
-
When print finishes, react to the reply with the checkmark emoji.
|
|
98
|
-
|
|
99
|
-
Then we'll return to the AI to ask for what the next occurrence is.
|
|
100
|
-
Note that a task might have finite occurrences, e.g. the user prompt might be "for the next five weeks, remind me at 2 PM to take my medicine on Tuesdays".
|
|
101
|
-
|
|
102
|
-
So we'll post a reply either like
|
|
103
|
-
|
|
104
|
-
> This occurrence has expired and no more prints are scheduled
|
|
105
|
-
|
|
106
|
-
*or*
|
|
107
|
-
|
|
108
|
-
> Printed at 2025-08-02 07:30 PM. The next print will be at 2025-08-09 07:30 PM.
|
|
109
|
-
|
|
110
|
-
You can thus interpret the absence of such a reply (but the presence of the checkmark) as meaning the prior run failed to reach the AI server, and needs a retry.
|
|
111
|
-
|
|
112
|
-
## OpenAI Notes
|
|
113
|
-
|
|
114
|
-
Use `gpt-5.4-nano`, low reasoning, with structured outputs with zod v4 JSON schema for all tasks.
|
|
115
|
-
|
|
116
|
-
Keep a global rolling window of never consuming more than 200,000 tokens during any 24 hour period (something would be EXTREMELY wrong if this happens). If this happens, literally delete the AI key from your config.
|
|
117
|
-
|
|
118
|
-
## Icon Feature
|
|
119
|
-
|
|
120
|
-
For our auto-generated icon, we'll use OpenAI API. Use `gpt-image-1-mini`, 1024x1024, auto quality, transparency on, png format, with a prompt like
|
|
121
|
-
|
|
122
|
-
> Black-on-transparent line drawing icon for the TODO item: "take out the trash". Do not produce any text. Use big, thick lines. No fine detailing.
|
|
123
|
-
|
|
124
|
-
Once you get an image back, store it somewhere sensible on disk with a name like `1837c1a3e.png` where the name is derived from a SHA of the prompt. Check this cache first before calling out to the API.
|
|
125
|
-
|
|
126
|
-
If you're trying to print an item and the AI API is not working for some reason, retry up to 3 times, then just give up and skip the icon, it's never mandatory.
|
|
127
|
-
|
|
128
|
-
## On-Demand Commands and Interaction Model
|
|
129
|
-
|
|
130
|
-
Both `!command` and `/command` are supported.
|
|
131
|
-
Follow our standard idempotency model.
|
|
132
|
-
All users can run commands.
|
|
133
|
-
|
|
134
|
-
## Persistence and Data Model
|
|
135
|
-
|
|
136
|
-
We have three primary data sources:
|
|
137
|
-
* Messages in the channel itself
|
|
138
|
-
* Our own messages and reactions, which indicate work we've already done
|
|
139
|
-
* Local config JSON file
|
|
140
|
-
* In-memory log. This is where we persist logging, etc - we do not need to persist logging between server restarts
|
|
141
|
-
|
|
142
|
-
## Security and Access Control
|
|
143
|
-
|
|
144
|
-
In the server configuration page, users can optionally set a password (hash this and store it in the config file). Once this is set, the config server can only be accessed using that password; use WWW-Authenticate and ignore the username portion.
|
|
145
|
-
|
|
146
|
-
Channel security is up to Discord config; any user posting in a behavior-configured channel is assumed to be authorized to trigger whatever they're doing.
|
|
147
|
-
|
|
148
|
-
## Printer Integration
|
|
149
|
-
|
|
150
|
-
We only support ESC/P protocol, and there must be at most one printer installed.
|
|
151
|
-
The server configuration page should show what printer is connected (if any), offer appropriate printer config options, and have a "print test page button" that prints a small test page demonstrating printer capabilities (fonts, images, alignments, etc).
|
|
152
|
-
|
|
153
|
-
## Diagnostics
|
|
154
|
-
|
|
155
|
-
The server log, visible in the server config page, should show enough events to allow basic diagnostic health, e.g.
|
|
156
|
-
* Successful discord gateway connection
|
|
157
|
-
* Print success/fail
|
|
158
|
-
|
|
159
|
-
Do not include keepalive pings or other routine "non-events".
|
|
160
|
-
|
|
161
|
-
## Testing
|
|
162
|
-
|
|
163
|
-
Write tasteful unit tests at module boundaries. Test for behavorial acceptance, not hardcoded results, and don't test things that TypeScript is already covering for us. Tests must be typechecked. Don't test things that are too much of a pain in the ass.
|
|
164
|
-
|
|
165
|
-
## Revision notes: alpha
|
|
166
|
-
|
|
167
|
-
Apply these revisions to the spec or readme IF APPROPRIATE
|
|
168
|
-
|
|
169
|
-
* It should be more apparent in the server config whether a Discord token has been set yet. Same for OpenAI. Don't actually hide these, just always show them
|
|
170
|
-
* Use "server" in all user-facing config, never "guild"
|
|
171
|
-
* Server ID is required
|
|
172
|
-
* Setting the discord token and server ID should reload the channel list in the server UI
|
|
173
|
-
* Fix this: "(node:49701) DeprecationWarning: The ready event has been renamed to clientReady to distinguish it from the gateway READY event and will only emit under that name in v15. Please use clientReady instead."
|
|
174
|
-
* Add a "refresh channels" button to the server UI, next to the channel list
|
|
175
|
-
* Remove all "back compat" code, you don't need to be backward compatible with anything, this is an alpha project
|
|
176
|
-
* Command invocation should automatically trigger a log event without extra per-code command
|
|
177
|
-
* Don't use `class` in this project, use a function/revealing-module pattern instead
|
|
178
|
-
* Server config UI should have a "Restart Server" button
|
|
179
|
-
* Don't show voice channels or forum channels in the channel listing
|
package/scripts/build-web.mjs
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { build } from 'esbuild';
|
|
2
|
-
|
|
3
|
-
await build({
|
|
4
|
-
entryPoints: ['src/web/app.tsx'],
|
|
5
|
-
bundle: true,
|
|
6
|
-
outfile: 'dist/web/app.bundle.js',
|
|
7
|
-
platform: 'browser',
|
|
8
|
-
format: 'esm',
|
|
9
|
-
target: ['es2022'],
|
|
10
|
-
jsx: 'automatic',
|
|
11
|
-
jsxImportSource: 'preact',
|
|
12
|
-
sourcemap: 'inline',
|
|
13
|
-
});
|
|
@@ -1,38 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
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;
|