Skip to content

Commit 6e50b90

Browse files
committed
Add slash command subcommand support in API and picker (#5403)
1 parent 33b5d9f commit 6e50b90

5 files changed

Lines changed: 120 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Haven uses [Sema
3434

3535
### Fixed
3636
- **Blank login page / blank app shell after updating directly from v3.18.0 (#5399).** Two SyntaxErrors had been quietly sitting in `main` since the v3.19.0 Guest mode merge (`b6b95bd`): a duplicate `const loginForm` redeclaration in `auth.js`, and an orphan `_setupServerBar() {` opener with no body in `app-ui.js` (a half-merged method definition that was never closed). Anyone already running v3.19.x kept working because their cached/loaded modules survived the crash on initial parse, but users updating directly from v3.18.0 — the prior version many self-hosters were sitting on — hit both errors on first load and got a blank login page followed by a blank app. Mistakenly attributed to the v3.20.1 STUN refresh at first; that work is unaffected.
37+
- **Bot slash command registration now supports discoverable subcommands in autocomplete (#5403).** `POST /api/webhooks/:token/commands` accepts an optional `subcommands` array (`{ name, description }`), persists it per command, and `/api/bot-commands` now flattens those into picker entries like `/rss add` with per-subcommand descriptions. Callback payload format remains unchanged (`command` is base command, `args` carries the subcommand text and arguments).
3738
- **`/api/ice-servers` was still returning dead STUN URLs (#5399 followup).** The server-side default in `/api/ice-servers` was still handing out `stun.stunprotocol.org` + `stun.nextcloud.com` — the same pair the v3.20.1 client fix had to route around. Any Haven server using the server-side STUN defaults was giving its clients dead endpoints and only working at all because `voice.js` had been updated to ignore them. Mirrored the same Cloudflare/Metered/Twilio/Google fallback list on the server so the two sides stay in sync.
3839
- **Right-click → Copy image silently failed for almost everyone, on both static images and GIFs.** Two compounding causes: the previous implementation awaited an `Image()` load + `canvas.toBlob` before calling `navigator.clipboard.write`, by which point the user-gesture token had been dropped and Chromium silently rejected the write with `NotAllowedError`; on top of that, Electron's renderer added enough latency around fetch+decode that even a corrected promise-based path was unreliable. Rewrote with three strategies tried in order: under Haven Desktop, hand the PNG bytes to the main process via a new `clipboard:write-image` IPC (Electron's `clipboard.writeImage` has no gesture restrictions); otherwise call `navigator.clipboard.write` with a promise-based `ClipboardItem` so the gesture token is preserved; last-ditch, copy the image URL as text so the user has something to paste. Failure toasts now include the underlying error message so diagnosing future regressions doesn't require devtools.
3940
- **Image and member context menus appeared offscreen on top of the image lightbox or PiP DM.** Both `.image-context-menu` and `.user-context-menu` sat at `z-index: 10001`, which left them buried under the image lightbox (`100010`) and the PiP DM panel (`99999`). Right-clicking an enlarged image or a member while a PiP DM was open made the menu look like it vanished. Bumped both to `100020`.

GUIDE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,27 @@ Content-Type: application/json
809809
}
810810
```
811811
812+
Optional subcommands can be included to improve autocomplete discoverability:
813+
814+
```
815+
POST https://your-server.com/api/webhooks/<token>/commands
816+
Content-Type: application/json
817+
818+
{
819+
"command": "rss",
820+
"description": "Manage RSS feeds",
821+
"subcommands": [
822+
{ "name": "add", "description": "Add an RSS feed" },
823+
{ "name": "remove", "description": "Remove an RSS feed" },
824+
{ "name": "list", "description": "List active feeds" }
825+
]
826+
}
827+
```
828+
829+
The callback payload format is unchanged. Haven still sends `command` as the
830+
base command (`rss`) and the full remaining text in `args` (for example
831+
`"add https://example.com/feed.xml"`).
832+
812833
**List:**
813834
```
814835
GET https://your-server.com/api/webhooks/<token>/commands

public/js/modules/app-admin.js

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1991,9 +1991,25 @@ _checkSlashTrigger(inputEl) {
19911991
this._slashInput = input;
19921992
const text = input.value;
19931993

1994-
// Only activate if text starts with / and cursor is in the first word
1995-
if (text.startsWith('/') && !text.includes(' ') && text.length < 25) {
1996-
const query = text.substring(1).toLowerCase();
1994+
// Show slash suggestions while typing the command token, or a single
1995+
// subcommand token (e.g. "/rss ad"). Hide once argument typing starts.
1996+
if (text.startsWith('/') && text.length < 80) {
1997+
const raw = text.substring(1);
1998+
const trimmed = raw.trim();
1999+
let query = '';
2000+
if (!trimmed) {
2001+
query = '';
2002+
} else {
2003+
const parts = trimmed.split(/\s+/);
2004+
if (parts.length === 1) {
2005+
query = parts[0].toLowerCase();
2006+
} else if (parts.length === 2 && !raw.endsWith(' ')) {
2007+
query = `${parts[0].toLowerCase()} ${parts[1].toLowerCase()}`;
2008+
} else {
2009+
this._hideSlashDropdown();
2010+
return;
2011+
}
2012+
}
19972013
this._showSlashDropdown(query);
19982014
} else {
19992015
this._hideSlashDropdown();

server.js

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3007,7 +3007,22 @@ app.get('/api/webhooks/:token/commands', webhookLimiter, (req, res) => {
30073007
if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' });
30083008

30093009
const { getDb } = require('./src/database');
3010-
const commands = getDb().prepare('SELECT id, command, description FROM bot_commands WHERE webhook_id = ?').all(webhook.id);
3010+
const rows = getDb().prepare('SELECT id, command, description, subcommands_json FROM bot_commands WHERE webhook_id = ?').all(webhook.id);
3011+
const commands = rows.map(r => {
3012+
let subcommands = [];
3013+
if (r.subcommands_json) {
3014+
try {
3015+
const parsed = JSON.parse(r.subcommands_json);
3016+
if (Array.isArray(parsed)) subcommands = parsed;
3017+
} catch { /* ignore malformed historic values */ }
3018+
}
3019+
return {
3020+
id: r.id,
3021+
command: r.command,
3022+
description: r.description,
3023+
subcommands
3024+
};
3025+
});
30113026
res.json({ commands });
30123027
});
30133028

@@ -3017,7 +3032,7 @@ app.post('/api/webhooks/:token/commands', webhookLimiter, express.json({ limit:
30173032
if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' });
30183033
if (!webhook.callback_url) return res.status(400).json({ error: 'Webhook must have a callback_url to register commands' });
30193034

3020-
const { command, description } = req.body;
3035+
const { command, description, subcommands } = req.body;
30213036
if (!command || typeof command !== 'string') return res.status(400).json({ error: 'command required (string)' });
30223037

30233038
const cmd = command.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 32);
@@ -3028,11 +3043,33 @@ app.post('/api/webhooks/:token/commands', webhookLimiter, express.json({ limit:
30283043
if (builtIn.includes(cmd)) return res.status(409).json({ error: `/${cmd} is a built-in command` });
30293044

30303045
const desc = typeof description === 'string' ? description.trim().slice(0, 100) : '';
3046+
let cleanSubs = [];
3047+
if (Array.isArray(subcommands)) {
3048+
if (subcommands.length > 25) {
3049+
return res.status(400).json({ error: 'subcommands can contain at most 25 items' });
3050+
}
3051+
cleanSubs = subcommands
3052+
.map(sc => {
3053+
if (!sc || typeof sc !== 'object') return null;
3054+
const name = String(sc.name || '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 32);
3055+
if (!name) return null;
3056+
const description = typeof sc.description === 'string' ? sc.description.trim().slice(0, 100) : '';
3057+
return { name, description };
3058+
})
3059+
.filter(Boolean);
3060+
const seen = new Set();
3061+
cleanSubs = cleanSubs.filter(sc => {
3062+
if (seen.has(sc.name)) return false;
3063+
seen.add(sc.name);
3064+
return true;
3065+
});
3066+
}
3067+
const subcommandsJson = cleanSubs.length ? JSON.stringify(cleanSubs) : null;
30313068

30323069
const { getDb } = require('./src/database');
30333070
try {
3034-
getDb().prepare('INSERT OR REPLACE INTO bot_commands (webhook_id, command, description) VALUES (?, ?, ?)').run(webhook.id, cmd, desc);
3035-
res.json({ success: true, command: cmd, description: desc });
3071+
getDb().prepare('INSERT OR REPLACE INTO bot_commands (webhook_id, command, description, subcommands_json) VALUES (?, ?, ?, ?)').run(webhook.id, cmd, desc, subcommandsJson);
3072+
res.json({ success: true, command: cmd, description: desc, subcommands: cleanSubs });
30363073
} catch (err) {
30373074
res.status(500).json({ error: 'Failed to register command' });
30383075
}
@@ -3055,12 +3092,41 @@ app.delete('/api/webhooks/:token/commands/:command', webhookLimiter, (req, res)
30553092
// GET /api/bot-commands — list all registered bot commands (for client autocomplete)
30563093
app.get('/api/bot-commands', (req, res) => {
30573094
const { getDb } = require('./src/database');
3058-
const commands = getDb().prepare(`
3059-
SELECT bc.command, bc.description, w.name as bot_name
3095+
const rows = getDb().prepare(`
3096+
SELECT bc.command, bc.description, bc.subcommands_json, w.name as bot_name
30603097
FROM bot_commands bc
30613098
JOIN webhooks w ON bc.webhook_id = w.id
30623099
WHERE w.is_active = 1
30633100
`).all();
3101+
const commands = [];
3102+
for (const row of rows) {
3103+
let subcommands = [];
3104+
if (row.subcommands_json) {
3105+
try {
3106+
const parsed = JSON.parse(row.subcommands_json);
3107+
if (Array.isArray(parsed)) subcommands = parsed;
3108+
} catch { /* ignore malformed historic values */ }
3109+
}
3110+
if (subcommands.length) {
3111+
for (const sc of subcommands) {
3112+
const subName = String(sc?.name || '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 32);
3113+
if (!subName) continue;
3114+
commands.push({
3115+
command: `${row.command} ${subName}`,
3116+
description: typeof sc.description === 'string' && sc.description.trim()
3117+
? sc.description.trim()
3118+
: (row.description || 'Bot command'),
3119+
bot_name: row.bot_name || 'Bot'
3120+
});
3121+
}
3122+
continue;
3123+
}
3124+
commands.push({
3125+
command: row.command,
3126+
description: row.description || '',
3127+
bot_name: row.bot_name || 'Bot'
3128+
});
3129+
}
30643130
res.json({ commands });
30653131
});
30663132

src/database.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,13 +1019,20 @@ function initDatabase() {
10191019
webhook_id INTEGER NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
10201020
command TEXT NOT NULL,
10211021
description TEXT DEFAULT '',
1022+
subcommands_json TEXT DEFAULT NULL,
10221023
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
10231024
UNIQUE(webhook_id, command)
10241025
);
10251026
CREATE INDEX IF NOT EXISTS idx_bot_commands_command ON bot_commands(command);
10261027
CREATE INDEX IF NOT EXISTS idx_bot_commands_webhook ON bot_commands(webhook_id);
10271028
`);
10281029

1030+
try {
1031+
db.prepare('SELECT subcommands_json FROM bot_commands LIMIT 0').get();
1032+
} catch {
1033+
db.exec('ALTER TABLE bot_commands ADD COLUMN subcommands_json TEXT DEFAULT NULL');
1034+
}
1035+
10291036
// ── Migration: chat threads (thread_id on messages) ─────
10301037
try {
10311038
db.prepare("SELECT thread_id FROM messages LIMIT 0").get();

0 commit comments

Comments
 (0)