From f14eca3bc98de209a19b14d3b0d4c4a988901674 Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Tue, 2 Jun 2026 13:54:12 +0200 Subject: [PATCH] feat(pocketbase-ai-bot): natural-language @pocketbase bot via GitHub Models Adds an isolated workflow that lets maintainers manage PocketBase script records in plain English by mentioning @pocketbase in an issue/PR comment (e.g. "@pocketbase change RAM to 4096 on zigbee2mqtt"). - Interprets the request with GitHub Models (built-in GITHUB_TOKEN + models:read) - Posts under a dedicated GitHub App identity (PB_BOT_APP_ID/PB_BOT_APP_PRIVATE_KEY) - Propose-then-confirm: replies with the parsed change set and a hidden marker; applies only after "@pocketbase confirm" - Reuses the slash bot's field/note/method allow-lists, validation, revalidate, and CT-defaults sync PR logic; self-author guard prevents trigger loops - Existing /pocketbase slash bot is untouched (triggers do not overlap) Inert until the GitHub App is created and its two secrets are added. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pocketbase-ai-bot.yml | 564 ++++++++++++++++++++++++ 1 file changed, 564 insertions(+) create mode 100644 .github/workflows/pocketbase-ai-bot.yml diff --git a/.github/workflows/pocketbase-ai-bot.yml b/.github/workflows/pocketbase-ai-bot.yml new file mode 100644 index 000000000..aa26eb21b --- /dev/null +++ b/.github/workflows/pocketbase-ai-bot.yml @@ -0,0 +1,564 @@ +name: PocketBase AI Bot + +# Natural-language companion to pocketbase-bot.yml. +# Mention the bot in plain English, e.g.: +# @pocketbase change RAM to 4096 on zigbee2mqtt +# @pocketbase disable script Nextcloud because upstream is broken +# The bot parses the request with GitHub Models, replies with the exact change(s) +# it understood, and only applies them after you reply "@pocketbase confirm". +# The slash-command bot (/pocketbase ...) is unaffected; triggers do not overlap. + +on: + issue_comment: + types: [created] + +permissions: + models: read # lets the built-in GITHUB_TOKEN call GitHub Models inference + +jobs: + ai-bot: + runs-on: self-hosted + # Broad gate; the script does precise keyword + self-author checks. + if: contains(github.event.comment.body, '@pocketbase') + + steps: + - name: Mint GitHub App token (bot identity) + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.PB_BOT_APP_ID }} + private-key: ${{ secrets.PB_BOT_APP_PRIVATE_KEY }} + + - name: Run PocketBase AI bot + env: + # GitHub REST as the bot identity + GH_APP_TOKEN: ${{ steps.app-token.outputs.token }} + # GitHub Models inference uses the built-in token (needs models: read) + MODELS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AI_MODEL: openai/gpt-4o + # PocketBase + POCKETBASE_URL: ${{ secrets.POCKETBASE_URL }} + POCKETBASE_COLLECTION: ${{ secrets.POCKETBASE_COLLECTION }} + POCKETBASE_ADMIN_EMAIL: ${{ secrets.POCKETBASE_ADMIN_EMAIL }} + POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }} + FRONTEND_URL: ${{ secrets.FRONTEND_URL }} + REVALIDATE_SECRET: ${{ secrets.REVALIDATE_SECRET }} + # Event context + COMMENT_BODY: ${{ github.event.comment.body }} + COMMENT_ID: ${{ github.event.comment.id }} + COMMENT_AUTHOR_TYPE: ${{ github.event.comment.user.type }} + 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 }} + 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 REST (as the bot app) ─────────────────────────────────── + 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.GH_APP_TOKEN, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'PocketBase-AI-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 }); + } + + 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); + return res.ok ? JSON.parse(res.body) : null; + } + async function updateComment(id, text) { + const res = await ghRequest('/repos/' + owner + '/' + repo + '/issues/comments/' + id, 'PATCH', { body: text }); + if (!res.ok) console.warn('Could not update comment:', res.body); + } + async function listIssueComments() { + const all = []; + let page = 1; + while (page <= 10) { + const res = await ghRequest('/repos/' + owner + '/' + repo + '/issues/' + issueNumber + '/comments?per_page=100&page=' + page); + if (!res.ok) break; + const batch = JSON.parse(res.body); + all.push.apply(all, batch); + if (batch.length < 100) break; + page++; + } + return all; + } + + // ── 1. Self-trigger guard (App-token comments DO re-fire this event) ─ + if (process.env.COMMENT_AUTHOR_TYPE === 'Bot') { + console.log('Comment authored by a bot — skipping to avoid loops.'); + return; + } + + // ── 2. Permission gate (mirrors the slash bot) ───────────────────── + const association = process.env.ACTOR_ASSOCIATION; + if (association !== 'OWNER' && association !== 'MEMBER') { + await addReaction('-1'); + await postComment( + '❌ **PocketBase AI Bot**: @' + actor + ' is not authorized to use this command.\n' + + 'Only org members (Contributors team) can use `@pocketbase`.' + ); + return; + } + + // ── 3. Extract the instruction after the @pocketbase handle ──────── + const commentBody = process.env.COMMENT_BODY || ''; + const handleMatch = commentBody.match(/@pocketbase[\w-]*(\[bot\])?/i); + if (!handleMatch) { + console.log('No @pocketbase handle found — ignoring.'); + return; + } + const instruction = commentBody.slice(handleMatch.index + handleMatch[0].length).trim(); + if (!instruction) { + await addReaction('-1'); + await postComment( + 'ℹ️ **PocketBase AI Bot**: Tell me what to do, e.g.\n' + + '`@pocketbase change RAM to 4096 on zigbee2mqtt` or `@pocketbase disable script Nextcloud`.' + ); + return; + } + + // ── PocketBase auth + low-level helpers ──────────────────────────── + const pbRaw = process.env.POCKETBASE_URL.replace(/\/$/, ''); + const apiBase = /\/api$/i.test(pbRaw) ? pbRaw : pbRaw + '/api'; + const coll = process.env.POCKETBASE_COLLECTION; + const recordsUrl = apiBase + '/collections/' + encodeURIComponent(coll) + '/records'; + + async function pbAuth() { + const res = 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 (!res.ok) throw new Error('PocketBase auth failed: ' + res.body); + return JSON.parse(res.body).token; + } + async function pbFindRecord(token, slug) { + const filter = "(slug='" + String(slug).replace(/'/g, "''") + "')"; + const res = await request(recordsUrl + '?filter=' + encodeURIComponent(filter) + '&perPage=1', { headers: { 'Authorization': token } }); + const list = JSON.parse(res.body); + return list.items && list.items[0]; + } + async function pbPatch(token, id, payload) { + return request(recordsUrl + '/' + id, { + method: 'PATCH', + headers: { 'Authorization': token, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + } + function readJsonBlob(val) { + if (Array.isArray(val)) return val; + try { return JSON.parse(val || '[]'); } catch (e) { return []; } + } + 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); } + } + + // ── CT-defaults sync PR (copied from slash bot) ──────────────────── + 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 = [], 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 defaultBranch = JSON.parse(repoRes.body).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.' }; + const putRes = await ghRequest('/repos/' + owner + '/' + repo + '/contents/' + encodedCtPath, 'PUT', { + message: 'chore(ct): sync ' + slugValue + ' defaults from PocketBase', + 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 createPrRes = await ghRequest('/repos/' + owner + '/' + repo + '/pulls', 'POST', { + title: 'chore(ct): sync ' + slugValue + ' defaults with PocketBase', + body: '## Summary\n- Sync default CT variables for `' + slugValue + '` after an `@pocketbase` update.\n- Updated vars: `' + updateResult.updatedVars.join('`, `') + '`.\n\n## Source\n- Triggered by @' + actor + ' via PocketBase AI bot.\n', + head: branchName, + base: defaultBranch + }); + if (!createPrRes.ok) throw new Error('Could not create PR: ' + createPrRes.body); + return { status: 'created', prUrl: JSON.parse(createPrRes.body).html_url, updatedVars: updateResult.updatedVars }; + } + + // ── Allow-lists (mirror the slash bot) ───────────────────────────── + const ALLOWED_FIELDS = { + name: 'string', description: 'string', logo: 'string', documentation: 'string', + website: 'string', project_url: 'string', github: 'string', config_path: 'string', + tags: 'string', port: 'number', default_user: 'nullable_string', default_passwd: 'nullable_string', + unprivileged: 'number', updateable: 'boolean', privileged: 'boolean', has_arm: 'boolean', + is_dev: 'boolean', is_disabled: 'boolean', disable_message: 'string', + is_deleted: 'boolean', deleted_message: 'string' + }; + const FIELD_TO_CT_VAR = { tags: 'var_tags', unprivileged: 'var_unprivileged' }; + 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 castFieldValue(key, rawVal) { + const type = ALLOWED_FIELDS[key]; + if (!type) return { error: 'Unknown field `' + key + '`' }; + if (type === 'boolean') { + if (rawVal === true || rawVal === 'true') return { value: true }; + if (rawVal === false || rawVal === 'false') return { value: false }; + return { error: '`' + key + '` must be true/false' }; + } + if (type === 'number') { + const n = parseInt(rawVal, 10); + if (isNaN(n)) return { error: '`' + key + '` must be a number' }; + return { value: n }; + } + if (type === 'nullable_string') return { value: rawVal === '' || rawVal == null ? null : String(rawVal) }; + return { value: String(rawVal) }; + } + + // ── Executor: apply a validated {slug, operations} set ───────────── + async function applyOperations(action) { + const token = await pbAuth(); + const record = await pbFindRecord(token, action.slug); + if (!record) return { ok: false, summary: '❌ No PocketBase record for slug `' + action.slug + '`.' }; + + const fieldPayload = {}; + let notesArr = readJsonBlob(record.notes); + let methodsArr = readJsonBlob(record.install_methods); + let notesChanged = false, methodsChanged = false; + const ctChanges = {}; + const lines = []; + + for (const op of action.operations) { + if (op.kind === 'field') { + const cast = castFieldValue(op.field, op.value); + if (cast.error) { lines.push('- ⚠️ skipped field: ' + cast.error); continue; } + fieldPayload[op.field] = cast.value; + if (FIELD_TO_CT_VAR[op.field]) ctChanges[FIELD_TO_CT_VAR[op.field]] = cast.value; + lines.push('- `' + op.field + '` → `' + JSON.stringify(cast.value) + '`'); + } else if (op.kind === 'note') { + const type = String(op.type || '').toLowerCase(); + if (op.action === 'add') { + notesArr.push({ type, text: String(op.text || '') }); + notesChanged = true; lines.push('- note add `' + type + '`: ' + op.text); + } else if (op.action === 'remove') { + const before = notesArr.length; + notesArr = notesArr.filter(function (n) { return !(String(n.type).toLowerCase() === type && n.text === op.text); }); + if (notesArr.length !== before) { notesChanged = true; lines.push('- note remove `' + type + '`: ' + op.text); } + else lines.push('- ⚠️ note remove: no `' + type + '` note matched'); + } else if (op.action === 'edit') { + const idx = notesArr.findIndex(function (n) { return String(n.type).toLowerCase() === type && n.text === op.text; }); + if (idx !== -1) { notesArr[idx].text = String(op.newText || ''); notesChanged = true; lines.push('- note edit `' + type + '`'); } + else lines.push('- ⚠️ note edit: no `' + type + '` note matched'); + } + } else if (op.kind === 'method') { + const type = String(op.type || '').toLowerCase(); + const changes = op.changes || {}; + if (op.action === 'remove') { + const before = methodsArr.length; + methodsArr = methodsArr.filter(function (im) { return String(im.type || '').toLowerCase() !== type; }); + if (methodsArr.length !== before) { methodsChanged = true; lines.push('- method remove `' + type + '`'); } + else lines.push('- ⚠️ method remove: `' + type + '` not found'); + } else { + let method = methodsArr.find(function (im) { return String(im.type || '').toLowerCase() === type; }); + if (!method && op.action === 'add') { method = { type, resources: { cpu: 1, ram: 512, hdd: 4, os: 'debian', version: '13' } }; methodsArr.push(method); } + if (!method) { lines.push('- ⚠️ method edit: `' + type + '` not found'); continue; } + if (!method.resources) method.resources = {}; + for (const [k, v] of Object.entries(changes)) { + if (RESOURCE_KEYS[k]) { + method.resources[k] = RESOURCE_KEYS[k] === 'number' ? parseInt(v, 10) : String(v); + if (RESOURCE_TO_CT_VAR[k]) ctChanges[RESOURCE_TO_CT_VAR[k]] = method.resources[k]; + } else if (METHOD_KEYS[k]) { + method[k] = v === '' ? null : String(v); + } + } + methodsChanged = true; + lines.push('- method `' + (op.action === 'add' ? 'add' : 'edit') + '` `' + type + '`: ' + JSON.stringify(changes)); + } + } + } + + if (Object.keys(fieldPayload).length) { + const r = await pbPatch(token, record.id, fieldPayload); + if (!r.ok) return { ok: false, summary: '❌ Field update failed:\n```\n' + r.body + '\n```' }; + } + if (notesChanged) { + const r = await pbPatch(token, record.id, { notes: notesArr }); + if (!r.ok) return { ok: false, summary: '❌ Notes update failed:\n```\n' + r.body + '\n```' }; + } + if (methodsChanged) { + const r = await pbPatch(token, record.id, { install_methods: methodsArr }); + if (!r.ok) return { ok: false, summary: '❌ Install-method update failed:\n```\n' + r.body + '\n```' }; + } + await revalidate(action.slug); + + let ctNote = ''; + if (Object.keys(ctChanges).length) { + try { + const sync = await upsertCtDefaultsPr(action.slug, ctChanges); + if (sync.status === 'created') ctNote = '\n\n**CT sync PR:** ' + sync.prUrl; + else if (sync.status === 'updated') ctNote = '\n\n**CT sync PR updated:** ' + sync.prUrl; + else if (sync.status === 'skipped') ctNote = '\n\n**CT sync skipped:** ' + sync.reason; + } catch (e) { ctNote = '\n\n**CT sync failed:** ' + e.message; } + } + return { ok: true, summary: lines.join('\n') + ctNote }; + } + + // ── 4. Confirm branch ────────────────────────────────────────────── + const PENDING_RE = //; + const isConfirm = /^(confirm|yes|apply|do it|y)\b/i.test(instruction); + + if (isConfirm) { + const comments = await listIssueComments(); + let pending = null, pendingComment = null; + for (let i = comments.length - 1; i >= 0; i--) { + const m = comments[i].body && comments[i].body.match(PENDING_RE); + if (m) { pending = m[1]; pendingComment = comments[i]; break; } + } + if (!pending) { + await addReaction('confused'); + await postComment('🤔 **PocketBase AI Bot**: I have no pending change to confirm in this thread.'); + return; + } + let action; + try { action = JSON.parse(Buffer.from(pending, 'base64').toString('utf8')); } + catch (e) { await postComment('❌ **PocketBase AI Bot**: Could not decode the pending change.'); return; } + + let result; + try { result = await applyOperations(action); } + catch (e) { await addReaction('-1'); await postComment('❌ **PocketBase AI Bot**: ' + e.message); return; } + + if (!result.ok) { await addReaction('-1'); await postComment(result.summary); return; } + await updateComment(pendingComment.id, pendingComment.body.replace(PENDING_RE, '')); + await addReaction('+1'); + await postComment( + '✅ **PocketBase AI Bot**: Applied to **`' + action.slug + '`**\n\n' + result.summary + + '\n\n*Confirmed by @' + actor + '*' + ); + return; + } + + // ── 5. New request: acknowledge, fetch script list, call the model ─ + await addReaction('eyes'); + + const token0 = await pbAuth(); + const scripts = []; + let page = 1; + while (page <= 5) { + const res = await request(recordsUrl + '?fields=slug,name&perPage=500&page=' + page, { headers: { 'Authorization': token0 } }); + if (!res.ok) break; + const data = JSON.parse(res.body); + (data.items || []).forEach(function (it) { if (it.slug) scripts.push({ slug: it.slug, name: it.name || it.slug }); }); + if (!data.items || data.items.length < 500) break; + page++; + } + + const SYSTEM_PROMPT = + 'You translate a maintainer\'s natural-language request into a STRICT JSON change-set for a ' + + 'script catalog (PocketBase). Respond with a SINGLE JSON object and nothing else.\n\n' + + 'Schema:\n' + + '{\n' + + ' "slug": string|null, // MUST be one of the known slugs below, chosen from the request\n' + + ' "operations": [ ... ], // [] if you cannot determine concrete changes\n' + + ' "human_summary": string, // short plain-English description\n' + + ' "clarification": string|null // a question to ask if ambiguous/unsupported; else null\n' + + '}\n\n' + + 'Operation kinds:\n' + + '- {"kind":"field","field":,"value":}\n' + + ' (booleans true/false; "is_disabled"/"is_deleted"/"is_dev" are booleans; "port"/"unprivileged" are numbers; rest strings.)\n' + + '- {"kind":"note","action":"add"|"edit"|"remove","type":string,"text":string,"newText":string?}\n' + + '- {"kind":"method","action":"add"|"edit"|"remove","type":string,"changes":{cpu?:number,ram?:number,hdd?:number,os?:string,version?:string,config_path?:string,script?:string}}\n' + + ' (RAM/HDD are in MB/GB; method "type" defaults to "default" if the user does not name one.)\n\n' + + 'Rules:\n' + + '- Only use fields/operations listed above. If the request needs something else, set clarification and operations=[].\n' + + '- "disable"/"enable" map to is_disabled true/false. If disabling and the user gave a reason, also set disable_message.\n' + + '- Resolve the target script to a slug from the list. If you cannot confidently match exactly one, set slug=null and ask via clarification.\n\n' + + 'Known scripts (slug — name):\n' + + scripts.map(function (s) { return s.slug + ' — ' + s.name; }).join('\n'); + + const modelRes = await request('https://models.github.ai/inference/chat/completions', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + process.env.MODELS_TOKEN, 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ + model: process.env.AI_MODEL || 'openai/gpt-4o', + temperature: 0.1, + response_format: { type: 'json_object' }, + messages: [{ role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: instruction }] + }) + }); + if (!modelRes.ok) { + await addReaction('-1'); + await postComment('❌ **PocketBase AI Bot**: Model request failed (' + modelRes.statusCode + ').\n```\n' + modelRes.body.slice(0, 500) + '\n```'); + return; + } + + let parsed; + try { + const content = JSON.parse(modelRes.body).choices[0].message.content; + const cleaned = content.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim(); + parsed = JSON.parse(cleaned); + } catch (e) { + await addReaction('-1'); + await postComment('❌ **PocketBase AI Bot**: Could not parse the model response. Please rephrase.'); + return; + } + + // ── 6. Validate ──────────────────────────────────────────────────── + const knownSlugs = new Set(scripts.map(function (s) { return s.slug; })); + const problems = []; + if (parsed.clarification) problems.push(parsed.clarification); + if (!parsed.slug || !knownSlugs.has(parsed.slug)) problems.push('I could not match the request to a known script.'); + const ops = Array.isArray(parsed.operations) ? parsed.operations : []; + const validOps = []; + for (const op of ops) { + if (op.kind === 'field') { + const cast = castFieldValue(op.field, op.value); + if (cast.error) { problems.push(cast.error); continue; } + validOps.push({ kind: 'field', field: op.field, value: cast.value }); + } else if (op.kind === 'note' && ['add', 'edit', 'remove'].includes(op.action)) { + validOps.push({ kind: 'note', action: op.action, type: op.type, text: op.text, newText: op.newText }); + } else if (op.kind === 'method' && ['add', 'edit', 'remove'].includes(op.action)) { + const changes = {}; + for (const [k, v] of Object.entries(op.changes || {})) { if (ALL_METHOD_KEYS[k]) changes[k] = v; } + validOps.push({ kind: 'method', action: op.action, type: op.type || 'default', changes }); + } else { + problems.push('Unsupported operation: `' + JSON.stringify(op) + '`'); + } + } + if (validOps.length === 0) problems.push('No concrete, supported change was found.'); + + if (problems.length) { + await addReaction('confused'); + await postComment( + '🤔 **PocketBase AI Bot**: I need a bit more to act on that.\n\n- ' + problems.join('\n- ') + + '\n\nTry naming the script and the exact change, e.g. `@pocketbase set RAM to 4096 on zigbee2mqtt`.' + ); + return; + } + + // ── 7. Propose (do NOT apply yet) ────────────────────────────────── + const action = { slug: parsed.slug, operations: validOps }; + const bullets = validOps.map(function (op) { + if (op.kind === 'field') return '- `' + op.field + '` → `' + JSON.stringify(op.value) + '`'; + if (op.kind === 'note') return '- note ' + op.action + ' `' + op.type + '`' + (op.text ? ': ' + op.text : ''); + return '- method ' + op.action + ' `' + op.type + '`: ' + JSON.stringify(op.changes); + }).join('\n'); + const marker = ''; + await addReaction('+1'); + await postComment( + '🤖 **PocketBase AI Bot** — please confirm\n\n' + + (parsed.human_summary ? '> ' + parsed.human_summary + '\n\n' : '') + + '**Target:** `' + action.slug + '`\n**Proposed changes:**\n' + bullets + '\n\n' + + 'Reply **`@pocketbase confirm`** to apply, or restate the request to adjust.\n' + marker + ); + })().catch(function (e) { + console.error('Fatal error:', e && (e.message || e)); + process.exit(1); + }); + ENDSCRIPT