windsor-bot 0.1.17 → 0.2.1
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 +17 -190
- package/dist/ai.js +10 -91
- package/dist/bot.js +152 -151
- package/dist/index.js +1 -5
- package/dist/server.js +20 -9
- package/dist/web/app.bundle.js +5 -5
- package/dist/web/app.js +5 -5
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -26,185 +26,17 @@ for the bot before starting it.
|
|
|
26
26
|
|
|
27
27
|
## Initial setup
|
|
28
28
|
|
|
29
|
-
### 1.
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
111
|
+
### 🔁 Reusable List
|
|
281
112
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
});
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
return d;
|
|
51
|
+
await access(getIconCachePath(text, cacheDir));
|
|
52
|
+
return true;
|
|
55
53
|
}
|
|
56
54
|
catch {
|
|
57
|
-
|
|
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
|
|
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);
|