windsor-bot 0.1.17 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,185 +26,17 @@ for the bot before starting it.
26
26
 
27
27
  ## Initial setup
28
28
 
29
- ### 1. Create a Discord bot
30
-
31
- 1. Open the [Discord Developer Portal](https://discord.com/developers/applications)
32
- and select **New Application**.
33
- 2. Give the application a name and open its **Bot** page.
34
- 3. Click **Reset Token**, then copy the token. Treat it like a password: do
35
- not commit it, paste it into public issues, or share it. Discord will not
36
- show the token again without resetting it.
37
- 4. On the **Bot** page, enable the **Message Content Intent**. Windsor reads
38
- ordinary message text, so this privileged intent is required. Windsor also
39
- uses guilds, guild messages, and guild message reactions.
40
-
41
- ### 2. Invite the bot to your server
42
-
43
- From the application's **Installation** or **OAuth2 > URL Generator** page,
44
- create an installation URL with the `bot` scope. Grant these permissions:
45
-
46
- * View Channels
47
- * Send Messages
48
- * Read Message History
49
- * Add Reactions
50
-
51
- Open the generated URL, choose the target server, and authorize it. The person
52
- installing the bot must have permission to manage the server. The bot must be
53
- able to view and read history in every channel that Windsor monitors.
54
-
55
- ### 3. Set up a Raspberry Pi (headless)
56
-
57
- These steps assume a Raspberry Pi 4 or 5, a blank microSD card, and a wired or
58
- wireless network. The [official Raspberry Pi OS documentation](https://www.raspberrypi.com/documentation/computers/getting-started.html)
59
- has screenshots and troubleshooting for each step.
60
-
61
- 1. Use [Raspberry Pi Imager](https://www.raspberrypi.com/software/) to write
62
- **Raspberry Pi OS Lite (64-bit)** to the microSD card. Before writing,
63
- open the OS customization screen and set:
64
- * a hostname such as `windsor-pi`
65
- * a username such as `piuser` (the Linux account that will run Windsor)
66
- * a strong password
67
- * your Wi-Fi country, network name, and password (if using Wi-Fi)
68
- * the correct time zone and keyboard layout
69
- * **Enable SSH**, using password authentication for the first login
70
- 2. Eject the card, insert it into the Pi, connect the printer, and power on the
71
- Pi. Wait two to five minutes for the first boot.
72
- 3. From another computer on the same network, connect using the hostname:
73
-
74
- ```bash
75
- ssh piuser@windsor-pi.local
76
- ```
77
-
78
- If that name does not resolve, find the Pi in the router's client list and
79
- use its address instead:
80
-
81
- ```bash
82
- ssh piuser@192.168.1.50
83
- ```
84
-
85
- See the
86
- [Raspberry Pi SSH guide](https://www.raspberrypi.com/documentation/computers/remote-access.html#ssh)
87
- if hostname discovery does not work.
88
- 4. Update Raspberry Pi OS and install Node.js, npm, and the optional CUPS
89
- printing tools:
90
-
91
- ```bash
92
- sudo apt update && sudo apt upgrade -y
93
- sudo apt install -y nodejs npm cups
94
- node --version
95
- npm --version
96
- ```
97
-
98
- 5. Install Windsor globally:
99
-
100
- ```bash
101
- sudo npm install --global windsor-bot
102
- command -v windsor-bot
103
- ```
104
-
105
- 6. Create a dedicated working directory. Configuration and cached icons will
106
- be stored here:
107
-
108
- ```bash
109
- mkdir -p ~/windsor
110
- cd ~/windsor
111
- ```
112
-
113
- 7. Connect the USB printer. For a direct ESC/P printer, identify the device:
114
-
115
- ```bash
116
- ls -l /dev/ttyUSB* /dev/ttyACM* 2>/dev/null
117
- ```
118
-
119
- The usual result is `/dev/ttyUSB0` or `/dev/ttyACM0`. Add the login user to
120
- the device-access group, then reconnect over SSH:
121
-
122
- ```bash
123
- sudo usermod -aG dialout "$USER"
124
- exit
125
- ssh piuser@windsor-pi.local
126
- cd ~/windsor
127
- ```
128
-
129
- For a CUPS printer, enable the service and list configured printers:
130
-
131
- ```bash
132
- sudo systemctl enable --now cups
133
- lpstat -p
134
- ```
135
-
136
- Add the printer in CUPS, then use the name returned by `lpstat -p` in the
137
- Windsor control panel.
138
-
139
- ### 4. Install Windsor as a boot-time service
29
+ ### 1. Set up Discord
140
30
 
141
- Windsor should run under `systemd`, so it starts automatically whenever the Pi
142
- boots and restarts if the process exits. `systemd` does not use your interactive
143
- shell's username or `nvm` settings, so use the actual account and absolute
144
- binary path. Record them while logged in as the account that should run
145
- Windsor:
31
+ Follow the [Discord setup guide](docs/discord-setup.md) to create and invite
32
+ the bot.
146
33
 
147
- ```bash
148
- id -un
149
- echo "$HOME"
150
- command -v windsor-bot
151
- ```
152
-
153
- The first command must print an existing Linux username, and the last command
154
- must print an executable path. In the example below the account is `piuser`,
155
- the home directory is `/home/piuser`, and the installed command is
156
- `/usr/bin/windsor-bot`. If your output is different, substitute your values
157
- everywhere in the service file. For example, a Pi account named `windsor`
158
- uses `User=windsor` and `/home/windsor/...`; do not leave `User=piuser` unless
159
- that account actually exists.
160
-
161
- Create `/etc/systemd/system/windsor.service`:
162
-
163
- ```bash
164
- sudo nano /etc/systemd/system/windsor.service
165
- ```
34
+ ### 2. Set up a Raspberry Pi (headless)
166
35
 
167
- Paste the following, replacing the three example values with the output from
168
- the commands above:
169
-
170
- ```ini
171
- [Unit]
172
- Description=Windsor Bot
173
- After=network-online.target
174
- Wants=network-online.target
175
-
176
- [Service]
177
- Type=simple
178
- User=piuser
179
- WorkingDirectory=/home/piuser/windsor
180
- ExecStart=/usr/bin/windsor-bot
181
- Restart=on-failure
182
- RestartSec=10
183
-
184
- [Install]
185
- WantedBy=multi-user.target
186
- ```
36
+ Follow the [Raspberry Pi setup guide](docs/pi-setup.md) to install Windsor and
37
+ configure it as a boot-time service.
187
38
 
188
- Enable and start the service:
189
-
190
- ```bash
191
- sudo systemctl daemon-reload
192
- sudo systemctl enable windsor
193
- sudo systemctl start windsor
194
- sudo systemctl status windsor
195
- ```
196
-
197
- The service starts Windsor on every boot. View its logs with:
198
-
199
- ```bash
200
- journalctl -u windsor -f
201
- ```
202
-
203
- If the unit was already started with the wrong username or path, edit the unit,
204
- then run `sudo systemctl daemon-reload`, `sudo systemctl reset-failed windsor`,
205
- and `sudo systemctl restart windsor`.
206
-
207
- ### 5. Configure credentials
39
+ ### 3. Configure credentials
208
40
 
209
41
  Open [http://localhost:8080](http://localhost:8080) (or port 8080 on the Pi's
210
42
  hostname/IP) from a computer on the same network. The service starts with no
@@ -214,8 +46,7 @@ Discord token, but the control panel is available immediately. Enter:
214
46
  2. Optionally, the server ID to restrict Windsor to one server. To copy
215
47
  IDs in Discord, enable **Developer Mode**, then right-click the server and
216
48
  choose **Copy Server ID**.
217
- 3. Optionally enter an OpenAI API key. It is required for AI-generated icons
218
- and recurring schedules.
49
+ 3. Optionally enter an OpenAI API key. It is required for AI-generated icons.
219
50
  4. Set a control-panel password in the **Security** section.
220
51
  5. Save, then click **Restart Server**. This restarts the bot while leaving the
221
52
  boot service enabled.
@@ -277,21 +108,17 @@ print and marks the trigger with a reaction. This is useful for shopping lists.
277
108
  Available options are **Header**, **Footer**, **Include metadata footer**, and
278
109
  **Include checklist boxes**.
279
110
 
280
- ### 🔄 Recurring Print
111
+ ### 🔁 Reusable List
281
112
 
282
- Each message becomes a scheduled print. This behavior requires an OpenAI key
283
- because Windsor uses it to interpret natural-language schedules. Examples:
284
-
285
- ```text
286
- Take out the trash every other Tuesday at 7 PM
287
- Change out the batteries on the 3rd of the month at 2 PM
288
- Call Bob a week before his birthday (6/12)
289
- ```
113
+ React to a message to print it. Windsor adds the same reaction after a
114
+ successful print, giving the message two matching reactions. Removing your
115
+ reaction also removes Windsor's matching reaction. Each message is printed at
116
+ most once, and existing unhandled reactions from the last 100 messages are
117
+ processed when Windsor starts.
290
118
 
291
- Windsor replies with the interpreted schedule. If it is wrong, delete the
292
- original message and post a corrected version. If no time is specified, the
293
- default is 8:00 AM local time. Schedules are restored from recent channel
294
- history when the bot starts.
119
+ If the printer is unavailable, Windsor adds and retries. Removing your
120
+ reaction while the print is waiting cancels the pending print. This behavior
121
+ does not add or ❌ reactions.
295
122
 
296
123
  ### 💬 On-Demand
297
124
 
package/dist/ai.js CHANGED
@@ -2,8 +2,6 @@ import { createHash } from 'crypto';
2
2
  import { mkdir, writeFile, readFile, access } from 'fs/promises';
3
3
  import { join } from 'path';
4
4
  import OpenAI from 'openai';
5
- import { zodResponseFormat } from 'openai/helpers/zod.js';
6
- import { z } from 'zod/v4';
7
5
  import { getCurrentConfig, updateConfig } from './config.js';
8
6
  import { logEvent } from './server.js';
9
7
  const MODEL = 'gpt-5.4-nano';
@@ -43,102 +41,23 @@ function getClient() {
43
41
  return null;
44
42
  return new OpenAI({ apiKey: key });
45
43
  }
46
- const ScheduleSchema = z.object({
47
- 'message without schedule': z.string(),
48
- 'next occurrence': z.string(),
49
- });
50
- function parseOccurrenceDate(str) {
44
+ function getIconCachePath(text, cacheDir) {
45
+ 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.`;
46
+ const hash = createHash('sha1').update(prompt).digest('hex').slice(0, 9);
47
+ return join(cacheDir, `${hash}.png`);
48
+ }
49
+ export async function isIconCached(text, cacheDir) {
51
50
  try {
52
- const d = new Date(str);
53
- if (!isNaN(d.getTime()))
54
- return d;
51
+ await access(getIconCachePath(text, cacheDir));
52
+ return true;
55
53
  }
56
54
  catch {
57
- // ignore
55
+ return false;
58
56
  }
59
- return null;
60
- }
61
- export async function parseRecurringSchedule(userMessage, now) {
62
- const client = getClient();
63
- if (!client)
64
- return null;
65
- if (await isOverTokenLimit())
66
- return null;
67
- const nowStr = formatScheduleDate(now);
68
- 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}`;
69
- const results = [];
70
- const ATTEMPTS = 5;
71
- for (let i = 0; i < ATTEMPTS; i++) {
72
- try {
73
- const completion = await client.chat.completions.create({
74
- model: MODEL,
75
- reasoning_effort: 'low',
76
- messages: [{ role: 'user', content: prompt }],
77
- response_format: zodResponseFormat(ScheduleSchema, 'schedule'),
78
- });
79
- const content = completion.choices[0]?.message?.content;
80
- if (content) {
81
- const parsed = ScheduleSchema.parse(JSON.parse(content));
82
- results.push(parsed);
83
- const tokens = completion.usage?.total_tokens ?? 0;
84
- trackTokenUsage(tokens);
85
- }
86
- }
87
- catch (err) {
88
- logEvent('error', `Schedule parsing attempt ${i + 1} failed: ${err}`);
89
- }
90
- }
91
- if (results.length < 3)
92
- return null;
93
- // Majority voting: need 60% consistency for each field
94
- const messageCounts = new Map();
95
- const occurrenceCounts = new Map();
96
- for (const r of results) {
97
- messageCounts.set(r['message without schedule'], (messageCounts.get(r['message without schedule']) ?? 0) + 1);
98
- occurrenceCounts.set(r['next occurrence'], (occurrenceCounts.get(r['next occurrence']) ?? 0) + 1);
99
- }
100
- const threshold = ATTEMPTS * 0.6;
101
- let bestMessage = null;
102
- for (const [msg, count] of messageCounts) {
103
- if (count >= threshold) {
104
- bestMessage = msg;
105
- break;
106
- }
107
- }
108
- let bestOccurrence = null;
109
- for (const [occ, count] of occurrenceCounts) {
110
- if (count >= threshold) {
111
- bestOccurrence = occ;
112
- break;
113
- }
114
- }
115
- if (!bestMessage || !bestOccurrence)
116
- return null;
117
- const occDate = parseOccurrenceDate(bestOccurrence);
118
- if (!occDate)
119
- return null;
120
- return { message: bestMessage, nextOccurrence: occDate };
121
- }
122
- function formatScheduleDate(date) {
123
- const year = date.getFullYear();
124
- const month = String(date.getMonth() + 1).padStart(2, '0');
125
- const day = String(date.getDate()).padStart(2, '0');
126
- let hours = date.getHours();
127
- const minutes = String(date.getMinutes()).padStart(2, '0');
128
- const ampm = hours >= 12 ? 'PM' : 'AM';
129
- hours = hours % 12 || 12;
130
- return `${year}-${month}-${day} ${hours}:${minutes} ${ampm}`;
131
- }
132
- export { formatScheduleDate };
133
- export async function getNextOccurrence(originalMessage, now) {
134
- const result = await parseRecurringSchedule(originalMessage, now);
135
- return result ? result.nextOccurrence : null;
136
57
  }
137
58
  export async function generateIcon(text, cacheDir) {
138
59
  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.`;
139
- const hash = createHash('sha1').update(prompt).digest('hex').slice(0, 9);
140
- const filename = `${hash}.png`;
141
- const cachePath = join(cacheDir, filename);
60
+ const cachePath = getIconCachePath(text, cacheDir);
142
61
  // Check cache first
143
62
  try {
144
63
  await access(cachePath);