diff --git a/.github/workflows/pocketbase-bot.yml b/.github/workflows/pocketbase-bot.yml index b22b0755e..46d056d4d 100644 --- a/.github/workflows/pocketbase-bot.yml +++ b/.github/workflows/pocketbase-bot.yml @@ -33,6 +33,10 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} FRONTEND_URL: ${{ secrets.FRONTEND_URL }} REVALIDATE_SECRET: ${{ secrets.REVALIDATE_SECRET }} + # Screenshot attaching is delegated to the frontend, which owns the + # fetching and validation. Without this the subcommand says so rather + # than failing halfway. + SCREENSHOT_IMPORT_SECRET: ${{ secrets.SCREENSHOT_IMPORT_SECRET }} run: | node << 'ENDSCRIPT' (async function () { @@ -323,11 +327,21 @@ jobs: '/pocketbase method remove \n' + '```\n' + 'Method fields: `cpu` `ram` `hdd` `os` `version` `config_path` `script`\n\n' + + '`cpu` `ram` `hdd` `os` `version` also work without `method` — they go to the\n' + + 'default install method, e.g. `/pocketbase hdd=25`\n\n' + '**Editable fields:** `name` `description` `logo` `documentation` `website` `project_url` `repository` ' + '`config_path` `port` `default_user` `default_passwd` ' + - '`updateable` `privileged` `is_dev` ' + + '`updateable` `privileged` `is_dev` `has_arm` ' + '`architectures` (amd64,arm64) `platforms` (pve,incus) ' + - '`is_disabled` `disable_message` `is_deleted` `deleted_message`'; + '`execute_in` (pve,lxc,pbs,vm,pmg,pdm) `categories` (names or ids) ' + + '`type` (ct, vm, addon, …) ' + + '`is_disabled` `disable_message` `is_deleted` `deleted_message`\n\n' + + '**Screenshots:**\n' + + '```\n' + + '/pocketbase screenshot https://example.com/one.png https://example.com/two.png\n' + + '```\n\n' + + '`slug` is deliberately not editable: it is the URL, the JSON filename and the\n' + + 'ct/.sh path all at once, so renaming it is a migration rather than an edit.'; if (!withoutCmd) { await addReaction('-1'); @@ -490,6 +504,7 @@ jobs: const noteMatch = rest.match(/^note\s+(list|add|edit|remove)\b/i); const methodMatch = rest.match(/^method\b/i); const setMatch = rest.match(/^set\s+(\S+)/i); + const shotMatch = rest.match(/^screenshots?\s+(.+)$/i); if (infoMatch) { // ── INFO SUBCOMMAND ────────────────────────────────────────────── @@ -535,6 +550,44 @@ jobs: await addReaction('+1'); await postComment(out.join('\n')); + } else if (shotMatch) { + // ── SCREENSHOT SUBCOMMAND ──────────────────────────────────────── + // Delegated to the frontend rather than reimplemented here: it + // already fetches the URL, checks the content type and size, and + // attaches the file to PocketBase. Doing that a second time in a + // workflow would be a second set of bugs. + const shotUrls = shotMatch[1].split(/[\s,]+/).map(function (u) { return u.trim(); }).filter(Boolean); + const bad = shotUrls.filter(function (u) { return !/^https?:\/\//i.test(u); }); + if (bad.length > 0) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: not a URL: `' + bad.join('`, `') + '`'); + process.exit(0); + } + const frontendUrl = process.env.FRONTEND_URL; + const shotSecret = process.env.SCREENSHOT_IMPORT_SECRET; + if (!frontendUrl || !shotSecret) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: screenshot import is not configured (FRONTEND_URL / SCREENSHOT_IMPORT_SECRET).'); + process.exit(1); + } + const shotRes = await request(frontendUrl.replace(/\/$/, '') + '/api/screenshots', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: slug, secret: shotSecret, urls: shotUrls }) + }); + if (!shotRes.ok) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: screenshot import failed:\n```\n' + shotRes.body + '\n```'); + process.exit(1); + } + await revalidate(slug); + await addReaction('+1'); + await postComment( + '✅ **PocketBase Bot**: attached ' + shotUrls.length + ' screenshot' + (shotUrls.length === 1 ? '' : 's') + + ' to **`' + slug + '`**\n\n' + shotUrls.map(function (u) { return '- ' + u; }).join('\n') + + '\n\n*Executed by @' + actor + '*' + ); + } else if (noteMatch) { // ── NOTE SUBCOMMAND ────────────────────────────────────────────── const noteAction = noteMatch[1].toLowerCase(); @@ -838,15 +891,20 @@ jobs: } else if (setMatch) { // ── SET SUBCOMMAND (value from code block) ─────────────────────── const fieldName = setMatch[1].toLowerCase(); + // app_vars is a JSON column, so it rides the code-block path like + // the long text fields - but its content is parsed, not stored + // verbatim, or PocketBase ends up with a string where an object + // belongs and every reader has to guess. const SET_ALLOWED = { name: 'string', description: 'string', logo: 'string', documentation: 'string', website: 'string', project_url: 'string', repository: 'string', - config_path: 'string', disable_message: 'string', deleted_message: 'string' + config_path: 'string', disable_message: 'string', deleted_message: 'string', + app_vars: 'json' }; if (!SET_ALLOWED[fieldName]) { await addReaction('-1'); await postComment( - '❌ **PocketBase Bot**: `set` only supports text fields.\n\n' + + '❌ **PocketBase Bot**: `set` only supports text and JSON fields.\n\n' + '**Allowed:** `' + Object.keys(SET_ALLOWED).join('`, `') + '`\n\n' + 'For boolean/number fields use `field=value` syntax instead.' ); @@ -861,7 +919,21 @@ jobs: process.exit(0); } const setPayload = {}; - setPayload[fieldName] = codeBlockValue; + if (SET_ALLOWED[fieldName] === 'json') { + let parsedJson; + try { + parsedJson = JSON.parse(codeBlockValue); + } catch (e) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: `' + fieldName + '` must be valid JSON.\n\n```\n' + e.message + '\n```' + ); + process.exit(0); + } + setPayload[fieldName] = parsedJson; + } else { + setPayload[fieldName] = codeBlockValue; + } const setPatchRes = await request(recordsUrl + '/' + record.id, { method: 'PATCH', headers: { 'Authorization': token, 'Content-Type': 'application/json' }, @@ -901,6 +973,10 @@ jobs: privileged: 'boolean', architectures: 'select_list', platforms: 'select_list', + execute_in: 'select_list', + has_arm: 'boolean', + categories: 'relation_list', + type: 'relation_single', is_dev: 'boolean', is_disabled: 'boolean', disable_message: 'string', @@ -910,7 +986,18 @@ jobs: const parsedFields = parseKVPairs(rest); - const unknownFields = Object.keys(parsedFields).filter(function (f) { return !ALLOWED_FIELDS[f]; }); + // cpu/ram/hdd/os/version live inside install_methods, not on the + // record itself. "/pocketbase immich hdd=25" is how people ask for + // it, so route those keys there instead of rejecting them as + // unknown fields. Everything else stays a plain record field. + const TOP_RESOURCE_KEYS = { cpu: 'number', ram: 'number', hdd: 'number', os: 'string', version: 'string' }; + const resourceFields = {}; + const scriptFields = {}; + for (const [rk, rv] of Object.entries(parsedFields)) { + if (TOP_RESOURCE_KEYS[rk]) resourceFields[rk] = rv; else scriptFields[rk] = rv; + } + + const unknownFields = Object.keys(scriptFields).filter(function (f) { return !ALLOWED_FIELDS[f]; }); if (unknownFields.length > 0) { await addReaction('-1'); await postComment( @@ -930,9 +1017,10 @@ jobs: const SELECT_VALUES = { architectures: ['amd64', 'arm64'], platforms: ['pve', 'incus'], + execute_in: ['pve', 'lxc', 'pbs', 'vm', 'pmg', 'pdm'], }; const payload = {}; - for (const [key, rawVal] of Object.entries(parsedFields)) { + for (const [key, rawVal] of Object.entries(scriptFields)) { const type = ALLOWED_FIELDS[key]; if (type === 'boolean') { if (rawVal === 'true') payload[key] = true; @@ -962,6 +1050,65 @@ jobs: process.exit(0); } payload[key] = values; + } else if (type === 'relation_list') { + // categories is a relation, so the stored value is a list of + // record ids. People write category names, so resolve them — + // and accept ids too, which is what a copy out of the + // PocketBase UI gives you. + const wanted = rawVal.split(',').map(function (v) { return v.trim(); }).filter(Boolean); + const catRes = await request(apiBase + '/collections/script_categories/records?perPage=500&fields=id,name', { + headers: { 'Authorization': token } + }); + if (!catRes.ok) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: could not read `script_categories` to resolve `' + key + '`.'); + process.exit(1); + } + const cats = JSON.parse(catRes.body).items || []; + const byName = {}; + const ids = {}; + cats.forEach(function (c) { byName[String(c.name).toLowerCase()] = c.id; ids[c.id] = true; }); + const resolved = []; + const unknownCats = []; + wanted.forEach(function (w) { + if (ids[w]) { resolved.push(w); return; } + const id = byName[w.toLowerCase()]; + if (id) resolved.push(id); else unknownCats.push(w); + }); + if (unknownCats.length > 0) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: unknown ' + key + ': `' + unknownCats.join('`, `') + '`\n\n' + + '**Available:** `' + cats.map(function (c) { return c.name; }).sort().join('`, `') + '`' + ); + process.exit(0); + } + payload[key] = resolved; + } else if (type === 'relation_single') { + // type points at one z_ref_script_types record. People write + // "ct" or "vm", not a 15-character id. + const typeRes = await request(apiBase + '/collections/z_ref_script_types/records?perPage=200&fields=id,type', { + headers: { 'Authorization': token } + }); + if (!typeRes.ok) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: could not read `z_ref_script_types` to resolve `' + key + '`.'); + process.exit(1); + } + const types = JSON.parse(typeRes.body).items || []; + const wantType = rawVal.trim().toLowerCase(); + const hit = types.find(function (t) { + return t.id === rawVal.trim() || String(t.type).toLowerCase() === wantType; + }); + if (!hit) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: unknown type `' + rawVal + '`\n\n' + + '**Available:** `' + types.map(function (t) { return t.type; }).sort().join('`, `') + '`' + ); + process.exit(0); + } + payload[key] = hit.id; } else if (type === 'nullable_string') { payload[key] = rawVal === '' ? null : rawVal; } else { @@ -969,6 +1116,42 @@ jobs: } } + // Resources go into install_methods. Which method: the only one if + // there is only one, otherwise the non-Alpine default — and the + // reply says which, so nobody has to guess where 25 GB landed. + const resourceKeys = Object.keys(resourceFields); + let resourceTargetType = null; + if (resourceKeys.length > 0) { + const methodsArr = readJsonBlob(record.install_methods) || []; + if (methodsArr.length === 0) { + await addReaction('-1'); + await postComment( + '❌ **PocketBase Bot**: `' + slug + '` has no install methods, so there is nowhere to put `' + + resourceKeys.join('`, `') + '`.\n\nAdd one first: `/pocketbase ' + slug + ' method add default cpu=2 ram=2048 hdd=8`' + ); + process.exit(0); + } + let target = methodsArr.find(function (m) { + return String(m.type || '').toLowerCase() !== 'alpine'; + }) || methodsArr[0]; + resourceTargetType = target.type || 'default'; + if (!target.resources) target.resources = {}; + for (const [rk, rv] of Object.entries(resourceFields)) { + if (TOP_RESOURCE_KEYS[rk] === 'number') { + const n = parseInt(rv, 10); + if (isNaN(n)) { + await addReaction('-1'); + await postComment('❌ **PocketBase Bot**: `' + rk + '` must be a number, got: `' + rv + '`'); + process.exit(0); + } + target.resources[rk] = n; + } else { + target.resources[rk] = rv; + } + } + payload.install_methods = methodsArr; + } + const patchRes = await request(recordsUrl + '/' + record.id, { method: 'PATCH', headers: { 'Authorization': token, 'Content-Type': 'application/json' }, @@ -981,10 +1164,16 @@ jobs: } await revalidate(slug); const FIELD_TO_CT_VAR = { tags: 'var_tags', unprivileged: 'var_unprivileged' }; + const RESOURCE_CT_VAR = { cpu: 'var_cpu', ram: 'var_ram', hdd: 'var_disk', os: 'var_os', version: 'var_version' }; const fieldCtChanges = {}; for (const [k, v] of Object.entries(payload)) { if (FIELD_TO_CT_VAR[k]) fieldCtChanges[FIELD_TO_CT_VAR[k]] = v; } + // Resources belong in the CT script too, exactly as the `method` + // path syncs them - otherwise PocketBase and ct/.sh drift. + for (const [k, v] of Object.entries(resourceFields)) { + if (RESOURCE_CT_VAR[k]) fieldCtChanges[RESOURCE_CT_VAR[k]] = v; + } let fieldCtSync = null; try { fieldCtSync = await upsertCtDefaultsPr(slug, fieldCtChanges); @@ -992,8 +1181,14 @@ jobs: fieldCtSync = { status: 'skipped', reason: 'CT sync failed: ' + e.message }; } await addReaction('+1'); + // install_methods is skipped here: dumping the whole array as JSON + // buries the one number that actually changed. const changesLines = Object.entries(payload) + .filter(function (e) { return e[0] !== 'install_methods'; }) .map(function ([k, v]) { return '- `' + k + '` → `' + JSON.stringify(v) + '`'; }) + .concat(Object.entries(resourceFields).map(function ([k, v]) { + return '- `' + k + '` → `' + v + '` *(install method `' + resourceTargetType + '`)*'; + })) .join('\n'); await postComment( '✅ **PocketBase Bot**: Updated **`' + slug + '`** successfully!\n\n' +