From 15117e56be1b60c2dbe8455f19c4a373336a2a43 Mon Sep 17 00:00:00 2001 From: MickLesk <47820557+MickLesk@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:41:55 +0200 Subject: [PATCH] Remove the PocketBase AI bot It called GitHub Models, which GitHub retired on 2026-07-30. The endpoint now answers every request with a plain-text "OK", so each mention ended in "Could not parse the model response". The /pocketbase slash-command bot is unaffected. --- .github/workflows/pocketbase-ai-bot.yml | 626 ------------------------ 1 file changed, 626 deletions(-) delete mode 100644 .github/workflows/pocketbase-ai-bot.yml diff --git a/.github/workflows/pocketbase-ai-bot.yml b/.github/workflows/pocketbase-ai-bot.yml deleted file mode 100644 index eb0622ef0..000000000 --- a/.github/workflows/pocketbase-ai-bot.yml +++ /dev/null @@ -1,626 +0,0 @@ -name: PocketBase AI Bot - -# Natural-language companion to pocketbase-bot.yml. -# Mention the bot in plain English, e.g.: -# @pocketbase-bot change RAM to 4096 on zigbee2mqtt -# @pocketbase-bot 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-bot 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 - contents: write # built-in token opens the CT-defaults sync PR (like the slash bot) - pull-requests: write - -jobs: - ai-bot: - runs-on: self-hosted - # Broad gate; the script does precise keyword + self-author checks. - if: contains(github.event.comment.body, '@pocketbase-bot') - - steps: - - name: Mint GitHub App token (bot identity) - id: app-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ vars.GHAPP_PBBOT_ID }} - private-key: ${{ secrets.GHAPP_PBBOT_PRIVATE_KEY }} - - - name: Run PocketBase AI bot - env: - # GitHub REST as the bot identity - GH_APP_TOKEN: ${{ steps.app-token.outputs.token }} - PB_BOT_APP_ID: ${{ vars.GHAPP_PBBOT_ID }} - # GitHub Models inference uses the built-in token (needs models: read) - MODELS_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Built-in token for git/PR ops (CT-defaults sync), mirroring the slash bot - GH_DEFAULT_TOKEN: ${{ secrets.GITHUB_TOKEN }} - AI_MODEL: openai/gpt-4o - # PocketBase - POCKETBASE_URL: ${{ vars.POCKETBASE_URL }} - POCKETBASE_COLLECTION: ${{ vars.POCKETBASE_COLLECTION }} - POCKETBASE_ADMIN_EMAIL: ${{ secrets.POCKETBASE_ADMIN_EMAIL }} - POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }} - FRONTEND_URL: ${{ vars.FRONTEND_URL }} - REVALIDATE_SECRET: ${{ secrets.FRONTEND_INGEST_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 }); - } - - // Same as ghRequest but authenticated with the built-in GITHUB_TOKEN. - // Used for the CT-defaults sync branch/PR (the App token lacks contents:write). - function ghDefault(path, method, body) { - const headers = { - 'Authorization': 'Bearer ' + process.env.GH_DEFAULT_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-bot`.' - ); - return; - } - - // ── 3. Extract the instruction after the @pocketbase-bot handle ──── - const commentBody = process.env.COMMENT_BODY || ''; - const handleMatch = commentBody.match(/@pocketbase-bot(\[bot\])?/i); - if (!handleMatch) { - console.log('No @pocketbase-bot 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-bot change RAM to 4096 on zigbee2mqtt` or `@pocketbase-bot 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 ghRequest = ghDefault; // git ops run as the built-in token - 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 ghRequest = ghDefault; // contents/PR ops run as the built-in token - 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-bot` 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', 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', - is_dev: 'boolean', is_disabled: 'boolean', disable_message: 'string', - is_deleted: 'boolean', deleted_message: 'string' - }; - const SELECT_VALUES = { - architectures: ['amd64', 'arm64'], - platforms: ['pve', 'incus'], - }; - 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 === 'select_list') { - // architectures/platforms are multi-selects over a closed set, - // so an unknown value is rejected rather than stored. The model - // may hand back an array or a comma-separated string. - const allowed = SELECT_VALUES[key] || []; - const raw = Array.isArray(rawVal) ? rawVal : String(rawVal == null ? '' : rawVal).split(','); - const values = raw.map(function (v) { return String(v).trim(); }).filter(Boolean); - const bad = values.filter(function (v) { return allowed.indexOf(v) === -1; }); - if (bad.length > 0) return { error: '`' + key + '` accepts ' + allowed.join(', ') + ' — got: ' + bad.join(', ') }; - return { value: values }; - } - if (type === 'nullable_string') return { value: rawVal === '' || rawVal == null ? null : String(rawVal) }; - return { value: String(rawVal) }; - } - - // ── Operation validation (used at propose AND confirm time) ──────── - // Never trust raw operations: enforce the field/op allow-lists and - // re-cast values. Returns only well-formed, allowed operations. - function sanitizeOperations(ops) { - const validOps = [], problems = []; - for (const op of (Array.isArray(ops) ? ops : [])) { - if (op && 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 && op.kind === 'note' && ['add', 'edit', 'remove'].includes(op.action)) { - validOps.push({ kind: 'note', action: op.action, type: String(op.type || ''), text: op.text, newText: op.newText }); - } else if (op && 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: String(op.type || 'default'), changes }); - } else { - problems.push('Unsupported operation: `' + JSON.stringify(op) + '`'); - } - } - return { validOps, problems }; - } - - // ── 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(); - const appId = String(process.env.PB_BOT_APP_ID || ''); - let pending = null, pendingComment = null; - for (let i = comments.length - 1; i >= 0; i--) { - const c = comments[i]; - // Only trust a marker in a comment THIS bot app authored — otherwise a - // user could hand-craft a forged pocketbase-pending marker and confirm it. - const byBotApp = c.user && c.user.type === 'Bot' && - c.performed_via_github_app && String(c.performed_via_github_app.id) === appId; - if (!byBotApp) continue; - const m = c.body && c.body.match(PENDING_RE); - if (m) { pending = m[1]; pendingComment = c; 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; } - - // Re-validate the decoded operations before applying (defense-in-depth). - const recheck = sanitizeOperations(action.operations); - if (!action.slug || recheck.validOps.length === 0) { - await addReaction('-1'); - await postComment('❌ **PocketBase AI Bot**: The pending change is no longer valid. Please restate the request.'); - return; - } - action.operations = recheck.validOps; - - 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 sanitized = sanitizeOperations(parsed.operations); - const validOps = sanitized.validOps; - problems.push.apply(problems, sanitized.problems); - 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-bot 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-bot 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