obol-ai 0.2.24 → 0.2.26

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/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 0.2.26
2
+ - show cleaning status instead of processing during /clean
3
+ - fix ask deadlock, clean writes tests and audits secrets
4
+ - clean confirmation gate, exec sandbox fix, mermaid tool, scheduler always-on
5
+
6
+ ## 0.2.25
7
+ - changelog
8
+ - add npmignore to exclude local files from package
9
+
1
10
  ## 0.2.24
2
11
  - update changelog and lockfile
3
12
  - ignore obol message export csvs
package/docs/DEPLOY.md ADDED
@@ -0,0 +1,383 @@
1
+ # Deploy OBOL on DigitalOcean
2
+
3
+ Complete guide: from zero to a running AI assistant in ~10 minutes.
4
+
5
+ ## 1. Create a Droplet
6
+
7
+ Go to [cloud.digitalocean.com](https://cloud.digitalocean.com) → **Create** → **Droplets**
8
+
9
+ | Setting | Value |
10
+ |---------|-------|
11
+ | **Region** | Pick the closest to you (e.g. Amsterdam, Frankfurt) |
12
+ | **Image** | Ubuntu 24.04 LTS |
13
+ | **Size** | Basic → Regular → **$6/mo** (1 vCPU, 1GB RAM, 25GB SSD) |
14
+ | **Auth** | SSH key (recommended) or password |
15
+ | **Hostname** | `obol` |
16
+
17
+ > 💡 The $6 droplet is enough. OBOL is a single Node.js process. The embedding model uses ~200MB RAM on first load, then stays resident. If you plan to run heavy scripts, go $12/mo (2GB RAM).
18
+
19
+ Click **Create Droplet**. Copy the IP address.
20
+
21
+ ## 2. Connect via SSH
22
+
23
+ ```bash
24
+ ssh root@YOUR_DROPLET_IP
25
+ ```
26
+
27
+ > ⚠️ **After first run**, OBOL hardens your server automatically — including moving SSH to port 2222. From then on:
28
+ > ```bash
29
+ > ssh -p 2222 root@YOUR_DROPLET_IP
30
+ > ```
31
+
32
+ ## 3. Install Node.js
33
+
34
+ ```bash
35
+ # Install Node.js 22 LTS via NodeSource
36
+ curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
37
+ apt-get install -y nodejs
38
+
39
+ # Verify
40
+ node -v # v22.x.x
41
+ npm -v # 10.x.x
42
+ ```
43
+
44
+ ## 4. Install OBOL and pm2
45
+
46
+ ```bash
47
+ npm install -g obol-ai pm2
48
+ ```
49
+
50
+ > `obol start -d` auto-installs pm2 if missing, but installing it upfront avoids surprises.
51
+
52
+ ## 5. Prepare Your Accounts
53
+
54
+ Before running `obol init`, have these ready:
55
+
56
+ ### Anthropic API Key
57
+ 1. Go to [console.anthropic.com](https://console.anthropic.com)
58
+ 2. Sign up / log in
59
+ 3. Go to **API Keys** → **Create Key**
60
+ 4. Copy the key (starts with `sk-ant-`)
61
+ 5. Add credits ($5 minimum) — go to **Billing** → **Add funds**
62
+
63
+ ### Telegram Bot Token
64
+ 1. Open Telegram, search for **@BotFather**
65
+ 2. Send `/newbot`
66
+ 3. Choose a name (e.g. "My OBOL")
67
+ 4. Choose a username (e.g. `my_obol_bot`)
68
+ 5. Copy the token (looks like `7123456789:AAF...`)
69
+
70
+ ### Your Telegram User ID
71
+ OBOL auto-detects your Telegram ID during setup — just send any message to your bot before running `obol init`. Alternatively:
72
+ 1. Open Telegram, search for **@userinfobot**
73
+ 2. Send `/start`
74
+ 3. It replies with your numeric ID (e.g. `206639616`)
75
+
76
+ ### Supabase (two options)
77
+
78
+ **Option A: Auto-create project** — you need an access token:
79
+ 1. Go to [supabase.com](https://supabase.com) → sign up (free)
80
+ 2. Go to [supabase.com/dashboard/account/tokens](https://supabase.com/dashboard/account/tokens)
81
+ 3. **Generate new token** → name it "obol" → copy it
82
+
83
+ **Option B: Use existing project** — you need the project ID + service role key:
84
+ 1. Go to your project's **Settings > API** page: `supabase.com/dashboard/project/<project-id>/settings/api`
85
+ 2. Copy the **Project ID** (or full URL like `https://xxx.supabase.co`)
86
+ 3. Copy the **service_role key** (under Project API keys — the one that bypasses RLS)
87
+
88
+ ### Vercel Token
89
+ 1. Go to [vercel.com](https://vercel.com) → sign up (free)
90
+ 2. Go to [vercel.com/account/tokens](https://vercel.com/account/tokens)
91
+ 3. **Create** → name it "obol" → copy the token
92
+
93
+ ### GitHub Token
94
+ 1. Go to [github.com/settings/tokens](https://github.com/settings/tokens)
95
+ 2. **Generate new token (classic)**
96
+ 3. Select scope: `repo`
97
+ 4. Copy the token
98
+
99
+ ## 6. Run Setup
100
+
101
+ ```bash
102
+ obol init
103
+ ```
104
+
105
+ The wizard walks you through everything with inline credential validation:
106
+
107
+ ```
108
+ 🪙 OBOL — Your AI, your rules.
109
+
110
+ ─── Step 1/7: Anthropic (AI brain) ───
111
+ Anthropic API key: ****
112
+ Validating Anthropic... ✅ Key valid
113
+
114
+ ─── Step 2/7: Telegram (chat interface) ───
115
+ Telegram bot token: ****
116
+ Validating Telegram... ✅ Bot: @my_obol_bot
117
+
118
+ ─── Step 3/7: Supabase (memory) ───
119
+ Supabase setup: Use existing project
120
+ Project URL or ID: abcdefghijklmnopqrst
121
+ Service role key: ****
122
+ Validating Supabase... ✅ Connected
123
+
124
+ ─── Step 4/7: GitHub (backup) ───
125
+ GitHub token: ****
126
+ Creating private repo: yourname/obol-brain... ✅
127
+
128
+ ─── Step 5/7: Vercel (deploy sites) ───
129
+ Vercel token: ****
130
+ Validating Vercel... ✅ Token valid
131
+
132
+ ─── Step 6/7: Identity ───
133
+ Your name: Jo
134
+ Bot name: Mr. Meeseeks
135
+
136
+ ─── Step 7/7: Access control ───
137
+ Found users who messaged this bot:
138
+ 206639616 — Jo (@jo)
139
+ Use this user? Yes
140
+
141
+ 🪙 Done! Setup complete.
142
+
143
+ Next steps:
144
+ obol start Start the bot
145
+ obol start -d Start as background daemon
146
+ obol config Edit configuration later
147
+ obol status Check bot status
148
+ ```
149
+
150
+ If a credential fails validation, you can continue and fix it later with `obol config`.
151
+
152
+ ## 7. Test It (Foreground)
153
+
154
+ ```bash
155
+ obol start
156
+ ```
157
+
158
+ Go to Telegram, open your bot, send a message. You should get a response from Claude.
159
+
160
+ > **Heads up:** Your first conversation triggers post-setup, which moves SSH to port 2222. If your terminal disconnects, reconnect with `ssh -p 2222 root@YOUR_DROPLET_IP`.
161
+
162
+ Press `Ctrl+C` to stop.
163
+
164
+ ## 8. Run as Daemon
165
+
166
+ ```bash
167
+ obol start -d
168
+ ```
169
+
170
+ This uses pm2 under the hood (auto-installs if needed). The bot auto-restarts on crash.
171
+
172
+ ```bash
173
+ obol status # is it running? uptime? memory?
174
+ obol logs # tail logs
175
+ obol stop # stop the daemon
176
+ ```
177
+
178
+ pm2 commands also work directly:
179
+
180
+ ```bash
181
+ pm2 logs obol # tail logs
182
+ pm2 restart obol # restart
183
+ pm2 monit # live monitoring dashboard
184
+ ```
185
+
186
+ ## 9. Survive Reboots
187
+
188
+ ```bash
189
+ pm2 startup
190
+ pm2 save
191
+ ```
192
+
193
+ That's it — OBOL auto-starts on boot and restarts if it crashes.
194
+
195
+ ## 10. Customize Your Bot
196
+
197
+ Edit personality files via SSH (replace `USER_ID` with your Telegram user ID):
198
+
199
+ ```bash
200
+ nano ~/.obol/users/USER_ID/personality/SOUL.md # Bot personality
201
+ nano ~/.obol/users/USER_ID/personality/USER.md # About you
202
+ nano ~/.obol/users/USER_ID/personality/AGENTS.md # How it works
203
+ ```
204
+
205
+ Or use Telegram commands:
206
+
207
+ ```
208
+ /traits — View or adjust personality traits (0-100 sliders)
209
+ /traits humor 80 — Set a specific trait
210
+ /secret set key val — Store per-user encrypted secrets (message auto-deleted)
211
+ /secret list — List stored secret keys
212
+ ```
213
+
214
+ Restart after editing files directly:
215
+
216
+ ```bash
217
+ pm2 restart obol
218
+ ```
219
+
220
+ ## Costs
221
+
222
+ | Service | Cost |
223
+ |---------|------|
224
+ | DigitalOcean droplet | $6/mo |
225
+ | Anthropic API (Claude Sonnet) | ~$3/mo for moderate use |
226
+ | Supabase | Free (500MB) |
227
+ | GitHub | Free (private repos) |
228
+ | Vercel | Free (100GB bandwidth) |
229
+ | Embeddings | Free (runs locally) |
230
+ | **Total** | **~$9/mo** |
231
+
232
+ ## Updating
233
+
234
+ ```bash
235
+ obol upgrade
236
+ ```
237
+
238
+ Checks npm for the latest version, stops the bot if running, installs the update, and restarts.
239
+
240
+ ## Backup & Restore
241
+
242
+ OBOL automatically backs up to GitHub daily at 3 AM (personality, scripts, commands, daily notes).
243
+
244
+ To restore on a new droplet:
245
+
246
+ ```bash
247
+ npm install -g obol-ai pm2
248
+ obol init --restore
249
+ # Paste GitHub token → it clones your brain
250
+ # Re-enter Telegram token + Anthropic key
251
+ obol start -d
252
+ ```
253
+
254
+ ## Editing Config
255
+
256
+ Edit any credential or setting interactively:
257
+
258
+ ```bash
259
+ obol config
260
+ ```
261
+
262
+ Sections: Anthropic, Telegram, Supabase, GitHub, Vercel, Identity, Access Control, Heartbeat, Evolution.
263
+
264
+ To start fresh:
265
+
266
+ ```bash
267
+ obol init --reset
268
+ ```
269
+
270
+ To completely remove OBOL and all its data:
271
+
272
+ ```bash
273
+ obol delete
274
+ ```
275
+
276
+ ## Troubleshooting
277
+
278
+ ### Can't SSH after first run
279
+ OBOL moves SSH to port 2222 during security hardening:
280
+ ```bash
281
+ ssh -p 2222 root@YOUR_DROPLET_IP
282
+ ```
283
+
284
+ ### Bot doesn't respond
285
+ ```bash
286
+ obol status # Is it running?
287
+ obol logs # Check for errors
288
+ ```
289
+
290
+ ### "Not authorized" / bot ignores messages
291
+ Check that your Telegram user ID is correct in `~/.obol/config.json`:
292
+ ```bash
293
+ cat ~/.obol/config.json | grep allowedUsers
294
+ ```
295
+
296
+ ### `pass` errors on startup
297
+
298
+ ```
299
+ Error: obol/anthropic-oauth-refresh is not in the password store.
300
+ [config] Failed to resolve obol/anthropic-oauth-refresh — key not found
301
+ ```
302
+
303
+ This means the config references a `pass` key that doesn't exist in the encrypted store. Common after a fresh install or failed secret migration.
304
+
305
+ **What happens:** The missing value resolves to `null`. If it's an OAuth token, OBOL falls back to API key auth. If it's the API key itself, the bot won't start.
306
+
307
+ **Fix it:**
308
+
309
+ ```bash
310
+ # Check what secrets are stored
311
+ pass ls
312
+
313
+ # Check what the config expects
314
+ cat ~/.obol/config.json
315
+
316
+ # Option A: Re-add the missing secret
317
+ pass insert obol/anthropic-oauth-refresh
318
+
319
+ # Option B: Switch to API key auth (if you're not using OAuth)
320
+ obol config
321
+ # → Anthropic → API Key
322
+
323
+ # Option C: Re-run the full setup
324
+ obol init --reset
325
+ ```
326
+
327
+ ### OAuth token expired
328
+
329
+ If you see `OAuth token expired and no refresh token available`:
330
+
331
+ 1. If you have an API key configured, OBOL silently falls back to it
332
+ 2. If not, re-authenticate: `obol config` > Anthropic > OAuth
333
+
334
+ ### Memory not working
335
+ ```bash
336
+ # Test Supabase connection
337
+ curl -s -H "apikey: YOUR_KEY" https://YOUR_PROJECT.supabase.co/rest/v1/obol_memory?limit=1
338
+ ```
339
+
340
+ ### Out of memory (OOM)
341
+ Upgrade to 2GB droplet:
342
+ ```bash
343
+ # In DigitalOcean dashboard: Droplet → Resize → 2GB ($12/mo)
344
+ ```
345
+
346
+ Or add swap (OBOL does this automatically, but if it didn't):
347
+ ```bash
348
+ fallocate -l 2G /swapfile
349
+ chmod 600 /swapfile
350
+ mkswap /swapfile
351
+ swapon /swapfile
352
+ echo '/swapfile none swap sw 0 0' >> /etc/fstab
353
+ ```
354
+
355
+ ### Firewall
356
+ OBOL only makes outbound connections (Telegram, Anthropic, Supabase). No ports need to be opened. But basic hardening is good practice:
357
+
358
+ ```bash
359
+ ufw allow 2222/tcp
360
+ ufw enable
361
+ ```
362
+ OBOL does this automatically during post-setup.
363
+
364
+ ## What OBOL Does on First Boot
365
+
366
+ After your first Telegram conversation, OBOL runs post-setup tasks automatically (Linux only). Progress is reported directly in the Telegram chat:
367
+
368
+ | Task | What |
369
+ |------|------|
370
+ | **GPG + pass** | Installs encrypted secret storage, migrates all plaintext secrets from config.json |
371
+ | **pm2** | Verifies pm2 is installed (already done in step 4, this is a safety check) |
372
+ | **Swap** | Creates 2GB swap if RAM < 2GB (embedding model needs ~200MB) |
373
+ | **SSH hardening** | Port 2222, key-only auth, max 3 retries, no root password |
374
+ | **fail2ban** | Bans IPs after 3 failed SSH attempts (1 hour ban) |
375
+ | **Firewall** | UFW deny-all inbound, allow port 2222 only |
376
+ | **Auto-updates** | Unattended security upgrades enabled |
377
+ | **Kernel hardening** | SYN cookies, reverse path filtering, no ICMP redirects |
378
+
379
+ These run once and are tracked in `~/.obol/.post-setup-complete`. To re-run, delete that file and restart.
380
+
381
+ ---
382
+
383
+ *That's it. One droplet, one process, one bot.*
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obol-ai",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "description": "Self-evolving AI assistant that learns, remembers, and acts on its own. Persistent vector memory, self-rewriting personality, proactive heartbeats.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/background.js CHANGED
@@ -8,7 +8,7 @@ class BackgroundRunner {
8
8
  this.taskCounter = 0;
9
9
  }
10
10
 
11
- spawn(claude, task, ctx, memory, parentContext) {
11
+ spawn(claude, task, ctx, memory, parentContext, opts = {}) {
12
12
  let running = 0;
13
13
  for (const t of this.tasks.values()) {
14
14
  if (t.status === 'running') running++;
@@ -30,13 +30,13 @@ class BackgroundRunner {
30
30
  const verbose = parentContext?.verbose || false;
31
31
  const verboseNotify = parentContext?._verboseNotify;
32
32
 
33
- const promise = this._runTask(claude, task, taskState, ctx, memory, verbose, verboseNotify);
33
+ const promise = this._runTask(claude, task, taskState, ctx, memory, verbose, verboseNotify, opts.model);
34
34
  taskState.promise = promise;
35
35
 
36
36
  return taskId;
37
37
  }
38
38
 
39
- async _runTask(claude, task, taskState, ctx, memory, verbose, verboseNotify) {
39
+ async _runTask(claude, task, taskState, ctx, memory, verbose, verboseNotify, model) {
40
40
  let statusMsgId = null;
41
41
  let statusTimer = null;
42
42
  let statusStart = Date.now();
@@ -76,6 +76,7 @@ TASK: ${task}`;
76
76
  chatId: `bg-${taskState.id}`,
77
77
  userName: 'BackgroundTask',
78
78
  verbose,
79
+ ...(model ? { _model: model } : {}),
79
80
  _verboseNotify: bgNotify,
80
81
  _onRouteDecision: (info) => {
81
82
  routeInfo = info;
@@ -24,16 +24,16 @@ const OPTIONAL_TOOLS = {
24
24
  tools: ['vercel_deploy', 'vercel_list'],
25
25
  config: {},
26
26
  },
27
- scheduler: {
28
- label: 'Scheduler',
29
- tools: ['schedule_event', 'list_events', 'cancel_event'],
30
- config: {},
31
- },
32
27
  background: {
33
28
  label: 'Background Tasks',
34
29
  tools: ['background_task'],
35
30
  config: {},
36
31
  },
32
+ mermaid: {
33
+ label: 'Flowchart',
34
+ tools: ['mermaid_chart'],
35
+ config: {},
36
+ },
37
37
  };
38
38
 
39
39
  const BLOCKED_EXEC_PATTERNS = [
@@ -56,7 +56,7 @@ ${workDir}/
56
56
  ├── scripts/ (utility scripts)
57
57
  ├── tests/ (test suite)
58
58
  ├── commands/ (command definitions)
59
- ├── apps/ (web apps for Vercel)
59
+ ├── apps/ (git repos and web apps any structure)
60
60
  ├── assets/ (uploaded files, images, media)
61
61
  └── logs/
62
62
  \`\`\`
@@ -177,6 +177,12 @@ Convert text to voice messages. Use when the user wants something read aloud.
177
177
  - \`text_to_speech\` — synthesize text and send as voice message. Voice defaults to user preference.
178
178
  - \`tts_voices\` — list available voices, filterable by language and gender
179
179
 
180
+ ### Flowchart / Diagram (\`mermaid_chart\`)
181
+ Generate diagrams and send them as images. Supports flowcharts, sequence diagrams, ER diagrams, Gantt charts, pie charts, etc.
182
+ - \`definition\` — Mermaid syntax (e.g. \`graph TD; A-->B\`)
183
+ - \`theme\` — default / dark / forest / neutral
184
+ - \`caption\` — optional caption on the image
185
+
180
186
  ### Bridge (\`bridge_ask\`, \`bridge_tell\`)
181
187
  Only available if bridge is enabled. Communicate with partner's AI agent.
182
188
  `);
@@ -14,6 +14,7 @@ const bridgeTool = require('./tools/bridge');
14
14
  const historyTool = require('./tools/history');
15
15
  const agentTool = require('./tools/agent');
16
16
  const sttTool = require('./tools/stt');
17
+ const mermaidTool = require('./tools/mermaid');
17
18
 
18
19
  const TOOL_MODULES = [
19
20
  execTool,
@@ -28,6 +29,7 @@ const TOOL_MODULES = [
28
29
  historyTool,
29
30
  agentTool,
30
31
  sttTool,
32
+ mermaidTool,
31
33
  ];
32
34
 
33
35
  const INPUT_SUMMARIES = {
@@ -1,3 +1,4 @@
1
+ const path = require('path');
1
2
  const { MAX_EXEC_TIMEOUT, BLOCKED_EXEC_PATTERNS } = require('../constants');
2
3
  const { execAsync } = require('../../sanitize');
3
4
 
@@ -20,6 +21,17 @@ function extractAbsolutePaths(command) {
20
21
  return [...paths];
21
22
  }
22
23
 
24
+ /** Extract and resolve relative traversal paths (e.g. ../../etc) against userDir */
25
+ function extractTraversalPaths(command, userDir) {
26
+ const re = /(?:^|[\s=|&;<>('"])(\.\.[\w.\-/]*)/g;
27
+ const paths = [];
28
+ let m;
29
+ while ((m = re.exec(command)) !== null) {
30
+ paths.push(path.resolve(userDir, m[1]));
31
+ }
32
+ return paths;
33
+ }
34
+
23
35
  /** Returns true if path is within userDir or a safe system prefix */
24
36
  function isAllowedPath(p, userDir) {
25
37
  if (p === userDir || p.startsWith(userDir + '/')) return true;
@@ -48,9 +60,12 @@ const handlers = {
48
60
  }
49
61
  }
50
62
  if (userDir) {
51
- const blockedPaths = extractAbsolutePaths(input.command).filter(p => !isAllowedPath(p, userDir));
52
- if (blockedPaths.length > 0) {
53
- return `Blocked: command accesses path(s) outside your workspace: ${blockedPaths.join(', ')}`;
63
+ const blocked = [
64
+ ...extractAbsolutePaths(input.command).filter(p => !isAllowedPath(p, userDir)),
65
+ ...extractTraversalPaths(input.command, userDir).filter(p => !isAllowedPath(p, userDir)),
66
+ ];
67
+ if (blocked.length > 0) {
68
+ return `Blocked: command accesses path(s) outside your workspace: ${blocked.join(', ')}. Your workspace is ${userDir} — all file operations must stay within it.`;
54
69
  }
55
70
  }
56
71
  const timeout = Math.min(input.timeout || 30, MAX_EXEC_TIMEOUT) * 1000;
@@ -0,0 +1,68 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const https = require('https');
4
+
5
+ const definitions = [
6
+ {
7
+ name: 'mermaid_chart',
8
+ description: 'Generate a diagram from a Mermaid definition and send it as an image to the chat. Supports flowcharts, sequence diagrams, ER diagrams, Gantt charts, etc.',
9
+ input_schema: {
10
+ type: 'object',
11
+ properties: {
12
+ definition: { type: 'string', description: 'Mermaid diagram definition (e.g. "graph TD; A-->B")' },
13
+ caption: { type: 'string', description: 'Optional caption for the image' },
14
+ theme: { type: 'string', enum: ['default', 'dark', 'forest', 'neutral'], description: 'Chart theme (default: default)' },
15
+ },
16
+ required: ['definition'],
17
+ },
18
+ },
19
+ ];
20
+
21
+ /** @param {string} url @returns {Promise<Buffer>} */
22
+ function fetchBuffer(url) {
23
+ return new Promise((resolve, reject) => {
24
+ https.get(url, (res) => {
25
+ if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
26
+ const chunks = [];
27
+ res.on('data', c => chunks.push(c));
28
+ res.on('end', () => resolve(Buffer.concat(chunks)));
29
+ res.on('error', reject);
30
+ }).on('error', reject);
31
+ });
32
+ }
33
+
34
+ const handlers = {
35
+ async mermaid_chart(input, memory, context) {
36
+ const telegramCtx = context.ctx;
37
+ if (!telegramCtx) return 'Cannot send charts in this context.';
38
+
39
+ const theme = input.theme || 'default';
40
+ const payload = JSON.stringify({ code: input.definition, mermaid: { theme } });
41
+ const encoded = Buffer.from(payload).toString('base64url');
42
+ const url = `https://mermaid.ink/img/${encoded}`;
43
+
44
+ let imgBuffer;
45
+ try {
46
+ imgBuffer = await fetchBuffer(url);
47
+ } catch (e) {
48
+ return `Failed to render chart: ${e.message}`;
49
+ }
50
+
51
+ const tmpPath = path.join('/tmp', `mermaid-${Date.now()}.png`);
52
+ fs.writeFileSync(tmpPath, imgBuffer);
53
+
54
+ try {
55
+ const { InputFile } = require('grammy');
56
+ await telegramCtx.replyWithPhoto(new InputFile(tmpPath), {
57
+ caption: input.caption || undefined,
58
+ });
59
+ return 'Chart sent.';
60
+ } catch (e) {
61
+ return `Failed to send chart: ${e.message}`;
62
+ } finally {
63
+ fs.unlink(tmpPath, () => {});
64
+ }
65
+ },
66
+ };
67
+
68
+ module.exports = { definitions, handlers };