mirror of
https://github.com/community-scripts/ProxmoxVE.git
synced 2026-08-27 18:45:54 +00:00
* Teach the PocketBase bot every field The bot covered most of script_scripts but not categories, has_arm, execute_in or app_vars, so those had to be edited by hand in the admin UI. categories is a relation, so names are resolved against script_categories and ids are accepted too - a copy out of the PocketBase UI works either way. app_vars is a JSON column and rides the code-block 'set' path with its content parsed rather than stored verbatim, so readers get an object. cpu/ram/hdd/os/version live inside install_methods, and '/pocketbase immich hdd=25' is how people ask for them. They now route to the default (non-Alpine) method instead of being rejected as unknown fields, the reply names which method was touched, and the value syncs into ct/<slug>.sh like the 'method' path already does. * Add screenshots and type to the bot Two gaps were left. type is a relation to z_ref_script_types, so it needs the same name-to-id resolution as categories - people write "ct", not a fifteen-character id. Screenshots had no command at all. The screenshot subcommand hands the URLs to the frontend's /api/screenshots rather than fetching images inside a workflow. That endpoint already checks the content type and size and attaches the file to PocketBase; doing it a second time here would be a second set of bugs. It needs SCREENSHOT_IMPORT_SECRET, and says so plainly when it is missing instead of failing halfway. slug stays deliberately out of reach. It is the URL, the JSON filename and the ct/<slug>.sh path at once, so renaming it is a migration rather than an edit, and the help text now says that instead of leaving people to wonder.
1208 lines
62 KiB
YAML
Generated
1208 lines
62 KiB
YAML
Generated
name: PocketBase Bot
|
||
|
||
on:
|
||
issue_comment:
|
||
types: [created]
|
||
|
||
permissions:
|
||
issues: write
|
||
pull-requests: write
|
||
contents: write
|
||
|
||
jobs:
|
||
pocketbase-bot:
|
||
runs-on: self-hosted
|
||
|
||
# Act on comments that contain a /pocketbase command line (precise line check happens in-script)
|
||
if: contains(github.event.comment.body, '/pocketbase')
|
||
|
||
steps:
|
||
- name: Execute PocketBase bot command
|
||
env:
|
||
POCKETBASE_URL: ${{ secrets.POCKETBASE_URL }}
|
||
POCKETBASE_COLLECTION: ${{ secrets.POCKETBASE_COLLECTION }}
|
||
POCKETBASE_ADMIN_EMAIL: ${{ secrets.POCKETBASE_ADMIN_EMAIL }}
|
||
POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }}
|
||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||
COMMENT_ID: ${{ github.event.comment.id }}
|
||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||
REPO_OWNER: ${{ github.repository_owner }}
|
||
REPO_NAME: ${{ github.event.repository.name }}
|
||
ACTOR: ${{ github.event.comment.user.login }}
|
||
ACTOR_ASSOCIATION: ${{ github.event.comment.author_association }}
|
||
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 () {
|
||
const https = require('https');
|
||
const http = require('http');
|
||
const url = require('url');
|
||
|
||
// ── HTTP helper with redirect following ────────────────────────────
|
||
function request(fullUrl, opts, redirectCount) {
|
||
redirectCount = redirectCount || 0;
|
||
return new Promise(function (resolve, reject) {
|
||
const u = url.parse(fullUrl);
|
||
const isHttps = u.protocol === 'https:';
|
||
const body = opts.body;
|
||
const options = {
|
||
hostname: u.hostname,
|
||
port: u.port || (isHttps ? 443 : 80),
|
||
path: u.path,
|
||
method: opts.method || 'GET',
|
||
headers: opts.headers || {}
|
||
};
|
||
if (body) options.headers['Content-Length'] = Buffer.byteLength(body);
|
||
const lib = isHttps ? https : http;
|
||
const req = lib.request(options, function (res) {
|
||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||
if (redirectCount >= 5) return reject(new Error('Too many redirects from ' + fullUrl));
|
||
const redirectUrl = url.resolve(fullUrl, res.headers.location);
|
||
res.resume();
|
||
resolve(request(redirectUrl, opts, redirectCount + 1));
|
||
return;
|
||
}
|
||
let data = '';
|
||
res.on('data', function (chunk) { data += chunk; });
|
||
res.on('end', function () {
|
||
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, statusCode: res.statusCode, body: data });
|
||
});
|
||
});
|
||
req.on('error', reject);
|
||
if (body) req.write(body);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
// ── GitHub API helpers ─────────────────────────────────────────────
|
||
const owner = process.env.REPO_OWNER;
|
||
const repo = process.env.REPO_NAME;
|
||
const issueNumber = parseInt(process.env.ISSUE_NUMBER, 10);
|
||
const commentId = parseInt(process.env.COMMENT_ID, 10);
|
||
const actor = process.env.ACTOR;
|
||
|
||
function ghRequest(path, method, body) {
|
||
const headers = {
|
||
'Authorization': 'Bearer ' + process.env.GITHUB_TOKEN,
|
||
'Accept': 'application/vnd.github+json',
|
||
'X-GitHub-Api-Version': '2022-11-28',
|
||
'User-Agent': 'PocketBase-Bot'
|
||
};
|
||
const bodyStr = body ? JSON.stringify(body) : undefined;
|
||
if (bodyStr) headers['Content-Type'] = 'application/json';
|
||
return request('https://api.github.com' + path, { method: method || 'GET', headers, body: bodyStr });
|
||
}
|
||
|
||
function encodeContentPath(filePath) {
|
||
return filePath.split('/').map(encodeURIComponent).join('/');
|
||
}
|
||
|
||
function decodeGitHubContent(content) {
|
||
return Buffer.from((content || '').replace(/\n/g, ''), 'base64').toString('utf8');
|
||
}
|
||
|
||
function sanitizeBranchPart(value) {
|
||
return (value || '')
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9._/-]+/g, '-')
|
||
.replace(/\/+/g, '/')
|
||
.replace(/^-+|-+$/g, '');
|
||
}
|
||
|
||
function applyCtDefaultChanges(scriptText, varChanges) {
|
||
let nextText = scriptText;
|
||
const updatedVars = [];
|
||
const unchangedVars = [];
|
||
for (const [varName, rawValue] of Object.entries(varChanges)) {
|
||
const newValue = String(rawValue);
|
||
const pattern = new RegExp('(^\\s*' + varName + '="\\$\\{' + varName + ':-)([^"}]*)(\\}"\\s*$)', 'm');
|
||
const match = nextText.match(pattern);
|
||
if (!match) continue;
|
||
if (match[2] === newValue) {
|
||
unchangedVars.push(varName);
|
||
continue;
|
||
}
|
||
nextText = nextText.replace(pattern, '$1' + newValue + '$3');
|
||
updatedVars.push(varName);
|
||
}
|
||
return { nextText, updatedVars, unchangedVars };
|
||
}
|
||
|
||
async function ensureBranch(defaultBranch, branchName) {
|
||
const branchRefRes = await ghRequest('/repos/' + owner + '/' + repo + '/git/ref/heads/' + encodeURIComponent(branchName));
|
||
if (branchRefRes.ok) return;
|
||
|
||
const defaultRefRes = await ghRequest('/repos/' + owner + '/' + repo + '/git/ref/heads/' + encodeURIComponent(defaultBranch));
|
||
if (!defaultRefRes.ok) {
|
||
throw new Error('Could not read default branch ref: ' + defaultRefRes.body);
|
||
}
|
||
const defaultRef = JSON.parse(defaultRefRes.body);
|
||
const createBranchRes = await ghRequest('/repos/' + owner + '/' + repo + '/git/refs', 'POST', {
|
||
ref: 'refs/heads/' + branchName,
|
||
sha: defaultRef.object.sha
|
||
});
|
||
if (!createBranchRes.ok) {
|
||
throw new Error('Could not create branch: ' + createBranchRes.body);
|
||
}
|
||
}
|
||
|
||
async function upsertCtDefaultsPr(slugValue, varChanges) {
|
||
const wantedEntries = Object.entries(varChanges || {}).filter(function ([, v]) {
|
||
return v !== undefined && v !== null && String(v) !== '';
|
||
});
|
||
if (wantedEntries.length === 0) {
|
||
return { status: 'skipped', reason: 'No mapped CT defaults changed.' };
|
||
}
|
||
|
||
const repoRes = await ghRequest('/repos/' + owner + '/' + repo);
|
||
if (!repoRes.ok) {
|
||
throw new Error('Could not read repository metadata: ' + repoRes.body);
|
||
}
|
||
const repoInfo = JSON.parse(repoRes.body);
|
||
const defaultBranch = repoInfo.default_branch;
|
||
|
||
const ctPath = 'ct/' + slugValue + '.sh';
|
||
const encodedCtPath = encodeContentPath(ctPath);
|
||
const defaultFileRes = await ghRequest('/repos/' + owner + '/' + repo + '/contents/' + encodedCtPath + '?ref=' + encodeURIComponent(defaultBranch));
|
||
if (defaultFileRes.statusCode === 404) {
|
||
return { status: 'skipped', reason: 'No matching CT file found at `' + ctPath + '`.' };
|
||
}
|
||
if (!defaultFileRes.ok) {
|
||
throw new Error('Could not read CT file from default branch: ' + defaultFileRes.body);
|
||
}
|
||
|
||
const branchName = 'pocketbase-sync/' + sanitizeBranchPart(slugValue || 'unknown');
|
||
await ensureBranch(defaultBranch, branchName);
|
||
|
||
const branchFileRes = await ghRequest('/repos/' + owner + '/' + repo + '/contents/' + encodedCtPath + '?ref=' + encodeURIComponent(branchName));
|
||
if (!branchFileRes.ok) {
|
||
throw new Error('Could not read CT file from sync branch: ' + branchFileRes.body);
|
||
}
|
||
const branchFile = JSON.parse(branchFileRes.body);
|
||
const currentBranchText = decodeGitHubContent(branchFile.content);
|
||
|
||
const updateResult = applyCtDefaultChanges(currentBranchText, Object.fromEntries(wantedEntries));
|
||
if (updateResult.updatedVars.length === 0) {
|
||
return { status: 'skipped', reason: 'CT defaults already up to date.', unchangedVars: updateResult.unchangedVars };
|
||
}
|
||
|
||
const commitMessage = 'chore(ct): sync ' + slugValue + ' defaults from PocketBase';
|
||
const putRes = await ghRequest('/repos/' + owner + '/' + repo + '/contents/' + encodedCtPath, 'PUT', {
|
||
message: commitMessage,
|
||
content: Buffer.from(updateResult.nextText, 'utf8').toString('base64'),
|
||
sha: branchFile.sha,
|
||
branch: branchName
|
||
});
|
||
if (!putRes.ok) {
|
||
throw new Error('Could not update CT file: ' + putRes.body);
|
||
}
|
||
|
||
const openPrRes = await ghRequest(
|
||
'/repos/' + owner + '/' + repo + '/pulls?state=open&head=' + encodeURIComponent(owner + ':' + branchName) + '&base=' + encodeURIComponent(defaultBranch)
|
||
);
|
||
if (!openPrRes.ok) {
|
||
throw new Error('Could not query existing PRs: ' + openPrRes.body);
|
||
}
|
||
const openPrs = JSON.parse(openPrRes.body);
|
||
if (openPrs.length > 0) {
|
||
return { status: 'updated', prUrl: openPrs[0].html_url, updatedVars: updateResult.updatedVars };
|
||
}
|
||
|
||
const prTitle = 'chore(ct): sync ' + slugValue + ' defaults with PocketBase';
|
||
const prBody =
|
||
'## Summary\n' +
|
||
'- Sync default CT variables for `' + slugValue + '` after `/pocketbase` update.\n' +
|
||
'- Updated vars: `' + updateResult.updatedVars.join('`, `') + '`.\n\n' +
|
||
'## Source\n' +
|
||
'- Triggered by @' + actor + ' via PocketBase bot.\n';
|
||
const createPrRes = await ghRequest('/repos/' + owner + '/' + repo + '/pulls', 'POST', {
|
||
title: prTitle,
|
||
body: prBody,
|
||
head: branchName,
|
||
base: defaultBranch
|
||
});
|
||
if (!createPrRes.ok) {
|
||
throw new Error('Could not create PR: ' + createPrRes.body);
|
||
}
|
||
const pr = JSON.parse(createPrRes.body);
|
||
return { status: 'created', prUrl: pr.html_url, updatedVars: updateResult.updatedVars };
|
||
}
|
||
|
||
function formatCtSyncResult(syncResult) {
|
||
if (!syncResult) return '';
|
||
if (syncResult.status === 'created') return '\n\n**CT sync PR:** ' + syncResult.prUrl;
|
||
if (syncResult.status === 'updated') return '\n\n**CT sync PR updated:** ' + syncResult.prUrl;
|
||
if (syncResult.status === 'skipped') return '\n\n**CT sync skipped:** ' + syncResult.reason;
|
||
return '';
|
||
}
|
||
|
||
async function addReaction(content) {
|
||
try {
|
||
await ghRequest(
|
||
'/repos/' + owner + '/' + repo + '/issues/comments/' + commentId + '/reactions',
|
||
'POST', { content }
|
||
);
|
||
} catch (e) {
|
||
console.warn('Could not add reaction:', e.message);
|
||
}
|
||
}
|
||
|
||
async function postComment(text) {
|
||
const res = await ghRequest(
|
||
'/repos/' + owner + '/' + repo + '/issues/' + issueNumber + '/comments',
|
||
'POST', { body: text }
|
||
);
|
||
if (!res.ok) console.warn('Could not post comment:', res.body);
|
||
}
|
||
|
||
// ── Locate the command line ────────────────────────────────────────
|
||
// Accept /pocketbase at the start of ANY line (leading whitespace ok),
|
||
// so the command works even when preceded by other text. Mid-sentence
|
||
// mentions and blockquoted ("> ...") examples are ignored.
|
||
const commentBody = process.env.COMMENT_BODY || '';
|
||
const cmdLine = commentBody
|
||
.split('\n')
|
||
.map(l => l.trim())
|
||
.find(l => l.startsWith('/pocketbase'));
|
||
|
||
if (!cmdLine) {
|
||
console.log('No /pocketbase command line found — ignoring comment.');
|
||
process.exit(0);
|
||
}
|
||
const withoutCmd = cmdLine.replace(/^\/pocketbase\s*/, '').trim();
|
||
|
||
// ── Permission check ───────────────────────────────────────────────
|
||
const association = process.env.ACTOR_ASSOCIATION;
|
||
if (association !== 'OWNER' && association !== 'MEMBER') {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: @' + actor + ' is not authorized to use this command.\n' +
|
||
'Only org members (Contributors team) can use `/pocketbase`.'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
// ── Acknowledge ────────────────────────────────────────────────────
|
||
await addReaction('eyes');
|
||
|
||
// ── Parse command ──────────────────────────────────────────────────
|
||
|
||
function extractCodeBlock(body) {
|
||
const m = body.match(/```[^\n]*\n([\s\S]*?)```/);
|
||
return m ? m[1].trim() : null;
|
||
}
|
||
const codeBlockValue = extractCodeBlock(commentBody);
|
||
|
||
const HELP_TEXT =
|
||
'**Show current state:**\n' +
|
||
'```\n/pocketbase <slug> info\n```\n\n' +
|
||
'**Field update (simple):** `/pocketbase <slug> field=value [field=value ...]`\n\n' +
|
||
'**Field update (HTML/multiline) — value from code block:**\n' +
|
||
'````\n' +
|
||
'/pocketbase <slug> set description\n' +
|
||
'```html\n' +
|
||
'<p>Your <b>HTML</b> or multi-line content here</p>\n' +
|
||
'```\n' +
|
||
'````\n\n' +
|
||
'**Note management:**\n' +
|
||
'```\n' +
|
||
'/pocketbase <slug> note list\n' +
|
||
'/pocketbase <slug> note add <type> "<text>"\n' +
|
||
'/pocketbase <slug> note edit <type> "<old text>" "<new text>"\n' +
|
||
'/pocketbase <slug> note remove <type> "<text>"\n' +
|
||
'```\n\n' +
|
||
'**Install method management:**\n' +
|
||
'```\n' +
|
||
'/pocketbase <slug> method list\n' +
|
||
'/pocketbase <slug> method <type> cpu=4 ram=2048 hdd=20\n' +
|
||
'/pocketbase <slug> method <type> config_path="/opt/app/.env"\n' +
|
||
'/pocketbase <slug> method <type> os=debian version=13\n' +
|
||
'/pocketbase <slug> method add <type> cpu=2 ram=2048 hdd=8 os=debian version=13\n' +
|
||
'/pocketbase <slug> method remove <type>\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 <slug> 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` `has_arm` ' +
|
||
'`architectures` (amd64,arm64) `platforms` (pve,incus) ' +
|
||
'`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 <slug> 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/<slug>.sh path all at once, so renaming it is a migration rather than an edit.';
|
||
|
||
if (!withoutCmd) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: No slug or command specified.\n\n' + HELP_TEXT);
|
||
process.exit(0);
|
||
}
|
||
|
||
const spaceIdx = withoutCmd.indexOf(' ');
|
||
const slug = (spaceIdx === -1 ? withoutCmd : withoutCmd.substring(0, spaceIdx)).trim();
|
||
const rest = spaceIdx === -1 ? '' : withoutCmd.substring(spaceIdx + 1).trim();
|
||
|
||
if (!rest) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: No command specified for slug `' + slug + '`.\n\n' + HELP_TEXT);
|
||
process.exit(0);
|
||
}
|
||
|
||
// ── PocketBase: authenticate ───────────────────────────────────────
|
||
const raw = process.env.POCKETBASE_URL.replace(/\/$/, '');
|
||
const apiBase = /\/api$/i.test(raw) ? raw : raw + '/api';
|
||
const coll = process.env.POCKETBASE_COLLECTION;
|
||
|
||
const authRes = await request(apiBase + '/collections/users/auth-with-password', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
identity: process.env.POCKETBASE_ADMIN_EMAIL,
|
||
password: process.env.POCKETBASE_ADMIN_PASSWORD
|
||
})
|
||
});
|
||
if (!authRes.ok) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: PocketBase authentication failed. CC @' + owner + '/maintainers');
|
||
process.exit(1);
|
||
}
|
||
const token = JSON.parse(authRes.body).token;
|
||
|
||
// ── PocketBase: find record by slug ────────────────────────────────
|
||
const recordsUrl = apiBase + '/collections/' + encodeURIComponent(coll) + '/records';
|
||
const filter = "(slug='" + slug.replace(/'/g, "''") + "')";
|
||
const listRes = await request(recordsUrl + '?filter=' + encodeURIComponent(filter) + '&perPage=1', {
|
||
headers: { 'Authorization': token }
|
||
});
|
||
const list = JSON.parse(listRes.body);
|
||
const record = list.items && list.items[0];
|
||
|
||
if (!record) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: No record found for slug `' + slug + '`.\n\n' +
|
||
'Make sure the script was already pushed to PocketBase (JSON must exist and have been synced).'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
// ── Shared helpers ─────────────────────────────────────────────────
|
||
|
||
// Key=value parser: handles unquoted and "quoted" values
|
||
function parseKVPairs(str) {
|
||
const fields = {};
|
||
let pos = 0;
|
||
while (pos < str.length) {
|
||
while (pos < str.length && /\s/.test(str[pos])) pos++;
|
||
if (pos >= str.length) break;
|
||
let keyStart = pos;
|
||
while (pos < str.length && str[pos] !== '=' && !/\s/.test(str[pos])) pos++;
|
||
const key = str.substring(keyStart, pos).trim();
|
||
if (!key || pos >= str.length || str[pos] !== '=') { pos++; continue; }
|
||
pos++;
|
||
let value;
|
||
if (pos < str.length && str[pos] === '"') {
|
||
pos++;
|
||
let valStart = pos;
|
||
while (pos < str.length && str[pos] !== '"') {
|
||
if (str[pos] === '\\') pos++;
|
||
pos++;
|
||
}
|
||
value = str.substring(valStart, pos).replace(/\\"/g, '"');
|
||
if (pos < str.length) pos++;
|
||
} else {
|
||
let valStart = pos;
|
||
while (pos < str.length && !/\s/.test(str[pos])) pos++;
|
||
value = str.substring(valStart, pos);
|
||
}
|
||
fields[key] = value;
|
||
}
|
||
return fields;
|
||
}
|
||
|
||
// Token parser for note commands: unquoted-word OR "quoted string"
|
||
function parseTokens(str) {
|
||
const tokens = [];
|
||
let pos = 0;
|
||
while (pos < str.length) {
|
||
while (pos < str.length && /\s/.test(str[pos])) pos++;
|
||
if (pos >= str.length) break;
|
||
if (str[pos] === '"') {
|
||
pos++;
|
||
let start = pos;
|
||
while (pos < str.length && str[pos] !== '"') {
|
||
if (str[pos] === '\\') pos++;
|
||
pos++;
|
||
}
|
||
tokens.push(str.substring(start, pos).replace(/\\"/g, '"'));
|
||
if (pos < str.length) pos++;
|
||
} else {
|
||
let start = pos;
|
||
while (pos < str.length && !/\s/.test(str[pos])) pos++;
|
||
tokens.push(str.substring(start, pos));
|
||
}
|
||
}
|
||
return tokens;
|
||
}
|
||
|
||
// Read JSON blob from record (handles parsed objects and strings)
|
||
function readJsonBlob(val) {
|
||
if (Array.isArray(val)) return val;
|
||
try { return JSON.parse(val || '[]'); } catch (e) { return []; }
|
||
}
|
||
|
||
// Frontend cache revalidation (silent, best-effort)
|
||
async function revalidate(s) {
|
||
const frontendUrl = process.env.FRONTEND_URL;
|
||
const secret = process.env.REVALIDATE_SECRET;
|
||
if (!frontendUrl || !secret) return;
|
||
try {
|
||
await request(frontendUrl.replace(/\/$/, '') + '/api/revalidate', {
|
||
method: 'POST',
|
||
headers: { 'Authorization': 'Bearer ' + secret, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tags: ['scripts', 'script-' + s] })
|
||
});
|
||
} catch (e) { console.warn('Revalidation skipped:', e.message); }
|
||
}
|
||
|
||
// Format notes list for display
|
||
function formatNotesList(arr) {
|
||
if (arr.length === 0) return '*None*';
|
||
return arr.map(function (n, i) {
|
||
return (i + 1) + '. **`' + (n.type || '?') + '`**: ' + (n.text || '');
|
||
}).join('\n');
|
||
}
|
||
|
||
// Format install methods list for display
|
||
function formatMethodsList(arr) {
|
||
if (arr.length === 0) return '*None*';
|
||
return arr.map(function (im, i) {
|
||
const r = im.resources || {};
|
||
const parts = [
|
||
(r.os || '?') + ' ' + (r.version || '?'),
|
||
(r.cpu != null ? r.cpu : '?') + 'C / ' + (r.ram != null ? r.ram : '?') + ' MB / ' + (r.hdd != null ? r.hdd : '?') + ' GB'
|
||
];
|
||
if (im.config_path) parts.push('config: `' + im.config_path + '`');
|
||
if (im.script) parts.push('script: `' + im.script + '`');
|
||
return (i + 1) + '. **`' + (im.type || '?') + '`** — ' + parts.join(', ');
|
||
}).join('\n');
|
||
}
|
||
|
||
// ── Route: dispatch to subcommand handler ──────────────────────────
|
||
const infoMatch = rest.match(/^info$/i);
|
||
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 ──────────────────────────────────────────────
|
||
const notesArr = readJsonBlob(record.notes);
|
||
const methodsArr = readJsonBlob(record.install_methods);
|
||
|
||
const out = [];
|
||
out.push('ℹ️ **PocketBase Bot**: Info for **`' + slug + '`**\n');
|
||
|
||
out.push('**Basic info:**');
|
||
out.push('- **Name:** ' + (record.name || '—'));
|
||
out.push('- **Slug:** `' + slug + '`');
|
||
out.push('- **Port:** ' + (record.port != null ? '`' + record.port + '`' : '—'));
|
||
out.push('- **Updateable:** ' + (record.updateable ? 'Yes' : 'No'));
|
||
out.push('- **Privileged:** ' + (record.privileged ? 'Yes' : 'No'));
|
||
out.push('- **Architectures:** ' + ((record.architectures || []).join(', ') || 'amd64'));
|
||
out.push('- **Platforms:** ' + ((record.platforms || []).join(', ') || 'pve'));
|
||
if (record.is_dev) out.push('- **Dev:** Yes');
|
||
if (record.is_disabled) out.push('- **Disabled:** Yes' + (record.disable_message ? ' — ' + record.disable_message : ''));
|
||
if (record.is_deleted) out.push('- **Deleted:** Yes' + (record.deleted_message ? ' — ' + record.deleted_message : ''));
|
||
out.push('');
|
||
|
||
out.push('**Links:**');
|
||
out.push('- **Website:** ' + (record.website || '—'));
|
||
out.push('- **Docs:** ' + (record.documentation || '—'));
|
||
out.push('- **Logo:** ' + (record.logo ? '[link](' + record.logo + ')' : '—'));
|
||
out.push('- **GitHub:** ' + (record.github || '—'));
|
||
if (record.config_path) out.push('- **Config:** `' + record.config_path + '`');
|
||
out.push('');
|
||
|
||
out.push('**Credentials:**');
|
||
out.push('- **User:** ' + (record.default_user || '—'));
|
||
out.push('- **Password:** ' + (record.default_passwd ? '*(set)*' : '—'));
|
||
out.push('');
|
||
|
||
out.push('**Install methods** (' + methodsArr.length + '):');
|
||
out.push(formatMethodsList(methodsArr));
|
||
out.push('');
|
||
|
||
out.push('**Notes** (' + notesArr.length + '):');
|
||
out.push(formatNotesList(notesArr));
|
||
|
||
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();
|
||
const noteArgsStr = rest.substring(noteMatch[0].length).trim();
|
||
let notesArr = readJsonBlob(record.notes);
|
||
|
||
async function patchNotes(arr) {
|
||
const res = await request(recordsUrl + '/' + record.id, {
|
||
method: 'PATCH',
|
||
headers: { 'Authorization': token, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ notes: arr })
|
||
});
|
||
if (!res.ok) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: Failed to update notes:\n```\n' + res.body + '\n```');
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
if (noteAction === 'list') {
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'ℹ️ **PocketBase Bot**: Notes for **`' + slug + '`** (' + notesArr.length + ' total)\n\n' +
|
||
formatNotesList(notesArr)
|
||
);
|
||
|
||
} else if (noteAction === 'add') {
|
||
const tokens = parseTokens(noteArgsStr);
|
||
if (tokens.length < 2) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: `note add` requires `<type>` and `"<text>"`.\n\n' +
|
||
'**Usage:** `/pocketbase ' + slug + ' note add <type> "<text>"`'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
const noteType = tokens[0].toLowerCase();
|
||
const noteText = tokens.slice(1).join(' ');
|
||
notesArr.push({ type: noteType, text: noteText });
|
||
await patchNotes(notesArr);
|
||
await revalidate(slug);
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Added note to **`' + slug + '`**\n\n' +
|
||
'- **Type:** `' + noteType + '`\n' +
|
||
'- **Text:** ' + noteText + '\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
|
||
} else if (noteAction === 'edit') {
|
||
const tokens = parseTokens(noteArgsStr);
|
||
if (tokens.length < 3) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: `note edit` requires `<type>`, `"<old text>"`, and `"<new text>"`.\n\n' +
|
||
'**Usage:** `/pocketbase ' + slug + ' note edit <type> "<old text>" "<new text>"`\n\n' +
|
||
'Use `/pocketbase ' + slug + ' note list` to see current notes.'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
const noteType = tokens[0].toLowerCase();
|
||
const oldText = tokens[1];
|
||
const newText = tokens[2];
|
||
const idx = notesArr.findIndex(function (n) {
|
||
return n.type.toLowerCase() === noteType && n.text === oldText;
|
||
});
|
||
if (idx === -1) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: No `' + noteType + '` note found with that exact text.\n\n' +
|
||
'**Current notes for `' + slug + '`:**\n' + formatNotesList(notesArr)
|
||
);
|
||
process.exit(0);
|
||
}
|
||
notesArr[idx].text = newText;
|
||
await patchNotes(notesArr);
|
||
await revalidate(slug);
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Edited note in **`' + slug + '`**\n\n' +
|
||
'- **Type:** `' + noteType + '`\n' +
|
||
'- **Old:** ' + oldText + '\n' +
|
||
'- **New:** ' + newText + '\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
|
||
} else if (noteAction === 'remove') {
|
||
const tokens = parseTokens(noteArgsStr);
|
||
if (tokens.length < 2) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: `note remove` requires `<type>` and `"<text>"`.\n\n' +
|
||
'**Usage:** `/pocketbase ' + slug + ' note remove <type> "<text>"`\n\n' +
|
||
'Use `/pocketbase ' + slug + ' note list` to see current notes.'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
const noteType = tokens[0].toLowerCase();
|
||
const noteText = tokens[1];
|
||
const before = notesArr.length;
|
||
notesArr = notesArr.filter(function (n) {
|
||
return !(n.type.toLowerCase() === noteType && n.text === noteText);
|
||
});
|
||
if (notesArr.length === before) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: No `' + noteType + '` note found with that exact text.\n\n' +
|
||
'**Current notes for `' + slug + '`:**\n' + formatNotesList(notesArr)
|
||
);
|
||
process.exit(0);
|
||
}
|
||
await patchNotes(notesArr);
|
||
await revalidate(slug);
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Removed note from **`' + slug + '`**\n\n' +
|
||
'- **Type:** `' + noteType + '`\n' +
|
||
'- **Text:** ' + noteText + '\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
}
|
||
|
||
} else if (methodMatch) {
|
||
// ── METHOD SUBCOMMAND ────────────────────────────────────────────
|
||
const methodArgs = rest.replace(/^method\s*/i, '').trim();
|
||
const methodListMode = !methodArgs || methodArgs.toLowerCase() === 'list';
|
||
let methodsArr = readJsonBlob(record.install_methods);
|
||
|
||
// Method field classification
|
||
const RESOURCE_KEYS = { cpu: 'number', ram: 'number', hdd: 'number', os: 'string', version: 'string' };
|
||
const METHOD_KEYS = { config_path: 'string', script: 'string' };
|
||
const ALL_METHOD_KEYS = Object.assign({}, RESOURCE_KEYS, METHOD_KEYS);
|
||
const RESOURCE_TO_CT_VAR = { cpu: 'var_cpu', ram: 'var_ram', hdd: 'var_disk', os: 'var_os', version: 'var_version' };
|
||
|
||
function applyMethodChanges(method, parsed) {
|
||
if (!method.resources) method.resources = {};
|
||
for (const [k, v] of Object.entries(parsed)) {
|
||
if (RESOURCE_KEYS[k]) {
|
||
method.resources[k] = RESOURCE_KEYS[k] === 'number' ? parseInt(v, 10) : v;
|
||
} else if (METHOD_KEYS[k]) {
|
||
method[k] = v === '' ? null : v;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function patchMethods(arr) {
|
||
const res = await request(recordsUrl + '/' + record.id, {
|
||
method: 'PATCH',
|
||
headers: { 'Authorization': token, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ install_methods: arr })
|
||
});
|
||
if (!res.ok) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: Failed to update install methods:\n```\n' + res.body + '\n```');
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
if (methodListMode) {
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'ℹ️ **PocketBase Bot**: Install methods for **`' + slug + '`** (' + methodsArr.length + ' total)\n\n' +
|
||
formatMethodsList(methodsArr)
|
||
);
|
||
|
||
} else {
|
||
// Check for add / remove sub-actions
|
||
const addMatch = methodArgs.match(/^add\s+(\S+)(?:\s+(.+))?$/i);
|
||
const removeMatch = methodArgs.match(/^remove\s+(\S+)$/i);
|
||
|
||
if (addMatch) {
|
||
// ── METHOD ADD ───────────────────────────────────────────────
|
||
const newType = addMatch[1];
|
||
const parsed = addMatch[2] ? parseKVPairs(addMatch[2]) : {};
|
||
if (methodsArr.some(function (im) { return (im.type || '').toLowerCase() === newType.toLowerCase(); })) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: Install method `' + newType + '` already exists for `' + slug + '`.\n\nUse `/pocketbase ' + slug + ' method list` to see all methods.');
|
||
process.exit(0);
|
||
}
|
||
const newMethod = { type: newType, resources: { cpu: 1, ram: 512, hdd: 4, os: 'debian', version: '13' } };
|
||
if (addMatch[2]) {
|
||
const unknown = Object.keys(parsed).filter(function (k) { return !ALL_METHOD_KEYS[k]; });
|
||
if (unknown.length > 0) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: Unknown method field(s): `' + unknown.join('`, `') + '`\n\n**Allowed:** `' + Object.keys(ALL_METHOD_KEYS).join('`, `') + '`');
|
||
process.exit(0);
|
||
}
|
||
applyMethodChanges(newMethod, parsed);
|
||
}
|
||
methodsArr.push(newMethod);
|
||
await patchMethods(methodsArr);
|
||
await revalidate(slug);
|
||
const addCtChanges = {};
|
||
for (const [k, v] of Object.entries(parsed)) {
|
||
if (RESOURCE_TO_CT_VAR[k]) addCtChanges[RESOURCE_TO_CT_VAR[k]] = v;
|
||
}
|
||
let addCtSync = null;
|
||
try {
|
||
addCtSync = await upsertCtDefaultsPr(slug, addCtChanges);
|
||
} catch (e) {
|
||
addCtSync = { status: 'skipped', reason: 'CT sync failed: ' + e.message };
|
||
}
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Added install method **`' + newType + '`** to **`' + slug + '`**\n\n' +
|
||
formatMethodsList([newMethod]) + '\n\n' +
|
||
formatCtSyncResult(addCtSync) + '\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
|
||
} else if (removeMatch) {
|
||
// ── METHOD REMOVE ────────────────────────────────────────────
|
||
const removeType = removeMatch[1].toLowerCase();
|
||
const removed = methodsArr.filter(function (im) { return (im.type || '').toLowerCase() === removeType; });
|
||
if (removed.length === 0) {
|
||
await addReaction('-1');
|
||
const available = methodsArr.map(function (im) { return im.type || '?'; });
|
||
await postComment('❌ **PocketBase Bot**: No install method `' + removeType + '` found.\n\n**Available:** `' + (available.length ? available.join('`, `') : '(none)') + '`');
|
||
process.exit(0);
|
||
}
|
||
methodsArr = methodsArr.filter(function (im) { return (im.type || '').toLowerCase() !== removeType; });
|
||
await patchMethods(methodsArr);
|
||
await revalidate(slug);
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Removed install method **`' + removed[0].type + '`** from **`' + slug + '`**\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
|
||
} else {
|
||
// ── METHOD EDIT ──────────────────────────────────────────────
|
||
const editParts = methodArgs.match(/^(\S+)\s+(.+)$/);
|
||
if (!editParts) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: Invalid `method` syntax.\n\n' +
|
||
'**Usage:**\n```\n/pocketbase ' + slug + ' method list\n' +
|
||
'/pocketbase ' + slug + ' method <type> cpu=4 ram=2048 hdd=20\n' +
|
||
'/pocketbase ' + slug + ' method <type> config_path="/opt/app/.env"\n' +
|
||
'/pocketbase ' + slug + ' method add <type> cpu=2 ram=2048 hdd=8\n' +
|
||
'/pocketbase ' + slug + ' method remove <type>\n```'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
const targetType = editParts[1].toLowerCase();
|
||
const parsed = parseKVPairs(editParts[2]);
|
||
|
||
const unknown = Object.keys(parsed).filter(function (k) { return !ALL_METHOD_KEYS[k]; });
|
||
if (unknown.length > 0) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: Unknown method field(s): `' + unknown.join('`, `') + '`\n\n**Allowed:** `' + Object.keys(ALL_METHOD_KEYS).join('`, `') + '`');
|
||
process.exit(0);
|
||
}
|
||
if (Object.keys(parsed).length === 0) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: No valid `key=value` pairs found.\n\n**Allowed:** `' + Object.keys(ALL_METHOD_KEYS).join('`, `') + '`');
|
||
process.exit(0);
|
||
}
|
||
|
||
const idx = methodsArr.findIndex(function (im) { return (im.type || '').toLowerCase() === targetType; });
|
||
if (idx === -1) {
|
||
await addReaction('-1');
|
||
const available = methodsArr.map(function (im) { return im.type || '?'; });
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: No install method `' + targetType + '` found for `' + slug + '`.\n\n' +
|
||
'**Available:** `' + (available.length ? available.join('`, `') : '(none)') + '`\n\n' +
|
||
'Use `/pocketbase ' + slug + ' method list` to see all methods.'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
applyMethodChanges(methodsArr[idx], parsed);
|
||
await patchMethods(methodsArr);
|
||
await revalidate(slug);
|
||
const editCtChanges = {};
|
||
for (const [k, v] of Object.entries(parsed)) {
|
||
if (RESOURCE_TO_CT_VAR[k]) editCtChanges[RESOURCE_TO_CT_VAR[k]] = v;
|
||
}
|
||
let editCtSync = null;
|
||
try {
|
||
editCtSync = await upsertCtDefaultsPr(slug, editCtChanges);
|
||
} catch (e) {
|
||
editCtSync = { status: 'skipped', reason: 'CT sync failed: ' + e.message };
|
||
}
|
||
|
||
const changesLines = Object.entries(parsed)
|
||
.map(function ([k, v]) {
|
||
const unit = k === 'ram' ? ' MB' : k === 'hdd' ? ' GB' : '';
|
||
return '- `' + k + '` → `' + v + unit + '`';
|
||
}).join('\n');
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Updated install method **`' + methodsArr[idx].type + '`** for **`' + slug + '`**\n\n' +
|
||
'**Changes applied:**\n' + changesLines + '\n\n' +
|
||
formatCtSyncResult(editCtSync) + '\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
}
|
||
}
|
||
|
||
} 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',
|
||
app_vars: 'json'
|
||
};
|
||
if (!SET_ALLOWED[fieldName]) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **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.'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
if (!codeBlockValue) {
|
||
await addReaction('-1');
|
||
await postComment(
|
||
'❌ **PocketBase Bot**: `set` requires a code block with the value.\n\n' +
|
||
'**Usage:**\n````\n/pocketbase ' + slug + ' set ' + fieldName + '\n```\nYour content here (HTML, multiline, special chars all fine)\n```\n````'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
const setPayload = {};
|
||
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' },
|
||
body: JSON.stringify(setPayload)
|
||
});
|
||
if (!setPatchRes.ok) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: PATCH failed for `' + slug + '`:\n```\n' + setPatchRes.body + '\n```');
|
||
process.exit(1);
|
||
}
|
||
await revalidate(slug);
|
||
const preview = codeBlockValue.length > 300 ? codeBlockValue.substring(0, 300) + '…' : codeBlockValue;
|
||
await addReaction('+1');
|
||
await postComment(
|
||
'✅ **PocketBase Bot**: Set `' + fieldName + '` for **`' + slug + '`**\n\n' +
|
||
'**Value set:**\n```\n' + preview + '\n```\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
|
||
} else {
|
||
// ── FIELD=VALUE PATH ─────────────────────────────────────────────
|
||
const ALLOWED_FIELDS = {
|
||
name: 'string',
|
||
description: 'string',
|
||
logo: 'string',
|
||
documentation: 'string',
|
||
website: 'string',
|
||
project_url: 'string',
|
||
repository: 'string',
|
||
config_path: 'string',
|
||
tags: 'string',
|
||
port: 'number',
|
||
default_user: 'nullable_string',
|
||
default_passwd: 'nullable_string',
|
||
unprivileged: 'number',
|
||
updateable: 'boolean',
|
||
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',
|
||
is_deleted: 'boolean',
|
||
deleted_message: 'string',
|
||
};
|
||
|
||
const parsedFields = parseKVPairs(rest);
|
||
|
||
// 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(
|
||
'❌ **PocketBase Bot**: Unknown field(s): `' + unknownFields.join('`, `') + '`\n\n' +
|
||
'**Allowed fields:** `' + Object.keys(ALLOWED_FIELDS).join('`, `') + '`'
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
if (Object.keys(parsedFields).length === 0) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: Could not parse any valid `field=value` pairs.\n\n' + HELP_TEXT);
|
||
process.exit(0);
|
||
}
|
||
|
||
// Cast values to correct types
|
||
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(scriptFields)) {
|
||
const type = ALLOWED_FIELDS[key];
|
||
if (type === 'boolean') {
|
||
if (rawVal === 'true') payload[key] = true;
|
||
else if (rawVal === 'false') payload[key] = false;
|
||
else {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: `' + key + '` must be `true` or `false`, got: `' + rawVal + '`');
|
||
process.exit(0);
|
||
}
|
||
} else if (type === 'number') {
|
||
const n = parseInt(rawVal, 10);
|
||
if (isNaN(n)) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: `' + key + '` must be a number, got: `' + rawVal + '`');
|
||
process.exit(0);
|
||
}
|
||
payload[key] = n;
|
||
} else if (type === 'select_list') {
|
||
// architectures/platforms are multi-selects over a closed set,
|
||
// so an unknown value is rejected rather than stored.
|
||
const allowed = SELECT_VALUES[key] || [];
|
||
const values = rawVal.split(',').map(function (v) { return v.trim(); }).filter(Boolean);
|
||
const bad = values.filter(function (v) { return allowed.indexOf(v) === -1; });
|
||
if (bad.length > 0) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: `' + key + '` accepts ' + allowed.join(', ') + ' — got: `' + bad.join(', ') + '`');
|
||
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 {
|
||
payload[key] = rawVal;
|
||
}
|
||
}
|
||
|
||
// 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' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (!patchRes.ok) {
|
||
await addReaction('-1');
|
||
await postComment('❌ **PocketBase Bot**: PATCH failed for `' + slug + '`:\n```\n' + patchRes.body + '\n```');
|
||
process.exit(1);
|
||
}
|
||
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/<slug>.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);
|
||
} catch (e) {
|
||
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' +
|
||
'**Changes applied:**\n' + changesLines + '\n\n' +
|
||
formatCtSyncResult(fieldCtSync) + '\n\n' +
|
||
'*Executed by @' + actor + '*'
|
||
);
|
||
}
|
||
|
||
console.log('Done.');
|
||
})().catch(function (e) {
|
||
console.error('Fatal error:', e.message || e);
|
||
process.exit(1);
|
||
});
|
||
ENDSCRIPT
|
||
shell: bash
|