9829 lines
316 KiB
JavaScript
9829 lines
316 KiB
JavaScript
/**
|
||
* Bare OS Discord slash-command catalog (guest-safe: no import/export).
|
||
* Used by /bin/discord-bot (prepended) and the bare-os-discord initd unit.
|
||
* Host/pack must static-import this .cjs (createRequire fails under app.bundle).
|
||
*/
|
||
|
||
var BARE_OS_DISCORD_REPLY_MAX = 1900
|
||
var BARE_OS_DISCORD_FS_MAX = 12 * 1024
|
||
/** Discord API hard caps (message content / embed parts / combined embed). */
|
||
var BARE_OS_DISCORD_LIMIT = {
|
||
content: 2000,
|
||
title: 256,
|
||
desc: 4096,
|
||
fieldName: 256,
|
||
fieldValue: 1024,
|
||
footer: 2048,
|
||
author: 256,
|
||
fields: 25,
|
||
embedTotal: 6000,
|
||
pageChars: 3000
|
||
}
|
||
var BARE_OS_DISCORD_MORE_SESSIONS = Object.create(null)
|
||
/** discord.js MessageFlags.Ephemeral (1 << 6). Avoid the deprecated `ephemeral` option. */
|
||
var BARE_OS_DISCORD_FLAG_EPHEMERAL = 64
|
||
var BARE_OS_DISCORD_WHITELIST_DENY =
|
||
'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.'
|
||
var BARE_OS_DISCORD_OWNER_DENY = 'This control belongs to another operator.'
|
||
/** ApplicationIntegrationType: GUILD_INSTALL / USER_INSTALL */
|
||
var BARE_OS_DISCORD_INTEGRATION_GUILD = 0
|
||
var BARE_OS_DISCORD_INTEGRATION_USER = 1
|
||
/** InteractionContextType: GUILD / BOT_DM / PRIVATE_CHANNEL */
|
||
var BARE_OS_DISCORD_CONTEXT_GUILD = 0
|
||
var BARE_OS_DISCORD_CONTEXT_BOT_DM = 1
|
||
var BARE_OS_DISCORD_CONTEXT_PRIVATE = 2
|
||
/** View Channel + Send Messages + Embed Links + Attach Files + Read History + Use App Commands */
|
||
var BARE_OS_DISCORD_GUILD_INSTALL_PERMISSIONS = '2147597312'
|
||
var BARE_OS_DISCORD_COLOR = 0x5865f2
|
||
var BARE_OS_DISCORD_COLOR_OK = 0x57f287
|
||
var BARE_OS_DISCORD_COLOR_WARN = 0xfee75c
|
||
var BARE_OS_DISCORD_COLOR_ERR = 0xed4245
|
||
var BARE_OS_DISCORD_SH_TTL_MS = 30 * 60 * 1000
|
||
var BARE_OS_DISCORD_SH_SESSIONS = Object.create(null)
|
||
var BARE_OS_DISCORD_SH_BINS = null
|
||
var BARE_OS_DISCORD_SH_BINS_AT = 0
|
||
var BARE_OS_DISCORD_SH_EXEC_MS = 20000
|
||
var BARE_OS_DISCORD_UPLOAD_MS = 60000
|
||
var BARE_OS_DISCORD_SH_BUILTINS = [
|
||
'alias',
|
||
'unalias',
|
||
'barerc',
|
||
'cd',
|
||
'export',
|
||
'unset',
|
||
'set',
|
||
'true',
|
||
'false',
|
||
'echo',
|
||
'pwd',
|
||
'type',
|
||
'command',
|
||
'umask',
|
||
'readonly',
|
||
'test',
|
||
'[',
|
||
'jobs',
|
||
'wait',
|
||
':',
|
||
'help'
|
||
]
|
||
var BARE_OS_DISCORD_SH_TTY = {
|
||
edit: 1,
|
||
nano: 1,
|
||
vim: 1,
|
||
vi: 1,
|
||
btop: 1,
|
||
baretop: 1,
|
||
irc: 1,
|
||
chat: 1,
|
||
dhttop: 1,
|
||
swarmmap: 1,
|
||
summon: 1,
|
||
'holepunch-view': 1,
|
||
routeview: 1
|
||
}
|
||
var BARE_OS_DISCORD_SH_FLAGS = {
|
||
ls: ['-a', '-l', '-la', '-lh', '-h', '-1', '-F'],
|
||
grep: ['-i', '-v', '-n', '-r', '-E', '-F'],
|
||
find: ['-name', '-type', '-size', '-path'],
|
||
rm: ['-r', '-f', '-rf', '-v'],
|
||
cp: ['-r', '-v', '-n'],
|
||
mv: ['-v', '-n'],
|
||
cat: ['-n', '-A'],
|
||
head: ['-n'],
|
||
tail: ['-n', '-f'],
|
||
chmod: ['-R'],
|
||
chown: ['-R'],
|
||
df: ['-h', '-T'],
|
||
du: ['-h', '-s', '-a'],
|
||
ps: ['-e', '-f'],
|
||
mkdir: ['-p'],
|
||
tar: ['-xvf', '-cvf', '-tzf']
|
||
}
|
||
var BARE_OS_DISCORD_RUN_ALLOW = {
|
||
uname: 1,
|
||
whoami: 1,
|
||
hostname: 1,
|
||
date: 1,
|
||
uptime: 1,
|
||
id: 1,
|
||
pwd: 1,
|
||
arch: 1,
|
||
nproc: 1,
|
||
help: 1,
|
||
motd: 1,
|
||
true: 1,
|
||
false: 1,
|
||
df: 1,
|
||
ps: 1,
|
||
procstat: 1,
|
||
'uname -a': 1
|
||
}
|
||
var BARE_OS_DISCORD_KNOWN_UNITS = [
|
||
'bare-os-discord',
|
||
'bare-os-www',
|
||
'bare-os-chat',
|
||
'bare-holesail',
|
||
'kernel-logger'
|
||
]
|
||
var BARE_OS_DISCORD_EDIT_CHUNK = 4000
|
||
var BARE_OS_DISCORD_EDIT_MAX_CHUNKS = 5
|
||
var BARE_OS_DISCORD_IDLE_MS = 2 * 60 * 1000
|
||
var BARE_OS_DISCORD_EDIT_TTL_MS = BARE_OS_DISCORD_IDLE_MS
|
||
var BARE_OS_DISCORD_EDIT_SESSIONS = Object.create(null)
|
||
var BARE_OS_DISCORD_FM_PAGE = 20
|
||
var BARE_OS_DISCORD_FM_TTL_MS = BARE_OS_DISCORD_IDLE_MS
|
||
var BARE_OS_DISCORD_FM_SESSIONS = Object.create(null)
|
||
var BARE_OS_DISCORD_LIVE = Object.create(null)
|
||
var BARE_OS_DISCORD_IDLE_SEQ = 0
|
||
var BARE_OS_DISCORD_EDIT_SUGGEST = [
|
||
'~/notes.txt',
|
||
'~/.barerc',
|
||
'~/TODO.md',
|
||
'/tmp/scratch.txt'
|
||
]
|
||
var BARE_OS_DISCORD_FS_SUGGEST = [
|
||
'~',
|
||
'/etc/os-release',
|
||
'/etc/motd',
|
||
'/proc/uptime',
|
||
'/proc/meminfo',
|
||
'/proc/bare_os/features',
|
||
'/proc/bare_os/security_posture.json',
|
||
'/proc/bare_os/process_table.json',
|
||
'/proc/bare_os/net_summary.json',
|
||
'/var/log/bare-os/discord.log',
|
||
'/var/log/bare-os/kernel-console.log',
|
||
'/mnt',
|
||
'/proc/bare_os/hdms_health.json',
|
||
'/proc/bare_os/hdms_hints.json',
|
||
'/share/man/man.json',
|
||
'/tmp'
|
||
]
|
||
|
||
/**
|
||
* bare-process has no process.emitWarning. discord.js 14 calls it whenever
|
||
* reply options include the deprecated `ephemeral` key (even when false).
|
||
*/
|
||
function discordInstallProcessEmitWarning(proc) {
|
||
const p =
|
||
proc ||
|
||
(typeof globalThis.process !== 'undefined' ? globalThis.process : null)
|
||
if (!p || typeof p.emitWarning === 'function') return p
|
||
p.emitWarning = function emitWarning(warning, type, code) {
|
||
let name = 'Warning'
|
||
let id = ''
|
||
let msg = ''
|
||
if (warning && typeof warning === 'object' && warning.type && !(warning instanceof Error)) {
|
||
name = String(warning.type || 'Warning')
|
||
id = String(warning.code || '')
|
||
msg = String(warning.message || warning)
|
||
} else if (type && typeof type === 'object') {
|
||
name = String(type.type || 'Warning')
|
||
id = String(type.code || '')
|
||
msg = warning instanceof Error ? warning.message : String(warning)
|
||
} else {
|
||
name = typeof type === 'string' ? type : 'Warning'
|
||
id = typeof code === 'string' ? code : ''
|
||
msg = warning instanceof Error ? warning.message : String(warning)
|
||
}
|
||
const line = id ? name + ' [' + id + ']: ' + msg : name + ': ' + msg
|
||
try {
|
||
if (typeof p.emit === 'function') {
|
||
const err = warning instanceof Error ? warning : new Error(msg)
|
||
err.name = name
|
||
if (id) err.code = id
|
||
p.emit('warning', err)
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
if (typeof console !== 'undefined' && typeof console.error === 'function') {
|
||
console.error(line)
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
return p
|
||
}
|
||
|
||
discordInstallProcessEmitWarning()
|
||
|
||
function discordStripAnsi(text) {
|
||
let s = String(text == null ? '' : text)
|
||
s = s.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '')
|
||
s = s.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
|
||
s = s.replace(/\u009b[0-?]*[ -/]*[@-~]/g, '')
|
||
s = s.replace(/\u001b[@-Z\\-_]/g, '')
|
||
s = s.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u009b]/g, '')
|
||
return s
|
||
}
|
||
|
||
function discordCmdClip(text, max) {
|
||
const s = discordStripAnsi(text)
|
||
const n = max || BARE_OS_DISCORD_REPLY_MAX
|
||
if (s.length <= n) return s
|
||
return discordClipSmart(s, n, '\n…(truncated)').text
|
||
}
|
||
|
||
function discordClipSmart(text, max, suffix) {
|
||
const s = String(text == null ? '' : text)
|
||
const tail = suffix == null ? '\n…' : suffix
|
||
if (s.length <= max) return { text: s, truncated: false, hidden: 0 }
|
||
const room = Math.max(8, max - tail.length)
|
||
let cut = s.lastIndexOf('\n', room)
|
||
if (cut < room * 0.55) {
|
||
const sp = s.lastIndexOf(' ', room)
|
||
if (sp > room * 0.55) cut = sp
|
||
}
|
||
if (cut < Math.floor(room * 0.4)) cut = room
|
||
return { text: s.slice(0, cut) + tail, truncated: true, hidden: s.length - cut }
|
||
}
|
||
|
||
function discordSafeDesc(desc, max) {
|
||
const s = String(desc == null ? '' : desc)
|
||
if (max <= 0) return ''
|
||
if (s.length <= max) return s
|
||
const open = s.indexOf('```')
|
||
if (open >= 0) {
|
||
const nl = s.indexOf('\n', open)
|
||
const close = s.lastIndexOf('```')
|
||
if (nl > open && close > nl) {
|
||
const head = s.slice(0, nl + 1)
|
||
const after = s.slice(close + 3)
|
||
const hint = '\n…'
|
||
const budget = max - head.length - 4 - after.length - hint.length
|
||
if (budget >= 16) {
|
||
const body = s.slice(nl + 1, close).replace(/\n+$/, '')
|
||
const fit = discordClipSmart(body, budget, hint)
|
||
return head + fit.text.replace(/```/g, '`ˋ`') + '\n```' + after
|
||
}
|
||
}
|
||
}
|
||
return discordClipSmart(s, max, '\n…').text
|
||
}
|
||
|
||
function discordCmdRedact(text) {
|
||
return discordStripAnsi(text)
|
||
.replace(/[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{5,}\.[A-Za-z0-9_\-]{20,}/g, '[token]')
|
||
.replace(/(DISCORD_TOKEN|BOT_TOKEN|TOKEN|SECRET|PASSWORD|PASSWD|API_KEY)\s*[=:]\s*\S+/gi, '$1=[redacted]')
|
||
}
|
||
|
||
function discordCmdFence(text, lang, max) {
|
||
const tag = lang ? String(lang) : ''
|
||
const wrap = 8 + tag.length
|
||
const cap = Math.max(32, (max || BARE_OS_DISCORD_LIMIT.desc) - wrap)
|
||
const fit = discordClipSmart(discordCmdRedact(text).replace(/```/g, '`ˋ`'), cap, '\n…')
|
||
return '```' + tag + '\n' + fit.text + '\n```'
|
||
}
|
||
|
||
function discordTextPages(text, pageChars) {
|
||
const s = String(text == null ? '' : text)
|
||
const size = Math.max(200, pageChars || BARE_OS_DISCORD_LIMIT.pageChars)
|
||
if (!s) return ['']
|
||
if (s.length <= size) return [s]
|
||
const pages = []
|
||
let i = 0
|
||
while (i < s.length) {
|
||
if (s.length - i <= size) {
|
||
pages.push(s.slice(i))
|
||
break
|
||
}
|
||
let cut = s.lastIndexOf('\n', i + size)
|
||
if (cut <= i + Math.floor(size * 0.4)) cut = i + size
|
||
pages.push(s.slice(i, cut))
|
||
i = cut
|
||
while (s.charAt(i) === '\n') i++
|
||
}
|
||
return pages
|
||
}
|
||
|
||
function discordMarkdownCloseFences(text) {
|
||
const s = String(text == null ? '' : text)
|
||
const n = (s.match(/```/g) || []).length
|
||
if (n % 2 === 1) return s.replace(/\s*$/, '') + '\n```'
|
||
return s
|
||
}
|
||
|
||
function discordMarkdownPages(text, pageChars) {
|
||
const s = String(text == null ? '' : text)
|
||
const size = Math.max(200, pageChars || BARE_OS_DISCORD_LIMIT.pageChars)
|
||
if (!s) return ['']
|
||
const raw = discordTextPages(s, size)
|
||
const pages = []
|
||
let open = false
|
||
for (let i = 0; i < raw.length; i++) {
|
||
let chunk = raw[i]
|
||
const marks = chunk.match(/```/g)
|
||
const count = marks ? marks.length : 0
|
||
if (open) chunk = '```\n' + chunk
|
||
const nowOpen = (open && count % 2 === 0) || (!open && count % 2 === 1)
|
||
if (nowOpen) chunk = discordMarkdownCloseFences(chunk)
|
||
open = nowOpen
|
||
pages.push(chunk)
|
||
}
|
||
return pages.length ? pages : ['']
|
||
}
|
||
|
||
function discordEmbedSize(e) {
|
||
if (!e) return 0
|
||
let n = 0
|
||
if (e.title) n += String(e.title).length
|
||
if (e.description) n += String(e.description).length
|
||
if (e.footer && e.footer.text) n += String(e.footer.text).length
|
||
if (e.author && e.author.name) n += String(e.author.name).length
|
||
const fs = e.fields || []
|
||
for (let i = 0; i < fs.length; i++) {
|
||
n += String(fs[i].name || '').length + String(fs[i].value || '').length
|
||
}
|
||
return n
|
||
}
|
||
|
||
function discordPackEmbed(opts) {
|
||
const o = opts || {}
|
||
const L = BARE_OS_DISCORD_LIMIT
|
||
const title = o.title ? discordStripAnsi(String(o.title)).slice(0, L.title) : ''
|
||
const footer = discordStripAnsi(String(o.footer || 'Bare OS · expires after 2m idle')).slice(0, L.footer)
|
||
const authorName =
|
||
o.author && o.author.name ? discordStripAnsi(String(o.author.name)).slice(0, L.author) : ''
|
||
const chrome = title.length + footer.length + authorName.length
|
||
const preferDesc = o.prefer === 'desc'
|
||
const rawFields = []
|
||
if (o.fields && o.fields.length) {
|
||
for (let i = 0; i < o.fields.length && rawFields.length < L.fields; i++) {
|
||
const f = o.fields[i]
|
||
if (!f) continue
|
||
let v = String(f.value == null ? '' : f.value).trim()
|
||
if (!v || v === '—') continue
|
||
v = discordCmdRedact(v)
|
||
if (v.length > L.fieldValue) v = discordClipSmart(v, L.fieldValue, '\n…').text
|
||
rawFields.push({
|
||
name: String(f.name || '·').slice(0, L.fieldName),
|
||
value: v,
|
||
inline: Boolean(f.inline)
|
||
})
|
||
}
|
||
}
|
||
let fieldBytes = 0
|
||
for (let i = 0; i < rawFields.length; i++) {
|
||
fieldBytes += rawFields[i].name.length + rawFields[i].value.length
|
||
}
|
||
let descRoom
|
||
if (preferDesc) {
|
||
descRoom = Math.min(L.desc, Math.max(0, L.embedTotal - chrome - Math.min(fieldBytes, 900)))
|
||
} else {
|
||
descRoom = Math.min(L.desc, Math.max(0, L.embedTotal - chrome - fieldBytes))
|
||
}
|
||
let desc = o.desc ? discordCmdRedact(String(o.desc)) : ''
|
||
if (desc) desc = discordSafeDesc(desc, descRoom)
|
||
let fields = rawFields.slice()
|
||
let used = chrome + desc.length + fieldBytes
|
||
if (used > L.embedTotal && fields.length) {
|
||
const keep = []
|
||
let acc = chrome + desc.length
|
||
for (let i = 0; i < fields.length; i++) {
|
||
const add = fields[i].name.length + fields[i].value.length
|
||
if (acc + add > L.embedTotal - 48) break
|
||
keep.push(fields[i])
|
||
acc += add
|
||
}
|
||
const omitted = fields.length - keep.length
|
||
if (omitted > 0) {
|
||
const note = '_' + omitted + ' more not shown_'
|
||
if (keep.length && acc + note.length + 8 <= L.embedTotal) {
|
||
keep.push({ name: '…', value: note, inline: false })
|
||
} else if (desc.length + 2 + note.length <= L.desc) {
|
||
desc = (desc ? desc + '\n' : '') + note
|
||
}
|
||
}
|
||
fields = keep
|
||
used = chrome + desc.length
|
||
for (let i = 0; i < fields.length; i++) used += fields[i].name.length + fields[i].value.length
|
||
}
|
||
if (used > L.embedTotal && desc) {
|
||
desc = discordSafeDesc(desc, Math.max(0, desc.length - (used - L.embedTotal)))
|
||
}
|
||
const embed = {
|
||
color: o.color == null ? BARE_OS_DISCORD_COLOR : o.color,
|
||
timestamp: new Date().toISOString(),
|
||
footer: { text: footer }
|
||
}
|
||
if (title) embed.title = title
|
||
if (desc) embed.description = desc
|
||
if (fields.length) embed.fields = fields
|
||
if (o.author) embed.author = { name: authorName || 'Bare OS' }
|
||
return {
|
||
embed: embed,
|
||
truncated: fields.length < rawFields.length || !!(o.desc && desc.length < String(o.desc).length),
|
||
size: discordEmbedSize(embed)
|
||
}
|
||
}
|
||
|
||
function discordTryJson(raw) {
|
||
if (raw && typeof raw === 'object') return raw
|
||
const s = String(raw || '').trim()
|
||
if (!s || (s.charAt(0) !== '{' && s.charAt(0) !== '[')) return null
|
||
try {
|
||
return JSON.parse(s)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function discordPrettyBytes(n) {
|
||
const x = Number(n)
|
||
if (!Number.isFinite(x) || x < 0) return String(n)
|
||
if (x < 1024) return String(Math.round(x)) + ' B'
|
||
const units = ['KiB', 'MiB', 'GiB', 'TiB']
|
||
let v = x / 1024
|
||
let i = 0
|
||
while (v >= 1024 && i < units.length - 1) {
|
||
v /= 1024
|
||
i++
|
||
}
|
||
return (v >= 10 ? v.toFixed(0) : v.toFixed(1)) + ' ' + units[i]
|
||
}
|
||
|
||
function discordIsByteishKey(k) {
|
||
return /byte|bytes|mem|rss|size|freemem|totalmem|avail|queued|f_bsize|f_frsize/i.test(
|
||
String(k || '')
|
||
)
|
||
}
|
||
|
||
function discordPrettyValue(key, val) {
|
||
if (val == null || val === '') return '—'
|
||
if (typeof val === 'boolean') return val ? 'yes' : 'no'
|
||
if (typeof val === 'number') {
|
||
if (discordIsByteishKey(key) && val >= 1024) return discordPrettyBytes(val)
|
||
if (key === 'atMs' || /AtMs$/.test(key)) {
|
||
if (val > 1e12) return new Date(val).toISOString()
|
||
return String(val)
|
||
}
|
||
if (/Ms$/.test(key) && val >= 1000) {
|
||
const s = Math.round(val / 1000)
|
||
if (s < 60) return s + 's'
|
||
return Math.floor(s / 60) + 'm ' + (s % 60) + 's'
|
||
}
|
||
return String(val)
|
||
}
|
||
if (typeof val === 'string') {
|
||
const t = discordCmdRedact(val)
|
||
if (/^[0-9a-f]{24,}$/i.test(t)) return '`' + t.slice(0, 16) + (t.length > 16 ? '…' : '') + '`'
|
||
return t.length > 180 ? t.slice(0, 177) + '…' : t
|
||
}
|
||
if (Array.isArray(val)) {
|
||
if (!val.length) return '(none)'
|
||
if (val.every(function (x) { return x == null || typeof x !== 'object' })) {
|
||
const shown = val.slice(0, 10).map(function (x) {
|
||
return String(x)
|
||
})
|
||
return shown.join(', ') + (val.length > 10 ? ' +' + (val.length - 10) : '')
|
||
}
|
||
return String(val.length) + ' items'
|
||
}
|
||
if (typeof val === 'object') return Object.keys(val).length + ' keys'
|
||
return String(val)
|
||
}
|
||
|
||
function discordSkipPrettyKey(k) {
|
||
return /TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|API_KEY/i.test(k) ||
|
||
k === 'note' ||
|
||
k === 'schema' ||
|
||
k === 'schemaVersion'
|
||
}
|
||
|
||
function discordValueEmpty(val) {
|
||
if (val == null) return true
|
||
if (val === '') return true
|
||
if (val === '—') return true
|
||
if (Array.isArray(val) && !val.length) return true
|
||
if (typeof val === 'object' && !Array.isArray(val) && !Object.keys(val).length) return true
|
||
return false
|
||
}
|
||
|
||
function discordHumanLabel(key) {
|
||
const known = {
|
||
schemaVersion: 'Schema',
|
||
topicHex: 'Topic',
|
||
peerCount: 'Peers',
|
||
seedHandshakeError: 'Handshake',
|
||
seedRole: 'Role',
|
||
replicationQueueDepth: 'Queue depth',
|
||
replicationQueue: 'Replication',
|
||
stagingSlot: 'Staging',
|
||
snapshotHints: 'Snapshot',
|
||
peerFirewallStats: 'Firewall',
|
||
peerFirewallAcceptedTotal: 'Accepted',
|
||
peerFirewallRejectedTotal: 'Rejected',
|
||
peerFirewallInboundTotal: 'Inbound',
|
||
peerFirewallOutboundTotal: 'Outbound',
|
||
peerFirewallE2e: 'Firewall e2e',
|
||
manifestPathCount: 'Manifests',
|
||
localRamBlockCount: 'RAM blocks',
|
||
hypercoreLengthHint: 'Core length',
|
||
queueDepthEstimate: 'Queue estimate',
|
||
snapshotWorkflowNote: 'Note',
|
||
activeSlot: 'Active slot',
|
||
pendingSlot: 'Pending',
|
||
previousSlot: 'Previous',
|
||
canarySlot: 'Canary',
|
||
drainDeadlineMs: 'Drain',
|
||
atMs: 'Updated'
|
||
}
|
||
if (known[key]) return known[key]
|
||
const s = String(key || '')
|
||
.replace(/[_-]+/g, ' ')
|
||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
if (!s) return String(key || '')
|
||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||
}
|
||
|
||
function discordCollectFields(obj, prefix, out, depth) {
|
||
if (!obj || typeof obj !== 'object' || Array.isArray(obj) || depth > 2) return
|
||
const keys = Object.keys(obj)
|
||
for (let i = 0; i < keys.length && out.length < 18; i++) {
|
||
const k = keys[i]
|
||
if (discordSkipPrettyKey(k)) continue
|
||
const val = obj[k]
|
||
if (discordValueEmpty(val)) continue
|
||
const label = prefix ? prefix + ' · ' + discordHumanLabel(k) : discordHumanLabel(k)
|
||
if (val && typeof val === 'object' && !Array.isArray(val) && depth < 2) {
|
||
const lines = []
|
||
const sub = Object.keys(val)
|
||
for (let j = 0; j < sub.length && lines.length < 8; j++) {
|
||
if (discordSkipPrettyKey(sub[j])) continue
|
||
const sv = val[sub[j]]
|
||
if (discordValueEmpty(sv)) continue
|
||
if (sv && typeof sv === 'object') continue
|
||
const pretty = discordPrettyValue(sub[j], sv)
|
||
if (pretty === '—' || pretty === '(none)') continue
|
||
lines.push('**' + discordHumanLabel(sub[j]) + '** ' + pretty)
|
||
}
|
||
if (lines.length) out.push(discordField(discordHumanLabel(k), lines.join('\n'), false))
|
||
} else {
|
||
const pretty = discordPrettyValue(k, val)
|
||
if (pretty === '—' || pretty === '(none)') continue
|
||
out.push(discordField(label, pretty, true))
|
||
}
|
||
}
|
||
}
|
||
|
||
function discordPrettyUptime(raw) {
|
||
const s = String(raw || '').trim()
|
||
const sec = Number(s.split(/\s+/)[0])
|
||
if (!Number.isFinite(sec) || sec < 0) return s || '—'
|
||
const d = Math.floor(sec / 86400)
|
||
const h = Math.floor((sec % 86400) / 3600)
|
||
const m = Math.floor((sec % 3600) / 60)
|
||
const parts = []
|
||
if (d) parts.push(d + 'd')
|
||
if (h || d) parts.push(h + 'h')
|
||
parts.push(m + 'm')
|
||
return parts.join(' ') + ' (' + Math.round(sec) + 's)'
|
||
}
|
||
|
||
function discordParseMeminfo(text) {
|
||
const out = {}
|
||
const lines = String(text || '').split(/\r?\n/)
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const m = /^([A-Za-z0-9_()]+):\s+(\d+)/.exec(lines[i])
|
||
if (!m) continue
|
||
out[m[1]] = Number(m[2]) * 1024
|
||
}
|
||
return out
|
||
}
|
||
|
||
function discordParseSystemctlList(text) {
|
||
const rows = []
|
||
const lines = String(text || '').split(/\r?\n/)
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i].trim()
|
||
if (!line || /^UNIT\b/.test(line) || line.indexOf('LOAD') === 0) continue
|
||
const parts = line.split(/\s+/)
|
||
if (parts.length < 4) continue
|
||
rows.push({
|
||
unit: parts[0].replace(/\.service$/, ''),
|
||
load: parts[1] || '',
|
||
preset: parts[2] || '',
|
||
active: parts[3] || '',
|
||
sub: parts[4] || '',
|
||
desc: parts.slice(5).join(' ')
|
||
})
|
||
}
|
||
return rows
|
||
}
|
||
|
||
function discordPrettySnapshot(title, raw, extras) {
|
||
const parsed = discordTryJson(raw)
|
||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||
const text = raw == null ? '' : String(raw)
|
||
if (!text) {
|
||
return discordResult(discordEmbed({ title: title, desc: 'unavailable' }), extras)
|
||
}
|
||
return {
|
||
more: {
|
||
title: title,
|
||
body: text,
|
||
fence: true,
|
||
components: extras && extras.components,
|
||
editPath: extras && extras.editPath,
|
||
created: extras && extras.created,
|
||
color: extras && extras.color
|
||
}
|
||
}
|
||
}
|
||
const fields = []
|
||
discordCollectFields(parsed, '', fields, 0)
|
||
const note = typeof parsed.note === 'string' ? parsed.note.slice(0, 280) : ''
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: title,
|
||
desc: note,
|
||
fields: fields.slice(0, 18),
|
||
color: extras && extras.color
|
||
}),
|
||
extras
|
||
)
|
||
}
|
||
|
||
function discordHex12(ctx, u8) {
|
||
if (!u8 || typeof u8.length !== 'number') return ''
|
||
if (ctx && ctx.b4a && typeof ctx.b4a.toString === 'function') {
|
||
try {
|
||
return String(ctx.b4a.toString(u8, 'hex') || '').slice(0, 12)
|
||
} catch {
|
||
/* fall through */
|
||
}
|
||
}
|
||
let hex = ''
|
||
const n = Math.min(u8.length, 6)
|
||
for (let i = 0; i < n; i++) {
|
||
const h = (u8[i] & 0xff).toString(16)
|
||
hex += h.length < 2 ? '0' + h : h
|
||
}
|
||
return hex.slice(0, 12)
|
||
}
|
||
|
||
/** Prefer live VFS env (identity mutates this). Keep ctx.env in sync. */
|
||
function discordSyncSessionIdentity(ctx) {
|
||
if (!ctx) return
|
||
const vfsEnv = ctx.vfs && ctx.vfs.env && typeof ctx.vfs.env === 'object' ? ctx.vfs.env : null
|
||
const env = vfsEnv || (ctx.env && typeof ctx.env === 'object' ? ctx.env : null)
|
||
if (!env) return
|
||
if (ctx.env && vfsEnv && ctx.env !== vfsEnv) {
|
||
const keys = [
|
||
'USER',
|
||
'LOGNAME',
|
||
'HOME',
|
||
'PWD',
|
||
'UID',
|
||
'GID',
|
||
'GROUP',
|
||
'SHELL',
|
||
'BARE_OS_IDENTITY',
|
||
'BARE_OS_PUBLIC_KEY'
|
||
]
|
||
for (let i = 0; i < keys.length; i++) {
|
||
const k = keys[i]
|
||
if (vfsEnv[k] != null && String(vfsEnv[k]) !== '') ctx.env[k] = vfsEnv[k]
|
||
}
|
||
}
|
||
const ident = ctx.identity
|
||
if (!ident || ident.state !== 'unlocked') return
|
||
let name = String(env.USER || env.LOGNAME || '').trim()
|
||
if (!name || name === 'guest') {
|
||
name = discordHex12(ctx, ident.publicKey)
|
||
if (!name) return
|
||
env.USER = name
|
||
env.LOGNAME = name
|
||
env.GROUP = name
|
||
if (ctx.env && ctx.env !== env) {
|
||
ctx.env.USER = name
|
||
ctx.env.LOGNAME = name
|
||
}
|
||
}
|
||
if (!env.HOME || env.HOME === '/home/guest') env.HOME = '/home/' + name
|
||
if (!env.PWD || env.PWD === '/home/guest') env.PWD = env.HOME
|
||
env.BARE_OS_IDENTITY = 'unlocked'
|
||
}
|
||
|
||
function discordCmdEnv(ctx) {
|
||
discordSyncSessionIdentity(ctx)
|
||
if (ctx && ctx.vfs && ctx.vfs.env) return ctx.vfs.env
|
||
if (ctx && ctx.env) return ctx.env
|
||
return {}
|
||
}
|
||
|
||
function discordSessionUser(ctx) {
|
||
const e = discordCmdEnv(ctx)
|
||
const u = String(e.USER || e.LOGNAME || e.USERNAME || '').trim()
|
||
return u || 'guest'
|
||
}
|
||
|
||
function discordUniqueComponents(rows) {
|
||
const seen = Object.create(null)
|
||
const out = []
|
||
if (!Array.isArray(rows)) return out
|
||
for (let i = 0; i < rows.length && out.length < 5; i++) {
|
||
const row = rows[i]
|
||
if (!row || !Array.isArray(row.components)) continue
|
||
const comps = []
|
||
for (let j = 0; j < row.components.length; j++) {
|
||
const c = row.components[j]
|
||
if (!c) continue
|
||
const id = c.custom_id != null ? String(c.custom_id) : ''
|
||
if (id) {
|
||
if (seen[id]) continue
|
||
seen[id] = 1
|
||
}
|
||
comps.push(c)
|
||
}
|
||
if (!comps.length) continue
|
||
out.push({
|
||
type: row.type || 1,
|
||
components: comps
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
function discordCmdReplyPayload(result) {
|
||
const payload = {}
|
||
if (result && result.embeds && result.embeds.length) payload.embeds = result.embeds
|
||
if (result && result.components && result.components.length) {
|
||
payload.components = discordUniqueComponents(result.components)
|
||
if (!payload.components.length) delete payload.components
|
||
}
|
||
if (result && result.text) payload.content = discordCmdClip(result.text)
|
||
if (!payload.content && !payload.embeds) payload.content = '(no output)'
|
||
if (result && result.ephemeral) payload.flags = BARE_OS_DISCORD_FLAG_EPHEMERAL
|
||
delete payload.fetchReply
|
||
delete payload.ephemeral
|
||
return payload
|
||
}
|
||
|
||
function discordEmbed(opts) {
|
||
return discordPackEmbed(opts).embed
|
||
}
|
||
|
||
function discordField(name, value, inline) {
|
||
let v = value == null || value === '' ? '—' : String(value)
|
||
v = discordCmdRedact(v)
|
||
if (v.length > BARE_OS_DISCORD_LIMIT.fieldValue) {
|
||
v = discordClipSmart(v, BARE_OS_DISCORD_LIMIT.fieldValue, '\n…').text
|
||
}
|
||
if (!v) v = '—'
|
||
return {
|
||
name: String(name).slice(0, BARE_OS_DISCORD_LIMIT.fieldName),
|
||
value: v,
|
||
inline: Boolean(inline)
|
||
}
|
||
}
|
||
|
||
function discordMoreKey(interaction) {
|
||
return discordInteractionUserId(interaction) || 'anon'
|
||
}
|
||
|
||
function discordMoreGc() {
|
||
const now = Date.now()
|
||
for (const k in BARE_OS_DISCORD_MORE_SESSIONS) {
|
||
const r = BARE_OS_DISCORD_MORE_SESSIONS[k]
|
||
if (!r || now - r.atMs > BARE_OS_DISCORD_IDLE_MS) delete BARE_OS_DISCORD_MORE_SESSIONS[k]
|
||
}
|
||
}
|
||
|
||
function discordMorePut(interaction, rec) {
|
||
discordMoreGc()
|
||
rec.atMs = Date.now()
|
||
BARE_OS_DISCORD_MORE_SESSIONS[discordMoreKey(interaction)] = rec
|
||
}
|
||
|
||
function discordMoreGet(interaction) {
|
||
discordMoreGc()
|
||
return BARE_OS_DISCORD_MORE_SESSIONS[discordMoreKey(interaction)] || null
|
||
}
|
||
|
||
function discordMoreResult(interaction) {
|
||
const rec = discordMoreGet(interaction)
|
||
if (!rec) return { text: 'This view expired after 2 minutes idle.', ephemeral: true }
|
||
const n = rec.pages.length
|
||
if (rec.page < 0) rec.page = n - 1
|
||
if (rec.page >= n) rec.page = 0
|
||
rec.atMs = Date.now()
|
||
const chunk = rec.pages[rec.page] || ''
|
||
const inner = rec.fence === false ? chunk : discordCmdFence(chunk, rec.lang, 3600)
|
||
const navline =
|
||
n > 1
|
||
? '\nPage **' + (rec.page + 1) + '/' + n + '** · ' + rec.total + ' characters'
|
||
: ''
|
||
const btns =
|
||
n > 1
|
||
? discordButtons([
|
||
{ id: 'more:prev', label: '← Prev' },
|
||
{ id: 'more:next', label: 'Next →', style: 1 }
|
||
])
|
||
: null
|
||
const extra = (rec.extra || []).concat(btns ? [btns] : [])
|
||
const out = discordResult(
|
||
discordEmbed({
|
||
title: rec.title,
|
||
desc: (rec.lead || '') + inner + navline,
|
||
color: rec.color,
|
||
footer: rec.footer,
|
||
prefer: 'desc'
|
||
}),
|
||
{ components: extra }
|
||
)
|
||
return out
|
||
}
|
||
|
||
function discordLongTextResult(interaction, opts) {
|
||
const o = opts || {}
|
||
const body = String(o.body == null ? '' : o.body)
|
||
const pages =
|
||
o.fence === false
|
||
? discordMarkdownPages(body, o.pageChars || BARE_OS_DISCORD_LIMIT.pageChars)
|
||
: discordTextPages(body, o.pageChars || BARE_OS_DISCORD_LIMIT.pageChars)
|
||
const extraComps = o.components || []
|
||
if (pages.length > 1) {
|
||
let lead = o.lead ? String(o.lead) : ''
|
||
if (lead && !/\n$/.test(lead)) lead += '\n'
|
||
discordMorePut(interaction, {
|
||
title: o.title,
|
||
pages: pages,
|
||
page: 0,
|
||
fence: o.fence !== false,
|
||
lang: o.lang || '',
|
||
lead: lead,
|
||
color: o.color,
|
||
footer: o.footer,
|
||
extra: extraComps,
|
||
total: body.length
|
||
})
|
||
const view = discordMoreResult(interaction)
|
||
if (o.editPath) view.editPath = o.editPath
|
||
if (o.created) view.created = true
|
||
return view
|
||
}
|
||
const inner = o.fence === false ? body : discordCmdFence(body, o.lang, 3800)
|
||
let lead = o.lead ? String(o.lead) : ''
|
||
if (lead && inner && !/\n$/.test(lead)) lead += '\n'
|
||
const out = discordResult(
|
||
discordEmbed({
|
||
title: o.title,
|
||
desc: lead + inner,
|
||
color: o.color,
|
||
footer: o.footer,
|
||
prefer: 'desc'
|
||
}),
|
||
{ components: extraComps, editPath: o.editPath, created: o.created }
|
||
)
|
||
return out
|
||
}
|
||
|
||
function discordButtons(items) {
|
||
const row = { type: 1, components: [] }
|
||
for (let i = 0; i < items.length && row.components.length < 5; i++) {
|
||
const it = items[i]
|
||
if (!it || !it.id) continue
|
||
row.components.push({
|
||
type: 2,
|
||
style: it.style || 2,
|
||
label: String(it.label || it.id).slice(0, 80),
|
||
custom_id: String(it.id).slice(0, 100)
|
||
})
|
||
}
|
||
return row.components.length ? row : null
|
||
}
|
||
|
||
function discordSelect(id, placeholder, options) {
|
||
const opts = []
|
||
for (let i = 0; i < options.length && opts.length < 25; i++) {
|
||
const o = options[i]
|
||
if (!o) continue
|
||
const value = String(o.value || o.label || '').slice(0, 100)
|
||
if (!value) continue
|
||
const item = {
|
||
label: String(o.label || value).slice(0, 100),
|
||
value: value
|
||
}
|
||
if (o.description) item.description = String(o.description).slice(0, 100)
|
||
opts.push(item)
|
||
}
|
||
if (!opts.length) return null
|
||
return {
|
||
type: 1,
|
||
components: [
|
||
{
|
||
type: 3,
|
||
custom_id: String(id).slice(0, 100),
|
||
placeholder: String(placeholder || 'Choose…').slice(0, 150),
|
||
min_values: 1,
|
||
max_values: 1,
|
||
options: opts
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
var BARE_OS_DISCORD_NAV_IDS = {
|
||
'nav:menu': 1,
|
||
'nav:panel': 1,
|
||
'bare:status': 1,
|
||
'bare:whoami': 1,
|
||
'bare:help': 1,
|
||
'sys:doctor': 1,
|
||
'svc:list': 1,
|
||
'sys:ps': 1,
|
||
'net:summary': 1,
|
||
'say:compose': 1,
|
||
'hdms:home': 1,
|
||
'holesail:home': 1,
|
||
'agent:home': 1,
|
||
'run:compose': 1,
|
||
'edit:new': 1,
|
||
'create:new': 1,
|
||
'fm:home': 1,
|
||
'set:home': 1
|
||
}
|
||
|
||
function discordCompCustomId(c) {
|
||
if (!c) return ''
|
||
if (c.custom_id != null) return String(c.custom_id)
|
||
if (c.customId != null) return String(c.customId)
|
||
if (c.data && c.data.custom_id != null) return String(c.data.custom_id)
|
||
return ''
|
||
}
|
||
|
||
function discordNormalizeRows(rows) {
|
||
const out = []
|
||
if (!Array.isArray(rows)) return out
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const row = rows[i]
|
||
if (!row) continue
|
||
const raw =
|
||
typeof row.toJSON === 'function'
|
||
? row.toJSON()
|
||
: {
|
||
type: row.type || 1,
|
||
components: row.components || (row.data && row.data.components) || []
|
||
}
|
||
const comps = []
|
||
const list = raw.components || []
|
||
for (let j = 0; j < list.length; j++) {
|
||
const c = list[j]
|
||
if (!c) continue
|
||
const json = typeof c.toJSON === 'function' ? c.toJSON() : c
|
||
if (json) comps.push(json)
|
||
}
|
||
if (comps.length) out.push({ type: raw.type || 1, components: comps })
|
||
}
|
||
return out
|
||
}
|
||
|
||
function discordNormalizeEmbeds(embeds) {
|
||
if (!Array.isArray(embeds) || !embeds.length) return null
|
||
const out = []
|
||
for (let i = 0; i < embeds.length; i++) {
|
||
const e = embeds[i]
|
||
if (!e) continue
|
||
out.push(typeof e.toJSON === 'function' ? e.toJSON() : e)
|
||
}
|
||
return out.length ? out : null
|
||
}
|
||
|
||
function discordNavIsExpanded(rows) {
|
||
const list = discordNormalizeRows(rows)
|
||
for (let i = 0; i < list.length; i++) {
|
||
const comps = list[i].components || []
|
||
for (let j = 0; j < comps.length; j++) {
|
||
if (discordCompCustomId(comps[j]) === 'nav:panel') return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
function discordRowIsNavChrome(comps) {
|
||
if (!comps || !comps.length) return false
|
||
for (let i = 0; i < comps.length; i++) {
|
||
const id = discordCompCustomId(comps[i])
|
||
if (!id || !BARE_OS_DISCORD_NAV_IDS[id]) return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
function discordStripNavIds(rows) {
|
||
const out = []
|
||
const list = discordNormalizeRows(rows)
|
||
for (let i = 0; i < list.length; i++) {
|
||
const src = list[i].components || []
|
||
if (discordRowIsNavChrome(src)) continue
|
||
const comps = []
|
||
for (let j = 0; j < src.length; j++) {
|
||
if (discordCompCustomId(src[j]) === 'nav:menu') continue
|
||
comps.push(src[j])
|
||
}
|
||
if (comps.length) out.push({ type: list[i].type || 1, components: comps })
|
||
}
|
||
return out
|
||
}
|
||
|
||
function discordMergeMenu(extraRows, menuRows) {
|
||
const extra = (extraRows || []).filter(Boolean)
|
||
const menu = menuRows && menuRows[0]
|
||
const btn = menu && menu.components && menu.components[0]
|
||
if (!btn) return extra
|
||
if (!extra.length) return menuRows
|
||
const last = extra[extra.length - 1]
|
||
const comps = last.components || []
|
||
if (last.type === 1 && comps.length && comps.length < 5 && comps[0].type === 2) {
|
||
const copy = extra.slice()
|
||
copy[copy.length - 1] = {
|
||
type: 1,
|
||
components: comps.concat([btn])
|
||
}
|
||
return copy
|
||
}
|
||
return extra.concat(menuRows)
|
||
}
|
||
|
||
function discordNavRows(open) {
|
||
if (!open) {
|
||
const row = discordButtons([{ id: 'nav:menu', label: 'Menu', style: 2 }])
|
||
return row ? [row] : []
|
||
}
|
||
const rows = []
|
||
const a = discordButtons([
|
||
{ id: 'nav:menu', label: 'Hide menu', style: 2 },
|
||
{ id: 'nav:panel', label: 'Panel', style: 1 },
|
||
{ id: 'bare:status', label: 'Status', style: 1 },
|
||
{ id: 'bare:whoami', label: 'Whoami' },
|
||
{ id: 'bare:help', label: 'Help' }
|
||
])
|
||
const b = discordButtons([
|
||
{ id: 'sys:doctor', label: 'Doctor' },
|
||
{ id: 'svc:list', label: 'Services' },
|
||
{ id: 'holesail:home', label: 'Holesail', style: 1 },
|
||
{ id: 'net:summary', label: 'Network' },
|
||
{ id: 'hdms:home', label: 'HDMS', style: 1 }
|
||
])
|
||
const c = discordButtons([
|
||
{ id: 'run:compose', label: 'Shell' },
|
||
{ id: 'edit:new', label: 'Edit file…', style: 1 },
|
||
{ id: 'create:new', label: 'Create file…', style: 3 },
|
||
{ id: 'fm:home', label: 'Files', style: 1 },
|
||
{ id: 'set:home', label: 'Settings' }
|
||
])
|
||
const d = discordButtons([{ id: 'agent:home', label: 'Agent', style: 1 }])
|
||
if (a) rows.push(a)
|
||
if (b) rows.push(b)
|
||
if (c) rows.push(c)
|
||
if (d) rows.push(d)
|
||
return rows
|
||
}
|
||
|
||
function discordApplyNav(rows, open) {
|
||
const base = discordStripNavIds(rows)
|
||
const nav = discordNavRows(open)
|
||
if (!open) return discordMergeMenu(base, nav)
|
||
const room = 5 - nav.length
|
||
const keep = room > 0 ? base.slice(0, room) : []
|
||
return keep.concat(nav)
|
||
}
|
||
|
||
function discordResult(embed, extra) {
|
||
const out = { embeds: [embed] }
|
||
const extraRows = extra && extra.components ? extra.components : []
|
||
const rows =
|
||
extra && extra.nav === false
|
||
? discordUniqueComponents(extraRows)
|
||
: discordUniqueComponents(discordApplyNav(extraRows, false))
|
||
if (rows.length) out.components = rows
|
||
if (extra && extra.ephemeral) out.ephemeral = true
|
||
if (extra && extra.text) out.text = extra.text
|
||
if (extra && extra.editPath) out.editPath = extra.editPath
|
||
if (extra && extra.created) out.created = true
|
||
return out
|
||
}
|
||
|
||
async function discordCmdReadText(ctx, logicalPath) {
|
||
if (!logicalPath || typeof ctx.vfs?.readFile !== 'function') return ''
|
||
try {
|
||
const buf = await ctx.vfs.readFile(logicalPath)
|
||
if (!buf) return ''
|
||
if (typeof ctx.b4a?.toString === 'function') return ctx.b4a.toString(buf)
|
||
return String(buf)
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
async function discordCmdReadJson(ctx, logicalPath) {
|
||
const t = await discordCmdReadText(ctx, logicalPath)
|
||
if (!t) return null
|
||
try {
|
||
return JSON.parse(t)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function discordCmdPathOk(raw) {
|
||
const p = String(raw || '').trim() || '.'
|
||
if (!p || p.indexOf('\0') >= 0) return null
|
||
if (p.indexOf('..') >= 0) return null
|
||
if (p === '~/.discord/.env' || p === '~/.discord.env') return null
|
||
const allow =
|
||
p === '.' ||
|
||
p === '~' ||
|
||
p.charAt(0) === '~' ||
|
||
p.indexOf('/proc') === 0 ||
|
||
p.indexOf('/etc') === 0 ||
|
||
p.indexOf('/var/log') === 0 ||
|
||
p.indexOf('/run') === 0 ||
|
||
p.indexOf('/home') === 0 ||
|
||
p.indexOf('/usr/share') === 0 ||
|
||
p.indexOf('/share') === 0 ||
|
||
p.indexOf('/tmp') === 0 ||
|
||
p === '/mnt' ||
|
||
p.indexOf('/mnt/') === 0
|
||
return allow ? p : null
|
||
}
|
||
|
||
function discordCmdPathWriteOk(ctx, raw) {
|
||
const p = discordCmdPathOk(raw)
|
||
if (!p) return null
|
||
if (p === '~/.discord/.env' || p === '~/.discord.env') return null
|
||
if (p.indexOf('~/.discord') === 0) return null
|
||
const user = discordSessionUser(ctx)
|
||
const home = '/home/' + user
|
||
const allow =
|
||
p === '~' ||
|
||
p.indexOf('~/') === 0 ||
|
||
p.indexOf('/tmp/') === 0 ||
|
||
p === '/tmp' ||
|
||
p === home ||
|
||
p.indexOf(home + '/') === 0 ||
|
||
(p.indexOf('/mnt/') === 0 && p.length > 5)
|
||
return allow ? p : null
|
||
}
|
||
|
||
async function discordFileExists(ctx, p) {
|
||
if (!ctx || !ctx.vfs) return false
|
||
const stfn = ctx.vfs.lstat || ctx.vfs.stat
|
||
if (typeof stfn === 'function') {
|
||
try {
|
||
const st = await stfn.call(ctx.vfs, p)
|
||
return Boolean(st)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
if (typeof ctx.vfs.readFile !== 'function') return false
|
||
try {
|
||
await ctx.vfs.readFile(p)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function discordLooksBinary(text) {
|
||
const s = String(text || '')
|
||
if (s.indexOf('\0') >= 0) return true
|
||
let bad = 0
|
||
const n = Math.min(s.length, 800)
|
||
for (let i = 0; i < n; i++) {
|
||
const c = s.charCodeAt(i)
|
||
if (c < 9 || (c > 13 && c < 32)) bad++
|
||
}
|
||
return bad > 8
|
||
}
|
||
|
||
function discordTextToBuf(ctx, text) {
|
||
const s = String(text == null ? '' : text)
|
||
if (ctx && ctx.b4a && typeof ctx.b4a.from === 'function') return ctx.b4a.from(s)
|
||
if (typeof Buffer !== 'undefined') return Buffer.from(s, 'utf8')
|
||
const out = new Uint8Array(s.length)
|
||
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i) & 0xff
|
||
return out
|
||
}
|
||
|
||
function discordEditGc() {
|
||
const now = Date.now()
|
||
for (const k in BARE_OS_DISCORD_EDIT_SESSIONS) {
|
||
const rec = BARE_OS_DISCORD_EDIT_SESSIONS[k]
|
||
if (!rec || now - rec.atMs > BARE_OS_DISCORD_EDIT_TTL_MS) {
|
||
delete BARE_OS_DISCORD_EDIT_SESSIONS[k]
|
||
}
|
||
}
|
||
}
|
||
|
||
function discordEditKey(interaction) {
|
||
return discordInteractionUserId(interaction) || 'anon'
|
||
}
|
||
|
||
function discordEditPut(interaction, rec) {
|
||
discordEditGc()
|
||
BARE_OS_DISCORD_EDIT_SESSIONS[discordEditKey(interaction)] = {
|
||
path: rec.path,
|
||
origLen: rec.origLen || 0,
|
||
truncated: !!rec.truncated,
|
||
created: !!rec.created,
|
||
atMs: Date.now()
|
||
}
|
||
}
|
||
|
||
function discordEditGet(interaction) {
|
||
discordEditGc()
|
||
const rec = BARE_OS_DISCORD_EDIT_SESSIONS[discordEditKey(interaction)]
|
||
return rec || null
|
||
}
|
||
|
||
function discordEditClear(interaction) {
|
||
delete BARE_OS_DISCORD_EDIT_SESSIONS[discordEditKey(interaction)]
|
||
}
|
||
|
||
function discordEditChunks(text) {
|
||
const s = String(text == null ? '' : text)
|
||
const cap = BARE_OS_DISCORD_EDIT_CHUNK * BARE_OS_DISCORD_EDIT_MAX_CHUNKS
|
||
const truncated = s.length > cap
|
||
const body = truncated ? s.slice(0, cap) : s
|
||
const chunks = []
|
||
if (!body.length) chunks.push('')
|
||
else {
|
||
for (let i = 0; i < body.length; i += BARE_OS_DISCORD_EDIT_CHUNK) {
|
||
chunks.push(body.slice(i, i + BARE_OS_DISCORD_EDIT_CHUNK))
|
||
}
|
||
}
|
||
return {
|
||
chunks: chunks.slice(0, BARE_OS_DISCORD_EDIT_MAX_CHUNKS),
|
||
truncated: truncated,
|
||
origLen: s.length
|
||
}
|
||
}
|
||
|
||
function discordEditModalPayload(path, chunks) {
|
||
const base = String(path || 'file').split('/').pop() || String(path)
|
||
const n = Math.max(1, chunks.length)
|
||
const components = []
|
||
for (let i = 0; i < n; i++) {
|
||
const label = n === 1 ? 'Contents (save closes the form)' : 'Part ' + (i + 1) + ' / ' + n
|
||
const field = {
|
||
type: 4,
|
||
custom_id: 'c' + i,
|
||
label: label.slice(0, 45),
|
||
style: 2,
|
||
required: false,
|
||
max_length: BARE_OS_DISCORD_EDIT_CHUNK
|
||
}
|
||
const v = String(chunks[i] || '').slice(0, BARE_OS_DISCORD_EDIT_CHUNK)
|
||
if (v) field.value = v
|
||
components.push({
|
||
type: 1,
|
||
components: [field]
|
||
})
|
||
}
|
||
return {
|
||
custom_id: 'edit:save',
|
||
title: ('Edit ' + base).slice(0, 45),
|
||
components: components
|
||
}
|
||
}
|
||
|
||
var BARE_OS_DISCORD_CAPTURE_STACK = []
|
||
var BARE_OS_DISCORD_STDIO_HOOK = null
|
||
|
||
function discordCaptureChunkToText(chunk, enc) {
|
||
if (chunk == null) return ''
|
||
if (typeof chunk === 'string') return chunk
|
||
const encoding = typeof enc === 'string' && enc ? enc : 'utf8'
|
||
if (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(chunk)) {
|
||
try {
|
||
return chunk.toString(encoding)
|
||
} catch {
|
||
return chunk.toString('utf8')
|
||
}
|
||
}
|
||
if (chunk instanceof Uint8Array || (chunk && typeof chunk.length === 'number' && chunk.buffer)) {
|
||
try {
|
||
if (typeof TextDecoder === 'function') {
|
||
return new TextDecoder(encoding === 'utf8' ? 'utf-8' : encoding).decode(chunk)
|
||
}
|
||
} catch {
|
||
/* fall through */
|
||
}
|
||
let s = ''
|
||
for (let i = 0; i < chunk.length; i++) s += String.fromCharCode(chunk[i] & 255)
|
||
return s
|
||
}
|
||
return String(chunk)
|
||
}
|
||
|
||
function discordCaptureAppend(text) {
|
||
const n = BARE_OS_DISCORD_CAPTURE_STACK.length
|
||
if (!n) return false
|
||
const top = BARE_OS_DISCORD_CAPTURE_STACK[n - 1]
|
||
top.buf += text
|
||
return true
|
||
}
|
||
|
||
function discordCaptureConsoleLine() {
|
||
const parts = []
|
||
for (let i = 0; i < arguments.length; i++) {
|
||
const a = arguments[i]
|
||
parts.push(typeof a === 'string' ? a : String(a))
|
||
}
|
||
discordCaptureAppend(parts.join(' ') + '\n')
|
||
}
|
||
|
||
function discordCaptureRaw(chunk, enc) {
|
||
discordCaptureAppend(discordCaptureChunkToText(chunk, enc))
|
||
}
|
||
|
||
function discordCaptureHookStdio() {
|
||
if (BARE_OS_DISCORD_STDIO_HOOK) return
|
||
const proc = typeof globalThis.process !== 'undefined' ? globalThis.process : null
|
||
if (!proc) return
|
||
const so = proc.stdout
|
||
const se = proc.stderr
|
||
function wrapWrite() {
|
||
return function (chunk, enc, cb) {
|
||
discordCaptureRaw(chunk, typeof enc === 'string' ? enc : undefined)
|
||
const done = typeof enc === 'function' ? enc : typeof cb === 'function' ? cb : null
|
||
if (done) {
|
||
try {
|
||
done()
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
BARE_OS_DISCORD_STDIO_HOOK = {
|
||
stdout: so,
|
||
stderr: se,
|
||
outWrite: so && so.write,
|
||
errWrite: se && se.write,
|
||
outIsTTY: so ? so.isTTY : undefined,
|
||
errIsTTY: se ? se.isTTY : undefined
|
||
}
|
||
if (so && typeof so.write === 'function') so.write = wrapWrite()
|
||
if (se && typeof se.write === 'function') se.write = wrapWrite()
|
||
try {
|
||
if (so) so.isTTY = false
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
if (se) se.isTTY = false
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function discordCaptureUnhookStdio() {
|
||
if (BARE_OS_DISCORD_CAPTURE_STACK.length) return
|
||
const h = BARE_OS_DISCORD_STDIO_HOOK
|
||
if (!h) return
|
||
if (h.stdout && h.outWrite) h.stdout.write = h.outWrite
|
||
if (h.stderr && h.errWrite) h.stderr.write = h.errWrite
|
||
try {
|
||
if (h.stdout && h.outIsTTY !== undefined) h.stdout.isTTY = h.outIsTTY
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
if (h.stderr && h.errIsTTY !== undefined) h.stderr.isTTY = h.errIsTTY
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
BARE_OS_DISCORD_STDIO_HOOK = null
|
||
}
|
||
|
||
var BARE_OS_DISCORD_COLOR_ENV = ['NO_COLOR', 'CLICOLOR', 'CLICOLOR_FORCE', 'FORCE_COLOR', 'TERM']
|
||
|
||
function discordCaptureColorOff(env, saved) {
|
||
if (!env || typeof env !== 'object') return
|
||
for (let i = 0; i < BARE_OS_DISCORD_COLOR_ENV.length; i++) {
|
||
const k = BARE_OS_DISCORD_COLOR_ENV[i]
|
||
saved[k] = Object.prototype.hasOwnProperty.call(env, k) ? env[k] : undefined
|
||
}
|
||
env.NO_COLOR = '1'
|
||
env.CLICOLOR = '0'
|
||
env.CLICOLOR_FORCE = '0'
|
||
env.FORCE_COLOR = '0'
|
||
env.TERM = 'dumb'
|
||
}
|
||
|
||
function discordCaptureColorRestore(env, saved) {
|
||
if (!env || typeof env !== 'object' || !saved) return
|
||
for (let i = 0; i < BARE_OS_DISCORD_COLOR_ENV.length; i++) {
|
||
const k = BARE_OS_DISCORD_COLOR_ENV[i]
|
||
if (saved[k] === undefined) delete env[k]
|
||
else env[k] = saved[k]
|
||
}
|
||
}
|
||
|
||
function discordCaptureFinish(buf) {
|
||
return discordStripAnsi(String(buf || ''))
|
||
.replace(/\r\n/g, '\n')
|
||
.replace(/\r/g, '\n')
|
||
.replace(/\n$/, '')
|
||
}
|
||
|
||
async function discordCmdCapture(ctx, fn) {
|
||
const rec = { buf: '' }
|
||
BARE_OS_DISCORD_CAPTURE_STACK.push(rec)
|
||
if (!ctx.console || typeof ctx.console !== 'object') ctx.console = {}
|
||
const cons = ctx.console
|
||
const env = ctx.env && typeof ctx.env === 'object' ? ctx.env : null
|
||
const vfsEnv =
|
||
ctx.vfs && ctx.vfs.env && typeof ctx.vfs.env === 'object' ? ctx.vfs.env : null
|
||
const saved = {
|
||
log: cons.log,
|
||
error: cons.error,
|
||
info: cons.info,
|
||
warn: cons.warn,
|
||
debug: cons.debug,
|
||
binWrite: ctx.bareOsBinWrite,
|
||
writeScreen: ctx.writeScreen,
|
||
captured: ctx.bareOsStdoutCaptured,
|
||
env: {},
|
||
vfsEnv: {}
|
||
}
|
||
discordCaptureColorOff(env, saved.env)
|
||
discordCaptureColorOff(vfsEnv, saved.vfsEnv)
|
||
ctx.bareOsStdoutCaptured = true
|
||
cons.log = discordCaptureConsoleLine
|
||
cons.error = discordCaptureConsoleLine
|
||
cons.info = discordCaptureConsoleLine
|
||
cons.warn = discordCaptureConsoleLine
|
||
cons.debug = discordCaptureConsoleLine
|
||
ctx.bareOsBinWrite = function (chunk) {
|
||
discordCaptureRaw(chunk)
|
||
}
|
||
ctx.writeScreen = function (chunk) {
|
||
discordCaptureRaw(chunk)
|
||
}
|
||
discordCaptureHookStdio()
|
||
try {
|
||
await fn()
|
||
} finally {
|
||
cons.log = saved.log
|
||
cons.error = saved.error
|
||
cons.info = saved.info
|
||
cons.warn = saved.warn
|
||
cons.debug = saved.debug
|
||
if (saved.binWrite === undefined) delete ctx.bareOsBinWrite
|
||
else ctx.bareOsBinWrite = saved.binWrite
|
||
if (saved.writeScreen === undefined) delete ctx.writeScreen
|
||
else ctx.writeScreen = saved.writeScreen
|
||
if (saved.captured === undefined) delete ctx.bareOsStdoutCaptured
|
||
else ctx.bareOsStdoutCaptured = saved.captured
|
||
discordCaptureColorRestore(env, saved.env)
|
||
discordCaptureColorRestore(vfsEnv, saved.vfsEnv)
|
||
BARE_OS_DISCORD_CAPTURE_STACK.pop()
|
||
discordCaptureUnhookStdio()
|
||
}
|
||
return discordCaptureFinish(rec.buf)
|
||
}
|
||
|
||
async function discordCmdOsRelease(ctx) {
|
||
const t = await discordCmdReadText(ctx, '/etc/os-release')
|
||
const out = {}
|
||
const lines = String(t).split(/\r?\n/)
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const m = /^([A-Z0-9_]+)=(.*)$/.exec(lines[i].trim())
|
||
if (!m) continue
|
||
out[m[1]] = m[2].replace(/^"|"$/g, '')
|
||
}
|
||
return out
|
||
}
|
||
|
||
function discordParseIdWhitelist(raw) {
|
||
const ids = Object.create(null)
|
||
const s = String(raw == null ? '' : raw).trim()
|
||
if (!s) return ids
|
||
const parts = s.split(',')
|
||
for (let i = 0; i < parts.length; i++) {
|
||
let id = String(parts[i] || '').trim()
|
||
if (!id) continue
|
||
if (id.charAt(0) === '<' && id.charAt(id.length - 1) === '>') {
|
||
id = id.slice(1, -1)
|
||
if (id.charAt(0) === '@') id = id.slice(1)
|
||
if (id.charAt(0) === '!') id = id.slice(1)
|
||
id = id.trim()
|
||
}
|
||
if (id) ids[id] = 1
|
||
}
|
||
return ids
|
||
}
|
||
|
||
function discordEnvLookup(ctx, keys) {
|
||
const maps = []
|
||
if (ctx && ctx.vfs && ctx.vfs.env && typeof ctx.vfs.env === 'object') {
|
||
maps.push(ctx.vfs.env)
|
||
}
|
||
if (ctx && ctx.env && typeof ctx.env === 'object' && ctx.env !== maps[0]) {
|
||
maps.push(ctx.env)
|
||
}
|
||
for (let i = 0; i < maps.length; i++) {
|
||
const e = maps[i]
|
||
for (let j = 0; j < keys.length; j++) {
|
||
const v = e[keys[j]]
|
||
if (v != null && String(v).trim()) return String(v)
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function discordWhitelistRaw(ctx) {
|
||
return discordEnvLookup(ctx, [
|
||
'DISCORD_ID_WHITELIST',
|
||
'BARE_OS_DISCORD_ID_WHITELIST'
|
||
])
|
||
}
|
||
|
||
function discordWhitelistCount(ctx) {
|
||
const ids = discordParseIdWhitelist(discordWhitelistRaw(ctx))
|
||
let n = 0
|
||
for (const k in ids) {
|
||
if (Object.prototype.hasOwnProperty.call(ids, k)) n++
|
||
}
|
||
return n
|
||
}
|
||
|
||
/**
|
||
* User-installable profile app (Discord "Add App" → User Install).
|
||
* Default on. Set DISCORD_USER_INSTALL=0 to keep a guild-only bot.
|
||
*/
|
||
function discordUserInstallEnabled(ctx) {
|
||
const raw = String(
|
||
discordEnvLookup(ctx, ['DISCORD_USER_INSTALL', 'BARE_OS_DISCORD_USER_INSTALL']) ||
|
||
'1'
|
||
)
|
||
.trim()
|
||
.toLowerCase()
|
||
return raw !== '0' && raw !== 'false' && raw !== 'no' && raw !== 'off'
|
||
}
|
||
|
||
/**
|
||
* True when this interaction was authorized by a user-install (profile app)
|
||
* or used in Bot DM / private channel. Those surfaces are world-reachable
|
||
* once the app is user-installable — whitelist is mandatory.
|
||
*/
|
||
function discordIsUserInstallInteraction(interaction) {
|
||
if (!interaction) return false
|
||
const owners =
|
||
interaction.authorizingIntegrationOwners ||
|
||
interaction.authorizing_integration_owners
|
||
if (owners && typeof owners === 'object') {
|
||
if (
|
||
owners[BARE_OS_DISCORD_INTEGRATION_USER] != null ||
|
||
owners[String(BARE_OS_DISCORD_INTEGRATION_USER)] != null
|
||
) {
|
||
return true
|
||
}
|
||
}
|
||
const c = interaction.context
|
||
if (
|
||
c === BARE_OS_DISCORD_CONTEXT_BOT_DM ||
|
||
c === BARE_OS_DISCORD_CONTEXT_PRIVATE ||
|
||
c === String(BARE_OS_DISCORD_CONTEXT_BOT_DM) ||
|
||
c === String(BARE_OS_DISCORD_CONTEXT_PRIVATE)
|
||
) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
function discordStampUserInstallCommand(json, ctx) {
|
||
if (!json || typeof json !== 'object') return json
|
||
if (!discordUserInstallEnabled(ctx)) return json
|
||
json.integration_types = [
|
||
BARE_OS_DISCORD_INTEGRATION_GUILD,
|
||
BARE_OS_DISCORD_INTEGRATION_USER
|
||
]
|
||
json.contexts = [
|
||
BARE_OS_DISCORD_CONTEXT_GUILD,
|
||
BARE_OS_DISCORD_CONTEXT_BOT_DM,
|
||
BARE_OS_DISCORD_CONTEXT_PRIVATE
|
||
]
|
||
return json
|
||
}
|
||
|
||
function discordStampUserInstallCommands(body, ctx) {
|
||
const list = Array.isArray(body) ? body : []
|
||
for (let i = 0; i < list.length; i++) {
|
||
discordStampUserInstallCommand(list[i], ctx)
|
||
}
|
||
return list
|
||
}
|
||
|
||
function discordUserInstallAuthorizeUrl(appId) {
|
||
const id = String(appId || '').trim()
|
||
if (!id) return ''
|
||
return 'https://discord.com/oauth2/authorize?client_id=' + encodeURIComponent(id)
|
||
}
|
||
|
||
function discordUserInstallAppConfig() {
|
||
const cfg = {}
|
||
cfg[String(BARE_OS_DISCORD_INTEGRATION_GUILD)] = {
|
||
oauth2_install_params: {
|
||
scopes: ['applications.commands', 'bot'],
|
||
permissions: BARE_OS_DISCORD_GUILD_INSTALL_PERMISSIONS
|
||
}
|
||
}
|
||
cfg[String(BARE_OS_DISCORD_INTEGRATION_USER)] = {
|
||
oauth2_install_params: {
|
||
scopes: ['applications.commands']
|
||
}
|
||
}
|
||
return { integration_types_config: cfg }
|
||
}
|
||
|
||
async function discordEnableUserInstallApp(rest, Routes) {
|
||
if (!rest || typeof rest.patch !== 'function') return false
|
||
if (!Routes || typeof Routes.currentApplication !== 'function') return false
|
||
await rest.patch(Routes.currentApplication(), {
|
||
body: discordUserInstallAppConfig()
|
||
})
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* User-install commands must be global. Also PUT guild commands when
|
||
* DISCORD_GUILD_ID is set so the home server updates immediately.
|
||
*/
|
||
async function discordPutSlashCommands(rest, Routes, appId, body, opts) {
|
||
opts = opts || {}
|
||
const guildId = String(opts.guildId || '').trim()
|
||
const userInstall = opts.userInstall !== false
|
||
const out = { global: false, guild: false }
|
||
if (!rest || typeof rest.put !== 'function' || !Routes || !appId) return out
|
||
if (userInstall || !guildId) {
|
||
if (typeof Routes.applicationCommands === 'function') {
|
||
await rest.put(Routes.applicationCommands(appId), { body: body })
|
||
out.global = true
|
||
}
|
||
}
|
||
if (guildId && typeof Routes.applicationGuildCommands === 'function') {
|
||
await rest.put(Routes.applicationGuildCommands(appId, guildId), {
|
||
body: body
|
||
})
|
||
out.guild = true
|
||
}
|
||
return out
|
||
}
|
||
|
||
function discordUserAllowed(ctx, userId, interaction) {
|
||
const ids = discordParseIdWhitelist(discordWhitelistRaw(ctx))
|
||
let n = 0
|
||
for (const k in ids) {
|
||
if (Object.prototype.hasOwnProperty.call(ids, k)) n++
|
||
}
|
||
const id = String(userId == null ? '' : userId).trim()
|
||
const userInstallIx = discordIsUserInstallInteraction(interaction)
|
||
// Profile-install / DM / private-channel: never open to the world.
|
||
if (n === 0) return !userInstallIx
|
||
return Boolean(id && ids[id])
|
||
}
|
||
|
||
function discordInteractionUserId(interaction) {
|
||
if (!interaction) return ''
|
||
if (interaction.user && interaction.user.id) return String(interaction.user.id)
|
||
const member = interaction.member
|
||
if (member && member.user && member.user.id) return String(member.user.id)
|
||
if (member && member.id) return String(member.id)
|
||
return ''
|
||
}
|
||
|
||
function discordCallFlag(interaction, name) {
|
||
return Boolean(
|
||
interaction && typeof interaction[name] === 'function' && interaction[name]()
|
||
)
|
||
}
|
||
|
||
function discordIsAutocomplete(interaction) {
|
||
return discordCallFlag(interaction, 'isAutocomplete')
|
||
}
|
||
|
||
function discordIsModalSubmit(interaction) {
|
||
return discordCallFlag(interaction, 'isModalSubmit')
|
||
}
|
||
|
||
function discordIsMessageComponent(interaction) {
|
||
if (!interaction) return false
|
||
if (discordCallFlag(interaction, 'isMessageComponent')) return true
|
||
if (discordCallFlag(interaction, 'isButton')) return true
|
||
if (discordCallFlag(interaction, 'isAnySelectMenu')) return true
|
||
if (discordCallFlag(interaction, 'isStringSelectMenu')) return true
|
||
if (discordCallFlag(interaction, 'isUserSelectMenu')) return true
|
||
if (discordCallFlag(interaction, 'isRoleSelectMenu')) return true
|
||
if (discordCallFlag(interaction, 'isMentionableSelectMenu')) return true
|
||
if (discordCallFlag(interaction, 'isChannelSelectMenu')) return true
|
||
return false
|
||
}
|
||
|
||
function discordInteractionOwnerId(interaction) {
|
||
const msg = interaction && interaction.message
|
||
if (msg) {
|
||
const meta = msg.interactionMetadata || msg.interaction_metadata
|
||
if (meta && meta.user && meta.user.id) return String(meta.user.id)
|
||
if (msg.interaction && msg.interaction.user && msg.interaction.user.id) {
|
||
return String(msg.interaction.user.id)
|
||
}
|
||
const mid = msg.id != null ? String(msg.id) : ''
|
||
if (mid && BARE_OS_DISCORD_LIVE[mid] && BARE_OS_DISCORD_LIVE[mid].userId) {
|
||
return String(BARE_OS_DISCORD_LIVE[mid].userId)
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function discordFilterChoices(items, q, toChoice) {
|
||
const needle = String(q || '').toLowerCase()
|
||
const out = []
|
||
for (let i = 0; i < items.length && out.length < 25; i++) {
|
||
const raw = items[i]
|
||
const choice = toChoice ? toChoice(raw) : { name: String(raw), value: String(raw) }
|
||
if (!choice || !choice.value) continue
|
||
const hay = (choice.name + ' ' + choice.value).toLowerCase()
|
||
if (needle && hay.indexOf(needle) < 0) continue
|
||
out.push({ name: String(choice.name).slice(0, 100), value: String(choice.value).slice(0, 100) })
|
||
}
|
||
return out
|
||
}
|
||
|
||
async function discordSuggestUnits(ctx, q) {
|
||
const names = BARE_OS_DISCORD_KNOWN_UNITS.slice()
|
||
if (typeof ctx.bareOsRunSystemctlCli === 'function') {
|
||
const out = await discordCmdCapture(ctx, function () {
|
||
return ctx.bareOsRunSystemctlCli(['systemctl', 'list'])
|
||
})
|
||
const lines = String(out || '').split(/\r?\n/)
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const m = /^([A-Za-z0-9_@.:-]+)\b/.exec(lines[i].trim())
|
||
if (!m) continue
|
||
const n = m[1].replace(/\.service$/, '')
|
||
if (names.indexOf(n) < 0) names.push(n)
|
||
}
|
||
}
|
||
return discordFilterChoices(names, q)
|
||
}
|
||
|
||
async function discordSuggestMan(ctx, q) {
|
||
const db = await discordCmdReadJson(ctx, '/share/man/man.json')
|
||
const pages = db && Array.isArray(db.pages) ? db.pages : []
|
||
const names = []
|
||
for (let i = 0; i < pages.length; i++) {
|
||
if (pages[i] && pages[i].name) names.push(String(pages[i].name))
|
||
}
|
||
if (!names.length) {
|
||
names.push('uname', 'whoami', 'login', 'discord-bot', 'systemctl', 'help')
|
||
}
|
||
return discordFilterChoices(names, q)
|
||
}
|
||
|
||
function discordShKey(interaction) {
|
||
return discordInteractionUserId(interaction) || 'anon'
|
||
}
|
||
|
||
function discordShGc() {
|
||
const now = Date.now()
|
||
for (const k in BARE_OS_DISCORD_SH_SESSIONS) {
|
||
const r = BARE_OS_DISCORD_SH_SESSIONS[k]
|
||
if (!r || now - r.atMs > BARE_OS_DISCORD_SH_TTL_MS) delete BARE_OS_DISCORD_SH_SESSIONS[k]
|
||
}
|
||
}
|
||
|
||
function discordShGet(interaction, ctx) {
|
||
discordShGc()
|
||
const key = discordShKey(interaction)
|
||
let rec = BARE_OS_DISCORD_SH_SESSIONS[key]
|
||
const env = discordCmdEnv(ctx)
|
||
const home = env.HOME || '/home/' + discordSessionUser(ctx)
|
||
if (!rec) {
|
||
rec = {
|
||
cwd: env.PWD || home || '~',
|
||
oldpwd: home,
|
||
hist: [],
|
||
lastCmd: '',
|
||
lastExit: 0,
|
||
lastMs: 0,
|
||
atMs: Date.now()
|
||
}
|
||
BARE_OS_DISCORD_SH_SESSIONS[key] = rec
|
||
}
|
||
rec.atMs = Date.now()
|
||
if (!rec.cwd) rec.cwd = env.PWD || home || '~'
|
||
return rec
|
||
}
|
||
|
||
function discordShPrettyCwd(ctx, cwd) {
|
||
const env = discordCmdEnv(ctx)
|
||
const home = env.HOME || '/home/' + discordSessionUser(ctx)
|
||
const c = String(cwd || '~')
|
||
if (c === '~' || c === home) return '~'
|
||
if (home && c.indexOf(home + '/') === 0) return '~' + c.slice(home.length)
|
||
return c
|
||
}
|
||
|
||
function discordShPrompt(ctx, rec) {
|
||
return (
|
||
discordSessionUser(ctx) +
|
||
'@' +
|
||
(discordCmdEnv(ctx).HOSTNAME || 'bare-os') +
|
||
':' +
|
||
discordShPrettyCwd(ctx, rec.cwd)
|
||
)
|
||
}
|
||
|
||
async function discordShPrepare(ctx, rec) {
|
||
const env = discordCmdEnv(ctx)
|
||
const cwd = rec.cwd || env.HOME || '~'
|
||
rec.cwd = cwd
|
||
env.PWD = cwd
|
||
if (ctx.env && ctx.env !== env) ctx.env.PWD = cwd
|
||
if (ctx.vfs && typeof ctx.vfs.chdir === 'function') {
|
||
try {
|
||
await ctx.vfs.chdir(cwd)
|
||
} catch {
|
||
/* keep env PWD */
|
||
}
|
||
}
|
||
}
|
||
|
||
function discordShHarvest(ctx, rec) {
|
||
const env = discordCmdEnv(ctx)
|
||
if (env.PWD) rec.cwd = String(env.PWD)
|
||
if (ctx.vfs && ctx.vfs.env && ctx.vfs.env.PWD) rec.cwd = String(ctx.vfs.env.PWD)
|
||
}
|
||
|
||
function discordShPushHist(rec, cmd, exit, ms, preview) {
|
||
rec.lastCmd = cmd
|
||
rec.lastExit = exit
|
||
rec.lastMs = ms
|
||
rec.hist = rec.hist.filter(function (h) {
|
||
return h && h.cmd !== cmd
|
||
})
|
||
rec.hist.unshift({
|
||
cmd: cmd,
|
||
exit: exit,
|
||
ms: ms,
|
||
preview: String(preview || '').slice(0, 80)
|
||
})
|
||
if (rec.hist.length > 25) rec.hist.length = 25
|
||
}
|
||
|
||
async function discordShCommandNames(ctx) {
|
||
const now = Date.now()
|
||
if (BARE_OS_DISCORD_SH_BINS && now - BARE_OS_DISCORD_SH_BINS_AT < 60000) {
|
||
return BARE_OS_DISCORD_SH_BINS
|
||
}
|
||
const set = Object.create(null)
|
||
for (let i = 0; i < BARE_OS_DISCORD_SH_BUILTINS.length; i++) {
|
||
set[BARE_OS_DISCORD_SH_BUILTINS[i]] = 1
|
||
}
|
||
for (const k in BARE_OS_DISCORD_RUN_ALLOW) {
|
||
if (Object.prototype.hasOwnProperty.call(BARE_OS_DISCORD_RUN_ALLOW, k) && k.indexOf(' ') < 0) {
|
||
set[k] = 1
|
||
}
|
||
}
|
||
if (ctx && ctx.shellAliases && typeof ctx.shellAliases === 'object') {
|
||
const aks = Object.keys(ctx.shellAliases)
|
||
for (let i = 0; i < aks.length; i++) set[aks[i]] = 1
|
||
}
|
||
try {
|
||
if (ctx && ctx.vfs && typeof ctx.vfs.readdir === 'function') {
|
||
const n = await ctx.vfs.readdir('/bin')
|
||
if (Array.isArray(n)) {
|
||
for (let i = 0; i < n.length; i++) set[String(n[i])] = 1
|
||
}
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
const man = await discordCmdReadText(ctx, '/share/man/man.json')
|
||
const j = discordTryJson(man)
|
||
if (j && Array.isArray(j.pages)) {
|
||
for (let i = 0; i < j.pages.length; i++) {
|
||
if (j.pages[i] && j.pages[i].name) set[String(j.pages[i].name)] = 1
|
||
}
|
||
}
|
||
const names = Object.keys(set).sort()
|
||
BARE_OS_DISCORD_SH_BINS = names
|
||
BARE_OS_DISCORD_SH_BINS_AT = now
|
||
return names
|
||
}
|
||
|
||
function discordShSplitLine(q) {
|
||
const s = String(q || '')
|
||
const m = /^(.*?)(\S*)$/.exec(s)
|
||
return { prefix: m ? m[1] : '', token: m ? m[2] : s }
|
||
}
|
||
|
||
function discordShFirstWord(q) {
|
||
const s = String(q || '').trim()
|
||
const i = s.search(/\s/)
|
||
return i < 0 ? s : s.slice(0, i)
|
||
}
|
||
|
||
async function discordShCompletePath(ctx, rec, token, dirsOnly) {
|
||
let dir = rec.cwd || '~'
|
||
let base = token
|
||
const slash = token.lastIndexOf('/')
|
||
if (slash >= 0) {
|
||
const head = token.slice(0, slash + 1)
|
||
base = token.slice(slash + 1)
|
||
if (head.charAt(0) === '/' || head.charAt(0) === '~') dir = head === '/' ? '/' : head.replace(/\/$/, '') || '/'
|
||
else dir = discordJoinPath(rec.cwd, head.replace(/\/$/, '') || '.')
|
||
}
|
||
let names = []
|
||
try {
|
||
if (ctx.vfs && typeof ctx.vfs.readdir === 'function') {
|
||
const n = await ctx.vfs.readdir(dir)
|
||
if (Array.isArray(n)) names = n
|
||
}
|
||
} catch {
|
||
names = []
|
||
}
|
||
const out = []
|
||
const want = String(base || '').toLowerCase()
|
||
for (let i = 0; i < names.length && out.length < 20; i++) {
|
||
const name = String(names[i])
|
||
if (want && name.toLowerCase().indexOf(want) !== 0) continue
|
||
const full = discordJoinPath(dir, name)
|
||
let isDir = false
|
||
try {
|
||
const st = await discordFmStat(ctx, full)
|
||
isDir = discordIsDirStat(st)
|
||
} catch {
|
||
isDir = false
|
||
}
|
||
if (dirsOnly && !isDir) continue
|
||
const shown = (slash >= 0 ? token.slice(0, slash + 1) : '') + name + (isDir ? '/' : '')
|
||
out.push({
|
||
label: shown,
|
||
value: shown,
|
||
description: isDir ? 'directory' : 'file'
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
function discordShPushChoice(out, seen, name, value, description) {
|
||
const v = String(value || '').slice(0, 100)
|
||
if (!v || seen[v]) return
|
||
if (out.length >= 25) return
|
||
seen[v] = 1
|
||
out.push({
|
||
name: String(name || v).slice(0, 100),
|
||
value: v,
|
||
description: description ? String(description).slice(0, 100) : undefined
|
||
})
|
||
}
|
||
|
||
async function discordSuggestRun(ctx, interaction, q) {
|
||
const rec = discordShGet(interaction, ctx)
|
||
const raw = String(q || '')
|
||
const split = discordShSplitLine(raw)
|
||
const first = discordShFirstWord(raw)
|
||
const out = []
|
||
const seen = Object.create(null)
|
||
if (!raw.trim() || (!split.prefix && first === split.token)) {
|
||
for (let i = 0; i < rec.hist.length && out.length < 6; i++) {
|
||
const h = rec.hist[i]
|
||
if (!h || !h.cmd) continue
|
||
if (split.token && h.cmd.indexOf(split.token) !== 0) continue
|
||
discordShPushChoice(out, seen, '↵ ' + h.cmd, h.cmd, 'history · exit ' + h.exit)
|
||
}
|
||
const cmds = await discordShCommandNames(ctx)
|
||
const needle = split.token.toLowerCase()
|
||
for (let i = 0; i < cmds.length && out.length < 25; i++) {
|
||
if (needle && cmds[i].toLowerCase().indexOf(needle) !== 0) continue
|
||
const kind = BARE_OS_DISCORD_SH_TTY[cmds[i]]
|
||
? 'TUI (needs a real TTY)'
|
||
: ctx.shellAliases && ctx.shellAliases[cmds[i]]
|
||
? 'alias'
|
||
: BARE_OS_DISCORD_SH_BUILTINS.indexOf(cmds[i]) >= 0
|
||
? 'builtin'
|
||
: 'command'
|
||
discordShPushChoice(out, seen, cmds[i], cmds[i], kind)
|
||
}
|
||
return out
|
||
}
|
||
const flags = BARE_OS_DISCORD_SH_FLAGS[first]
|
||
if (split.token.charAt(0) === '-' && flags) {
|
||
for (let i = 0; i < flags.length && out.length < 25; i++) {
|
||
if (split.token && flags[i].indexOf(split.token) !== 0) continue
|
||
const line = (split.prefix + flags[i]).slice(0, 100)
|
||
discordShPushChoice(out, seen, line, line, first + ' flag')
|
||
}
|
||
return out
|
||
}
|
||
const pathish =
|
||
!split.token ||
|
||
split.token.charAt(0) === '.' ||
|
||
split.token.charAt(0) === '/' ||
|
||
split.token.charAt(0) === '~' ||
|
||
split.token.indexOf('/') >= 0 ||
|
||
first === 'cd' ||
|
||
first === 'ls' ||
|
||
first === 'cat' ||
|
||
first === 'rm' ||
|
||
first === 'mv' ||
|
||
first === 'cp' ||
|
||
first === 'mkdir' ||
|
||
first === 'stat' ||
|
||
first === 'head' ||
|
||
first === 'tail' ||
|
||
first === 'edit'
|
||
if (pathish) {
|
||
const paths = await discordShCompletePath(ctx, rec, split.token, first === 'cd')
|
||
for (let i = 0; i < paths.length && out.length < 25; i++) {
|
||
const line = (split.prefix + paths[i].value).slice(0, 100)
|
||
discordShPushChoice(out, seen, line, line, paths[i].description)
|
||
}
|
||
}
|
||
if (!out.length && split.token) {
|
||
const cmds = await discordShCommandNames(ctx)
|
||
const needle = split.token.toLowerCase()
|
||
for (let i = 0; i < cmds.length && out.length < 25; i++) {
|
||
if (cmds[i].toLowerCase().indexOf(needle) !== 0) continue
|
||
const line = (split.prefix + cmds[i]).slice(0, 100)
|
||
discordShPushChoice(out, seen, line, line, 'command')
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
function discordFmKey(interaction) {
|
||
return discordInteractionUserId(interaction) || 'anon'
|
||
}
|
||
|
||
function discordFmGc() {
|
||
const now = Date.now()
|
||
for (const k in BARE_OS_DISCORD_FM_SESSIONS) {
|
||
const rec = BARE_OS_DISCORD_FM_SESSIONS[k]
|
||
if (!rec || now - rec.atMs > BARE_OS_DISCORD_FM_TTL_MS) {
|
||
delete BARE_OS_DISCORD_FM_SESSIONS[k]
|
||
}
|
||
}
|
||
}
|
||
|
||
function discordFmGet(interaction, ctx) {
|
||
discordFmGc()
|
||
let rec = BARE_OS_DISCORD_FM_SESSIONS[discordFmKey(interaction)]
|
||
if (!rec) {
|
||
rec = {
|
||
cwd: '~',
|
||
page: 0,
|
||
sel: '',
|
||
confirm: '',
|
||
atMs: Date.now()
|
||
}
|
||
BARE_OS_DISCORD_FM_SESSIONS[discordFmKey(interaction)] = rec
|
||
}
|
||
rec.atMs = Date.now()
|
||
if (ctx) discordSyncSessionIdentity(ctx)
|
||
return rec
|
||
}
|
||
|
||
function discordJoinPath(dir, name) {
|
||
const n = String(name || '').replace(/^\/+/, '')
|
||
if (!n || n === '.') return dir || '~'
|
||
if (n === '..') return discordParentPath(dir)
|
||
const d = String(dir || '~').replace(/\/+$/, '') || '/'
|
||
if (d === '/') return '/' + n
|
||
if (d === '~') return '~/' + n
|
||
return d + '/' + n
|
||
}
|
||
|
||
function discordParentPath(p) {
|
||
const s = String(p || '~').replace(/\/+$/, '') || '/'
|
||
if (s === '/' || s === '~') return s
|
||
const i = s.lastIndexOf('/')
|
||
if (i <= 0) return s.charAt(0) === '~' ? '~' : '/'
|
||
const parent = s.slice(0, i)
|
||
return parent || (s.charAt(0) === '~' ? '~' : '/')
|
||
}
|
||
|
||
function discordBaseName(p) {
|
||
const s = String(p || '').replace(/\/+$/, '')
|
||
const i = s.lastIndexOf('/')
|
||
return i >= 0 ? s.slice(i + 1) : s
|
||
}
|
||
|
||
function discordIsDirStat(st) {
|
||
if (!st) return false
|
||
if (st.isDirectory === true) return true
|
||
if (st.type === 'directory') return true
|
||
return false
|
||
}
|
||
|
||
async function discordFmList(ctx, cwd) {
|
||
const p = discordCmdPathOk(cwd || '~') || '~'
|
||
if (typeof ctx.vfs?.readdir !== 'function') return { path: p, names: [], err: 'readdir unavailable' }
|
||
try {
|
||
const raw = await ctx.vfs.readdir(p)
|
||
const names = []
|
||
const list = Array.isArray(raw) ? raw : []
|
||
for (let i = 0; i < list.length; i++) {
|
||
const n = String(list[i] || '')
|
||
if (!n || n === '.bareos_empty') continue
|
||
names.push(n)
|
||
}
|
||
names.sort(function (a, b) {
|
||
return a < b ? -1 : a > b ? 1 : 0
|
||
})
|
||
return { path: p, names: names, err: '' }
|
||
} catch (err) {
|
||
return { path: p, names: [], err: (err && err.message) || String(err) }
|
||
}
|
||
}
|
||
|
||
async function discordFmStat(ctx, path) {
|
||
const stfn = ctx.vfs && (ctx.vfs.lstat || ctx.vfs.stat)
|
||
if (typeof stfn !== 'function') return null
|
||
try {
|
||
return await stfn.call(ctx.vfs, path)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function discordFmView(ctx, interaction) {
|
||
const rec = discordFmGet(interaction, ctx)
|
||
const listing = await discordFmList(ctx, rec.cwd)
|
||
rec.cwd = listing.path
|
||
const names = listing.names
|
||
const pages = Math.max(1, Math.ceil(names.length / BARE_OS_DISCORD_FM_PAGE))
|
||
if (rec.page >= pages) rec.page = pages - 1
|
||
if (rec.page < 0) rec.page = 0
|
||
const start = rec.page * BARE_OS_DISCORD_FM_PAGE
|
||
const slice = names.slice(start, start + BARE_OS_DISCORD_FM_PAGE)
|
||
if (rec.sel && names.indexOf(rec.sel) < 0) rec.sel = ''
|
||
const writable = Boolean(discordCmdPathWriteOk(ctx, rec.cwd))
|
||
const fields = []
|
||
const options = []
|
||
if (rec.cwd !== '/' && rec.cwd !== '~') {
|
||
options.push({ label: '⬆ .. (parent)', value: '..', description: discordParentPath(rec.cwd) })
|
||
}
|
||
for (let i = 0; i < slice.length; i++) {
|
||
const name = slice[i]
|
||
const full = discordJoinPath(rec.cwd, name)
|
||
const st = await discordFmStat(ctx, full)
|
||
const dir = discordIsDirStat(st)
|
||
const size = st && st.size != null && !dir ? discordPrettyBytes(st.size) : dir ? 'dir' : ''
|
||
const mark = rec.sel === name ? '▸ ' : ''
|
||
fields.push(
|
||
discordField(
|
||
mark + (dir ? '📁 ' : '📄 ') + name,
|
||
size || (dir ? 'folder' : 'file'),
|
||
true
|
||
)
|
||
)
|
||
options.push({
|
||
label: (dir ? '📁 ' : '📄 ') + name,
|
||
value: name.slice(0, 100),
|
||
description: size || (dir ? 'open folder' : 'file')
|
||
})
|
||
}
|
||
const sel = discordSelect(
|
||
'fm:pick',
|
||
slice.length ? 'Open an entry…' : 'Empty folder',
|
||
options
|
||
)
|
||
const nav = discordButtons([
|
||
{ id: 'fm:up', label: 'Parent' },
|
||
{ id: 'fm:home', label: 'Home', style: 1 },
|
||
{ id: 'fm:goto', label: 'Go to…' },
|
||
{ id: 'fm:prev', label: '◀' },
|
||
{ id: 'fm:next', label: '▶' }
|
||
])
|
||
const acts = discordButtons(
|
||
writable
|
||
? [
|
||
{ id: 'fm:open', label: 'Open', style: 1 },
|
||
{ id: 'fm:edit', label: 'Edit' },
|
||
{ id: 'fm:new', label: 'New file', style: 3 },
|
||
{ id: 'fm:mkdir', label: 'New folder' },
|
||
{ id: 'fm:more', label: 'Manage…' }
|
||
]
|
||
: [
|
||
{ id: 'fm:open', label: 'Open', style: 1 },
|
||
{ id: 'nav:panel', label: 'Panel' }
|
||
]
|
||
)
|
||
const manage = rec.confirm
|
||
? discordButtons([
|
||
{ id: 'fm:ok', label: 'Confirm delete', style: 4 },
|
||
{ id: 'fm:no', label: 'Cancel', style: 2 }
|
||
])
|
||
: writable
|
||
? discordButtons([
|
||
{ id: 'fm:ren', label: 'Rename' },
|
||
{ id: 'fm:copy', label: 'Copy' },
|
||
{ id: 'fm:del', label: 'Delete', style: 4 },
|
||
{ id: 'fm:ref', label: 'Refresh' },
|
||
{ id: 'nav:panel', label: 'Panel' }
|
||
])
|
||
: discordButtons([{ id: 'fm:ref', label: 'Refresh' }])
|
||
const rows = [sel, nav, acts, manage].filter(Boolean)
|
||
let desc = '`' + rec.cwd + '` · ' + names.length + ' items · page ' + (rec.page + 1) + '/' + pages
|
||
desc += writable ? ' · writable' : ' · read-only'
|
||
if (rec.sel) desc += '\nSelected: **' + rec.sel + '**'
|
||
if (rec.confirm) desc += '\n**Delete `' + rec.confirm + '`?** This cannot be undone.'
|
||
if (listing.err) desc += '\n' + listing.err
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Files',
|
||
desc: desc,
|
||
fields: fields.slice(0, 20),
|
||
color: rec.confirm ? BARE_OS_DISCORD_COLOR_ERR : BARE_OS_DISCORD_COLOR,
|
||
footer: rec.cwd + ' · ' + discordSessionUser(ctx)
|
||
}),
|
||
{ components: rows, nav: false }
|
||
)
|
||
}
|
||
|
||
function discordSuggestEditPaths(ctx, q) {
|
||
const user = discordSessionUser(ctx)
|
||
const names = BARE_OS_DISCORD_EDIT_SUGGEST.concat([
|
||
'/home/' + user + '/notes.txt',
|
||
'/tmp/' + user + '.txt'
|
||
])
|
||
return discordFilterChoices(names, q)
|
||
}
|
||
|
||
function discordSuggestPaths(ctx, q) {
|
||
const e = discordCmdEnv(ctx)
|
||
const home = e.HOME || '/home/' + discordSessionUser(ctx)
|
||
const extra = [home, home + '/.discord', '/home/' + discordSessionUser(ctx)]
|
||
const names = BARE_OS_DISCORD_FS_SUGGEST.concat(extra)
|
||
return discordFilterChoices(names, q)
|
||
}
|
||
|
||
async function discordCmdStatus(ctx) {
|
||
const e = discordCmdEnv(ctx)
|
||
const os = await discordCmdOsRelease(ctx)
|
||
const user = discordSessionUser(ctx)
|
||
const ident = ctx && ctx.identity && ctx.identity.state ? String(ctx.identity.state) : e.BARE_OS_IDENTITY || 'guest'
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Bare OS session',
|
||
color: ident === 'unlocked' ? BARE_OS_DISCORD_COLOR_OK : BARE_OS_DISCORD_COLOR_WARN,
|
||
author: { name: user + '@' + (e.HOSTNAME || e.NAME || 'bare-os') },
|
||
fields: [
|
||
discordField('OS', os.PRETTY_NAME || os.NAME || 'Bare OS', true),
|
||
discordField('User', user, true),
|
||
discordField('Identity', ident, true),
|
||
discordField('Home', e.HOME || '/home/' + user, true),
|
||
discordField('Shell', e.SHELL || '/bin/sh', true),
|
||
discordField('UID', e.UID || '—', true),
|
||
discordField('Version', os.VERSION_ID || e.BARE_OS_KERNEL_VERSION || e.BARE_OS_RELEASE || '—', true),
|
||
discordField('Arch', e.BARE_OS_ARCH || e.MACHINE || '—', true),
|
||
discordField('Booter', e.BARE_OS_BOOTER_PACKAGE_VERSION || '—', true)
|
||
]
|
||
})
|
||
)
|
||
}
|
||
|
||
async function discordCmdUname(ctx) {
|
||
const e = discordCmdEnv(ctx)
|
||
const os = await discordCmdOsRelease(ctx)
|
||
const line = [
|
||
os.NAME || 'BareOS',
|
||
e.HOSTNAME || 'bare-os',
|
||
os.VERSION_ID || e.BARE_OS_KERNEL_VERSION || '0.1',
|
||
e.BARE_OS_ARCH || e.MACHINE || 'unknown',
|
||
os.PRETTY_NAME || e.BARE_OS_BUILD || 'bare-userland'
|
||
].join(' ')
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'uname',
|
||
desc: discordCmdFence(line)
|
||
})
|
||
)
|
||
}
|
||
|
||
async function discordCmdHandleBare(ctx, sub) {
|
||
const e = discordCmdEnv(ctx)
|
||
const user = discordSessionUser(ctx)
|
||
if (sub === 'ping') {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Pong',
|
||
color: BARE_OS_DISCORD_COLOR_OK,
|
||
desc: 'Bare OS is online as **' + user + '**.'
|
||
})
|
||
)
|
||
}
|
||
if (sub === 'about') {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Bare OS Discord bot',
|
||
desc:
|
||
'Slash control surface for **this logged-in session** (`' +
|
||
user +
|
||
'`). Commands run with that user’s `HOME`, VFS, and `systemctl`.'
|
||
})
|
||
)
|
||
}
|
||
if (sub === 'help') {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Commands',
|
||
desc: 'Running as **' + user + '**. Open **Menu** for destinations, or use a slash command.',
|
||
fields: [
|
||
discordField('/bare', 'Session — status, whoami, hostname, date, uptime, motd, uname'),
|
||
discordField('/sys', 'Host — disk, memory, processes, env, doctor, features, limits'),
|
||
discordField('/svc', 'Services — list, start, stop, restart, logs'),
|
||
discordField('/fs', 'Read-only VFS — ls, cat, stat, head'),
|
||
discordField('/net', 'Swarm — peers, summary'),
|
||
discordField('/files /edit /create /upload', 'Browse, edit, or wget an attachment into `~/` `/tmp`'),
|
||
discordField('/settings', 'Live session knobs (theme, shell, Discord, agent, aliases)'),
|
||
discordField('/r /journal /say /man', 'Full shell (cwd + autocomplete), logs, speak, man pages'),
|
||
discordField('/plugins', 'List, reload, enable/disable ~/.discord/plugins'),
|
||
discordField('/hdms', 'Hyperdrives — list, create, add, invite, pair, /mnt'),
|
||
discordField('/holesail', 'Tunnels — list, add, edit, start/stop, enable, service'),
|
||
discordField('/agent', 'Guest AI agent — ask, status, reset (when configured)')
|
||
]
|
||
})
|
||
)
|
||
}
|
||
if (sub === 'status') return discordCmdStatus(ctx)
|
||
if (sub === 'uname') return discordCmdUname(ctx)
|
||
if (sub === 'whoami') {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Whoami',
|
||
color: BARE_OS_DISCORD_COLOR_OK,
|
||
fields: [
|
||
discordField('User', user, true),
|
||
discordField('Home', '`' + (e.HOME || '/home/' + user) + '`', true),
|
||
discordField('Identity', e.BARE_OS_IDENTITY || (ctx.identity && ctx.identity.state) || '', true)
|
||
]
|
||
})
|
||
)
|
||
}
|
||
if (sub === 'hostname') {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Hostname',
|
||
desc: '`' + String(e.HOSTNAME || e.NAME || 'bare-os') + '`'
|
||
})
|
||
)
|
||
}
|
||
if (sub === 'date') {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Date',
|
||
desc: '`' + new Date().toISOString() + '`'
|
||
})
|
||
)
|
||
}
|
||
if (sub === 'uptime') {
|
||
const t = await discordCmdReadText(ctx, '/proc/uptime')
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Uptime',
|
||
fields: [
|
||
discordField('Session', t ? discordPrettyUptime(t) : 'unavailable', true)
|
||
]
|
||
})
|
||
)
|
||
}
|
||
if (sub === 'motd') {
|
||
const t = await discordCmdReadText(ctx, '/etc/motd')
|
||
if (!t) {
|
||
return discordResult(discordEmbed({ title: 'motd', desc: 'No `/etc/motd`.' }))
|
||
}
|
||
return { more: { title: 'motd', body: t, fence: true } }
|
||
}
|
||
return { text: 'unknown /bare subcommand', ephemeral: true }
|
||
}
|
||
|
||
function discordFormatHostDf(obj) {
|
||
const host = obj && obj.host && typeof obj.host === 'object' ? obj.host : obj
|
||
const sv = obj && obj.statvfs && typeof obj.statvfs === 'object' ? obj.statvfs : null
|
||
const fields = []
|
||
if (host) {
|
||
if (host.platform || host.arch) {
|
||
fields.push(
|
||
discordField(
|
||
'Host',
|
||
[host.platform, host.arch, host.hostname || host.machine]
|
||
.filter(Boolean)
|
||
.join(' · '),
|
||
true
|
||
)
|
||
)
|
||
}
|
||
if (host.totalmem != null) {
|
||
const used =
|
||
host.freemem != null ? Number(host.totalmem) - Number(host.freemem) : null
|
||
fields.push(discordField('RAM', discordPrettyBytes(host.totalmem), true))
|
||
if (host.freemem != null) {
|
||
fields.push(discordField('Free RAM', discordPrettyBytes(host.freemem), true))
|
||
}
|
||
if (used != null && host.totalmem) {
|
||
const pct = Math.round((used / Number(host.totalmem)) * 100)
|
||
fields.push(discordField('RAM used', pct + '%', true))
|
||
}
|
||
}
|
||
if (host.availableParallelism != null) {
|
||
fields.push(discordField('CPUs', String(host.availableParallelism), true))
|
||
}
|
||
if (host.release) fields.push(discordField('Release', host.release, true))
|
||
}
|
||
if (obj && obj.peers != null) fields.push(discordField('Peers', String(obj.peers), true))
|
||
if (sv) {
|
||
fields.push(discordField('Volume', sv.volume_class || sv.f_basetype || '—', true))
|
||
if (sv.pwd_logical) fields.push(discordField('PWD', sv.pwd_logical, true))
|
||
const bsize = Number(sv.f_frsize || sv.f_bsize || 4096)
|
||
if (sv.f_blocks != null) {
|
||
fields.push(discordField('System size', discordPrettyBytes(Number(sv.f_blocks) * bsize), true))
|
||
}
|
||
if (sv.f_bavail != null) {
|
||
fields.push(discordField('System avail', discordPrettyBytes(Number(sv.f_bavail) * bsize), true))
|
||
}
|
||
const pers = sv.personalDrive
|
||
if (pers && pers.f_blocks != null) {
|
||
const pb = Number(pers.f_frsize || pers.f_bsize || bsize)
|
||
fields.push(
|
||
discordField('Personal size', discordPrettyBytes(Number(pers.f_blocks) * pb), true)
|
||
)
|
||
}
|
||
}
|
||
if (obj && obj.pipeline && obj.pipeline.maxBytes != null) {
|
||
fields.push(discordField('Pipe cap', discordPrettyBytes(obj.pipeline.maxBytes), true))
|
||
}
|
||
return fields
|
||
}
|
||
|
||
function discordFormatMem(raw) {
|
||
const json = discordTryJson(raw)
|
||
if (json && json.host) {
|
||
return discordFormatHostDf(json)
|
||
}
|
||
const mi = discordParseMeminfo(raw)
|
||
const fields = []
|
||
const pick = [
|
||
['MemTotal', 'Total'],
|
||
['MemFree', 'Free'],
|
||
['MemAvailable', 'Available'],
|
||
['Buffers', 'Buffers'],
|
||
['Cached', 'Cached'],
|
||
['SwapTotal', 'Swap'],
|
||
['SwapFree', 'Swap free']
|
||
]
|
||
for (let i = 0; i < pick.length; i++) {
|
||
if (mi[pick[i][0]] != null) {
|
||
fields.push(discordField(pick[i][1], discordPrettyBytes(mi[pick[i][0]]), true))
|
||
}
|
||
}
|
||
if (mi.MemTotal && mi.MemAvailable != null) {
|
||
const pct = Math.round((1 - mi.MemAvailable / mi.MemTotal) * 100)
|
||
fields.push(discordField('Used', pct + '%', true))
|
||
}
|
||
return fields
|
||
}
|
||
|
||
function discordFormatRlimits(obj) {
|
||
const lim = obj && obj.rlimits && typeof obj.rlimits === 'object' ? obj.rlimits : obj
|
||
const fields = []
|
||
if (!lim || typeof lim !== 'object') return fields
|
||
const names = {
|
||
NOFILE: 'Open files',
|
||
NPROC: 'Processes',
|
||
AS: 'Address space',
|
||
DATA: 'Data',
|
||
STACK: 'Stack',
|
||
CORE: 'Core dump',
|
||
RSS: 'RSS',
|
||
CPU: 'CPU time',
|
||
FSIZE: 'File size',
|
||
NOVM: 'No VM',
|
||
MEMLOCK: 'Locked mem'
|
||
}
|
||
const keys = Object.keys(lim)
|
||
for (let i = 0; i < keys.length && fields.length < 18; i++) {
|
||
const raw = keys[i]
|
||
const v = lim[raw]
|
||
if (v && typeof v === 'object' && (v.cur != null || v.max != null)) {
|
||
const short = raw.replace(/^RLIMIT_/, '')
|
||
const label = names[short] || discordHumanLabel(short)
|
||
const cur = discordIsByteishKey(raw) ? discordPrettyBytes(v.cur) : String(v.cur)
|
||
const max = discordIsByteishKey(raw) ? discordPrettyBytes(v.max) : String(v.max)
|
||
fields.push(discordField(label, cur + ' / ' + max, true))
|
||
}
|
||
}
|
||
if (obj && obj.bareOsExecMaxDepth != null) {
|
||
fields.push(discordField('Exec depth', String(obj.bareOsExecMaxDepth), true))
|
||
}
|
||
return fields
|
||
}
|
||
|
||
function discordFormatFeatures(obj) {
|
||
const feat =
|
||
obj && obj.features && typeof obj.features === 'object' ? obj.features : obj
|
||
if (!feat || typeof feat !== 'object') return []
|
||
const on = []
|
||
const off = []
|
||
const keys = Object.keys(feat).sort()
|
||
for (let i = 0; i < keys.length; i++) {
|
||
const v = feat[keys[i]]
|
||
if (v === true || v === 1 || v === '1') on.push('`' + keys[i] + '`')
|
||
else if (v === false || v === 0 || v === '0') off.push('`' + keys[i] + '`')
|
||
}
|
||
const fields = []
|
||
if (on.length) fields.push(discordField('On', on.slice(0, 24).join(' ')))
|
||
if (off.length) fields.push(discordField('Off', off.slice(0, 16).join(' ')))
|
||
return fields
|
||
}
|
||
|
||
function discordFormatDoctor(obj) {
|
||
if (!obj || typeof obj !== 'object') {
|
||
return { fields: [], desc: '', color: BARE_OS_DISCORD_COLOR_WARN }
|
||
}
|
||
const pa = obj.peerAdmission && typeof obj.peerAdmission === 'object' ? obj.peerAdmission : {}
|
||
const hn = obj.hostnameMutation && typeof obj.hostnameMutation === 'object' ? obj.hostnameMutation : {}
|
||
const kh = obj.keyHandlePolicy && typeof obj.keyHandlePolicy === 'object' ? obj.keyHandlePolicy : {}
|
||
const fields = []
|
||
if (pa.allowlistConfigured != null) {
|
||
fields.push(discordField('Peer allowlist', pa.allowlistConfigured ? 'configured' : 'open', true))
|
||
}
|
||
if (pa.denylistConfigured != null) {
|
||
fields.push(discordField('Peer denylist', pa.denylistConfigured ? 'configured' : 'none', true))
|
||
}
|
||
if (pa.requireCapsConfigured != null) {
|
||
const n = pa.requireCapsTokenCount
|
||
fields.push(
|
||
discordField(
|
||
'Require caps',
|
||
pa.requireCapsConfigured ? (n ? n + ' tokens' : 'yes') : 'no',
|
||
true
|
||
)
|
||
)
|
||
}
|
||
if (hn.enabled != null) {
|
||
fields.push(discordField('Hostname set', hn.enabled ? 'allowed' : 'locked', true))
|
||
}
|
||
if (kh.defaultTtlMs) {
|
||
fields.push(discordField('Key TTL', discordPrettyValue('defaultTtlMs', kh.defaultTtlMs), true))
|
||
}
|
||
if (Array.isArray(obj.mfaExtensionPoints) && obj.mfaExtensionPoints.length) {
|
||
fields.push(discordField('MFA hooks', obj.mfaExtensionPoints.join(' · '), false))
|
||
}
|
||
const bits = []
|
||
if (pa.allowlistConfigured) bits.push('peer allowlist on')
|
||
else if (pa.allowlistConfigured === false) bits.push('peer allowlist **open**')
|
||
if (hn.enabled === true) bits.push('hostname mutation on')
|
||
else if (hn.enabled === false) bits.push('hostname locked')
|
||
return {
|
||
fields: fields,
|
||
desc: bits.join(' · '),
|
||
color: pa.allowlistConfigured ? BARE_OS_DISCORD_COLOR_OK : BARE_OS_DISCORD_COLOR_WARN
|
||
}
|
||
}
|
||
|
||
async function discordCmdHandleSys(ctx, sub) {
|
||
if (sub === 'df') {
|
||
const t =
|
||
(await discordCmdReadText(ctx, '/proc/bare_os_resources')) ||
|
||
(await discordCmdReadText(ctx, '/proc/bare_os/resources')) ||
|
||
(await discordCmdReadText(ctx, '/proc/bare_os/host_os.json'))
|
||
const obj = discordTryJson(t)
|
||
const fields = obj ? discordFormatHostDf(obj) : []
|
||
if (fields.length) {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Disk / host',
|
||
fields: fields
|
||
})
|
||
)
|
||
}
|
||
return discordPrettySnapshot('Disk / host', t)
|
||
}
|
||
if (sub === 'mem') {
|
||
const t =
|
||
(await discordCmdReadText(ctx, '/proc/meminfo')) ||
|
||
(await discordCmdReadText(ctx, '/proc/bare_os/host_os.json'))
|
||
const fields = discordFormatMem(t)
|
||
if (fields.length) {
|
||
return discordResult(discordEmbed({ title: 'Memory', fields: fields }))
|
||
}
|
||
return discordPrettySnapshot('Memory', t)
|
||
}
|
||
if (sub === 'ps') {
|
||
const table = await discordCmdReadJson(ctx, '/proc/bare_os/process_table.json')
|
||
const rows = table && Array.isArray(table.processes) ? table.processes : []
|
||
const fields = []
|
||
for (let i = 0; i < Math.min(rows.length, 18); i++) {
|
||
const r = rows[i] || {}
|
||
const name = String(r.name || r.comm || r.cmd || r.id || 'proc')
|
||
const st = String(r.state || r.status || '')
|
||
const pid = String(r.pid || r.id || i)
|
||
fields.push(discordField(name, 'pid ' + pid + (st ? ' · ' + st : ''), true))
|
||
}
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Processes · ' + rows.length,
|
||
desc: rows.length ? rows.length + ' logical processes in this session.' : 'No logical processes in the session table.',
|
||
fields: fields
|
||
})
|
||
)
|
||
}
|
||
if (sub === 'env') {
|
||
const e = discordCmdEnv(ctx)
|
||
const fields = [
|
||
discordField('User', e.USER, true),
|
||
discordField('Home', e.HOME, true),
|
||
discordField('Identity', e.BARE_OS_IDENTITY, true),
|
||
discordField('Shell', e.SHELL, true),
|
||
discordField('Pwd', e.PWD, true),
|
||
discordField(
|
||
'UID / GID',
|
||
e.UID || e.GID ? String(e.UID || '—') + ' / ' + String(e.GID || '—') : '',
|
||
true
|
||
)
|
||
]
|
||
const prefer = [
|
||
'HOSTNAME',
|
||
'PATH',
|
||
'TERM',
|
||
'TZ',
|
||
'LANG',
|
||
'EDITOR',
|
||
'PAGER',
|
||
'BARE_OS_THEME',
|
||
'BARE_OS_COLOR_DEPTH'
|
||
]
|
||
const skip = /TOKEN|SECRET|PASSWORD|PASSWD|KEY|PRIVATE|CREDENTIAL|^USER$|^HOME$|^SHELL$|^PWD$|^LOGNAME$|^UID$|^GID$|^GROUP$|^BARE_OS_IDENTITY$/i
|
||
const seen = Object.create(null)
|
||
for (let i = 0; i < prefer.length; i++) {
|
||
const k = prefer[i]
|
||
seen[k] = 1
|
||
if (e[k] == null || e[k] === '') continue
|
||
fields.push(discordField(discordHumanLabel(k.replace(/^BARE_OS_/, '')), String(e[k]).slice(0, 80), true))
|
||
}
|
||
const keys = Object.keys(e).sort()
|
||
for (let i = 0; i < keys.length && fields.length < 15; i++) {
|
||
if (seen[keys[i]] || skip.test(keys[i])) continue
|
||
if (e[keys[i]] == null || e[keys[i]] === '') continue
|
||
fields.push(discordField(keys[i], String(e[keys[i]]).slice(0, 72), true))
|
||
}
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Environment',
|
||
desc: 'Session env for **' + discordSessionUser(ctx) + '** (secrets omitted).',
|
||
fields: fields
|
||
}),
|
||
{ ephemeral: true }
|
||
)
|
||
}
|
||
if (sub === 'doctor') {
|
||
const t =
|
||
(await discordCmdReadText(ctx, '/proc/bare_os/security_posture.json')) ||
|
||
(await discordCmdReadText(ctx, '/proc/bare_os/debug.json'))
|
||
const obj = discordTryJson(t)
|
||
const fmt = obj ? discordFormatDoctor(obj) : null
|
||
if (fmt && fmt.fields.length) {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Doctor',
|
||
desc: fmt.desc,
|
||
fields: fmt.fields,
|
||
color: fmt.color
|
||
})
|
||
)
|
||
}
|
||
return discordPrettySnapshot('Doctor', t, { color: fmt && fmt.color })
|
||
}
|
||
if (sub === 'features') {
|
||
const t =
|
||
(await discordCmdReadText(ctx, '/proc/bare_os/features')) ||
|
||
(await discordCmdReadText(ctx, '/proc/bare_os_features')) ||
|
||
(await discordCmdReadText(ctx, '/proc/bare_os/features.json'))
|
||
const obj = discordTryJson(t)
|
||
const fields = obj ? discordFormatFeatures(obj) : []
|
||
if (fields.length) {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Features',
|
||
desc: 'Guest capability flags on this booter.',
|
||
fields: fields
|
||
})
|
||
)
|
||
}
|
||
return discordPrettySnapshot('Features', t)
|
||
}
|
||
if (sub === 'rlimits') {
|
||
const t = await discordCmdReadText(ctx, '/proc/bare_os/rlimits.json')
|
||
const obj = discordTryJson(t)
|
||
const fields = obj ? discordFormatRlimits(obj) : []
|
||
if (fields.length) {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Resource limits',
|
||
desc: 'Soft / hard · Bare OS runtime caps (not Linux rlimits).',
|
||
fields: fields
|
||
})
|
||
)
|
||
}
|
||
return discordPrettySnapshot('Resource limits', t)
|
||
}
|
||
return { text: 'unknown /sys subcommand', ephemeral: true }
|
||
}
|
||
|
||
async function discordCmdHandleSvc(ctx, sub, unit) {
|
||
const name = String(unit || '').replace(/\.service$/, '')
|
||
if (typeof ctx.bareOsRunSystemctlCli !== 'function') {
|
||
return { text: 'systemctl is not available on this ctx', ephemeral: true }
|
||
}
|
||
if (sub === 'list') {
|
||
const out = await discordCmdCapture(ctx, function () {
|
||
return ctx.bareOsRunSystemctlCli(['systemctl', 'list'])
|
||
})
|
||
const parsed = discordParseSystemctlList(out)
|
||
const fields = []
|
||
for (let i = 0; i < parsed.length && fields.length < 20; i++) {
|
||
const r = parsed[i]
|
||
const bits = []
|
||
if (r.active) bits.push('**' + r.active + '**')
|
||
if (r.sub && r.sub !== r.active) bits.push(r.sub)
|
||
if (r.preset && r.preset !== 'enabled') bits.push(r.preset)
|
||
fields.push(discordField(r.unit, bits.join(' · ') || 'unknown', true))
|
||
}
|
||
const units = await discordSuggestUnits(ctx, '')
|
||
const sel = discordSelect(
|
||
'svc:pick',
|
||
'Inspect a unit…',
|
||
units.map(function (c) {
|
||
return { label: c.name, value: c.value }
|
||
})
|
||
)
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Services · ' + (parsed.length || 0),
|
||
desc: parsed.length
|
||
? 'Pick a unit to inspect, start, or stop.'
|
||
: out
|
||
? discordCmdClip(out, 800)
|
||
: 'No units registered in this session.',
|
||
fields: fields,
|
||
color: BARE_OS_DISCORD_COLOR
|
||
}),
|
||
{ components: sel ? [sel] : [] }
|
||
)
|
||
}
|
||
if (!name) return { text: 'unit name required — pick from autocomplete', ephemeral: true }
|
||
const argv = ['systemctl', sub === 'logs' ? 'status' : sub, name]
|
||
if (sub === 'logs') {
|
||
return discordCmdHandleJournal(ctx, name)
|
||
}
|
||
const out = await discordCmdCapture(ctx, function () {
|
||
return ctx.bareOsRunSystemctlCli(argv)
|
||
})
|
||
const act = discordButtons([
|
||
{ id: 'svc:status:' + name, label: 'Status', style: 1 },
|
||
{ id: 'svc:start:' + name, label: 'Start', style: 3 },
|
||
{ id: 'svc:stop:' + name, label: 'Stop', style: 4 },
|
||
{ id: 'svc:restart:' + name, label: 'Restart' },
|
||
{ id: 'svc:logs:' + name, label: 'Logs' }
|
||
])
|
||
const color =
|
||
sub === 'stop'
|
||
? BARE_OS_DISCORD_COLOR_WARN
|
||
: sub === 'start' || sub === 'restart'
|
||
? BARE_OS_DISCORD_COLOR_OK
|
||
: BARE_OS_DISCORD_COLOR
|
||
if (!out) {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: name,
|
||
desc: 'No output from `systemctl ' + sub + '`.',
|
||
color: color
|
||
}),
|
||
{ components: act ? [act] : [] }
|
||
)
|
||
}
|
||
return {
|
||
more: {
|
||
title: name,
|
||
lead: '`' + sub + '`',
|
||
body: discordCmdRedact(out),
|
||
fence: true,
|
||
color: color,
|
||
components: act ? [act] : []
|
||
}
|
||
}
|
||
}
|
||
|
||
async function discordCmdHandleFs(ctx, sub, rawPath, nlines) {
|
||
const p = discordCmdPathOk(rawPath || '~')
|
||
if (!p) {
|
||
return {
|
||
text: 'path not allowed (no `..`; stay under /proc /etc /var/log /run /home ~ /share)',
|
||
ephemeral: true
|
||
}
|
||
}
|
||
if (sub === 'ls') {
|
||
if (typeof ctx.vfs?.readdir !== 'function') return { text: 'readdir unavailable', ephemeral: true }
|
||
try {
|
||
const names = await ctx.vfs.readdir(p)
|
||
const list = Array.isArray(names) ? names : []
|
||
const shown = list.slice(0, 60)
|
||
const desc = shown.length
|
||
? shown
|
||
.map(function (n) {
|
||
return '`' + String(n).replace(/`/g, "'") + '`'
|
||
})
|
||
.join(' ')
|
||
: '(empty)'
|
||
return {
|
||
more: {
|
||
title: 'Listing',
|
||
lead:
|
||
'`' +
|
||
p +
|
||
'` · ' +
|
||
list.length +
|
||
' ' +
|
||
(list.length === 1 ? 'entry' : 'entries'),
|
||
body: desc === '(empty)' ? '' : shown.join('\n'),
|
||
fence: desc !== '(empty)',
|
||
footer: discordSessionUser(ctx)
|
||
}
|
||
}
|
||
} catch (err) {
|
||
return { text: 'ls failed: ' + ((err && err.message) || err), ephemeral: true }
|
||
}
|
||
}
|
||
if (sub === 'stat') {
|
||
const stfn = ctx.vfs && (ctx.vfs.lstat || ctx.vfs.stat)
|
||
if (typeof stfn !== 'function') return { text: 'stat unavailable', ephemeral: true }
|
||
try {
|
||
const st = await stfn.call(ctx.vfs, p)
|
||
const fields = []
|
||
const kind = st.isDirectory
|
||
? 'directory'
|
||
: st.isFile
|
||
? 'file'
|
||
: st.isSymbolicLink
|
||
? 'symlink'
|
||
: st.type || 'entry'
|
||
fields.push(discordField('Type', String(kind), true))
|
||
if (st.size != null) fields.push(discordField('Size', discordPrettyBytes(st.size), true))
|
||
if (st.mode != null) fields.push(discordField('Mode', String(st.mode), true))
|
||
if (st.mtime || st.mtimeMs) {
|
||
const ms = st.mtimeMs || Date.parse(st.mtime)
|
||
fields.push(
|
||
discordField('Modified', Number.isFinite(ms) ? new Date(ms).toISOString() : String(st.mtime), true)
|
||
)
|
||
}
|
||
if (st.path && st.path !== p) fields.push(discordField('Resolved', String(st.path), false))
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Stat',
|
||
desc: '`' + p + '`',
|
||
fields: fields.slice(0, 12)
|
||
})
|
||
)
|
||
} catch (err) {
|
||
return { text: 'stat failed: ' + ((err && err.message) || err), ephemeral: true }
|
||
}
|
||
}
|
||
const text = await discordCmdReadText(ctx, p)
|
||
if (!text) {
|
||
return discordResult(discordEmbed({ title: 'File', desc: '`' + p + '` is empty or unreadable.' }))
|
||
}
|
||
const writable = Boolean(discordCmdPathWriteOk(ctx, p))
|
||
const editRow = writable
|
||
? discordButtons([{ id: 'edit:open', label: 'Edit in modal', style: 1 }])
|
||
: null
|
||
const extra = {
|
||
components: editRow ? [editRow] : [],
|
||
editPath: writable ? p : ''
|
||
}
|
||
const asJson = discordTryJson(text)
|
||
if (asJson && typeof asJson === 'object' && !Array.isArray(asJson)) {
|
||
return discordPrettySnapshot(p, asJson, extra)
|
||
}
|
||
if (sub === 'head') {
|
||
const n = Math.max(1, Math.min(80, Number(nlines) || 12))
|
||
const head = text.split(/\r?\n/).slice(0, n).join('\n')
|
||
return {
|
||
more: {
|
||
title: 'Head',
|
||
lead: '`' + p + '` · first ' + n + ' lines',
|
||
body: head,
|
||
fence: true,
|
||
components: extra.components,
|
||
editPath: extra.editPath
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
more: {
|
||
title: 'File',
|
||
lead: '`' + p + '`',
|
||
body: text.slice(0, BARE_OS_DISCORD_FS_MAX),
|
||
fence: true,
|
||
footer: text.length + ' bytes · ' + discordSessionUser(ctx),
|
||
components: extra.components,
|
||
editPath: extra.editPath
|
||
}
|
||
}
|
||
}
|
||
|
||
function discordFormatNetSummary(obj) {
|
||
if (!obj || typeof obj !== 'object') {
|
||
return { fields: [], desc: '', color: BARE_OS_DISCORD_COLOR }
|
||
}
|
||
const rq =
|
||
obj.replicationQueue && typeof obj.replicationQueue === 'object' ? obj.replicationQueue : {}
|
||
const st = obj.stagingSlot && typeof obj.stagingSlot === 'object' ? obj.stagingSlot : {}
|
||
const fields = []
|
||
const peers = obj.peerCount
|
||
const role = obj.seedRole || rq.role || st.role
|
||
const proto = rq.protocol || st.protocol || obj.protocol
|
||
if (peers != null) fields.push(discordField('Peers', String(peers), true))
|
||
if (role) fields.push(discordField('Role', String(role), true))
|
||
if (proto) fields.push(discordField('Protocol', '`' + String(proto) + '`', true))
|
||
if (obj.topicHex) {
|
||
const hex = String(obj.topicHex)
|
||
fields.push(
|
||
discordField('Topic', '`' + hex.slice(0, 16) + (hex.length > 16 ? '…' : '') + '`', true)
|
||
)
|
||
}
|
||
if (obj.replicationQueueDepth != null) {
|
||
fields.push(discordField('Queue depth', String(obj.replicationQueueDepth), true))
|
||
}
|
||
if (rq.queueDepthEstimate != null) {
|
||
fields.push(discordField('Queue estimate', String(rq.queueDepthEstimate), true))
|
||
}
|
||
if (rq.hypercoreLengthHint != null) {
|
||
fields.push(discordField('Core length', String(rq.hypercoreLengthHint), true))
|
||
}
|
||
if (rq.manifestPathCount != null) {
|
||
fields.push(discordField('Manifests', String(rq.manifestPathCount), true))
|
||
}
|
||
if (rq.localRamBlockCount != null) {
|
||
fields.push(discordField('RAM blocks', String(rq.localRamBlockCount), true))
|
||
}
|
||
if (st.activeSlot) fields.push(discordField('Active slot', String(st.activeSlot), true))
|
||
const fwA = obj.peerFirewallAcceptedTotal
|
||
const fwR = obj.peerFirewallRejectedTotal
|
||
const fwI = obj.peerFirewallInboundTotal
|
||
const fwO = obj.peerFirewallOutboundTotal
|
||
if (fwA != null || fwR != null || fwI != null || fwO != null) {
|
||
fields.push(
|
||
discordField(
|
||
'Firewall',
|
||
'accept ' +
|
||
(fwA == null ? '0' : fwA) +
|
||
' · reject ' +
|
||
(fwR == null ? '0' : fwR) +
|
||
'\nin ' +
|
||
(fwI == null ? '0' : fwI) +
|
||
' · out ' +
|
||
(fwO == null ? '0' : fwO),
|
||
true
|
||
)
|
||
)
|
||
}
|
||
if (obj.seedHandshakeError) {
|
||
fields.push(discordField('Handshake', String(obj.seedHandshakeError), false))
|
||
}
|
||
const peerN = Number(peers)
|
||
const head = []
|
||
if (Number.isFinite(peerN)) head.push('**' + peerN + '** peer' + (peerN === 1 ? '' : 's'))
|
||
if (role) head.push(String(role))
|
||
if (proto) head.push('`' + proto + '`')
|
||
let desc = head.join(' · ')
|
||
const note = rq.snapshotWorkflowNote || obj.note
|
||
if (note) desc += (desc ? '\n' : '') + '*' + String(note).slice(0, 220) + '*'
|
||
const at = rq.atMs || obj.atMs
|
||
return {
|
||
fields: fields,
|
||
desc: desc,
|
||
color: Number.isFinite(peerN) && peerN > 0 ? BARE_OS_DISCORD_COLOR_OK : BARE_OS_DISCORD_COLOR_WARN,
|
||
footer: at ? 'Updated ' + new Date(Number(at)).toISOString() + ' · expires after 2m idle' : ''
|
||
}
|
||
}
|
||
|
||
function discordFormatSwarm(obj) {
|
||
if (!obj || typeof obj !== 'object') return []
|
||
const fields = []
|
||
if (obj.peerCount != null) fields.push(discordField('Peers', String(obj.peerCount), true))
|
||
if (obj.protocol) fields.push(discordField('Protocol', '`' + String(obj.protocol) + '`', true))
|
||
if (obj.topicCount != null) fields.push(discordField('Topics', String(obj.topicCount), true))
|
||
if (obj.topicHex) {
|
||
const hex = String(obj.topicHex)
|
||
fields.push(
|
||
discordField('Topic', '`' + hex.slice(0, 16) + (hex.length > 16 ? '…' : '') + '`', true)
|
||
)
|
||
}
|
||
const lc = obj.lifecycle
|
||
if (lc && typeof lc === 'object') {
|
||
const bits = []
|
||
const lk = Object.keys(lc)
|
||
for (let i = 0; i < lk.length && bits.length < 6; i++) {
|
||
if (typeof lc[lk[i]] === 'object' || discordValueEmpty(lc[lk[i]])) continue
|
||
bits.push('**' + discordHumanLabel(lk[i]) + '** ' + discordPrettyValue(lk[i], lc[lk[i]]))
|
||
}
|
||
if (bits.length) fields.push(discordField('Lifecycle', bits.join('\n'), false))
|
||
}
|
||
const sc = obj.peerScoringAggregates
|
||
if (sc && typeof sc === 'object') {
|
||
const banned = sc.bannedActiveCount
|
||
const hi = sc.highLatencyEwmaCount
|
||
const low = sc.lowSuccessRateBucketCount
|
||
if (banned != null || hi != null || low != null) {
|
||
fields.push(
|
||
discordField(
|
||
'Scoring',
|
||
'banned ' +
|
||
String(banned || 0) +
|
||
' · high-lat ' +
|
||
String(hi || 0) +
|
||
' · low-ok ' +
|
||
String(low || 0),
|
||
true
|
||
)
|
||
)
|
||
}
|
||
}
|
||
if (Array.isArray(obj.peers) && obj.peers.length) {
|
||
fields.push(discordField('Peer list', String(obj.peers.length), true))
|
||
}
|
||
return fields
|
||
}
|
||
|
||
async function discordCmdHandleNet(ctx, sub) {
|
||
if (sub === 'peers' || sub === 'swarm') {
|
||
const t =
|
||
(await discordCmdReadText(ctx, '/proc/bare_os/swarm')) ||
|
||
(await discordCmdReadText(ctx, '/proc/bare_os/swarm.json')) ||
|
||
(await discordCmdReadText(ctx, '/proc/bare_os_swarm'))
|
||
const obj = discordTryJson(t)
|
||
const fields = obj ? discordFormatSwarm(obj) : []
|
||
if (fields.length) {
|
||
const n = obj && obj.peerCount
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Swarm',
|
||
desc:
|
||
n != null
|
||
? '**' + n + '** peer' + (Number(n) === 1 ? '' : 's') + (obj.protocol ? ' · `' + obj.protocol + '`' : '')
|
||
: '',
|
||
fields: fields,
|
||
color: obj && obj.peerCount > 0 ? BARE_OS_DISCORD_COLOR_OK : BARE_OS_DISCORD_COLOR_WARN
|
||
})
|
||
)
|
||
}
|
||
return discordPrettySnapshot('Swarm', t)
|
||
}
|
||
const t =
|
||
(await discordCmdReadText(ctx, '/proc/bare_os/net_summary.json')) ||
|
||
(await discordCmdReadText(ctx, '/proc/net/dev'))
|
||
const obj = discordTryJson(t)
|
||
if (obj) {
|
||
const fmt = discordFormatNetSummary(obj)
|
||
if (fmt.fields.length) {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Network',
|
||
desc: fmt.desc,
|
||
fields: fmt.fields,
|
||
color: fmt.color,
|
||
footer: fmt.footer || undefined
|
||
})
|
||
)
|
||
}
|
||
return discordPrettySnapshot('Network', obj)
|
||
}
|
||
if (t && t.indexOf('Inter-|') >= 0) {
|
||
const lines = t.split(/\r?\n/).filter(Boolean)
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Network interfaces',
|
||
desc: discordCmdFence(lines.slice(0, 16).join('\n'))
|
||
})
|
||
)
|
||
}
|
||
return discordPrettySnapshot('Network', t)
|
||
}
|
||
|
||
async function discordCmdHandleMan(ctx, page) {
|
||
const name = String(page || '').replace(/[^a-zA-Z0-9._+-]/g, '')
|
||
if (!name) return { text: 'man page name required — start typing for autocomplete', ephemeral: true }
|
||
if (typeof ctx.execLine === 'function') {
|
||
const out = await discordCmdCapture(ctx, function () {
|
||
return ctx.execLine('man ' + name)
|
||
})
|
||
if (out) {
|
||
const clean = String(out)
|
||
.replace(/\x1b\[[0-9;]*m/g, '')
|
||
.trim()
|
||
return {
|
||
more: {
|
||
title: 'man ' + name,
|
||
body: clean,
|
||
fence: false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const t = await discordCmdReadText(ctx, '/share/man/man.json')
|
||
if (!t) return { text: 'man database unavailable' }
|
||
try {
|
||
const db = JSON.parse(t)
|
||
const pages = (db && db.pages) || []
|
||
for (let i = 0; i < pages.length; i++) {
|
||
if (pages[i] && pages[i].name === name) {
|
||
const p = pages[i]
|
||
return {
|
||
more: {
|
||
title: 'man ' + (p.title || name),
|
||
lead: p.synopsis && p.synopsis[0] ? '`' + p.synopsis[0] + '`' : '',
|
||
body: String(p.description || ''),
|
||
fence: false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
/* fall through */
|
||
}
|
||
return { text: 'no man page for ' + name, ephemeral: true }
|
||
}
|
||
|
||
function discordCmdSayBox(text) {
|
||
const s = String(text || '').slice(0, 200)
|
||
const lines = s.split(/\r?\n/).slice(0, 6)
|
||
let w = 8
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (lines[i].length > w) w = lines[i].length
|
||
}
|
||
if (w > 48) w = 48
|
||
const bar = '+' + Array(w + 3).join('-') + '+'
|
||
const body = lines.map(function (ln) {
|
||
const t = ln.slice(0, w)
|
||
return '| ' + t + Array(w - t.length + 1).join(' ') + ' |'
|
||
})
|
||
return [bar, body.join('\n'), bar, ' \\', ' cow-ish · bare-os'].join('\n')
|
||
}
|
||
|
||
function discordShHud(ctx, interaction, note) {
|
||
const rec = discordShGet(interaction, ctx)
|
||
const fields = [
|
||
discordField('Directory', '`' + discordShPrettyCwd(ctx, rec.cwd) + '`', true),
|
||
discordField('Last exit', rec.lastCmd ? String(rec.lastExit) : '—', true),
|
||
discordField('User', discordSessionUser(ctx), true)
|
||
]
|
||
if (rec.lastCmd) {
|
||
fields.push(discordField('Last command', '`' + rec.lastCmd + '`', false))
|
||
}
|
||
const hist = rec.hist.slice(0, 6)
|
||
if (hist.length) {
|
||
fields.push(
|
||
discordField(
|
||
'History',
|
||
hist
|
||
.map(function (h) {
|
||
return '`' + h.cmd + '` · ' + h.exit
|
||
})
|
||
.join('\n')
|
||
)
|
||
)
|
||
}
|
||
const histSel = discordSelect(
|
||
'sh:hist',
|
||
'Replay a command…',
|
||
rec.hist.slice(0, 20).map(function (h, i) {
|
||
return {
|
||
label: h.cmd.slice(0, 90),
|
||
value: String(i),
|
||
description: 'exit ' + h.exit + (h.ms ? ' · ' + h.ms + 'ms' : '')
|
||
}
|
||
})
|
||
)
|
||
const acts = discordButtons([
|
||
{ id: 'sh:again', label: 'Repeat', style: 1 },
|
||
{ id: 'sh:up', label: 'cd ..' },
|
||
{ id: 'sh:home', label: 'cd ~' },
|
||
{ id: 'sh:pwd', label: 'pwd' }
|
||
])
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Shell · ' + discordShPrompt(ctx, rec),
|
||
desc:
|
||
(note ? note + '\n' : '') +
|
||
'Type **`/r cmd:`** and keep going — `cd`, pipes, `&&`, redirects, and `$VAR` all work.\n' +
|
||
'Working directory sticks for this Discord user.',
|
||
fields: fields,
|
||
color: rec.lastCmd && rec.lastExit ? BARE_OS_DISCORD_COLOR_WARN : BARE_OS_DISCORD_COLOR_OK,
|
||
footer: discordShPrompt(ctx, rec) + ' · /r cmd:'
|
||
}),
|
||
{ components: [histSel, acts].filter(Boolean) }
|
||
)
|
||
}
|
||
|
||
async function discordCmdHandleRun(ctx, interaction, raw) {
|
||
const rec = discordShGet(interaction, ctx)
|
||
const cmd = String(raw || '').trim()
|
||
if (!cmd) return discordShHud(ctx, interaction, '')
|
||
if (cmd.length > 4000) {
|
||
return { text: 'Command line is too long (max 4000).', ephemeral: true }
|
||
}
|
||
const first = discordShFirstWord(cmd)
|
||
if (first === 'exit') {
|
||
rec.lastCmd = cmd
|
||
rec.lastExit = 0
|
||
return discordShHud(ctx, interaction, 'Stayed attached — `exit` does not stop the Discord bot.')
|
||
}
|
||
if (first === 'logout' || first === 'login') {
|
||
return {
|
||
text:
|
||
'`login` / `logout` need the guest TTY. This Discord shell stays as **' +
|
||
discordSessionUser(ctx) +
|
||
'**.',
|
||
ephemeral: true
|
||
}
|
||
}
|
||
if (typeof ctx.execLine !== 'function') {
|
||
return { text: 'execLine unavailable in this session', ephemeral: true }
|
||
}
|
||
await discordShPrepare(ctx, rec)
|
||
const t0 = Date.now()
|
||
let out = ''
|
||
let errNote = ''
|
||
try {
|
||
out = await discordCmdCapture(ctx, function () {
|
||
return ctx.execLine(cmd, { timeoutMs: BARE_OS_DISCORD_SH_EXEC_MS })
|
||
})
|
||
} catch (err) {
|
||
errNote = String((err && err.message) || err)
|
||
out = (out ? out + '\n' : '') + errNote
|
||
}
|
||
const ms = Date.now() - t0
|
||
const exit = ctx.exitCode == null ? (errNote ? 1 : 0) : Number(ctx.exitCode) || 0
|
||
discordShHarvest(ctx, rec)
|
||
discordShPushHist(rec, cmd, exit, ms, out)
|
||
const tty = BARE_OS_DISCORD_SH_TTY[first]
|
||
if (!String(out || '').trim() && (first === 'cd' || first === 'export' || first === 'unset' || first === 'alias')) {
|
||
return discordShHud(ctx, interaction, '`$ ' + cmd + '` · exit **' + exit + '** · ' + ms + 'ms')
|
||
}
|
||
const parsed = discordTryJson(out)
|
||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && String(out).length < 800) {
|
||
return discordPrettySnapshot(discordShPrompt(ctx, rec) + ' $ ' + cmd, parsed, {
|
||
color: exit ? BARE_OS_DISCORD_COLOR_WARN : BARE_OS_DISCORD_COLOR_OK
|
||
})
|
||
}
|
||
const lead =
|
||
'`' +
|
||
discordShPrompt(ctx, rec) +
|
||
'`\n`$ ' +
|
||
cmd +
|
||
'`' +
|
||
(tty ? '\n_Interactive TUI — needs a real terminal; output may be empty._' : '')
|
||
if (!String(out || '').trim()) {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Shell',
|
||
desc: lead + '\n(no output)',
|
||
color: exit ? BARE_OS_DISCORD_COLOR_WARN : BARE_OS_DISCORD_COLOR_OK,
|
||
footer: 'exit ' + exit + ' · ' + ms + 'ms · ' + discordShPrettyCwd(ctx, rec.cwd)
|
||
}),
|
||
{
|
||
components: [
|
||
discordButtons([
|
||
{ id: 'sh:again', label: 'Repeat', style: 1 },
|
||
{ id: 'sh:hud', label: 'Shell' }
|
||
])
|
||
]
|
||
}
|
||
)
|
||
}
|
||
return {
|
||
more: {
|
||
title: 'Shell',
|
||
lead: lead,
|
||
body: discordCmdRedact(out),
|
||
fence: true,
|
||
color: exit ? BARE_OS_DISCORD_COLOR_WARN : BARE_OS_DISCORD_COLOR_OK,
|
||
footer: 'exit ' + exit + ' · ' + ms + 'ms · ' + discordShPrettyCwd(ctx, rec.cwd),
|
||
components: [
|
||
discordButtons([
|
||
{ id: 'sh:again', label: 'Repeat', style: 1 },
|
||
{ id: 'sh:up', label: 'cd ..' },
|
||
{ id: 'sh:home', label: 'cd ~' },
|
||
{ id: 'sh:hud', label: 'Shell' }
|
||
])
|
||
]
|
||
}
|
||
}
|
||
}
|
||
|
||
async function discordShAction(ctx, interaction, id) {
|
||
const rec = discordShGet(interaction, ctx)
|
||
if (id === 'sh:hud' || id === 'run:compose') {
|
||
return discordSendResult(ctx, interaction, discordShHud(ctx, interaction, ''))
|
||
}
|
||
if (id === 'sh:pwd') {
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleRun(ctx, interaction, 'pwd'))
|
||
}
|
||
if (id === 'sh:again') {
|
||
if (!rec.lastCmd) {
|
||
return discordSendResult(ctx, interaction, discordShHud(ctx, interaction, 'No command to repeat.'))
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleRun(ctx, interaction, rec.lastCmd))
|
||
}
|
||
if (id === 'sh:up') {
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleRun(ctx, interaction, 'cd ..'))
|
||
}
|
||
if (id === 'sh:home') {
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleRun(ctx, interaction, 'cd'))
|
||
}
|
||
if (id === 'sh:hist') {
|
||
const i = Number((interaction.values && interaction.values[0]) || 0)
|
||
const hit = rec.hist[i]
|
||
if (!hit) return discordSendResult(ctx, interaction, discordShHud(ctx, interaction, ''))
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleRun(ctx, interaction, hit.cmd))
|
||
}
|
||
return discordSendResult(ctx, interaction, discordShHud(ctx, interaction, ''))
|
||
}
|
||
|
||
async function discordCmdHandleUpload(ctx, interaction, destRaw) {
|
||
const att = discordInteractionAttachment(interaction, 'file')
|
||
const url = att && (att.url || att.proxyURL || att.proxy_url)
|
||
if (!att || !url) {
|
||
return {
|
||
text: 'Attach a file to `/upload`. Discord hosts it, then Bare OS `wget`s it into the VFS.',
|
||
ephemeral: true
|
||
}
|
||
}
|
||
const filename = discordUploadSafeName(att.name || att.filename || 'upload.bin')
|
||
const dest = await discordUploadResolveDest(ctx, interaction, destRaw, filename)
|
||
const allowed = discordCmdPathWriteOk(ctx, dest)
|
||
if (!allowed) {
|
||
return {
|
||
text: 'path not writable (stay under `~/` or `/tmp`; no `..`, no `~/.discord`)',
|
||
ephemeral: true
|
||
}
|
||
}
|
||
if (!/^https:\/\//i.test(String(url))) {
|
||
return { text: 'Attachment URL is not https.', ephemeral: true }
|
||
}
|
||
const rec = discordShGet(interaction, ctx)
|
||
await discordShPrepare(ctx, rec)
|
||
const line = 'wget -T 60 -O ' + discordShQuote(allowed) + ' ' + discordShQuote(url)
|
||
if (typeof ctx.execLine !== 'function' && typeof ctx.bareOsRunWgetCli !== 'function') {
|
||
return { text: 'wget unavailable in this session (`execLine` / `bareOsRunWgetCli`)', ephemeral: true }
|
||
}
|
||
const t0 = Date.now()
|
||
let out = ''
|
||
let errNote = ''
|
||
try {
|
||
out = await discordCmdCapture(ctx, function () {
|
||
if (typeof ctx.execLine === 'function') {
|
||
return ctx.execLine(line, { timeoutMs: BARE_OS_DISCORD_UPLOAD_MS })
|
||
}
|
||
return ctx.bareOsRunWgetCli(['wget', '-T', '60', '-O', allowed, String(url)])
|
||
})
|
||
} catch (err) {
|
||
errNote = String((err && err.message) || err)
|
||
out = (out ? out + '\n' : '') + errNote
|
||
}
|
||
const ms = Date.now() - t0
|
||
const exit = ctx.exitCode == null ? (errNote ? 1 : 0) : Number(ctx.exitCode) || 0
|
||
discordShHarvest(ctx, rec)
|
||
discordShPushHist(rec, line, exit, ms, out)
|
||
let size = att.size
|
||
try {
|
||
const st = await discordFmStat(ctx, allowed)
|
||
if (st && st.size != null) size = st.size
|
||
} catch {
|
||
/* keep attachment size */
|
||
}
|
||
const fields = [
|
||
discordField('Saved as', '`' + allowed + '`', false),
|
||
discordField('Name', filename, true),
|
||
discordField('Size', size != null ? discordPrettyBytes(size) : '—', true),
|
||
discordField('Directory', '`' + discordShPrettyCwd(ctx, rec.cwd) + '`', true)
|
||
]
|
||
if (att.contentType || att.content_type) {
|
||
fields.push(discordField('Type', String(att.contentType || att.content_type), true))
|
||
}
|
||
const note = String(out || '').trim()
|
||
const desc =
|
||
'`$ wget -O ' +
|
||
allowed +
|
||
' <cdn>`\n' +
|
||
(exit
|
||
? 'wget failed. If `BARE_OS_HTTP_ALLOWLIST` is set, add `cdn.discordapp.com` and `media.discordapp.net`.\n'
|
||
: 'Downloaded from the Discord CDN via `/bin/wget`.\n') +
|
||
(note ? discordCmdFence(note, '', 1800) : '')
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: exit ? 'Upload failed' : 'Uploaded',
|
||
desc: desc,
|
||
fields: fields,
|
||
color: exit ? BARE_OS_DISCORD_COLOR_ERR : BARE_OS_DISCORD_COLOR_OK,
|
||
footer: 'exit ' + exit + ' · ' + ms + 'ms · wget'
|
||
})
|
||
)
|
||
}
|
||
|
||
var BARE_OS_DISCORD_HDMS_SESSIONS = Object.create(null)
|
||
|
||
function discordHdmsKey(interaction) {
|
||
return discordInteractionUserId(interaction) || 'anon'
|
||
}
|
||
|
||
function discordHdmsGet(interaction) {
|
||
const k = discordHdmsKey(interaction)
|
||
let rec = BARE_OS_DISCORD_HDMS_SESSIONS[k]
|
||
if (!rec) {
|
||
rec = { sel: '', confirm: '', atMs: Date.now() }
|
||
BARE_OS_DISCORD_HDMS_SESSIONS[k] = rec
|
||
}
|
||
rec.atMs = Date.now()
|
||
return rec
|
||
}
|
||
|
||
function discordHdmsUnlocked(ctx) {
|
||
if (ctx && ctx.identity && ctx.identity.state === 'unlocked') return true
|
||
const e = discordCmdEnv(ctx)
|
||
const id = String(e.BARE_OS_IDENTITY || '')
|
||
return Boolean(id && id !== 'guest')
|
||
}
|
||
|
||
function discordHdmsLabelOk(label) {
|
||
return /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$/.test(String(label || ''))
|
||
}
|
||
|
||
function discordHdmsRedact(text) {
|
||
return discordCmdRedact(String(text == null ? '' : text))
|
||
.replace(/("writerSecretHex"\s*:\s*")[^"]*"/gi, '$1[redacted]"')
|
||
.replace(/("secretKey"\s*:\s*")[^"]*"/gi, '$1[redacted]"')
|
||
}
|
||
|
||
function discordParseHdmsList(text) {
|
||
const rows = []
|
||
const lines = String(text || '').split(/\r?\n/)
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i].trim()
|
||
if (!line || line.indexOf('(no extra drives)') >= 0) continue
|
||
if (/^hdms:/i.test(line) || /log in/i.test(line) || /unavailable/i.test(line)) continue
|
||
const parts = line.split('\t')
|
||
const label = String(parts[0] || '').trim()
|
||
if (!label || !discordHdmsLabelOk(label)) continue
|
||
rows.push({
|
||
label: label,
|
||
mode: String(parts[1] || '').trim() || '—',
|
||
key: String(parts[2] || '').trim(),
|
||
extra: String(parts[3] || '').trim()
|
||
})
|
||
}
|
||
return rows
|
||
}
|
||
|
||
async function discordHdmsCli(ctx, argv) {
|
||
if (typeof ctx.runHdms !== 'function') {
|
||
return { text: '', error: 'HDMS unavailable (`ctx.runHdms` missing). Log in on the guest TTY.' }
|
||
}
|
||
let out = ''
|
||
try {
|
||
out = await discordCmdCapture(ctx, function () {
|
||
return ctx.runHdms(argv)
|
||
})
|
||
} catch (err) {
|
||
return { text: String((err && err.message) || err), error: String((err && err.message) || err) }
|
||
}
|
||
const fail = ctx.exitCode && Number(ctx.exitCode) !== 0
|
||
return { text: String(out || ''), error: fail ? String(out || 'hdms failed').trim() : '' }
|
||
}
|
||
|
||
async function discordHdmsListRows(ctx) {
|
||
const got = await discordHdmsCli(ctx, ['hdms', 'list'])
|
||
if (got.error && !got.text) return { rows: [], error: got.error }
|
||
return { rows: discordParseHdmsList(got.text), error: got.error, raw: got.text }
|
||
}
|
||
|
||
async function discordSuggestHdmsLabels(ctx, q) {
|
||
const got = await discordHdmsListRows(ctx)
|
||
const names = got.rows.map(function (r) {
|
||
return r.label
|
||
})
|
||
return discordFilterChoices(names, q)
|
||
}
|
||
|
||
async function discordDefer(interaction) {
|
||
if (!interaction || interaction.deferred || interaction.replied) return false
|
||
if (typeof interaction.deferReply !== 'function') return false
|
||
try {
|
||
await interaction.deferReply()
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function discordHdmsUnavailable(ctx, note) {
|
||
const e = discordCmdEnv(ctx)
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'HDMS',
|
||
color: BARE_OS_DISCORD_COLOR_WARN,
|
||
desc:
|
||
(note || 'Hyperdrive management needs an **unlocked** session and `ctx.runHdms`.') +
|
||
'\nIdentity: **' +
|
||
(e.BARE_OS_IDENTITY || (ctx.identity && ctx.identity.state) || 'guest') +
|
||
'**. Use the guest TTY: `login`.',
|
||
fields: [
|
||
discordField('Health', '`/proc/bare_os/hdms_health.json`', false),
|
||
discordField('Hints', '`/proc/bare_os/hdms_hints.json`', false)
|
||
]
|
||
})
|
||
)
|
||
}
|
||
|
||
async function discordHdmsHealthView(ctx) {
|
||
const t =
|
||
(await discordCmdReadText(ctx, '/proc/bare_os/hdms_health.json')) ||
|
||
(await discordCmdReadText(ctx, '/proc/bare_os_hdms_health.json'))
|
||
const obj = discordTryJson(t)
|
||
if (obj && typeof obj === 'object') {
|
||
const labels = Array.isArray(obj.labels) ? obj.labels : []
|
||
const fields = [
|
||
discordField('Active', obj.active ? 'yes' : 'no', true),
|
||
discordField('Mounts', String(obj.mountCount != null ? obj.mountCount : labels.length), true),
|
||
discordField('Identity', discordHdmsUnlocked(ctx) ? 'unlocked' : 'guest', true)
|
||
]
|
||
for (let i = 0; i < labels.length && fields.length < 20; i++) {
|
||
const L = labels[i] && typeof labels[i] === 'object' ? labels[i] : { label: labels[i] }
|
||
fields.push(discordField(String(L.label || 'drive'), String(L.mode || 'mounted'), true))
|
||
}
|
||
const th = obj.thresholdHints
|
||
if (th && typeof th === 'object') {
|
||
if (th.diskPressureWarnPercent != null) {
|
||
fields.push(discordField('Disk warn', String(th.diskPressureWarnPercent) + '%', true))
|
||
}
|
||
if (th.pairingBackoffMs != null) {
|
||
fields.push(discordField('Pair backoff', String(th.pairingBackoffMs) + 'ms', true))
|
||
}
|
||
}
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'HDMS health',
|
||
desc: obj.note || 'Registry metadata only — no keys.',
|
||
fields: fields,
|
||
color: obj.active ? BARE_OS_DISCORD_COLOR_OK : BARE_OS_DISCORD_COLOR_WARN
|
||
})
|
||
)
|
||
}
|
||
return discordPrettySnapshot('HDMS health', t)
|
||
}
|
||
|
||
async function discordHdmsHintsView(ctx) {
|
||
const t =
|
||
(await discordCmdReadText(ctx, '/proc/bare_os/hdms_hints.json')) ||
|
||
(await discordCmdReadText(ctx, '/proc/bare_os_hdms_hints.json'))
|
||
const obj = discordTryJson(t)
|
||
if (obj && typeof obj === 'object') {
|
||
const fields = []
|
||
const keys = Object.keys(obj)
|
||
for (let i = 0; i < keys.length && fields.length < 20; i++) {
|
||
const k = keys[i]
|
||
if (/secret|token|invite|key/i.test(k) && k !== 'schema') {
|
||
fields.push(discordField(k, '[redacted]', false))
|
||
continue
|
||
}
|
||
let v = obj[k]
|
||
if (v && typeof v === 'object') v = JSON.stringify(v)
|
||
fields.push(discordField(k, String(v == null ? '—' : v).slice(0, 200), false))
|
||
}
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'HDMS hints',
|
||
desc: 'Operator pairing hints. The guest does **not** open invite URLs by itself.',
|
||
fields: fields.length ? fields : [discordField('hints', 'None')]
|
||
})
|
||
)
|
||
}
|
||
return discordPrettySnapshot('HDMS hints', t)
|
||
}
|
||
|
||
async function discordHdmsHud(ctx, interaction, note) {
|
||
if (!discordHdmsUnlocked(ctx) || typeof ctx.runHdms !== 'function') {
|
||
return discordHdmsUnavailable(ctx)
|
||
}
|
||
const rec = discordHdmsGet(interaction)
|
||
const listed = await discordHdmsListRows(ctx)
|
||
const rows = listed.rows
|
||
const labels = {}
|
||
for (let i = 0; i < rows.length; i++) labels[rows[i].label] = 1
|
||
if (rec.sel && !labels[rec.sel]) rec.sel = ''
|
||
if (rec.confirm && !labels[rec.confirm]) rec.confirm = ''
|
||
const fields = []
|
||
for (let i = 0; i < rows.length && fields.length < 20; i++) {
|
||
const d = rows[i]
|
||
const mark = rec.sel === d.label ? '▸ ' : ''
|
||
const bits = [d.mode]
|
||
if (d.extra) bits.push(d.extra)
|
||
if (d.key && d.key !== '(local)') bits.push('`' + d.key.slice(0, 18) + '…`')
|
||
else if (d.key) bits.push(d.key)
|
||
fields.push(discordField(mark + d.label, bits.join(' · ') || 'mounted', false))
|
||
}
|
||
const sel = discordSelect(
|
||
'hdms:pick',
|
||
rows.length ? 'Select a drive…' : 'No extra drives',
|
||
rows.map(function (d) {
|
||
return {
|
||
label: d.label,
|
||
value: d.label,
|
||
description: (d.mode + (d.extra ? ' · ' + d.extra : '')).slice(0, 100)
|
||
}
|
||
})
|
||
)
|
||
const top = discordButtons([
|
||
{ id: 'hdms:refresh', label: 'Refresh', style: 1 },
|
||
{ id: 'hdms:create', label: 'Create' },
|
||
{ id: 'hdms:add', label: 'Add RO' },
|
||
{ id: 'hdms:pair', label: 'Pair' },
|
||
{ id: 'hdms:health', label: 'Health' }
|
||
])
|
||
const acts = rec.sel
|
||
? rec.confirm === rec.sel
|
||
? discordButtons([
|
||
{ id: 'hdms:ok', label: 'Confirm remove', style: 4 },
|
||
{ id: 'hdms:no', label: 'Cancel' }
|
||
])
|
||
: discordButtons([
|
||
{ id: 'hdms:show', label: 'Show', style: 1 },
|
||
{ id: 'hdms:browse', label: 'Browse /mnt' },
|
||
{ id: 'hdms:invite', label: 'Invite' },
|
||
{ id: 'hdms:inviterw', label: 'Invite RW', style: 3 },
|
||
{ id: 'hdms:rm', label: 'Remove', style: 4 }
|
||
])
|
||
: discordButtons([
|
||
{ id: 'hdms:invite0', label: 'Invite (no drive)' },
|
||
{ id: 'hdms:hints', label: 'Hints' }
|
||
])
|
||
let desc =
|
||
'Extra Hyperdrives under `/mnt/<label>`. Unlocked as **' +
|
||
discordSessionUser(ctx) +
|
||
'**.'
|
||
if (note) desc = note + '\n' + desc
|
||
if (listed.error) desc += '\n' + listed.error
|
||
if (rec.sel) desc += '\nSelected: **' + rec.sel + '** (`/mnt/' + rec.sel + '`)'
|
||
if (rec.confirm) desc += '\n**Remove `' + rec.confirm + '`?** Registry + mount go away.'
|
||
if (!rows.length) desc += '\nNo extra drives. **Create** a writable one, **Add RO** a key, or **Pair** an invite.'
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'HDMS · ' + rows.length,
|
||
desc: desc,
|
||
fields: fields,
|
||
color: listed.error ? BARE_OS_DISCORD_COLOR_WARN : BARE_OS_DISCORD_COLOR
|
||
}),
|
||
{ components: [sel, top, acts].filter(Boolean) }
|
||
)
|
||
}
|
||
|
||
async function discordHdmsShowView(ctx, interaction, label) {
|
||
const got = await discordHdmsCli(ctx, ['hdms', 'show', label])
|
||
const raw = discordHdmsRedact(got.text || got.error || '')
|
||
const obj = discordTryJson(raw)
|
||
if (obj && typeof obj === 'object') {
|
||
const fields = [
|
||
discordField('Label', String(obj.label || label), true),
|
||
discordField('Mode', String(obj.mode || '—'), true),
|
||
discordField('Mount', '`/mnt/' + label + '`', true)
|
||
]
|
||
if (obj.key) fields.push(discordField('Key', '`' + String(obj.key) + '`', false))
|
||
if (obj.ns) fields.push(discordField('Namespace', String(obj.ns), false))
|
||
if (obj.ephemeral) fields.push(discordField('Lifetime', 'ephemeral', true))
|
||
if (obj.id) fields.push(discordField('Id', String(obj.id), true))
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'HDMS · ' + label,
|
||
desc: got.error || 'Drive record (secrets redacted).',
|
||
fields: fields,
|
||
color: got.error ? BARE_OS_DISCORD_COLOR_ERR : BARE_OS_DISCORD_COLOR_OK
|
||
}),
|
||
{
|
||
components: [
|
||
discordButtons([
|
||
{ id: 'hdms:browse', label: 'Browse /mnt', style: 1 },
|
||
{ id: 'hdms:invite', label: 'Invite' },
|
||
{ id: 'hdms:home', label: 'HDMS' }
|
||
])
|
||
]
|
||
}
|
||
)
|
||
}
|
||
return {
|
||
more: {
|
||
title: 'HDMS · ' + label,
|
||
body: raw || '(empty)',
|
||
fence: true,
|
||
components: [
|
||
discordButtons([{ id: 'hdms:home', label: 'HDMS' }])
|
||
]
|
||
}
|
||
}
|
||
}
|
||
|
||
async function discordCmdHandleHdms(ctx, interaction, sub, opt) {
|
||
const s = String(sub || 'list')
|
||
if (s === 'help') {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'HDMS',
|
||
desc:
|
||
'Manage extra Hyperdrives (`/mnt/<label>`). Requires **login**.\n' +
|
||
'Same surface as `/bin/hdms`: list, create, add, remove, show, invite, pair.',
|
||
fields: [
|
||
discordField('/hdms', 'Interactive manager (default)'),
|
||
discordField('/hdms create', '`label` — new writable drive'),
|
||
discordField('/hdms add', '`label` + `key` — mount a read-only z32 key'),
|
||
discordField('/hdms remove / show', '`label`'),
|
||
discordField('/hdms invite', 'optional `label`, `mode` pair / readonly / rw'),
|
||
discordField('/hdms pair', '`invite` token; `persist` default on'),
|
||
discordField('/hdms health / hints', '`/proc/bare_os/hdms_*.json`')
|
||
]
|
||
})
|
||
)
|
||
}
|
||
if (s === 'health') return discordHdmsHealthView(ctx)
|
||
if (s === 'hints') return discordHdmsHintsView(ctx)
|
||
if (!discordHdmsUnlocked(ctx) || typeof ctx.runHdms !== 'function') {
|
||
return discordHdmsUnavailable(ctx)
|
||
}
|
||
if (s === 'create') {
|
||
const label = String(opt('label') || '').trim()
|
||
if (!label) return { modal: 'hdms:create' }
|
||
if (!discordHdmsLabelOk(label)) {
|
||
return { text: 'Invalid label (alphanumeric, then . _ - ; max 63).', ephemeral: true }
|
||
}
|
||
const got = await discordHdmsCli(ctx, ['hdms', 'create', label])
|
||
const rec = discordHdmsGet(interaction)
|
||
rec.sel = label
|
||
return discordHdmsHud(ctx, interaction, got.error || got.text || 'Created `' + label + '`.')
|
||
}
|
||
if (s === 'add') {
|
||
const label = String(opt('label') || '').trim()
|
||
const key = String(opt('key') || '').trim()
|
||
if (!label || !key) return { modal: 'hdms:add' }
|
||
if (!discordHdmsLabelOk(label)) {
|
||
return { text: 'Invalid label (alphanumeric, then . _ - ; max 63).', ephemeral: true }
|
||
}
|
||
const got = await discordHdmsCli(ctx, ['hdms', 'add', label, key])
|
||
const rec = discordHdmsGet(interaction)
|
||
rec.sel = label
|
||
return discordHdmsHud(ctx, interaction, got.error || got.text || 'Added `' + label + '`.')
|
||
}
|
||
if (s === 'remove') {
|
||
const label = String(opt('label') || '').trim()
|
||
if (!label) return discordHdmsHud(ctx, interaction, 'Pick a drive to remove.')
|
||
const got = await discordHdmsCli(ctx, ['hdms', 'remove', label])
|
||
const rec = discordHdmsGet(interaction)
|
||
rec.sel = ''
|
||
rec.confirm = ''
|
||
return discordHdmsHud(ctx, interaction, got.error || got.text || 'Removed `' + label + '`.')
|
||
}
|
||
if (s === 'show') {
|
||
const label = String(opt('label') || '').trim() || discordHdmsGet(interaction).sel
|
||
if (!label) return discordHdmsHud(ctx, interaction, 'Pick a drive to show.')
|
||
discordHdmsGet(interaction).sel = label
|
||
return discordHdmsShowView(ctx, interaction, label)
|
||
}
|
||
if (s === 'invite') {
|
||
const label = String(opt('label') || '').trim()
|
||
const mode = String(opt('mode') || 'pair').toLowerCase()
|
||
const argv = ['hdms', 'invite']
|
||
if (mode === 'readonly' || mode === 'ro' || mode === 'read-only') argv.push('--read-only')
|
||
if (mode === 'rw' || mode === 'readwrite' || mode === 'read-write') argv.push('--rw')
|
||
if (label) argv.push(label)
|
||
await discordDefer(interaction)
|
||
const got = await discordHdmsCli(ctx, argv)
|
||
const body = discordHdmsRedact(got.text || got.error || '')
|
||
const first = body.split(/\r?\n/).filter(Boolean)[0] || ''
|
||
if (label) discordHdmsGet(interaction).sel = label
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: got.error && !first ? 'Invite failed' : 'HDMS invite',
|
||
desc:
|
||
(got.error && !/invite/i.test(body) ? got.error + '\n' : '') +
|
||
(first ? 'Token:\n' + discordCmdFence(first, '', 900) : body ? discordCmdFence(body, '', 1800) : 'No invite.'),
|
||
color: got.error && !first ? BARE_OS_DISCORD_COLOR_ERR : BARE_OS_DISCORD_COLOR_OK,
|
||
footer: 'Peer: hdms pair <token> · same HYPERSWARM_BOOTSTRAP'
|
||
}),
|
||
{ components: [discordButtons([{ id: 'hdms:home', label: 'HDMS' }])] }
|
||
)
|
||
}
|
||
if (s === 'pair') {
|
||
const invite = String(opt('invite') || '').trim()
|
||
if (!invite) return { modal: 'hdms:pair' }
|
||
const persist = String(opt('persist') || 'yes').toLowerCase()
|
||
const argv = ['hdms', 'pair']
|
||
if (persist === 'no' || persist === '0' || persist === 'ephemeral' || persist === 'false') {
|
||
argv.push('--no-persist')
|
||
}
|
||
argv.push(invite)
|
||
await discordDefer(interaction)
|
||
const got = await discordHdmsCli(ctx, argv)
|
||
return discordHdmsHud(ctx, interaction, got.error || got.text || 'Paired.')
|
||
}
|
||
return discordHdmsHud(ctx, interaction, '')
|
||
}
|
||
|
||
async function discordHdmsAction(ctx, interaction, id) {
|
||
const rec = discordHdmsGet(interaction)
|
||
if (id === 'hdms:home' || id === 'hdms:refresh' || id === 'hdms:no') {
|
||
if (id === 'hdms:no') rec.confirm = ''
|
||
return discordSendResult(ctx, interaction, await discordHdmsHud(ctx, interaction, ''))
|
||
}
|
||
if (id === 'hdms:pick') {
|
||
rec.sel = String((interaction.values && interaction.values[0]) || '')
|
||
rec.confirm = ''
|
||
return discordSendResult(ctx, interaction, await discordHdmsHud(ctx, interaction, ''))
|
||
}
|
||
if (id === 'hdms:health') {
|
||
return discordSendResult(ctx, interaction, await discordHdmsHealthView(ctx))
|
||
}
|
||
if (id === 'hdms:hints') {
|
||
return discordSendResult(ctx, interaction, await discordHdmsHintsView(ctx))
|
||
}
|
||
if (id === 'hdms:create') {
|
||
return discordSendResult(ctx, interaction, { modal: 'hdms:create' })
|
||
}
|
||
if (id === 'hdms:add') {
|
||
return discordSendResult(ctx, interaction, { modal: 'hdms:add' })
|
||
}
|
||
if (id === 'hdms:pair') {
|
||
return discordSendResult(ctx, interaction, { modal: 'hdms:pair' })
|
||
}
|
||
if (id === 'hdms:show') {
|
||
if (!rec.sel) return discordSendResult(ctx, interaction, await discordHdmsHud(ctx, interaction, 'Pick a drive first.'))
|
||
return discordSendResult(ctx, interaction, await discordHdmsShowView(ctx, interaction, rec.sel))
|
||
}
|
||
if (id === 'hdms:browse') {
|
||
if (!rec.sel) return discordSendResult(ctx, interaction, await discordHdmsHud(ctx, interaction, 'Pick a drive first.'))
|
||
return discordSendResult(ctx, interaction, { files: '/mnt/' + rec.sel })
|
||
}
|
||
if (id === 'hdms:rm') {
|
||
if (!rec.sel) return discordSendResult(ctx, interaction, await discordHdmsHud(ctx, interaction, 'Pick a drive first.'))
|
||
rec.confirm = rec.sel
|
||
return discordSendResult(ctx, interaction, await discordHdmsHud(ctx, interaction, ''))
|
||
}
|
||
if (id === 'hdms:ok') {
|
||
const label = rec.confirm || rec.sel
|
||
rec.confirm = ''
|
||
rec.sel = ''
|
||
if (!label) return discordSendResult(ctx, interaction, await discordHdmsHud(ctx, interaction, ''))
|
||
const empty = function () {
|
||
return ''
|
||
}
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordCmdHandleHdms(ctx, interaction, 'remove', function (k) {
|
||
return k === 'label' ? label : empty()
|
||
})
|
||
)
|
||
}
|
||
if (id === 'hdms:invite' || id === 'hdms:inviterw' || id === 'hdms:invite0') {
|
||
const label = id === 'hdms:invite0' ? '' : rec.sel
|
||
const mode = id === 'hdms:inviterw' ? 'rw' : 'pair'
|
||
if (id !== 'hdms:invite0' && !label) {
|
||
return discordSendResult(ctx, interaction, await discordHdmsHud(ctx, interaction, 'Pick a drive first.'))
|
||
}
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordCmdHandleHdms(ctx, interaction, 'invite', function (k) {
|
||
if (k === 'label') return label
|
||
if (k === 'mode') return mode
|
||
return ''
|
||
})
|
||
)
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordHdmsHud(ctx, interaction, ''))
|
||
}
|
||
|
||
var BARE_OS_DISCORD_HOLESAIL_SESSIONS = Object.create(null)
|
||
var BARE_OS_DISCORD_HOLESAIL_UNIT = 'bare-holesail'
|
||
|
||
function discordHolesailKey(interaction) {
|
||
return discordInteractionUserId(interaction) || 'anon'
|
||
}
|
||
|
||
function discordHolesailGet(interaction) {
|
||
const k = discordHolesailKey(interaction)
|
||
let rec = BARE_OS_DISCORD_HOLESAIL_SESSIONS[k]
|
||
if (!rec) {
|
||
rec = { sel: '', confirm: '', atMs: Date.now() }
|
||
BARE_OS_DISCORD_HOLESAIL_SESSIONS[k] = rec
|
||
}
|
||
rec.atMs = Date.now()
|
||
return rec
|
||
}
|
||
|
||
function discordHolesailIdOk(id) {
|
||
return /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(String(id || ''))
|
||
}
|
||
|
||
function discordHolesailRedact(text) {
|
||
return discordCmdRedact(String(text == null ? '' : text)).replace(
|
||
/\b[0-9a-f]{64}\b/gi,
|
||
'[redacted-seed]'
|
||
)
|
||
}
|
||
|
||
function discordHolesailBool(s) {
|
||
const v = String(s == null ? '' : s).trim().toLowerCase()
|
||
if (!v) return null
|
||
if (v === '1' || v === 'true' || v === 'yes' || v === 'on' || v === 'udp') return true
|
||
if (v === '0' || v === 'false' || v === 'no' || v === 'off' || v === 'tcp') return false
|
||
return null
|
||
}
|
||
|
||
function discordParseHolesailKv(parts) {
|
||
const kv = {}
|
||
for (let i = 0; i < parts.length; i++) {
|
||
const p = String(parts[i] || '')
|
||
const eq = p.indexOf('=')
|
||
if (eq > 0) kv[p.slice(0, eq)] = p.slice(eq + 1)
|
||
}
|
||
return kv
|
||
}
|
||
|
||
function discordParseHolesailList(text) {
|
||
const rows = []
|
||
let daemon = ''
|
||
let path = ''
|
||
const lines = String(text || '').split(/\r?\n/)
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i].trim()
|
||
if (!line) continue
|
||
if (/^state:\s*/i.test(line)) {
|
||
path = line.replace(/^state:\s*/i, '').trim()
|
||
continue
|
||
}
|
||
if (/^daemon:\s*/i.test(line)) {
|
||
daemon = line.replace(/^daemon:\s*/i, '').trim()
|
||
continue
|
||
}
|
||
if (/^connections:\s*/i.test(line)) continue
|
||
if (line.indexOf('(no connections)') >= 0) continue
|
||
if (/^holesail:/i.test(line)) continue
|
||
const parts = line.split('\t')
|
||
const id = String(parts[0] || '').trim()
|
||
if (!id || !discordHolesailIdOk(id)) continue
|
||
const kv = discordParseHolesailKv(parts.slice(2))
|
||
const invalid = parts.join('\t').indexOf('INVALID:') >= 0
|
||
rows.push({
|
||
id: id,
|
||
mode: String(parts[1] || kv.mode || '').trim() || '?',
|
||
enabled: String(kv.enabled || '') === 'true',
|
||
live: String(kv.live || '') === 'true',
|
||
port: String(kv.port || '').trim(),
|
||
host: String(kv.host || '').trim(),
|
||
udp: String(kv.udp || ''),
|
||
secure: String(kv.secure || ''),
|
||
url: String(kv.url || '').trim(),
|
||
invalid: invalid
|
||
})
|
||
}
|
||
return { rows: rows, daemon: daemon, path: path }
|
||
}
|
||
|
||
function discordParseHolesailShow(text) {
|
||
const out = {}
|
||
const lines = String(text || '').split(/\r?\n/)
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i]
|
||
const tab = line.indexOf('\t')
|
||
if (tab < 1) continue
|
||
const k = line.slice(0, tab).trim()
|
||
const v = line.slice(tab + 1).trim()
|
||
if (k) out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
async function discordHolesailCli(ctx, argv) {
|
||
if (typeof ctx.bareOsRunHolesailCli !== 'function') {
|
||
return {
|
||
text: '',
|
||
error: 'Holesail unavailable (`ctx.bareOsRunHolesailCli` missing).'
|
||
}
|
||
}
|
||
let out = ''
|
||
try {
|
||
out = await discordCmdCapture(ctx, function () {
|
||
return ctx.bareOsRunHolesailCli(argv)
|
||
})
|
||
} catch (err) {
|
||
return { text: String((err && err.message) || err), error: String((err && err.message) || err) }
|
||
}
|
||
const fail = ctx.exitCode && Number(ctx.exitCode) !== 0
|
||
return {
|
||
text: String(out || ''),
|
||
error: fail ? String(out || 'holesail failed').trim() : ''
|
||
}
|
||
}
|
||
|
||
async function discordHolesailListRows(ctx) {
|
||
const got = await discordHolesailCli(ctx, ['holesail', 'list'])
|
||
const parsed = discordParseHolesailList(got.text)
|
||
return {
|
||
rows: parsed.rows,
|
||
daemon: parsed.daemon,
|
||
path: parsed.path,
|
||
error: got.error,
|
||
raw: got.text
|
||
}
|
||
}
|
||
|
||
async function discordSuggestHolesailIds(ctx, q) {
|
||
const got = await discordHolesailListRows(ctx)
|
||
const names = got.rows.map(function (r) {
|
||
return r.id
|
||
})
|
||
return discordFilterChoices(names, q)
|
||
}
|
||
|
||
function discordHolesailUnavailable(note) {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Holesail',
|
||
color: BARE_OS_DISCORD_COLOR_WARN,
|
||
desc:
|
||
(note || 'Managed Holesail needs `ctx.bareOsRunHolesailCli` from the booter.') +
|
||
'\nUse `/bin/holesail` on the guest TTY, or start **bare-holesail**.',
|
||
fields: [
|
||
discordField('State', '`~/.holesail/state.json`', false),
|
||
discordField('Unit', '`systemctl status bare-holesail`', false)
|
||
]
|
||
})
|
||
)
|
||
}
|
||
|
||
function discordHolesailDaemonLive(listed) {
|
||
const d = String((listed && listed.daemon) || '').toLowerCase()
|
||
return d.indexOf('running') >= 0 && d.indexOf('no managed') < 0
|
||
}
|
||
|
||
function discordHolesailRowBits(row) {
|
||
const bits = [row.mode || '?']
|
||
bits.push(row.enabled ? 'enabled' : 'disabled')
|
||
bits.push(row.live ? 'live' : 'stopped')
|
||
if (row.host || row.port) {
|
||
bits.push((row.host || '') + (row.port ? ':' + row.port : ''))
|
||
}
|
||
if (row.udp === 'true') bits.push('udp')
|
||
if (row.secure === 'true') bits.push('secure')
|
||
if (row.invalid) bits.push('INVALID')
|
||
return bits.join(' · ')
|
||
}
|
||
|
||
async function discordHolesailHud(ctx, interaction, note) {
|
||
if (typeof ctx.bareOsRunHolesailCli !== 'function') {
|
||
return discordHolesailUnavailable()
|
||
}
|
||
const rec = discordHolesailGet(interaction)
|
||
const listed = await discordHolesailListRows(ctx)
|
||
const rows = listed.rows
|
||
const ids = {}
|
||
for (let i = 0; i < rows.length; i++) ids[rows[i].id] = 1
|
||
if (rec.sel && !ids[rec.sel]) rec.sel = ''
|
||
if (rec.confirm && !ids[rec.confirm]) rec.confirm = ''
|
||
const fields = []
|
||
for (let i = 0; i < rows.length && fields.length < 20; i++) {
|
||
const r = rows[i]
|
||
const mark = rec.sel === r.id ? '▸ ' : ''
|
||
let val = discordHolesailRowBits(r)
|
||
if (r.url) val += '\n`' + r.url.slice(0, 80) + (r.url.length > 80 ? '…' : '') + '`'
|
||
fields.push(discordField(mark + r.id, val || '—', false))
|
||
}
|
||
const sel = discordSelect(
|
||
'holesail:pick',
|
||
rows.length ? 'Select a tunnel…' : 'No connections',
|
||
rows.map(function (r) {
|
||
return {
|
||
label: r.id,
|
||
value: r.id,
|
||
description: discordHolesailRowBits(r).slice(0, 100)
|
||
}
|
||
})
|
||
)
|
||
const top = discordButtons([
|
||
{ id: 'holesail:refresh', label: 'Refresh', style: 1 },
|
||
{ id: 'holesail:addsrv', label: 'Add server' },
|
||
{ id: 'holesail:addcli', label: 'Add client' },
|
||
{ id: 'holesail:status', label: 'Status' },
|
||
{ id: 'holesail:logs', label: 'Logs' }
|
||
])
|
||
let acts
|
||
if (rec.sel && rec.confirm === rec.sel) {
|
||
acts = discordButtons([
|
||
{ id: 'holesail:ok', label: 'Confirm remove', style: 4 },
|
||
{ id: 'holesail:no', label: 'Cancel' }
|
||
])
|
||
} else if (rec.sel) {
|
||
const cur = rows.filter(function (r) {
|
||
return r.id === rec.sel
|
||
})[0]
|
||
acts = discordButtons([
|
||
{ id: 'holesail:show', label: 'Show', style: 1 },
|
||
{
|
||
id: cur && cur.live ? 'holesail:stop' : 'holesail:start',
|
||
label: cur && cur.live ? 'Stop' : 'Start',
|
||
style: cur && cur.live ? 4 : 3
|
||
},
|
||
{
|
||
id: cur && cur.enabled ? 'holesail:disable' : 'holesail:enable',
|
||
label: cur && cur.enabled ? 'Disable' : 'Enable'
|
||
},
|
||
{ id: 'holesail:edit', label: 'Edit' },
|
||
{ id: 'holesail:rm', label: 'Remove', style: 4 }
|
||
])
|
||
} else {
|
||
acts = discordButtons([
|
||
{ id: 'holesail:svcstart', label: 'Start unit', style: 3 },
|
||
{ id: 'holesail:svcstop', label: 'Stop unit', style: 4 },
|
||
{ id: 'holesail:svcrestart', label: 'Restart unit' },
|
||
{ id: 'holesail:path', label: 'Path' }
|
||
])
|
||
}
|
||
let desc =
|
||
'Managed tunnels (`/bin/holesail`). Daemon: **' +
|
||
(listed.daemon || 'unknown') +
|
||
'**.'
|
||
if (listed.path) desc += '\nState: `' + listed.path + '`'
|
||
if (note) desc = note + '\n' + desc
|
||
if (listed.error) desc += '\n' + listed.error
|
||
if (rec.sel) desc += '\nSelected: **' + rec.sel + '**'
|
||
if (rec.confirm) desc += '\n**Remove `' + rec.confirm + '`?** State row and live tunnel go away.'
|
||
if (!rows.length) {
|
||
desc +=
|
||
'\nNo connections. **Add server** to share a local port, or **Add client** to connect to an `hs://` URL.'
|
||
}
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Holesail · ' + rows.length,
|
||
desc: desc,
|
||
fields: fields,
|
||
color: listed.error
|
||
? BARE_OS_DISCORD_COLOR_WARN
|
||
: discordHolesailDaemonLive(listed)
|
||
? BARE_OS_DISCORD_COLOR_OK
|
||
: BARE_OS_DISCORD_COLOR
|
||
}),
|
||
{ components: [sel, top, acts].filter(Boolean) }
|
||
)
|
||
}
|
||
|
||
async function discordHolesailShowView(ctx, interaction, id) {
|
||
const got = await discordHolesailCli(ctx, ['holesail', 'show', id])
|
||
const raw = discordHolesailRedact(got.text || got.error || '')
|
||
const rec = discordParseHolesailShow(raw)
|
||
const fields = [
|
||
discordField('Id', String(rec.id || id), true),
|
||
discordField('Mode', String(rec.mode || '—'), true),
|
||
discordField('Enabled', String(rec.enabled || '—'), true),
|
||
discordField('Live', String(rec.live || '—'), true)
|
||
]
|
||
if (rec.port) fields.push(discordField('Port', String(rec.port), true))
|
||
if (rec.host) fields.push(discordField('Host', String(rec.host), true))
|
||
if (rec.udp) fields.push(discordField('UDP', String(rec.udp), true))
|
||
if (rec.secure) fields.push(discordField('Secure', String(rec.secure), true))
|
||
if (rec.log) fields.push(discordField('Log', String(rec.log), true))
|
||
if (rec.seed) fields.push(discordField('Seed set', String(rec.seed), true))
|
||
if (rec.daemon) fields.push(discordField('Daemon', String(rec.daemon), true))
|
||
if (rec.state) fields.push(discordField('State', '`' + String(rec.state) + '`', false))
|
||
if (rec.url) fields.push(discordField('URL', '`' + String(rec.url) + '`', false))
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Holesail · ' + id,
|
||
desc:
|
||
got.error ||
|
||
(rec.url
|
||
? 'Shareable URL is the `hs://` key. Seed material is never shown.'
|
||
: 'Connection record (seed redacted).'),
|
||
fields: fields,
|
||
color: got.error ? BARE_OS_DISCORD_COLOR_ERR : BARE_OS_DISCORD_COLOR_OK
|
||
}),
|
||
{
|
||
components: [
|
||
discordButtons([
|
||
{ id: 'holesail:url', label: 'Copy URL', style: 1 },
|
||
{ id: 'holesail:restart', label: 'Restart' },
|
||
{ id: 'holesail:home', label: 'Holesail' }
|
||
])
|
||
]
|
||
}
|
||
)
|
||
}
|
||
|
||
async function discordHolesailStatusView(ctx, interaction) {
|
||
const got = await discordHolesailCli(ctx, ['holesail', 'status'])
|
||
const parsed = discordParseHolesailList(got.text)
|
||
const counts = {}
|
||
let total = ''
|
||
const lines = String(got.text || '').split(/\r?\n/)
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (/^connections:\s*/i.test(lines[i])) {
|
||
const bits = lines[i].replace(/^connections:\s*/i, '').split(/\s+/)
|
||
if (bits[0] && bits[0].indexOf('=') < 0) total = bits[0]
|
||
Object.assign(counts, discordParseHolesailKv(bits))
|
||
}
|
||
}
|
||
const fields = [
|
||
discordField('Daemon', parsed.daemon || '—', false),
|
||
discordField('State', parsed.path ? '`' + parsed.path + '`' : '—', false),
|
||
discordField('Connections', String(total || parsed.rows.length || '0'), true),
|
||
discordField('Enabled', String(counts.enabled != null ? counts.enabled : '—'), true),
|
||
discordField('Live', String(counts.live != null ? counts.live : '—'), true),
|
||
discordField('Servers', String(counts.server != null ? counts.server : '—'), true),
|
||
discordField('Clients', String(counts.client != null ? counts.client : '—'), true)
|
||
]
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Holesail status',
|
||
desc: got.error || 'Managed daemon + persisted rows.',
|
||
fields: fields,
|
||
color: got.error
|
||
? BARE_OS_DISCORD_COLOR_ERR
|
||
: discordHolesailDaemonLive(parsed)
|
||
? BARE_OS_DISCORD_COLOR_OK
|
||
: BARE_OS_DISCORD_COLOR_WARN
|
||
}),
|
||
{
|
||
components: [
|
||
discordButtons([
|
||
{ id: 'holesail:home', label: 'Holesail', style: 1 },
|
||
{ id: 'holesail:svcstart', label: 'Start unit', style: 3 },
|
||
{ id: 'holesail:svcstop', label: 'Stop unit' },
|
||
{ id: 'holesail:svcrestart', label: 'Restart unit' },
|
||
{ id: 'holesail:logs', label: 'Logs' }
|
||
])
|
||
]
|
||
}
|
||
)
|
||
}
|
||
|
||
async function discordHolesailUrlView(ctx, interaction, id) {
|
||
const got = await discordHolesailCli(ctx, ['holesail', 'show', id])
|
||
const rec = discordParseHolesailShow(discordHolesailRedact(got.text || ''))
|
||
const url = String(rec.url || '').trim()
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Holesail URL · ' + id,
|
||
desc: url
|
||
? 'Share this with a **client**:\n' + discordCmdFence(url, '', 900)
|
||
: got.error || 'No `hs://` URL yet. Start a **server** tunnel and refresh after `ready()`.',
|
||
color: url ? BARE_OS_DISCORD_COLOR_OK : BARE_OS_DISCORD_COLOR_WARN,
|
||
footer: 'Peer: holesail add ID --client --key <url> --port N'
|
||
}),
|
||
{
|
||
components: [
|
||
discordButtons([
|
||
{ id: 'holesail:show', label: 'Show' },
|
||
{ id: 'holesail:home', label: 'Holesail' }
|
||
])
|
||
]
|
||
}
|
||
)
|
||
}
|
||
|
||
async function discordHolesailService(ctx, action) {
|
||
const act = String(action || 'status').toLowerCase()
|
||
if (typeof ctx.bareOsRunSystemctlCli !== 'function') {
|
||
return { text: '', error: 'systemctl unavailable (`ctx.bareOsRunSystemctlCli` missing).' }
|
||
}
|
||
const argv =
|
||
act === 'logs'
|
||
? ['journalctl', '-u', BARE_OS_DISCORD_HOLESAIL_UNIT, '--lines', '30']
|
||
: ['systemctl', act, BARE_OS_DISCORD_HOLESAIL_UNIT]
|
||
let out = ''
|
||
try {
|
||
out = await discordCmdCapture(ctx, function () {
|
||
return ctx.bareOsRunSystemctlCli(argv)
|
||
})
|
||
} catch (err) {
|
||
return { text: String((err && err.message) || err), error: String((err && err.message) || err) }
|
||
}
|
||
const fail = ctx.exitCode && Number(ctx.exitCode) !== 0
|
||
return {
|
||
text: String(out || ''),
|
||
error: fail ? String(out || 'systemctl failed').trim() : ''
|
||
}
|
||
}
|
||
|
||
function discordHolesailAddArgv(id, mode, opt) {
|
||
const argv = ['holesail', 'add', id, mode === 'client' ? '--client' : '--server']
|
||
const port = String(opt('port') || '').trim()
|
||
const host = String(opt('host') || '').trim()
|
||
const key = String(opt('key') || '').trim()
|
||
const udp = discordHolesailBool(opt('udp'))
|
||
const secure = discordHolesailBool(opt('secure'))
|
||
if (port) {
|
||
argv.push('--port')
|
||
argv.push(port)
|
||
}
|
||
if (host) {
|
||
argv.push('--host')
|
||
argv.push(host)
|
||
}
|
||
if (key) {
|
||
argv.push('--key')
|
||
argv.push(key)
|
||
}
|
||
if (udp === true) argv.push('--udp')
|
||
if (secure === true) argv.push('--secure')
|
||
if (secure === false) argv.push('--no-secure')
|
||
return argv
|
||
}
|
||
|
||
function discordHolesailEditArgv(id, opt) {
|
||
const argv = ['holesail', 'edit', id]
|
||
let touched = false
|
||
const port = String(opt('port') || '').trim()
|
||
const hostRaw = opt('host')
|
||
const host = hostRaw == null ? '' : String(hostRaw).trim()
|
||
const key = String(opt('key') || '').trim()
|
||
const udp = discordHolesailBool(opt('udp'))
|
||
const secure = discordHolesailBool(opt('secure'))
|
||
if (port) {
|
||
argv.push('--port')
|
||
argv.push(port)
|
||
touched = true
|
||
}
|
||
if (hostRaw != null && String(hostRaw) !== '') {
|
||
if (!host || host === '-' || host.toLowerCase() === 'clear') argv.push('--clear-host')
|
||
else {
|
||
argv.push('--host')
|
||
argv.push(host)
|
||
}
|
||
touched = true
|
||
}
|
||
if (key) {
|
||
argv.push('--key')
|
||
argv.push(key)
|
||
touched = true
|
||
}
|
||
if (udp === true) {
|
||
argv.push('--udp')
|
||
touched = true
|
||
} else if (udp === false) {
|
||
argv.push('--no-udp')
|
||
touched = true
|
||
}
|
||
if (secure === true) {
|
||
argv.push('--secure')
|
||
touched = true
|
||
} else if (secure === false) {
|
||
argv.push('--no-secure')
|
||
touched = true
|
||
}
|
||
return { argv: argv, touched: touched }
|
||
}
|
||
|
||
async function discordCmdHandleHolesail(ctx, interaction, sub, opt) {
|
||
const s = String(sub || 'list')
|
||
if (s === 'help') {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Holesail',
|
||
desc:
|
||
'Manage persisted P2P tunnels (`~/.holesail/state.json`) and the **bare-holesail** unit.\n' +
|
||
'Same surface as `/bin/holesail`. **show** never prints seed material.',
|
||
fields: [
|
||
discordField('/holesail', 'Interactive manager (default)'),
|
||
discordField('/holesail add', '`id` + `mode` server|client; port, host, key, udp, secure'),
|
||
discordField('/holesail edit', '`id` plus fields to change (live tunnels restart)'),
|
||
discordField('/holesail show / url', '`id` — record or shareable `hs://` URL'),
|
||
discordField('/holesail start|stop|restart', '`id` — live tunnel'),
|
||
discordField('/holesail enable|disable|remove', '`id`'),
|
||
discordField('/holesail status / path / logs', 'Daemon snapshot, state file, journal'),
|
||
discordField('/holesail service', '`action` start|stop|restart|status of **bare-holesail**')
|
||
]
|
||
})
|
||
)
|
||
}
|
||
if (typeof ctx.bareOsRunHolesailCli !== 'function') {
|
||
return discordHolesailUnavailable()
|
||
}
|
||
if (s === 'status') return discordHolesailStatusView(ctx, interaction)
|
||
if (s === 'path') {
|
||
const got = await discordHolesailCli(ctx, ['holesail', 'path'])
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Holesail state path',
|
||
desc: discordCmdFence((got.text || got.error || '').trim() || '—', '', 900),
|
||
color: got.error ? BARE_OS_DISCORD_COLOR_ERR : BARE_OS_DISCORD_COLOR
|
||
}),
|
||
{ components: [discordButtons([{ id: 'holesail:home', label: 'Holesail' }])] }
|
||
)
|
||
}
|
||
if (s === 'logs') {
|
||
await discordDefer(interaction)
|
||
const got = await discordHolesailService(ctx, 'logs')
|
||
const body = discordHolesailRedact(got.text || got.error || '')
|
||
if (!String(body).trim()) {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Holesail journal',
|
||
desc: 'Empty journal for `bare-holesail`.'
|
||
}),
|
||
{ components: [discordButtons([{ id: 'holesail:home', label: 'Holesail' }])] }
|
||
)
|
||
}
|
||
return {
|
||
more: {
|
||
title: 'Holesail journal',
|
||
body: body,
|
||
fence: true,
|
||
components: [discordButtons([{ id: 'holesail:home', label: 'Holesail' }])]
|
||
}
|
||
}
|
||
}
|
||
if (s === 'service') {
|
||
const action = String(opt('action') || 'status').toLowerCase()
|
||
if (action === 'logs') return discordCmdHandleHolesail(ctx, interaction, 'logs', opt)
|
||
await discordDefer(interaction)
|
||
const got = await discordHolesailService(ctx, action)
|
||
if (action === 'status') {
|
||
const body = discordHolesailRedact(got.text || got.error || '')
|
||
return {
|
||
more: {
|
||
title: 'bare-holesail',
|
||
body: body || '(empty)',
|
||
fence: true,
|
||
components: [discordButtons([{ id: 'holesail:home', label: 'Holesail' }])]
|
||
}
|
||
}
|
||
}
|
||
return discordHolesailHud(
|
||
ctx,
|
||
interaction,
|
||
got.error || got.text || action + ' **bare-holesail**.'
|
||
)
|
||
}
|
||
if (s === 'add') {
|
||
const id = String(opt('id') || '').trim()
|
||
const mode = String(opt('mode') || '').toLowerCase()
|
||
if (!id) {
|
||
return { modal: mode === 'client' ? 'holesail:addcli' : 'holesail:addsrv' }
|
||
}
|
||
if (!discordHolesailIdOk(id)) {
|
||
return { text: 'Invalid id (alphanumeric, then . _ - ; max 64).', ephemeral: true }
|
||
}
|
||
const inferred = mode || (String(opt('key') || '').trim() ? 'client' : 'server')
|
||
if (inferred === 'client' && !String(opt('key') || '').trim()) {
|
||
return { modal: 'holesail:addcli' }
|
||
}
|
||
await discordDefer(interaction)
|
||
const got = await discordHolesailCli(ctx, discordHolesailAddArgv(id, inferred, opt))
|
||
discordHolesailGet(interaction).sel = id
|
||
return discordHolesailHud(ctx, interaction, got.error || got.text || 'Added `' + id + '`.')
|
||
}
|
||
if (s === 'edit') {
|
||
const id = String(opt('id') || '').trim() || discordHolesailGet(interaction).sel
|
||
if (!id) return discordHolesailHud(ctx, interaction, 'Pick a tunnel to edit.')
|
||
if (!discordHolesailIdOk(id)) {
|
||
return { text: 'Invalid id (alphanumeric, then . _ - ; max 64).', ephemeral: true }
|
||
}
|
||
discordHolesailGet(interaction).sel = id
|
||
const built = discordHolesailEditArgv(id, opt)
|
||
if (!built.touched) return { modal: 'holesail:edit' }
|
||
await discordDefer(interaction)
|
||
const got = await discordHolesailCli(ctx, built.argv)
|
||
return discordHolesailHud(ctx, interaction, got.error || got.text || 'Updated `' + id + '`.')
|
||
}
|
||
if (s === 'remove') {
|
||
const id = String(opt('id') || '').trim()
|
||
if (!id) return discordHolesailHud(ctx, interaction, 'Pick a tunnel to remove.')
|
||
await discordDefer(interaction)
|
||
const got = await discordHolesailCli(ctx, ['holesail', 'remove', id])
|
||
const rec = discordHolesailGet(interaction)
|
||
rec.sel = ''
|
||
rec.confirm = ''
|
||
return discordHolesailHud(ctx, interaction, got.error || got.text || 'Removed `' + id + '`.')
|
||
}
|
||
if (s === 'show') {
|
||
const id = String(opt('id') || '').trim() || discordHolesailGet(interaction).sel
|
||
if (!id) return discordHolesailHud(ctx, interaction, 'Pick a tunnel to show.')
|
||
discordHolesailGet(interaction).sel = id
|
||
return discordHolesailShowView(ctx, interaction, id)
|
||
}
|
||
if (s === 'url') {
|
||
const id = String(opt('id') || '').trim() || discordHolesailGet(interaction).sel
|
||
if (!id) return discordHolesailHud(ctx, interaction, 'Pick a tunnel to show its URL.')
|
||
discordHolesailGet(interaction).sel = id
|
||
return discordHolesailUrlView(ctx, interaction, id)
|
||
}
|
||
if (s === 'start' || s === 'stop' || s === 'restart' || s === 'enable' || s === 'disable') {
|
||
const id = String(opt('id') || '').trim() || discordHolesailGet(interaction).sel
|
||
if (!id) return discordHolesailHud(ctx, interaction, 'Pick a tunnel first.')
|
||
discordHolesailGet(interaction).sel = id
|
||
await discordDefer(interaction)
|
||
const got = await discordHolesailCli(ctx, ['holesail', s, id])
|
||
return discordHolesailHud(ctx, interaction, got.error || got.text || s + ' `' + id + '`.')
|
||
}
|
||
return discordHolesailHud(ctx, interaction, '')
|
||
}
|
||
|
||
async function discordHolesailAction(ctx, interaction, id) {
|
||
const rec = discordHolesailGet(interaction)
|
||
if (id === 'holesail:home' || id === 'holesail:refresh' || id === 'holesail:no') {
|
||
if (id === 'holesail:no') rec.confirm = ''
|
||
return discordSendResult(ctx, interaction, await discordHolesailHud(ctx, interaction, ''))
|
||
}
|
||
if (id === 'holesail:pick') {
|
||
rec.sel = String((interaction.values && interaction.values[0]) || '')
|
||
rec.confirm = ''
|
||
return discordSendResult(ctx, interaction, await discordHolesailHud(ctx, interaction, ''))
|
||
}
|
||
if (id === 'holesail:status') {
|
||
return discordSendResult(ctx, interaction, await discordHolesailStatusView(ctx, interaction))
|
||
}
|
||
if (id === 'holesail:path') {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordCmdHandleHolesail(ctx, interaction, 'path', function () {
|
||
return ''
|
||
})
|
||
)
|
||
}
|
||
if (id === 'holesail:logs') {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordCmdHandleHolesail(ctx, interaction, 'logs', function () {
|
||
return ''
|
||
})
|
||
)
|
||
}
|
||
if (id === 'holesail:addsrv') {
|
||
return discordSendResult(ctx, interaction, { modal: 'holesail:addsrv' })
|
||
}
|
||
if (id === 'holesail:addcli') {
|
||
return discordSendResult(ctx, interaction, { modal: 'holesail:addcli' })
|
||
}
|
||
if (id === 'holesail:edit') {
|
||
if (!rec.sel) {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordHolesailHud(ctx, interaction, 'Pick a tunnel first.')
|
||
)
|
||
}
|
||
return discordSendResult(ctx, interaction, { modal: 'holesail:edit' })
|
||
}
|
||
if (id === 'holesail:show') {
|
||
if (!rec.sel) {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordHolesailHud(ctx, interaction, 'Pick a tunnel first.')
|
||
)
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordHolesailShowView(ctx, interaction, rec.sel))
|
||
}
|
||
if (id === 'holesail:url') {
|
||
if (!rec.sel) {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordHolesailHud(ctx, interaction, 'Pick a tunnel first.')
|
||
)
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordHolesailUrlView(ctx, interaction, rec.sel))
|
||
}
|
||
if (id === 'holesail:rm') {
|
||
if (!rec.sel) {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordHolesailHud(ctx, interaction, 'Pick a tunnel first.')
|
||
)
|
||
}
|
||
rec.confirm = rec.sel
|
||
return discordSendResult(ctx, interaction, await discordHolesailHud(ctx, interaction, ''))
|
||
}
|
||
if (id === 'holesail:ok') {
|
||
const conn = rec.confirm || rec.sel
|
||
rec.confirm = ''
|
||
rec.sel = ''
|
||
if (!conn) return discordSendResult(ctx, interaction, await discordHolesailHud(ctx, interaction, ''))
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordCmdHandleHolesail(ctx, interaction, 'remove', function (k) {
|
||
return k === 'id' ? conn : ''
|
||
})
|
||
)
|
||
}
|
||
if (
|
||
id === 'holesail:start' ||
|
||
id === 'holesail:stop' ||
|
||
id === 'holesail:restart' ||
|
||
id === 'holesail:enable' ||
|
||
id === 'holesail:disable'
|
||
) {
|
||
const act = id.slice('holesail:'.length)
|
||
if (!rec.sel) {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordHolesailHud(ctx, interaction, 'Pick a tunnel first.')
|
||
)
|
||
}
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordCmdHandleHolesail(ctx, interaction, act, function (k) {
|
||
return k === 'id' ? rec.sel : ''
|
||
})
|
||
)
|
||
}
|
||
if (id === 'holesail:svcstart' || id === 'holesail:svcstop' || id === 'holesail:svcrestart') {
|
||
const act = id === 'holesail:svcstart' ? 'start' : id === 'holesail:svcstop' ? 'stop' : 'restart'
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordCmdHandleHolesail(ctx, interaction, 'service', function (k) {
|
||
return k === 'action' ? act : ''
|
||
})
|
||
)
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordHolesailHud(ctx, interaction, ''))
|
||
}
|
||
|
||
async function discordCmdHandleJournal(ctx, unit) {
|
||
if (typeof ctx.bareOsRunSystemctlCli === 'function' && unit) {
|
||
const out = await discordCmdCapture(ctx, function () {
|
||
return ctx.bareOsRunSystemctlCli([
|
||
'journalctl',
|
||
'-u',
|
||
String(unit),
|
||
'--lines',
|
||
'30'
|
||
])
|
||
})
|
||
const body = discordCmdRedact(String(out || ''))
|
||
if (!body.trim()) {
|
||
return discordResult(
|
||
discordEmbed({ title: 'Journal · ' + unit, desc: 'Empty journal for `' + unit + '`.' })
|
||
)
|
||
}
|
||
return { more: { title: 'Journal · ' + unit, body: body, fence: true } }
|
||
}
|
||
const t =
|
||
(await discordCmdReadText(ctx, '/var/log/bare-os/discord.log')) ||
|
||
(await discordCmdReadText(ctx, '/var/log/bare-os/kernel-console.log')) ||
|
||
(await discordCmdReadText(ctx, '/var/log/messages'))
|
||
const body = discordCmdRedact(String(t || ''))
|
||
if (!body.trim()) {
|
||
return discordResult(discordEmbed({ title: 'Journal', desc: 'No journal in `/var/log/bare-os`.' }))
|
||
}
|
||
return { more: { title: 'Journal', body: body, fence: true } }
|
||
}
|
||
|
||
var BARE_OS_DISCORD_AGENT_MS = 13 * 60 * 1000
|
||
var BARE_OS_DISCORD_AGENT_POLL_MS = 2500
|
||
var BARE_OS_DISCORD_AGENT_RUNS = Object.create(null)
|
||
var BARE_OS_DISCORD_AGENT_SECRET_RE =
|
||
/api[_-]?key|token|password|secret|passwd|authorization/i
|
||
|
||
function discordAgentPaths(_ctx) {
|
||
return {
|
||
dir: '~/.agent',
|
||
config: '~/.agent/config.json',
|
||
history: '~/.agent/history.json',
|
||
progress: '~/.agent/progress.txt'
|
||
}
|
||
}
|
||
|
||
function discordAgentBackend(cfg) {
|
||
const b = String((cfg && cfg.backend) || '').trim().toLowerCase()
|
||
if (b === 'rest' || b === 'openai' || b === 'http') return 'rest'
|
||
if (b === 'qvac') return 'qvac'
|
||
const p = String((cfg && cfg.provider) || '').trim().toLowerCase()
|
||
if (p === 'qvac') return 'qvac'
|
||
if (p === 'groq' || p === 'xai' || p === 'openai' || p === 'custom') return 'rest'
|
||
if (cfg && cfg.rest_api_key && String(cfg.rest_api_key).trim()) return 'rest'
|
||
return 'qvac'
|
||
}
|
||
|
||
var BARE_OS_DISCORD_QVAC_MODELS =
|
||
typeof BARE_AGENT_QVAC_CHAT_MODELS !== 'undefined' && Array.isArray(BARE_AGENT_QVAC_CHAT_MODELS)
|
||
? BARE_AGENT_QVAC_CHAT_MODELS
|
||
: [
|
||
{ id: 'QWEN3_600M_INST_Q4', family: 'qwen3', label: 'Qwen3 0.6B Instruct Q4', tools: true, ramGb: 4, profile: 'lite' },
|
||
{ id: 'QWEN3_1_7B_INST_Q4', family: 'qwen3', label: 'Qwen3 1.7B Instruct Q4', tools: true, ramGb: 8, profile: 'recommended' },
|
||
{ id: 'QWEN3_4B_INST_Q4_K_M', family: 'qwen3', label: 'Qwen3 4B Instruct Q4_K_M', tools: true, ramGb: 16, profile: 'strong' },
|
||
{ id: 'QWEN3_4B_Q4_K_M', family: 'qwen3', label: 'Qwen3 4B Q4_K_M', tools: true, ramGb: 16 },
|
||
{ id: 'QWEN3_8B_INST_Q4_K_M', family: 'qwen3', label: 'Qwen3 8B Instruct Q4_K_M', tools: true, ramGb: 24 },
|
||
{ id: 'LLAMA_TOOL_CALLING_1B_INST_Q4_K', family: 'llama', label: 'Llama 3.2 1B tool-calling', tools: true, ramGb: 6, profile: 'tool-tiny' },
|
||
{ id: 'LLAMA_3_2_1B_INST_Q4_0', family: 'llama', label: 'Llama 3.2 1B Instruct Q4_0', tools: true, ramGb: 6 },
|
||
{ id: 'SMOLLM2_360M_INST_Q8', family: 'smol', label: 'SmolLM2 360M Instruct Q8', tools: false, ramGb: 3 },
|
||
{ id: 'GPT_OSS_20B_INST_Q4_K_M', family: 'gpt-oss', label: 'GPT-OSS 20B Instruct Q4_K_M', tools: true, ramGb: 24 },
|
||
{ id: 'GEMMA4_2B_MULTIMODAL_Q4_K_M', family: 'gemma', label: 'Gemma 4 2B multimodal Q4', tools: true, ramGb: 8 },
|
||
{ id: 'GEMMA4_4B_MULTIMODAL_Q4_K_M', family: 'gemma', label: 'Gemma 4 4B multimodal Q4', tools: true, ramGb: 16 },
|
||
{ id: 'GEMMA4_31B_MULTIMODAL_Q4_K_M', family: 'gemma', label: 'Gemma 4 31B multimodal Q4', tools: true, ramGb: 48 },
|
||
{ id: 'QWEN3VL_2B_MULTIMODAL_Q4_K', family: 'qwen3', label: 'Qwen3-VL 2B multimodal Q4', tools: true, ramGb: 10 },
|
||
{ id: 'QWEN3_5_2B_MULTIMODAL_Q4_K_M', family: 'qwen3.5', label: 'Qwen3.5 2B multimodal Q4', tools: true, ramGb: 10 },
|
||
{ id: 'QWEN3_5_4B_MULTIMODAL_Q4_K_M', family: 'qwen3.5', label: 'Qwen3.5 4B multimodal Q4', tools: true, ramGb: 16 },
|
||
{ id: 'QWEN3_5_0_8B_MULTIMODAL_Q4_K_M', family: 'qwen3.5', label: 'Qwen3.5 8B multimodal Q4', tools: true, ramGb: 24 },
|
||
{ id: 'QWEN3_5_9B_MULTIMODAL_Q4_K_M', family: 'qwen3.5', label: 'Qwen3.5 9B multimodal Q4', tools: true, ramGb: 28 },
|
||
{ id: 'QWEN3_6_27B_MULTIMODAL_Q4_K_XL', family: 'large', label: 'Qwen3.6 27B multimodal Q4', tools: true, ramGb: 48 }
|
||
]
|
||
|
||
var BARE_OS_DISCORD_REST_MODEL_FALLBACKS = {
|
||
groq: [
|
||
'llama-3.3-70b-versatile',
|
||
'llama-3.1-8b-instant',
|
||
'openai/gpt-oss-120b',
|
||
'openai/gpt-oss-20b',
|
||
'qwen/qwen3-32b',
|
||
'moonshotai/kimi-k2-instruct',
|
||
'meta-llama/llama-4-scout-17b-16e-instruct',
|
||
'meta-llama/llama-4-maverick-17b-128e-instruct'
|
||
],
|
||
xai: ['grok-4', 'grok-3', 'grok-3-mini', 'grok-3-fast', 'grok-2-1212', 'grok-2-vision-1212'],
|
||
openai: ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4o', 'gpt-4o-mini', 'o4-mini', 'o3'],
|
||
custom: []
|
||
}
|
||
|
||
var BARE_OS_DISCORD_AGENT_MODELS = Object.create(null)
|
||
var BARE_OS_DISCORD_MODEL_PAGE = 23
|
||
|
||
function discordParseOpenAiModels(json) {
|
||
if (typeof bareAgentParseOpenAiModels === 'function') return bareAgentParseOpenAiModels(json)
|
||
const raw =
|
||
json && json.data && Array.isArray(json.data) ? json.data : Array.isArray(json) ? json : []
|
||
const skip = /embed|whisper|tts|dall-e|davinci|babbage|audio|moderation|realtime|image|sora/i
|
||
const out = []
|
||
const seen = Object.create(null)
|
||
for (let i = 0; i < raw.length; i++) {
|
||
const row = raw[i] && typeof raw[i] === 'object' ? raw[i] : null
|
||
if (!row) continue
|
||
const id = String(row.id || row.name || '').trim()
|
||
if (!id || seen[id] || skip.test(id)) continue
|
||
seen[id] = 1
|
||
out.push({
|
||
id: id,
|
||
label: id,
|
||
family: String(row.owned_by || 'api'),
|
||
owned_by: String(row.owned_by || '')
|
||
})
|
||
}
|
||
out.sort(function (a, b) {
|
||
return a.id.localeCompare(b.id)
|
||
})
|
||
return out
|
||
}
|
||
|
||
function discordRestModelsFallback(provider) {
|
||
if (typeof bareAgentRestModelsFallback === 'function') return bareAgentRestModelsFallback(provider)
|
||
const key = String(provider || 'groq').trim().toLowerCase()
|
||
const ids = BARE_OS_DISCORD_REST_MODEL_FALLBACKS[key] || BARE_OS_DISCORD_REST_MODEL_FALLBACKS.groq
|
||
return (ids || []).map(function (id) {
|
||
return { id: id, label: id, family: key, owned_by: key }
|
||
})
|
||
}
|
||
|
||
function discordQvacModelForProfile(profileId) {
|
||
if (typeof bareAgentQvacModelForProfile === 'function') return bareAgentQvacModelForProfile(profileId)
|
||
const id = String(profileId || '').trim().toLowerCase()
|
||
for (let i = 0; i < BARE_OS_DISCORD_QVAC_MODELS.length; i++) {
|
||
if (BARE_OS_DISCORD_QVAC_MODELS[i].profile === id) return BARE_OS_DISCORD_QVAC_MODELS[i]
|
||
}
|
||
return BARE_OS_DISCORD_QVAC_MODELS[1] || BARE_OS_DISCORD_QVAC_MODELS[0]
|
||
}
|
||
|
||
function discordFilterModels(models, family, query) {
|
||
if (typeof bareAgentFilterModelList === 'function') {
|
||
return bareAgentFilterModelList(models, { family: family, query: query })
|
||
}
|
||
const fam = String(family || 'all').toLowerCase()
|
||
const q = String(query || '').toLowerCase()
|
||
const out = []
|
||
for (let i = 0; i < models.length; i++) {
|
||
const m = models[i]
|
||
if (fam !== 'all' && String(m.family || '').toLowerCase() !== fam) continue
|
||
if (q && (String(m.id) + ' ' + String(m.label || '')).toLowerCase().indexOf(q) === -1) continue
|
||
out.push(m)
|
||
}
|
||
return out
|
||
}
|
||
|
||
function discordAgentFetchFn(ctx) {
|
||
if (ctx && typeof ctx.httpFetch === 'function') return ctx.httpFetch.bind(ctx)
|
||
if (ctx && ctx.bare && typeof ctx.bare.fetch === 'function') return ctx.bare.fetch.bind(ctx.bare)
|
||
if (typeof fetch === 'function') return fetch
|
||
return null
|
||
}
|
||
|
||
async function discordFetchRestModels(ctx, cfg) {
|
||
const provider = String((cfg && cfg.provider) || 'groq').trim().toLowerCase()
|
||
const base = String((cfg && cfg.rest_base_url) || '').trim()
|
||
const key = String((cfg && cfg.rest_api_key) || '').trim()
|
||
const fetchFn = discordAgentFetchFn(ctx)
|
||
if (fetchFn && base) {
|
||
try {
|
||
if (typeof bareAgentFetchRestModels === 'function') {
|
||
return {
|
||
models: await bareAgentFetchRestModels(fetchFn, { baseUrl: base, apiKey: key }),
|
||
source: 'live',
|
||
error: ''
|
||
}
|
||
}
|
||
const res = await fetchFn(base.replace(/\/+$/, '') + '/models', {
|
||
method: 'GET',
|
||
headers: {
|
||
Accept: 'application/json',
|
||
Authorization: key ? 'Bearer ' + key : ''
|
||
}
|
||
})
|
||
if (res && res.ok !== false) {
|
||
const json = typeof res.json === 'function' ? await res.json() : JSON.parse(await res.text())
|
||
const models = discordParseOpenAiModels(json)
|
||
if (models.length) return { models: models, source: 'live', error: '' }
|
||
}
|
||
} catch (err) {
|
||
return {
|
||
models: discordRestModelsFallback(provider),
|
||
source: 'fallback',
|
||
error: String((err && err.message) || err)
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
models: discordRestModelsFallback(provider),
|
||
source: fetchFn ? 'fallback' : 'fallback',
|
||
error: fetchFn ? (base ? 'empty_or_denied' : 'no_base_url') : 'no_fetch'
|
||
}
|
||
}
|
||
|
||
function discordAgentModelKey(interaction, ctx) {
|
||
return discordAgentRunKey(interaction, ctx)
|
||
}
|
||
|
||
function discordAgentModelGet(interaction, ctx) {
|
||
const k = discordAgentModelKey(interaction, ctx)
|
||
if (!BARE_OS_DISCORD_AGENT_MODELS[k]) {
|
||
BARE_OS_DISCORD_AGENT_MODELS[k] = {
|
||
page: 0,
|
||
family: 'all',
|
||
query: '',
|
||
source: '',
|
||
error: '',
|
||
models: [],
|
||
backend: ''
|
||
}
|
||
}
|
||
return BARE_OS_DISCORD_AGENT_MODELS[k]
|
||
}
|
||
|
||
async function discordAgentLoadModelChoices(ctx, cfg, rec, force) {
|
||
const backend = discordAgentBackend(cfg)
|
||
rec.backend = backend
|
||
if (backend === 'qvac') {
|
||
rec.models = BARE_OS_DISCORD_QVAC_MODELS.slice()
|
||
rec.source = 'qvac-catalog'
|
||
rec.error = ''
|
||
return rec
|
||
}
|
||
if (!force && rec.models && rec.models.length && rec.source === 'live' && rec.backend === 'rest') {
|
||
return rec
|
||
}
|
||
const got = await discordFetchRestModels(ctx, cfg)
|
||
rec.models = got.models
|
||
rec.source = got.source
|
||
rec.error = got.error || ''
|
||
return rec
|
||
}
|
||
|
||
async function discordAgentApplyModel(ctx, modelId) {
|
||
const id = String(modelId || '').trim()
|
||
if (!id) throw new Error('model id required')
|
||
const cfg = (await discordAgentLoadConfig(ctx)) || {}
|
||
const backend = discordAgentBackend(cfg)
|
||
if (backend === 'qvac') {
|
||
cfg.backend = 'qvac'
|
||
cfg.provider = 'qvac'
|
||
cfg.qvac_model = id
|
||
cfg.model = id
|
||
const hit = discordQvacModelForProfile('')
|
||
for (let i = 0; i < BARE_OS_DISCORD_QVAC_MODELS.length; i++) {
|
||
if (BARE_OS_DISCORD_QVAC_MODELS[i].id === id && BARE_OS_DISCORD_QVAC_MODELS[i].profile) {
|
||
cfg.qvac_profile = BARE_OS_DISCORD_QVAC_MODELS[i].profile
|
||
break
|
||
}
|
||
}
|
||
void hit
|
||
} else {
|
||
cfg.backend = 'rest'
|
||
cfg.model = id
|
||
}
|
||
const clean = discordSanitizeAgentConfig(cfg)
|
||
await discordWriteJsonFile(ctx, '~/.agent/config.json', clean)
|
||
return clean
|
||
}
|
||
|
||
async function discordAgentModelView(ctx, interaction, note) {
|
||
const cfg = (await discordAgentLoadConfig(ctx)) || {}
|
||
const rec = discordAgentModelGet(interaction, ctx)
|
||
await discordAgentLoadModelChoices(ctx, cfg, rec, false)
|
||
const backend = discordAgentBackend(cfg)
|
||
const current = backend === 'qvac' ? String(cfg.qvac_model || cfg.model || '') : String(cfg.model || '')
|
||
const filtered = discordFilterModels(rec.models, rec.family, rec.query)
|
||
const pages = Math.max(1, Math.ceil(filtered.length / BARE_OS_DISCORD_MODEL_PAGE) || 1)
|
||
rec.page = Math.min(Math.max(0, rec.page | 0), pages - 1)
|
||
const slice = filtered.slice(rec.page * BARE_OS_DISCORD_MODEL_PAGE, rec.page * BARE_OS_DISCORD_MODEL_PAGE + BARE_OS_DISCORD_MODEL_PAGE)
|
||
const fams = { all: 1 }
|
||
for (let i = 0; i < rec.models.length; i++) {
|
||
const f = String(rec.models[i].family || '').trim()
|
||
if (f) fams[f] = 1
|
||
}
|
||
const famOpts = Object.keys(fams)
|
||
.sort()
|
||
.map(function (f) {
|
||
return { label: f === 'all' ? 'All families' : f, value: f }
|
||
})
|
||
const modelOpts = slice.map(function (m) {
|
||
const mark = m.id === current ? '▸ ' : ''
|
||
const extra =
|
||
backend === 'qvac'
|
||
? (m.tools ? 'tools' : 'no-tools') + (m.ramGb ? ' · ~' + m.ramGb + 'GB' : '')
|
||
: String(m.owned_by || m.family || '')
|
||
return {
|
||
label: (mark + (m.label || m.id)).slice(0, 100),
|
||
value: m.id,
|
||
description: extra.slice(0, 100)
|
||
}
|
||
})
|
||
const src =
|
||
rec.source === 'live'
|
||
? 'Live from `' + String(cfg.rest_base_url || '').replace(/^https?:\/\//, '') + '`'
|
||
: rec.source === 'qvac-catalog'
|
||
? 'QVAC chat catalog (registry ids the host can load)'
|
||
: 'Curated fallback' + (rec.error ? ' (' + rec.error + ')' : '')
|
||
const fields = [
|
||
discordField('Backend', backend, true),
|
||
discordField('Current', current || '(unset)', true),
|
||
discordField('Shown', String(filtered.length) + ' / ' + String(rec.models.length), true)
|
||
]
|
||
let desc =
|
||
backend === 'qvac'
|
||
? 'Pick a **QVAC** chat model. Large ids download on first load.'
|
||
: 'Pick a **REST** model. Live list comes from `GET /models` on the configured API.'
|
||
desc += '\n' + src + '.'
|
||
if (rec.query) desc += '\nFilter: `' + rec.query + '`'
|
||
if (note) desc += '\n' + note
|
||
const comps = [
|
||
discordSelect('agent:mdlfam', 'Family', famOpts),
|
||
discordSelect('agent:mdl', 'Model · p' + (rec.page + 1) + '/' + pages, modelOpts),
|
||
discordButtons([
|
||
{ id: 'agent:mdlprev', label: '← Prev', style: 2 },
|
||
{ id: 'agent:mdlnext', label: 'Next →', style: 2 },
|
||
{ id: 'agent:mdlref', label: 'Refresh' },
|
||
{ id: 'agent:mdltype', label: 'Custom…', style: 1 },
|
||
{ id: 'agent:home', label: 'Agent' }
|
||
])
|
||
].filter(Boolean)
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: backend === 'qvac' ? 'QVAC models' : 'REST models',
|
||
desc: desc,
|
||
fields: fields,
|
||
footer: 'Page ' + (rec.page + 1) + '/' + pages
|
||
}),
|
||
{ components: comps }
|
||
)
|
||
}
|
||
|
||
function discordAgentQvacReady(ctx) {
|
||
if (!ctx) return false
|
||
if (typeof ctx.bareOsQvacAvailable === 'function') {
|
||
try {
|
||
return Boolean(ctx.bareOsQvacAvailable())
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
return (
|
||
typeof ctx.bareOsQvacComplete === 'function' ||
|
||
typeof ctx.bareOsQvacStatus === 'function'
|
||
)
|
||
}
|
||
|
||
async function discordAgentLoadConfig(ctx) {
|
||
const paths = discordAgentPaths(ctx)
|
||
const raw = await discordCmdReadJson(ctx, paths.config)
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||
return raw
|
||
}
|
||
|
||
function discordAgentReady(ctx, cfg) {
|
||
if (!cfg) {
|
||
return {
|
||
ok: false,
|
||
backend: '',
|
||
model: '',
|
||
reason:
|
||
'Agent is not configured. Run `agent --setup` on the guest TTY, or set knobs in **/settings → Agent**.'
|
||
}
|
||
}
|
||
const backend = discordAgentBackend(cfg)
|
||
if (backend === 'rest') {
|
||
if (!String(cfg.rest_api_key || '').trim()) {
|
||
return {
|
||
ok: false,
|
||
backend: backend,
|
||
model: String(cfg.model || ''),
|
||
reason: 'REST backend has no API key. Run `agent --setup` (not stored in Discord).'
|
||
}
|
||
}
|
||
if (!String(cfg.rest_base_url || '').trim()) {
|
||
return {
|
||
ok: false,
|
||
backend: backend,
|
||
model: String(cfg.model || ''),
|
||
reason:
|
||
'REST backend has no base URL. Set `rest_base_url` in **/settings → Agent** or run `agent --setup`.'
|
||
}
|
||
}
|
||
return {
|
||
ok: true,
|
||
backend: backend,
|
||
model: String(cfg.model || ''),
|
||
reason: ''
|
||
}
|
||
}
|
||
const model = String(cfg.qvac_model || cfg.model || '').trim()
|
||
if (!model) {
|
||
return {
|
||
ok: false,
|
||
backend: backend,
|
||
model: '',
|
||
reason: 'QVAC backend has no model. Set `qvac_model` in **/settings → Agent** or run `agent --setup`.'
|
||
}
|
||
}
|
||
if (!discordAgentQvacReady(ctx)) {
|
||
return {
|
||
ok: false,
|
||
backend: backend,
|
||
model: model,
|
||
reason:
|
||
'QVAC host bridge is unavailable (`BARE_OS_SKIP_QVAC` or missing `@qvac/sdk`). Switch to REST via `agent --config`.'
|
||
}
|
||
}
|
||
return { ok: true, backend: backend, model: model, reason: '' }
|
||
}
|
||
|
||
function discordAgentPublicConfig(cfg) {
|
||
const out = {}
|
||
if (!cfg || typeof cfg !== 'object') return out
|
||
const keys = Object.keys(cfg)
|
||
for (let i = 0; i < keys.length; i++) {
|
||
const k = keys[i]
|
||
if (DISCORD_SETTINGS_IS_SECRET(k) || BARE_OS_DISCORD_AGENT_SECRET_RE.test(k)) {
|
||
out[k] = cfg[k] ? '[set]' : '[empty]'
|
||
continue
|
||
}
|
||
const v = cfg[k]
|
||
if (v != null && typeof v === 'object') continue
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
function DISCORD_SETTINGS_IS_SECRET(k) {
|
||
return typeof discordSettingsIsSecretKey === 'function'
|
||
? discordSettingsIsSecretKey(k)
|
||
: /token|secret|password|api_key/i.test(String(k || ''))
|
||
}
|
||
|
||
function discordAgentRunKey(interaction, ctx) {
|
||
return (
|
||
discordInteractionUserId(interaction) ||
|
||
discordSessionUser(ctx) ||
|
||
'anon'
|
||
)
|
||
}
|
||
|
||
function discordAgentGetRun(interaction, ctx) {
|
||
return BARE_OS_DISCORD_AGENT_RUNS[discordAgentRunKey(interaction, ctx)] || null
|
||
}
|
||
|
||
function discordAgentSetRun(interaction, ctx, rec) {
|
||
const k = discordAgentRunKey(interaction, ctx)
|
||
if (!rec) {
|
||
delete BARE_OS_DISCORD_AGENT_RUNS[k]
|
||
return
|
||
}
|
||
BARE_OS_DISCORD_AGENT_RUNS[k] = rec
|
||
}
|
||
|
||
function discordAgentProgressTail(text, n) {
|
||
const lines = String(text || '')
|
||
.split(/\r?\n/)
|
||
.filter(Boolean)
|
||
return lines.slice(-(n || 8)).join('\n')
|
||
}
|
||
|
||
function discordAgentParseProgress(text) {
|
||
const lines = String(text || '')
|
||
.split(/\r?\n/)
|
||
.map(function (l) {
|
||
return l.replace(/^\d{4}-\d{2}-\d{2}T\S+\s+/, '').trim()
|
||
})
|
||
.filter(Boolean)
|
||
const steps = []
|
||
let iteration = 0
|
||
let tools = []
|
||
let done = ''
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const body = lines[i]
|
||
const iter = /^iteration\s+(\d+)\s+tools\s+(\S+)/i.exec(body)
|
||
if (iter) {
|
||
iteration = Number(iter[1]) || 0
|
||
tools = String(iter[2] || '')
|
||
.split(',')
|
||
.map(function (t) {
|
||
return t.trim()
|
||
})
|
||
.filter(Boolean)
|
||
continue
|
||
}
|
||
if (/^task_complete:/i.test(body)) {
|
||
done = body.replace(/^task_complete:\s*/i, '').trim()
|
||
continue
|
||
}
|
||
if (/^autonomous /i.test(body) || /^provider_shape_keys\b/i.test(body)) continue
|
||
const sp = body.indexOf(' ')
|
||
const tool = sp === -1 ? body : body.slice(0, sp)
|
||
const detail = sp === -1 ? '' : body.slice(sp + 1)
|
||
if (tool) steps.push({ tool: tool, detail: detail })
|
||
}
|
||
return {
|
||
steps: steps,
|
||
iteration: iteration,
|
||
tools: tools,
|
||
done: done,
|
||
current: steps.length ? steps[steps.length - 1] : null
|
||
}
|
||
}
|
||
|
||
function discordAgentSplitStdout(raw) {
|
||
const text = discordStripAnsi(String(raw == null ? '' : raw))
|
||
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/<think>[\s\S]*$/i, '')
|
||
const lines = cleaned.split(/\r?\n/)
|
||
const answer = []
|
||
const process = []
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i]
|
||
if (/^→\s+\S/.test(line) || /^\[process\]/.test(line) || /^Done:\s/.test(line)) {
|
||
process.push(line)
|
||
continue
|
||
}
|
||
if (/^[.…·•]+$/.test(line.trim())) continue
|
||
if (/^\[harness\]/.test(line)) continue
|
||
answer.push(line)
|
||
}
|
||
return {
|
||
answer: answer.join('\n').replace(/^\s+|\s+$/g, ''),
|
||
process: process
|
||
}
|
||
}
|
||
|
||
function discordAgentHeadingsToBold(text) {
|
||
return String(text || '').replace(/^#{1,6}\s+(.+)$/gm, '**$1**')
|
||
}
|
||
|
||
function discordAgentFormatProcess(parsed, limit) {
|
||
const steps = (parsed && parsed.steps) || []
|
||
const keep = Math.max(3, Number(limit) || 8)
|
||
const slice = steps.length > keep ? steps.slice(-keep) : steps
|
||
if (!slice.length) return ''
|
||
const omitted = steps.length - slice.length
|
||
const lines = []
|
||
if (omitted > 0) lines.push('_+' + String(omitted) + ' earlier steps_')
|
||
for (let i = 0; i < slice.length; i++) {
|
||
const s = slice[i]
|
||
const detail = String(s.detail || '').replace(/\s+/g, ' ').slice(0, 80)
|
||
lines.push('- `' + s.tool + '`' + (detail ? ' ' + detail : ''))
|
||
}
|
||
return lines.join('\n')
|
||
}
|
||
|
||
async function discordAgentHistoryCount(ctx) {
|
||
const paths = discordAgentPaths(ctx)
|
||
const raw = await discordCmdReadJson(ctx, paths.history)
|
||
return Array.isArray(raw) ? raw.length : 0
|
||
}
|
||
|
||
function discordAgentSleep(ms) {
|
||
return new Promise(function (resolve) {
|
||
setTimeout(resolve, ms)
|
||
})
|
||
}
|
||
|
||
function discordAgentEnvPatch(ctx, key, val) {
|
||
const prev = []
|
||
function setOn(obj) {
|
||
if (!obj || typeof obj !== 'object') return
|
||
prev.push([obj, key, obj[key]])
|
||
obj[key] = val
|
||
}
|
||
setOn(ctx && ctx.env)
|
||
setOn(ctx && ctx.vfs && ctx.vfs.env)
|
||
return function restore() {
|
||
for (let i = 0; i < prev.length; i++) {
|
||
const row = prev[i]
|
||
if (row[2] === undefined) delete row[0][row[1]]
|
||
else row[0][row[1]] = row[2]
|
||
}
|
||
}
|
||
}
|
||
|
||
async function discordAgentHud(ctx, interaction, note) {
|
||
const cfg = await discordAgentLoadConfig(ctx)
|
||
const ready = discordAgentReady(ctx, cfg)
|
||
const run = discordAgentGetRun(interaction, ctx)
|
||
const hist = await discordAgentHistoryCount(ctx)
|
||
const paths = discordAgentPaths(ctx)
|
||
const progress = discordAgentProgressTail(
|
||
await discordCmdReadText(ctx, paths.progress),
|
||
4
|
||
)
|
||
let qvac = ''
|
||
if (typeof ctx.bareOsQvacStatus === 'function') {
|
||
try {
|
||
const st = ctx.bareOsQvacStatus()
|
||
if (st && typeof st === 'object') {
|
||
qvac =
|
||
String(st.status || (st.available ? 'available' : 'unavailable')) +
|
||
(st.modelId ? ' · ' + st.modelId : '')
|
||
}
|
||
} catch {
|
||
qvac = ''
|
||
}
|
||
}
|
||
const fields = [
|
||
discordField('Ready', ready.ok ? '**yes**' : '**no**', true),
|
||
discordField('Backend', ready.backend || '—', true),
|
||
discordField('Model', ready.model || '—', true),
|
||
discordField('History', String(hist) + ' msgs', true),
|
||
discordField(
|
||
'Run',
|
||
run
|
||
? '**working**' + (run.currentTool ? ' · `' + run.currentTool + '`' : '')
|
||
: 'idle',
|
||
true
|
||
),
|
||
discordField(
|
||
'Plan mode',
|
||
cfg && cfg.plan_mode_active ? '**on**' : 'off',
|
||
true
|
||
),
|
||
discordField('Compaction', String((cfg && cfg.context_compaction) || 'auto'), true),
|
||
discordField(
|
||
'Autonomous',
|
||
cfg && cfg.autonomous_mode_enabled === false ? 'off' : 'on',
|
||
true
|
||
)
|
||
]
|
||
if (qvac) fields.push(discordField('QVAC', qvac, true))
|
||
const label = cfg && cfg.agent_label ? String(cfg.agent_label) : ''
|
||
const owner = cfg && cfg.owner_name ? String(cfg.owner_name) : ''
|
||
if (label || owner) {
|
||
fields.push(discordField('Identity', (label || 'agent') + (owner ? ' · ' + owner : ''), true))
|
||
}
|
||
let desc = ready.ok
|
||
? 'Talk to the guest **agent** (`~/.agent`). Same session as `/bin/agent` — history, skills, todos, plan, and memory are shared.'
|
||
: ready.reason
|
||
if (run && run.prompt) desc += '\nCurrent: _' + String(run.prompt).slice(0, 180) + '_'
|
||
if (note) desc += '\n' + note
|
||
if (progress) desc += '\n' + discordCmdFence(progress, '', 700)
|
||
const btns = []
|
||
if (ready.ok) {
|
||
btns.push({ id: 'agent:ask', label: 'Ask…', style: 1 })
|
||
if (run) btns.push({ id: 'agent:stop', label: 'Stop', style: 4 })
|
||
btns.push({ id: 'agent:reset', label: 'Reset chat', style: 4 })
|
||
}
|
||
btns.push({ id: 'agent:models', label: 'Models' })
|
||
btns.push({ id: 'agent:status', label: 'Status' })
|
||
btns.push({ id: 'agent:config', label: 'Config' })
|
||
if (ready.ok) {
|
||
btns.push({ id: 'agent:recap', label: 'Recap' })
|
||
btns.push({ id: 'agent:compact', label: 'Compact' })
|
||
}
|
||
btns.push({ id: 'agent:refresh', label: 'Refresh' })
|
||
const rows = []
|
||
for (let i = 0; i < btns.length; i += 5) {
|
||
const row = discordButtons(btns.slice(i, i + 5))
|
||
if (row) rows.push(row)
|
||
}
|
||
if (ready.ok) {
|
||
const inspect = discordSelect('agent:inspect', 'Inspect / session…', [
|
||
{ label: 'Skills catalog', value: 'skills', description: 'agent skills' },
|
||
{ label: 'Todos', value: 'todos', description: 'agent todos' },
|
||
{ label: 'Plan.md', value: 'plan', description: 'agent plan' },
|
||
{ label: 'History search…', value: 'history', description: 'agent history [query]' },
|
||
{ label: 'Recap last turn', value: 'recap', description: 'agent recap' },
|
||
{ label: 'Hooks', value: 'hooks', description: 'agent hooks' },
|
||
{ label: 'Undo last edit', value: 'undo', description: 'agent undo' },
|
||
{ label: 'Rewind last user turn', value: 'rewind', description: 'agent rewind 1' },
|
||
{ label: 'Compact history', value: 'compact', description: 'agent compact' },
|
||
{ label: 'Export transcript', value: 'export', description: 'agent export' },
|
||
{ label: 'Remember a fact…', value: 'remember', description: 'agent remember TEXT' }
|
||
])
|
||
if (inspect) rows.push(inspect)
|
||
}
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Agent',
|
||
color: ready.ok
|
||
? run
|
||
? BARE_OS_DISCORD_COLOR_WARN
|
||
: BARE_OS_DISCORD_COLOR_OK
|
||
: BARE_OS_DISCORD_COLOR_WARN,
|
||
desc: desc,
|
||
fields: fields,
|
||
footer: paths.config
|
||
}),
|
||
{ components: rows }
|
||
)
|
||
}
|
||
|
||
async function discordAgentConfigView(ctx) {
|
||
const cfg = await discordAgentLoadConfig(ctx)
|
||
const pub = discordAgentPublicConfig(cfg)
|
||
const keys = Object.keys(pub)
|
||
const fields = []
|
||
for (let i = 0; i < keys.length && fields.length < 20; i++) {
|
||
fields.push(discordField(keys[i], '`' + String(pub[keys[i]]) + '`', true))
|
||
}
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Agent config',
|
||
desc:
|
||
'Non-secret keys from `~/.agent/config.json`. API keys stay on the guest drive. Edit via **/settings → Agent**.',
|
||
fields: fields.length
|
||
? fields
|
||
: [discordField('config', 'Missing. Run `agent --setup` on the TTY.')],
|
||
footer: discordAgentPaths(ctx).config
|
||
}),
|
||
{ components: [discordButtons([{ id: 'agent:home', label: 'Agent' }])] }
|
||
)
|
||
}
|
||
|
||
async function discordAgentReset(ctx, interaction) {
|
||
const paths = discordAgentPaths(ctx)
|
||
if (!ctx.vfs || typeof ctx.vfs.writeFile !== 'function') {
|
||
return { text: 'vfs.writeFile unavailable — cannot reset agent history.', ephemeral: true }
|
||
}
|
||
if (typeof ctx.vfs.mkdir === 'function') {
|
||
try {
|
||
await ctx.vfs.mkdir(paths.dir, { recursive: true })
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
const emptyHist =
|
||
typeof ctx.b4a !== 'undefined' && ctx.b4a && typeof ctx.b4a.from === 'function'
|
||
? ctx.b4a.from('[]\n')
|
||
: typeof Buffer !== 'undefined'
|
||
? Buffer.from('[]\n')
|
||
: '[]\n'
|
||
const emptyProg =
|
||
typeof ctx.b4a !== 'undefined' && ctx.b4a && typeof ctx.b4a.from === 'function'
|
||
? ctx.b4a.from('')
|
||
: typeof Buffer !== 'undefined'
|
||
? Buffer.from('')
|
||
: ''
|
||
await ctx.vfs.writeFile(paths.history, emptyHist)
|
||
try {
|
||
await ctx.vfs.writeFile(paths.progress, emptyProg)
|
||
} catch {
|
||
/* optional */
|
||
}
|
||
return discordAgentHud(ctx, interaction, 'Chat history cleared (`history.json` + `progress.txt`). Config kept.')
|
||
}
|
||
|
||
async function discordAgentStop(ctx, interaction) {
|
||
const run = discordAgentGetRun(interaction, ctx)
|
||
if (!run) return discordAgentHud(ctx, interaction, 'No agent run is active.')
|
||
run.stop = true
|
||
try {
|
||
if (typeof ctx.bareOsAbortActiveAgent === 'function') ctx.bareOsAbortActiveAgent()
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return discordAgentHud(ctx, interaction, 'Stop requested. The in-flight turn may take a moment to unwind.')
|
||
}
|
||
|
||
var BARE_OS_DISCORD_AGENT_INSPECT = {
|
||
skills: { title: 'Skills', fence: true },
|
||
todos: { title: 'Todos', fence: false },
|
||
plan: { title: 'Plan', fence: false },
|
||
hooks: { title: 'Hooks', fence: true },
|
||
history: { title: 'History', fence: true },
|
||
recap: { title: 'Recap', fence: false },
|
||
undo: { title: 'Undo', fence: false },
|
||
rewind: { title: 'Rewind', fence: false },
|
||
compact: { title: 'Compact', fence: false },
|
||
export: { title: 'Export', fence: false },
|
||
remember: { title: 'Remember', fence: false }
|
||
}
|
||
|
||
async function discordAgentInspect(ctx, interaction, sub, extra) {
|
||
const name = String(sub || '').trim()
|
||
const spec = BARE_OS_DISCORD_AGENT_INSPECT[name]
|
||
if (!spec) return discordAgentHud(ctx, interaction, 'Unknown inspect command.')
|
||
if (name === 'remember' && !String(extra || '').trim()) {
|
||
return { modal: 'agent:remember' }
|
||
}
|
||
if (name === 'history' && extra === undefined) {
|
||
return { modal: 'agent:history' }
|
||
}
|
||
if (typeof ctx.execLine !== 'function') {
|
||
return { text: 'execLine unavailable — cannot run `agent ' + name + '`.', ephemeral: true }
|
||
}
|
||
const extraArg = extra == null ? '' : String(extra).trim()
|
||
let line = 'agent ' + name
|
||
if (extraArg) line += ' ' + discordShQuote(extraArg)
|
||
let out = ''
|
||
let errNote = ''
|
||
try {
|
||
out = await discordCmdCapture(ctx, function () {
|
||
return ctx.execLine(line, { timeoutMs: 45000 })
|
||
})
|
||
} catch (err) {
|
||
errNote = String((err && err.message) || err)
|
||
out = (out ? out + '\n' : '') + errNote
|
||
}
|
||
const raw = discordStripAnsi(String(out || '')).replace(/^\s+|\s+$/g, '')
|
||
const body = discordAgentHeadingsToBold(discordCmdRedact(raw || '(empty)'))
|
||
const comps = [
|
||
discordButtons([
|
||
{ id: 'agent:home', label: 'Agent' },
|
||
{ id: 'agent:ask', label: 'Ask…', style: 1 }
|
||
])
|
||
]
|
||
if (spec.fence) {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Agent · ' + spec.title,
|
||
desc: discordCmdFence(body, '', 3500),
|
||
color: errNote ? BARE_OS_DISCORD_COLOR_WARN : BARE_OS_DISCORD_COLOR_OK,
|
||
footer: 'guest /bin/agent ' + name
|
||
}),
|
||
{ components: comps }
|
||
)
|
||
}
|
||
return {
|
||
more: {
|
||
title: 'Agent · ' + spec.title,
|
||
lead: '',
|
||
body: body,
|
||
fence: false,
|
||
color: errNote ? BARE_OS_DISCORD_COLOR_WARN : BARE_OS_DISCORD_COLOR_OK,
|
||
footer: 'guest /bin/agent ' + name,
|
||
components: comps
|
||
}
|
||
}
|
||
}
|
||
|
||
async function discordAgentAsk(ctx, interaction, prompt, flags) {
|
||
const task = String(prompt || '').trim()
|
||
if (!task) {
|
||
return {
|
||
text: 'Prompt is required. Use `/agent ask` and fill **prompt** — Discord will not let you submit an empty one.',
|
||
ephemeral: true
|
||
}
|
||
}
|
||
if (task.length > 4000) {
|
||
return { text: 'Prompt is too long (max 4000).', ephemeral: true }
|
||
}
|
||
const wantNew = Boolean(flags && (flags.fresh || flags.new))
|
||
const wantPlan = Boolean(flags && flags.plan)
|
||
const wantAuto = Boolean(flags && (flags.auto || flags.autonomous))
|
||
const wantCompact = Boolean(flags && flags.compact)
|
||
const maxTurns = Math.max(0, Math.floor(Number(flags && flags.maxTurns) || 0))
|
||
const modelOverride = flags && flags.model ? String(flags.model).trim() : ''
|
||
const cfg = await discordAgentLoadConfig(ctx)
|
||
const ready = discordAgentReady(ctx, cfg)
|
||
if (!ready.ok) {
|
||
return discordAgentHud(ctx, interaction, ready.reason)
|
||
}
|
||
if (discordAgentGetRun(interaction, ctx)) {
|
||
return {
|
||
text: 'An agent turn is already running for you. Use **Stop** first.',
|
||
ephemeral: true
|
||
}
|
||
}
|
||
if (typeof ctx.execLine !== 'function') {
|
||
return { text: 'execLine unavailable in this session.', ephemeral: true }
|
||
}
|
||
await discordDefer(interaction)
|
||
const rec = { prompt: task, stop: false, started: Date.now(), currentTool: '' }
|
||
discordAgentSetRun(interaction, ctx, rec)
|
||
const restoreDiscord = discordAgentEnvPatch(ctx, 'BARE_OS_AGENT_DISCORD', '1')
|
||
const restoreThink = discordAgentEnvPatch(ctx, 'BARE_OS_AGENT_HIDE_THINK', '1')
|
||
let out = ''
|
||
let errNote = ''
|
||
let polling = true
|
||
const paths = discordAgentPaths(ctx)
|
||
try {
|
||
if (ctx.vfs && typeof ctx.vfs.writeFile === 'function') {
|
||
const empty =
|
||
typeof ctx.b4a !== 'undefined' && ctx.b4a && typeof ctx.b4a.from === 'function'
|
||
? ctx.b4a.from('')
|
||
: typeof Buffer !== 'undefined'
|
||
? Buffer.from('')
|
||
: ''
|
||
await ctx.vfs.writeFile(paths.progress, empty)
|
||
}
|
||
} catch {
|
||
/* optional */
|
||
}
|
||
const poll = (async function () {
|
||
while (polling) {
|
||
const t0 = Date.now()
|
||
while (polling && Date.now() - t0 < BARE_OS_DISCORD_AGENT_POLL_MS) {
|
||
await discordAgentSleep(200)
|
||
}
|
||
if (!polling) break
|
||
try {
|
||
const parsed = discordAgentParseProgress(await discordCmdReadText(ctx, paths.progress))
|
||
if (parsed.current) rec.currentTool = parsed.current.tool
|
||
const elapsed = Math.round((Date.now() - rec.started) / 1000)
|
||
const proc = discordAgentFormatProcess(parsed, 8)
|
||
const fields = [
|
||
discordField('Status', rec.stop ? 'stopping' : '**working**', true),
|
||
discordField('Backend', ready.backend + ' · `' + (ready.model || 'model') + '`', true),
|
||
discordField('Elapsed', String(elapsed) + 's', true),
|
||
discordField(
|
||
'Current',
|
||
parsed.current ? '`' + parsed.current.tool + '`' : '_starting…_',
|
||
true
|
||
)
|
||
]
|
||
if (parsed.iteration) {
|
||
fields.push(discordField('Iteration', String(parsed.iteration), true))
|
||
}
|
||
if (proc) fields.push(discordField('Process', proc))
|
||
await discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
discordResult(
|
||
discordEmbed({
|
||
title: 'Agent · working',
|
||
color: BARE_OS_DISCORD_COLOR_WARN,
|
||
desc: '_' + task.slice(0, 280) + '_',
|
||
fields: fields,
|
||
footer: 'Live from ~/.agent/progress.txt · Stop anytime'
|
||
}),
|
||
{
|
||
components: [
|
||
discordButtons([
|
||
{ id: 'agent:stop', label: 'Stop', style: 4 },
|
||
{ id: 'agent:home', label: 'Agent' }
|
||
])
|
||
]
|
||
}
|
||
)
|
||
)
|
||
} catch {
|
||
/* keep polling */
|
||
}
|
||
}
|
||
})()
|
||
try {
|
||
out = await discordCmdCapture(ctx, function () {
|
||
let line = 'agent'
|
||
if (wantNew) line += ' --new'
|
||
if (wantPlan) line += ' --plan'
|
||
if (wantAuto) line += ' --auto'
|
||
if (wantCompact) line += ' --compact'
|
||
if (maxTurns > 0) line += ' --max-turns ' + String(maxTurns)
|
||
if (modelOverride) line += ' --model ' + discordShQuote(modelOverride)
|
||
line += ' ' + discordShQuote(task)
|
||
return ctx.execLine(line, {
|
||
timeoutMs: BARE_OS_DISCORD_AGENT_MS
|
||
})
|
||
})
|
||
} catch (err) {
|
||
errNote = String((err && err.message) || err)
|
||
out = (out ? out + '\n' : '') + errNote
|
||
} finally {
|
||
polling = false
|
||
restoreDiscord()
|
||
restoreThink()
|
||
discordAgentSetRun(interaction, ctx, null)
|
||
}
|
||
try {
|
||
await poll
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
const exit = ctx.exitCode == null ? (errNote ? 1 : 0) : Number(ctx.exitCode) || 0
|
||
const parsed = discordAgentParseProgress(await discordCmdReadText(ctx, paths.progress))
|
||
const split = discordAgentSplitStdout(out)
|
||
let answer = discordAgentHeadingsToBold(discordCmdRedact(split.answer || ''))
|
||
if (!answer && parsed.done) answer = discordCmdRedact(parsed.done)
|
||
const elapsed = Math.round((Date.now() - rec.started) / 1000)
|
||
const proc = discordAgentFormatProcess(parsed, 10)
|
||
const leadParts = [
|
||
'**' + ready.backend + '** · `' + (ready.model || 'model') + '` · ' + elapsed + 's',
|
||
'_' + task.slice(0, 240) + '_'
|
||
]
|
||
if (proc) leadParts.push('', proc, '')
|
||
const lead = leadParts.join('\n')
|
||
const comps = [
|
||
discordButtons([
|
||
{ id: 'agent:ask', label: 'Ask again…', style: 1 },
|
||
{ id: 'agent:reset', label: 'Reset chat', style: 4 },
|
||
{ id: 'agent:home', label: 'Agent' }
|
||
])
|
||
]
|
||
if (!answer) {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: rec.stop ? 'Agent · stopped' : 'Agent',
|
||
desc: lead + '\n_(no markdown reply)_',
|
||
color: exit ? BARE_OS_DISCORD_COLOR_WARN : BARE_OS_DISCORD_COLOR_OK,
|
||
footer: 'exit ' + exit
|
||
}),
|
||
{ components: comps }
|
||
)
|
||
}
|
||
return {
|
||
more: {
|
||
title: rec.stop ? 'Agent · stopped' : 'Agent',
|
||
lead: lead + (lead && answer ? '\n' : ''),
|
||
body: answer,
|
||
fence: false,
|
||
color: exit ? BARE_OS_DISCORD_COLOR_WARN : BARE_OS_DISCORD_COLOR_OK,
|
||
footer: 'exit ' + exit + ' · Discord markdown · shared ~/.agent/history.json',
|
||
components: comps
|
||
}
|
||
}
|
||
}
|
||
|
||
async function discordCmdHandleAgent(ctx, interaction, sub, opt) {
|
||
const s = String(sub || 'list')
|
||
if (s === 'help') {
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Agent',
|
||
desc:
|
||
'Guest `/bin/agent` from Discord. Uses `~/.agent/config.json` and the same history as the TTY.\n' +
|
||
'Hidden until configured (QVAC model + host bridge, or REST + API key).',
|
||
fields: [
|
||
discordField('/agent', 'Interactive manager (default)'),
|
||
discordField(
|
||
'/agent ask',
|
||
'**prompt** (required). Optional `new` / `plan` / `auto` / `compact` / `max_turns` / `model`.'
|
||
),
|
||
discordField('/agent status', 'Ready / backend / model / QVAC / plan / autonomous'),
|
||
discordField('/agent config', 'Non-secret ~/.agent/config.json'),
|
||
discordField('/agent models', 'QVAC catalog or live REST /models picker'),
|
||
discordField(
|
||
'/agent skills | todos | plan | hooks | history | recap',
|
||
'Same inspect surface as `/bin/agent`'
|
||
),
|
||
discordField(
|
||
'/agent undo | rewind | compact | export | remember',
|
||
'Session edits, rewind, compaction, transcript, MEMORY.md'
|
||
),
|
||
discordField('/agent reset', 'Clear history.json (keeps config)'),
|
||
discordField('/agent stop', 'Abort the in-flight turn')
|
||
]
|
||
})
|
||
)
|
||
}
|
||
if (s === 'status' || s === 'list') return discordAgentHud(ctx, interaction, '')
|
||
if (s === 'config') return discordAgentConfigView(ctx)
|
||
if (s === 'models' || s === 'model') return discordAgentModelView(ctx, interaction, '')
|
||
if (s === 'reset') return discordAgentReset(ctx, interaction)
|
||
if (s === 'stop') return discordAgentStop(ctx, interaction)
|
||
if (s === 'ask') {
|
||
return discordAgentAsk(ctx, interaction, opt('prompt') || opt('text') || '', {
|
||
fresh: opt('new') === 'true',
|
||
plan: opt('plan') === 'true',
|
||
auto: opt('auto') === 'true' || opt('autonomous') === 'true',
|
||
compact: opt('compact') === 'true',
|
||
maxTurns: opt('max_turns') || opt('max-turns'),
|
||
model: opt('model')
|
||
})
|
||
}
|
||
if (BARE_OS_DISCORD_AGENT_INSPECT[s]) {
|
||
let extra = ''
|
||
if (s === 'remember') extra = opt('text') || opt('note') || ''
|
||
else if (s === 'history') extra = opt('query') || opt('q') || ''
|
||
else if (s === 'rewind') extra = opt('steps') || opt('n') || ''
|
||
else if (s === 'export') extra = opt('path') || ''
|
||
return discordAgentInspect(ctx, interaction, s, extra)
|
||
}
|
||
return discordAgentHud(ctx, interaction, '')
|
||
}
|
||
|
||
async function discordAgentAction(ctx, interaction, id) {
|
||
if (id === 'agent:home' || id === 'agent:refresh' || id === 'agent:status') {
|
||
return discordSendResult(ctx, interaction, await discordAgentHud(ctx, interaction, ''))
|
||
}
|
||
if (id === 'agent:ask') {
|
||
return discordSendResult(ctx, interaction, { modal: 'agent:ask' })
|
||
}
|
||
if (id === 'agent:config') {
|
||
return discordSendResult(ctx, interaction, await discordAgentConfigView(ctx))
|
||
}
|
||
if (id === 'agent:reset') {
|
||
return discordSendResult(ctx, interaction, await discordAgentReset(ctx, interaction))
|
||
}
|
||
if (id === 'agent:stop') {
|
||
return discordSendResult(ctx, interaction, await discordAgentStop(ctx, interaction))
|
||
}
|
||
if (id === 'agent:recap' || id === 'agent:compact' || id === 'agent:undo') {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordAgentInspect(ctx, interaction, id.slice('agent:'.length), '')
|
||
)
|
||
}
|
||
if (id === 'agent:inspect') {
|
||
const which = String((interaction.values && interaction.values[0]) || '')
|
||
if (which === 'remember') {
|
||
return discordSendResult(ctx, interaction, { modal: 'agent:remember' })
|
||
}
|
||
if (which === 'history') {
|
||
return discordSendResult(ctx, interaction, { modal: 'agent:history' })
|
||
}
|
||
if (BARE_OS_DISCORD_AGENT_INSPECT[which]) {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordAgentInspect(ctx, interaction, which, which === 'rewind' ? '1' : '')
|
||
)
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordAgentHud(ctx, interaction, ''))
|
||
}
|
||
if (
|
||
id === 'agent:models' ||
|
||
id === 'agent:mdl' ||
|
||
id === 'agent:mdlfam' ||
|
||
id === 'agent:mdlprev' ||
|
||
id === 'agent:mdlnext' ||
|
||
id === 'agent:mdlref' ||
|
||
id === 'agent:mdltype'
|
||
) {
|
||
const rec = discordAgentModelGet(interaction, ctx)
|
||
if (id === 'agent:mdlfam') {
|
||
rec.family = String((interaction.values && interaction.values[0]) || 'all')
|
||
rec.page = 0
|
||
} else if (id === 'agent:mdl') {
|
||
const mid = String((interaction.values && interaction.values[0]) || '')
|
||
try {
|
||
await discordAgentApplyModel(ctx, mid)
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordAgentModelView(ctx, interaction, 'Now using **' + mid + '**.')
|
||
)
|
||
} catch (err) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Could not set model: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
})
|
||
}
|
||
} else if (id === 'agent:mdlprev') {
|
||
rec.page = Math.max(0, (rec.page | 0) - 1)
|
||
} else if (id === 'agent:mdlnext') {
|
||
rec.page = (rec.page | 0) + 1
|
||
} else if (id === 'agent:mdlref') {
|
||
rec.models = []
|
||
rec.source = ''
|
||
const cfg = (await discordAgentLoadConfig(ctx)) || {}
|
||
await discordAgentLoadModelChoices(ctx, cfg, rec, true)
|
||
} else if (id === 'agent:mdltype') {
|
||
return discordSendResult(ctx, interaction, { modal: 'agent:mdltype' })
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordAgentModelView(ctx, interaction, ''))
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordAgentHud(ctx, interaction, ''))
|
||
}
|
||
|
||
function discordPanel(ctx) {
|
||
const user = discordSessionUser(ctx)
|
||
const e = discordCmdEnv(ctx)
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Bare OS control panel',
|
||
color: BARE_OS_DISCORD_COLOR,
|
||
desc:
|
||
'Signed in as **' +
|
||
user +
|
||
'** · `' +
|
||
(e.HOME || '/home/' + user) +
|
||
'`\nOpen **Menu** for destinations, or use a slash command.',
|
||
fields: [
|
||
discordField('User', user, true),
|
||
discordField('Identity', e.BARE_OS_IDENTITY || (ctx.identity && ctx.identity.state) || '', true),
|
||
discordField('Host', e.HOSTNAME || 'bare-os', true)
|
||
]
|
||
}),
|
||
{
|
||
components: [
|
||
discordButtons([
|
||
{ id: 'agent:home', label: 'Agent', style: 1 },
|
||
{ id: 'hdms:home', label: 'HDMS' },
|
||
{ id: 'holesail:home', label: 'Holesail' },
|
||
{ id: 'run:compose', label: 'Shell' }
|
||
])
|
||
]
|
||
}
|
||
)
|
||
}
|
||
|
||
function discordCmdOpt(s, name, desc, required, autocomplete, extra) {
|
||
if (!s || typeof s.addStringOption !== 'function') return s
|
||
return s.addStringOption(function (o) {
|
||
o.setName(name).setDescription(desc)
|
||
if (required && typeof o.setRequired === 'function') o.setRequired(true)
|
||
if (autocomplete && typeof o.setAutocomplete === 'function') o.setAutocomplete(true)
|
||
const min = extra && Number(extra.min)
|
||
const max = extra && Number(extra.max)
|
||
if (min > 0 && typeof o.setMinLength === 'function') o.setMinLength(Math.floor(min))
|
||
if (max > 0 && typeof o.setMaxLength === 'function') o.setMaxLength(Math.floor(max))
|
||
return o
|
||
})
|
||
}
|
||
|
||
function discordCmdBool(s, name, desc) {
|
||
if (!s || typeof s.addBooleanOption !== 'function') return s
|
||
return s.addBooleanOption(function (o) {
|
||
o.setName(name).setDescription(desc)
|
||
return o
|
||
})
|
||
}
|
||
|
||
function discordCmdInt(s, name, desc, extra) {
|
||
if (!s || typeof s.addIntegerOption !== 'function') {
|
||
return discordCmdOpt(s, name, desc, false)
|
||
}
|
||
return s.addIntegerOption(function (o) {
|
||
o.setName(name).setDescription(desc)
|
||
if (extra && extra.min != null && typeof o.setMinValue === 'function') {
|
||
o.setMinValue(Math.floor(Number(extra.min)))
|
||
}
|
||
if (extra && extra.max != null && typeof o.setMaxValue === 'function') {
|
||
o.setMaxValue(Math.floor(Number(extra.max)))
|
||
}
|
||
return o
|
||
})
|
||
}
|
||
|
||
function discordCmdAtt(s, name, desc, required) {
|
||
if (!s || typeof s.addAttachmentOption !== 'function') return s
|
||
return s.addAttachmentOption(function (o) {
|
||
o.setName(name).setDescription(desc)
|
||
if (required && typeof o.setRequired === 'function') o.setRequired(true)
|
||
return o
|
||
})
|
||
}
|
||
|
||
function discordShQuote(s) {
|
||
return "'" + String(s == null ? '' : s).replace(/'/g, "'\\''") + "'"
|
||
}
|
||
|
||
function discordUploadSafeName(name) {
|
||
let n = String(name || '').replace(/\\/g, '/')
|
||
const slash = n.lastIndexOf('/')
|
||
if (slash >= 0) n = n.slice(slash + 1)
|
||
n = n.replace(/[\u0000-\u001f]/g, '').trim()
|
||
if (!n || n === '.' || n === '..') n = 'upload.bin'
|
||
if (n.length > 180) {
|
||
const i = n.lastIndexOf('.')
|
||
const ext = i > 0 && i > n.length - 12 ? n.slice(i) : ''
|
||
n = n.slice(0, 180 - ext.length) + ext
|
||
}
|
||
return n
|
||
}
|
||
|
||
function discordInteractionAttachment(interaction, name) {
|
||
const key = name || 'file'
|
||
if (!interaction || !interaction.options) return null
|
||
if (typeof interaction.options.getAttachment === 'function') {
|
||
try {
|
||
const a = interaction.options.getAttachment(key)
|
||
if (a) return a
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
async function discordUploadResolveDest(ctx, interaction, destRaw, filename) {
|
||
const rec = discordShGet(interaction, ctx)
|
||
const cwd = rec.cwd || '~'
|
||
const raw = String(destRaw || '').trim()
|
||
if (!raw || raw === '.' || raw === './') return discordJoinPath(cwd, filename)
|
||
if (raw.charAt(raw.length - 1) === '/') {
|
||
return discordJoinPath(raw.replace(/\/+$/, '') || cwd, filename)
|
||
}
|
||
try {
|
||
const st = await discordFmStat(ctx, raw)
|
||
if (discordIsDirStat(st)) return discordJoinPath(raw, filename)
|
||
} catch {
|
||
/* treat as a file path */
|
||
}
|
||
if (raw.indexOf('/') < 0 && raw.charAt(0) !== '~') return discordJoinPath(cwd, raw)
|
||
return raw
|
||
}
|
||
|
||
function discordCmdAddSubs(builder, items) {
|
||
if (!builder || typeof builder.addSubcommand !== 'function') return false
|
||
for (let i = 0; i < items.length; i++) {
|
||
const it = items[i]
|
||
builder.addSubcommand(function (s) {
|
||
s.setName(it[0]).setDescription(it[1])
|
||
if (it[2]) it[2](s)
|
||
return s
|
||
})
|
||
}
|
||
return true
|
||
}
|
||
|
||
var BARE_OS_DISCORD_PLUGIN_DIR = '~/.discord/plugins'
|
||
var BARE_OS_DISCORD_PLUGIN_MAX = 40
|
||
var BARE_OS_DISCORD_PLUGIN_SRC_MAX = 64 * 1024
|
||
var BARE_OS_DISCORD_PLUGIN_RESERVED = {
|
||
bare: 1,
|
||
sys: 1,
|
||
svc: 1,
|
||
fs: 1,
|
||
net: 1,
|
||
man: 1,
|
||
say: 1,
|
||
run: 1,
|
||
r: 1,
|
||
journal: 1,
|
||
edit: 1,
|
||
create: 1,
|
||
files: 1,
|
||
browse: 1,
|
||
settings: 1,
|
||
panel: 1,
|
||
ping: 1,
|
||
plugins: 1,
|
||
upload: 1,
|
||
hdms: 1,
|
||
holesail: 1,
|
||
agent: 1
|
||
}
|
||
var BARE_OS_DISCORD_PLUGINS = []
|
||
var BARE_OS_DISCORD_PLUGINS_READY = false
|
||
var BARE_OS_DISCORD_PLUGIN_REREGISTER = null
|
||
|
||
function discordPluginNameOk(name) {
|
||
return /^[a-z0-9][a-z0-9-]{0,31}$/.test(String(name || ''))
|
||
}
|
||
|
||
function discordPluginsSetRegistrar(fn) {
|
||
BARE_OS_DISCORD_PLUGIN_REREGISTER = typeof fn === 'function' ? fn : null
|
||
}
|
||
|
||
function discordPluginFind(name) {
|
||
const n = String(name || '')
|
||
for (let i = 0; i < BARE_OS_DISCORD_PLUGINS.length; i++) {
|
||
if (BARE_OS_DISCORD_PLUGINS[i].name === n && BARE_OS_DISCORD_PLUGINS[i].ok) {
|
||
return BARE_OS_DISCORD_PLUGINS[i]
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function discordPluginExpand(tpl, opt, env) {
|
||
return String(tpl == null ? '' : tpl).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, function (_, a, def, b) {
|
||
const key = a || b
|
||
if (opt) {
|
||
const v = opt(key)
|
||
if (v != null && v !== '') return String(v)
|
||
}
|
||
if (env && env[key] != null && env[key] !== '') return String(env[key])
|
||
return def != null ? String(def) : ''
|
||
})
|
||
}
|
||
|
||
function discordPluginSdk(ctx, plug) {
|
||
const prefix = 'plug:' + plug.name + ':'
|
||
const bot = {
|
||
name: plug.name,
|
||
version: plug.manifest && plug.manifest.version,
|
||
command: function (spec) {
|
||
if (spec && typeof spec === 'object') plug.handlers.push(spec)
|
||
return bot
|
||
},
|
||
embed: function (opts) {
|
||
return discordEmbed(opts)
|
||
},
|
||
field: discordField,
|
||
result: function (embed, extra) {
|
||
return discordResult(embed, extra)
|
||
},
|
||
fence: discordCmdFence,
|
||
redact: discordCmdRedact,
|
||
more: function (opts) {
|
||
return { more: opts }
|
||
},
|
||
id: function (rest) {
|
||
return prefix + String(rest || '').replace(/^plug:[^:]+:/, '')
|
||
},
|
||
user: function () {
|
||
return discordSessionUser(ctx)
|
||
},
|
||
home: function () {
|
||
return discordCmdEnv(ctx).HOME || '/home/' + discordSessionUser(ctx)
|
||
},
|
||
env: function () {
|
||
const e = discordCmdEnv(ctx)
|
||
const out = {}
|
||
const skip = /TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|PRIVATE|CREDENTIAL/i
|
||
const keys = Object.keys(e)
|
||
for (let i = 0; i < keys.length; i++) {
|
||
if (skip.test(keys[i])) continue
|
||
out[keys[i]] = e[keys[i]]
|
||
}
|
||
return out
|
||
},
|
||
read: function (path) {
|
||
return discordCmdReadText(ctx, path)
|
||
},
|
||
write: function (path, text) {
|
||
const p = discordCmdPathWriteOk(ctx, path)
|
||
if (!p) return Promise.reject(new Error('not writable: ' + path))
|
||
if (!ctx.vfs || typeof ctx.vfs.writeFile !== 'function') {
|
||
return Promise.reject(new Error('writeFile unavailable'))
|
||
}
|
||
return ctx.vfs.writeFile(p, discordTextToBuf(ctx, String(text == null ? '' : text)))
|
||
},
|
||
ls: async function (path) {
|
||
if (!ctx.vfs || typeof ctx.vfs.readdir !== 'function') return []
|
||
const names = await ctx.vfs.readdir(path || '~')
|
||
return Array.isArray(names) ? names : []
|
||
},
|
||
sh: function (line) {
|
||
if (typeof ctx.execLine !== 'function') {
|
||
return Promise.reject(new Error('execLine unavailable'))
|
||
}
|
||
return discordCmdCapture(ctx, function () {
|
||
return ctx.execLine(String(line || ''), { timeoutMs: BARE_OS_DISCORD_SH_EXEC_MS })
|
||
})
|
||
}
|
||
}
|
||
return bot
|
||
}
|
||
|
||
function discordPluginNormalize(raw, file) {
|
||
const m = raw && typeof raw === 'object' ? raw : {}
|
||
const name = String(m.name || '').toLowerCase()
|
||
return {
|
||
name: name,
|
||
description: String(m.description || name || 'plugin'),
|
||
version: String(m.version || '1.0.0'),
|
||
enabled: m.enabled !== false,
|
||
options: Array.isArray(m.options) ? m.options : [],
|
||
subcommands: Array.isArray(m.subcommands) ? m.subcommands : [],
|
||
run: typeof m.run === 'string' ? m.run : m.shell || '',
|
||
title: m.title || '',
|
||
readFile: m.file || '',
|
||
embed: m.embed && typeof m.embed === 'object' ? m.embed : null,
|
||
ephemeral: !!m.ephemeral,
|
||
source: file || '',
|
||
handlers: [],
|
||
onComponent: typeof m.onComponent === 'function' ? m.onComponent : null,
|
||
onModal: typeof m.onModal === 'function' ? m.onModal : null,
|
||
onAutocomplete: typeof m.onAutocomplete === 'function' ? m.onAutocomplete : null,
|
||
runFn: typeof m.run === 'function' ? m.run : null,
|
||
manifest: m,
|
||
ok: false,
|
||
error: '',
|
||
kind: 'json'
|
||
}
|
||
}
|
||
|
||
async function discordPluginEvalJs(ctx, plug, source) {
|
||
const bot = discordPluginSdk(ctx, plug)
|
||
let AsyncFn
|
||
try {
|
||
AsyncFn = Object.getPrototypeOf(async function () {}).constructor
|
||
} catch {
|
||
AsyncFn = Function
|
||
}
|
||
const body =
|
||
String(source) +
|
||
'\n;' +
|
||
'if (typeof register === "function") await register(bot);\n' +
|
||
'else if (typeof plugin === "object" && plugin) bot.command(plugin);\n'
|
||
const fn = new AsyncFn('bot', 'sdk', 'register', body)
|
||
function register(spec) {
|
||
bot.command(spec)
|
||
}
|
||
await fn(bot, bot, register)
|
||
if (plug.handlers.length) {
|
||
const first = plug.handlers[0]
|
||
if (!plug.name && first.name) plug.name = String(first.name).toLowerCase()
|
||
if (first.description && plug.description === plug.name) plug.description = first.description
|
||
if (first.options) plug.options = first.options
|
||
if (first.subcommands) plug.subcommands = first.subcommands
|
||
if (typeof first.run === 'function') plug.runFn = first.run
|
||
if (typeof first.onComponent === 'function') plug.onComponent = first.onComponent
|
||
if (typeof first.onModal === 'function') plug.onModal = first.onModal
|
||
if (typeof first.onAutocomplete === 'function') plug.onAutocomplete = first.onAutocomplete
|
||
}
|
||
plug.kind = 'js'
|
||
}
|
||
|
||
async function discordPluginsDisabledSet(ctx) {
|
||
const text = await discordCmdReadText(ctx, BARE_OS_DISCORD_PLUGIN_DIR + '/disabled.txt')
|
||
const set = Object.create(null)
|
||
const lines = String(text || '').split(/\r?\n/)
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const n = lines[i].trim()
|
||
if (!n || n.charAt(0) === '#') continue
|
||
set[n] = 1
|
||
}
|
||
return set
|
||
}
|
||
|
||
async function discordPluginsWriteDisabled(ctx, set) {
|
||
const names = Object.keys(set).sort()
|
||
const body = names.length ? names.join('\n') + '\n' : ''
|
||
if (!ctx.vfs || typeof ctx.vfs.writeFile !== 'function') throw new Error('writeFile unavailable')
|
||
await discordEnsureDir(ctx, BARE_OS_DISCORD_PLUGIN_DIR)
|
||
await ctx.vfs.writeFile(BARE_OS_DISCORD_PLUGIN_DIR + '/disabled.txt', discordTextToBuf(ctx, body))
|
||
}
|
||
|
||
async function discordPluginsLoad(ctx) {
|
||
BARE_OS_DISCORD_PLUGINS = []
|
||
BARE_OS_DISCORD_PLUGINS_READY = true
|
||
if (!ctx || !ctx.vfs) return BARE_OS_DISCORD_PLUGINS
|
||
await discordEnsureDir(ctx, BARE_OS_DISCORD_PLUGIN_DIR)
|
||
let names = []
|
||
try {
|
||
if (typeof ctx.vfs.readdir === 'function') {
|
||
names = await ctx.vfs.readdir(BARE_OS_DISCORD_PLUGIN_DIR)
|
||
}
|
||
} catch {
|
||
names = []
|
||
}
|
||
if (!Array.isArray(names)) names = []
|
||
const disabled = await discordPluginsDisabledSet(ctx)
|
||
const seen = Object.create(null)
|
||
for (let i = 0; i < names.length && BARE_OS_DISCORD_PLUGINS.length < BARE_OS_DISCORD_PLUGIN_MAX; i++) {
|
||
const entry = String(names[i] || '')
|
||
if (!entry || entry.charAt(0) === '.' || entry === 'disabled.txt' || entry === 'README.md') continue
|
||
if (/\.disabled$/.test(entry)) continue
|
||
let id = entry.replace(/\.(json|js)$/i, '')
|
||
let kind = ''
|
||
let srcPath = ''
|
||
let jsonPath = ''
|
||
if (/\.json$/i.test(entry)) {
|
||
kind = 'json'
|
||
jsonPath = BARE_OS_DISCORD_PLUGIN_DIR + '/' + entry
|
||
} else if (/\.js$/i.test(entry)) {
|
||
kind = 'js'
|
||
srcPath = BARE_OS_DISCORD_PLUGIN_DIR + '/' + entry
|
||
} else {
|
||
jsonPath = BARE_OS_DISCORD_PLUGIN_DIR + '/' + entry + '/plugin.json'
|
||
srcPath = BARE_OS_DISCORD_PLUGIN_DIR + '/' + entry + '/index.js'
|
||
const hasJson = await discordFileExists(ctx, jsonPath)
|
||
const hasJs = await discordFileExists(ctx, srcPath)
|
||
if (hasJson) kind = 'dir-json'
|
||
else if (hasJs) kind = 'dir-js'
|
||
else continue
|
||
if (!hasJson) jsonPath = ''
|
||
if (!hasJs) srcPath = ''
|
||
}
|
||
if (seen[id]) continue
|
||
seen[id] = 1
|
||
const plug = discordPluginNormalize({ name: id }, entry)
|
||
plug.source = BARE_OS_DISCORD_PLUGIN_DIR + '/' + entry
|
||
if (disabled[id]) {
|
||
plug.enabled = false
|
||
plug.error = 'disabled'
|
||
BARE_OS_DISCORD_PLUGINS.push(plug)
|
||
continue
|
||
}
|
||
try {
|
||
if (jsonPath) {
|
||
const raw = await discordCmdReadText(ctx, jsonPath)
|
||
if (raw.length > BARE_OS_DISCORD_PLUGIN_SRC_MAX) throw new Error('manifest too large')
|
||
const j = discordTryJson(raw)
|
||
if (!j) throw new Error('invalid JSON')
|
||
const n = discordPluginNormalize(j, entry)
|
||
plug.name = n.name || id
|
||
plug.description = n.description
|
||
plug.version = n.version
|
||
plug.enabled = n.enabled
|
||
plug.options = n.options
|
||
plug.subcommands = n.subcommands
|
||
plug.run = n.run
|
||
plug.title = n.title
|
||
plug.readFile = n.readFile
|
||
plug.embed = n.embed
|
||
plug.ephemeral = n.ephemeral
|
||
plug.manifest = j
|
||
}
|
||
if (srcPath) {
|
||
const src = await discordCmdReadText(ctx, srcPath)
|
||
if (src.length > BARE_OS_DISCORD_PLUGIN_SRC_MAX) throw new Error('script too large')
|
||
await discordPluginEvalJs(ctx, plug, src)
|
||
}
|
||
if (!discordPluginNameOk(plug.name)) throw new Error('bad command name (use a-z0-9-, 1–32)')
|
||
if (BARE_OS_DISCORD_PLUGIN_RESERVED[plug.name]) throw new Error('name is reserved: /' + plug.name)
|
||
if (plug.enabled === false) {
|
||
plug.error = 'disabled in manifest'
|
||
BARE_OS_DISCORD_PLUGINS.push(plug)
|
||
continue
|
||
}
|
||
plug.ok = true
|
||
BARE_OS_DISCORD_PLUGINS.push(plug)
|
||
} catch (err) {
|
||
plug.ok = false
|
||
plug.error = String((err && err.message) || err)
|
||
BARE_OS_DISCORD_PLUGINS.push(plug)
|
||
}
|
||
}
|
||
return BARE_OS_DISCORD_PLUGINS
|
||
}
|
||
|
||
async function discordPluginsEnsure(ctx) {
|
||
if (!BARE_OS_DISCORD_PLUGINS_READY) await discordPluginsLoad(ctx)
|
||
return BARE_OS_DISCORD_PLUGINS
|
||
}
|
||
|
||
function discordPluginAddOptions(builder, opts) {
|
||
if (!opts || !opts.length) return
|
||
for (let i = 0; i < opts.length && i < 10; i++) {
|
||
const o = opts[i]
|
||
if (!o || !o.name) continue
|
||
discordCmdOpt(
|
||
builder,
|
||
String(o.name).slice(0, 32),
|
||
String(o.description || o.name).slice(0, 100),
|
||
!!o.required,
|
||
!!o.autocomplete
|
||
)
|
||
}
|
||
}
|
||
|
||
function discordPluginsSlash(dj, ctx) {
|
||
const B = dj && dj.SlashCommandBuilder
|
||
if (typeof B !== 'function') return []
|
||
const out = []
|
||
for (let i = 0; i < BARE_OS_DISCORD_PLUGINS.length; i++) {
|
||
const p = BARE_OS_DISCORD_PLUGINS[i]
|
||
if (!p.ok) continue
|
||
try {
|
||
const c = new B().setName(p.name).setDescription(String(p.description || p.name).slice(0, 100))
|
||
if (p.subcommands && p.subcommands.length) {
|
||
const items = []
|
||
for (let s = 0; s < p.subcommands.length && s < 10; s++) {
|
||
const sc = p.subcommands[s]
|
||
if (!sc || !sc.name) continue
|
||
items.push([
|
||
String(sc.name).slice(0, 32),
|
||
String(sc.description || sc.name).slice(0, 100),
|
||
sc.options
|
||
? function (b) {
|
||
discordPluginAddOptions(b, sc.options)
|
||
}
|
||
: null
|
||
])
|
||
}
|
||
discordCmdAddSubs(c, items)
|
||
} else {
|
||
discordPluginAddOptions(c, p.options)
|
||
}
|
||
out.push(discordStampUserInstallCommand(c.toJSON(), ctx))
|
||
} catch {
|
||
/* skip bad slash shape */
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
function discordPluginEvent(ctx, interaction, plug, sub, opt) {
|
||
const bot = discordPluginSdk(ctx, plug)
|
||
return {
|
||
name: plug.name,
|
||
sub: sub || '',
|
||
opt: opt || function () {
|
||
return ''
|
||
},
|
||
interaction: interaction,
|
||
userId: discordInteractionUserId(interaction),
|
||
user: discordSessionUser(ctx),
|
||
ctx: ctx,
|
||
bot: bot,
|
||
embed: bot.embed,
|
||
field: bot.field,
|
||
fence: bot.fence,
|
||
sh: bot.sh,
|
||
read: bot.read,
|
||
write: bot.write,
|
||
ls: bot.ls
|
||
}
|
||
}
|
||
|
||
async function discordPluginInvoke(ctx, interaction, plug, sub, opt) {
|
||
const event = discordPluginEvent(ctx, interaction, plug, sub, opt)
|
||
let spec = plug
|
||
if (sub && plug.subcommands) {
|
||
for (let i = 0; i < plug.subcommands.length; i++) {
|
||
if (plug.subcommands[i] && plug.subcommands[i].name === sub) {
|
||
spec = plug.subcommands[i]
|
||
break
|
||
}
|
||
}
|
||
}
|
||
const runFn = spec.runFn || plug.runFn || (typeof spec.run === 'function' ? spec.run : null)
|
||
if (typeof runFn === 'function') {
|
||
const out = await runFn(event)
|
||
if (out == null) return { text: '(no output)', ephemeral: true }
|
||
if (typeof out === 'string') return { text: discordCmdRedact(out), ephemeral: !!(spec.ephemeral || plug.ephemeral) }
|
||
if (out.embeds || out.text || out.more || out.modal || out.components) {
|
||
if ((spec.ephemeral || plug.ephemeral) && out.ephemeral == null) out.ephemeral = true
|
||
return out
|
||
}
|
||
if (out.title || out.desc || out.fields) {
|
||
return discordResult(discordEmbed(out), { ephemeral: !!(spec.ephemeral || plug.ephemeral) })
|
||
}
|
||
return { text: String(out), ephemeral: !!(spec.ephemeral || plug.ephemeral) }
|
||
}
|
||
if (spec.readFile || spec.file || (plug.embed && plug.embed.file)) {
|
||
const path = spec.readFile || spec.file || plug.embed.file
|
||
const body = await discordCmdReadText(ctx, path)
|
||
return {
|
||
more: {
|
||
title: spec.title || plug.title || plug.name,
|
||
body: body || '(empty)',
|
||
fence: true
|
||
}
|
||
}
|
||
}
|
||
const shell = typeof spec.run === 'string' && spec.run ? spec.run : plug.run
|
||
if (shell) {
|
||
const line = discordPluginExpand(shell, opt, discordCmdEnv(ctx))
|
||
const out = await discordCmdCapture(ctx, function () {
|
||
if (typeof ctx.execLine !== 'function') throw new Error('execLine unavailable')
|
||
return ctx.execLine(line, { timeoutMs: BARE_OS_DISCORD_SH_EXEC_MS })
|
||
})
|
||
return {
|
||
more: {
|
||
title: spec.title || plug.title || '/' + plug.name,
|
||
lead: '`' + line + '`',
|
||
body: out || '(no output)',
|
||
fence: true,
|
||
footer: 'plugin · ' + plug.name + ' · ' + discordSessionUser(ctx)
|
||
}
|
||
}
|
||
}
|
||
if (spec.embed || plug.embed) {
|
||
const em = spec.embed || plug.embed
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: em.title || plug.title || plug.name,
|
||
desc: em.desc || em.description || '',
|
||
fields: em.fields
|
||
}),
|
||
{ ephemeral: !!(spec.ephemeral || plug.ephemeral) }
|
||
)
|
||
}
|
||
return { text: 'Plugin /' + plug.name + ' has nothing to run.', ephemeral: true }
|
||
}
|
||
|
||
async function discordPluginComponent(ctx, interaction, id) {
|
||
const parts = String(id || '').split(':')
|
||
const name = parts[1] || ''
|
||
const rest = parts.slice(2).join(':')
|
||
const plug = discordPluginFind(name)
|
||
if (!plug || typeof plug.onComponent !== 'function') {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Plugin button expired or unknown.',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
const event = discordPluginEvent(ctx, interaction, plug, '', function () {
|
||
return ''
|
||
})
|
||
event.id = rest
|
||
event.values = interaction.values || []
|
||
const out = await plug.onComponent(event)
|
||
if (out == null) return discordSendResult(ctx, interaction, { text: 'ok', ephemeral: true })
|
||
return discordSendResult(ctx, interaction, typeof out === 'string' ? { text: out } : out)
|
||
}
|
||
|
||
async function discordPluginsAdmin(ctx, interaction, sub, opt) {
|
||
await discordPluginsEnsure(ctx)
|
||
const action = sub || 'list'
|
||
if (action === 'reload') {
|
||
await discordPluginsLoad(ctx)
|
||
let note = 'Reloaded **' + BARE_OS_DISCORD_PLUGINS.length + '** plugin file(s).'
|
||
if (typeof BARE_OS_DISCORD_PLUGIN_REREGISTER === 'function') {
|
||
try {
|
||
await BARE_OS_DISCORD_PLUGIN_REREGISTER()
|
||
note += ' Slash commands re-registered.'
|
||
} catch (err) {
|
||
note += ' Reload dispatch ok; slash re-register failed: ' + ((err && err.message) || err)
|
||
}
|
||
} else {
|
||
note += ' Restart `discord-bot` (or the initd unit) to publish new slash names to Discord.'
|
||
}
|
||
return discordPluginsAdmin(ctx, interaction, 'list', opt).then(function (res) {
|
||
if (res && res.embeds && res.embeds[0]) {
|
||
res.embeds[0].description = note + (res.embeds[0].description ? '\n' + res.embeds[0].description : '')
|
||
}
|
||
return res
|
||
})
|
||
}
|
||
if (action === 'disable' || action === 'enable') {
|
||
const want = String(opt('name') || '').toLowerCase()
|
||
if (!want) return { text: 'plugin name required', ephemeral: true }
|
||
const set = await discordPluginsDisabledSet(ctx)
|
||
if (action === 'disable') set[want] = 1
|
||
else delete set[want]
|
||
await discordPluginsWriteDisabled(ctx, set)
|
||
await discordPluginsLoad(ctx)
|
||
return {
|
||
text: (action === 'disable' ? 'Disabled' : 'Enabled') + ' `' + want + '`. Restart or `/plugins reload` to refresh slash names.',
|
||
ephemeral: true
|
||
}
|
||
}
|
||
if (action === 'info') {
|
||
const want = String(opt('name') || '').toLowerCase()
|
||
let plug = null
|
||
for (let i = 0; i < BARE_OS_DISCORD_PLUGINS.length; i++) {
|
||
if (BARE_OS_DISCORD_PLUGINS[i].name === want) plug = BARE_OS_DISCORD_PLUGINS[i]
|
||
}
|
||
if (!plug) return { text: 'No plugin named `' + want + '`.', ephemeral: true }
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: '/' + plug.name,
|
||
desc: plug.description,
|
||
fields: [
|
||
discordField('Status', plug.ok ? 'loaded' : plug.error || 'error', true),
|
||
discordField('Kind', plug.kind, true),
|
||
discordField('Version', plug.version, true),
|
||
discordField('File', plug.source || plug.file || '—', false)
|
||
],
|
||
color: plug.ok ? BARE_OS_DISCORD_COLOR_OK : BARE_OS_DISCORD_COLOR_WARN
|
||
})
|
||
)
|
||
}
|
||
const fields = []
|
||
for (let i = 0; i < BARE_OS_DISCORD_PLUGINS.length && fields.length < 20; i++) {
|
||
const p = BARE_OS_DISCORD_PLUGINS[i]
|
||
fields.push(
|
||
discordField(
|
||
'/' + p.name,
|
||
(p.ok ? '**on**' : '**off**') + ' · ' + (p.error || p.description || p.kind)
|
||
)
|
||
)
|
||
}
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Plugins',
|
||
desc:
|
||
'Drop JSON or JS in `~/.discord/plugins`. See **developer-guide ch.21** for the SDK.\n' +
|
||
BARE_OS_DISCORD_PLUGINS.length +
|
||
' discovered.',
|
||
fields: fields.length ? fields : [discordField('plugins', 'None yet. Add `~/.discord/plugins/hello.json`.')]
|
||
})
|
||
)
|
||
}
|
||
|
||
function discordBuildSlashCommands(dj, ctx) {
|
||
const B = dj && dj.SlashCommandBuilder
|
||
if (typeof B !== 'function') return []
|
||
const finish = function () {
|
||
const B2 = dj.SlashCommandBuilder
|
||
const bare = new B2().setName('bare').setDescription('Bare OS session (logged-in user)')
|
||
const sys = new B2().setName('sys').setDescription('Bare OS system snapshots')
|
||
const svc = new B2().setName('svc').setDescription('systemctl units')
|
||
const fsCmd = new B2().setName('fs').setDescription('Read-only VFS')
|
||
const net = new B2().setName('net').setDescription('Swarm / network')
|
||
const man = new B2().setName('man').setDescription('Look up a man page')
|
||
const say = new B2().setName('say').setDescription('Speak as Bare OS (or open a compose form)')
|
||
const run = new B2()
|
||
.setName('r')
|
||
.setDescription('Run a Bare OS shell command (cwd, pipes, cd, history)')
|
||
const journal = new B2()
|
||
.setName('journal')
|
||
.setDescription('Tail a unit or system log')
|
||
const edit = new B2()
|
||
.setName('edit')
|
||
.setDescription('Edit a text file in a Discord modal (home or /tmp)')
|
||
const create = new B2()
|
||
.setName('create')
|
||
.setDescription('Create a new text file (pick a path, then enter contents)')
|
||
const files = new B2()
|
||
.setName('files')
|
||
.setDescription('Browse and manage files (list, open, mkdir, rename, delete)')
|
||
const settings = new B2()
|
||
.setName('settings')
|
||
.setDescription('Live session settings (theme, shell, discord, agent, aliases)')
|
||
const plugins = new B2()
|
||
.setName('plugins')
|
||
.setDescription('List, reload, and manage ~/.discord/plugins')
|
||
const panel = new B2()
|
||
.setName('panel')
|
||
.setDescription('Interactive Bare OS control panel')
|
||
const ping = new B2().setName('ping').setDescription('Reply pong')
|
||
const upload = new B2()
|
||
.setName('upload')
|
||
.setDescription('Upload an attachment; wget saves it in the /r cwd or path')
|
||
const hdms = new B2()
|
||
.setName('hdms')
|
||
.setDescription('Hyperdrive management (list, create, add, invite, pair)')
|
||
const holesail = new B2()
|
||
.setName('holesail')
|
||
.setDescription('Holesail tunnels (list, add, edit, start/stop, service)')
|
||
const agent = new B2()
|
||
.setName('agent')
|
||
.setDescription('Guest AI agent — full /bin/agent harness (ask, inspect, session)')
|
||
const ok =
|
||
discordCmdAddSubs(bare, [
|
||
['ping', 'Latency / liveness'],
|
||
['about', 'What this bot is'],
|
||
['help', 'Command map'],
|
||
['status', 'Session snapshot (logged-in user)'],
|
||
['whoami', 'Session user'],
|
||
['hostname', 'Guest hostname'],
|
||
['date', 'Clock'],
|
||
['uptime', '/proc/uptime'],
|
||
['motd', '/etc/motd'],
|
||
['uname', 'uname -a style']
|
||
]) &&
|
||
discordCmdAddSubs(sys, [
|
||
['df', 'Disk / host resources'],
|
||
['mem', 'Memory info'],
|
||
['ps', 'Process table'],
|
||
['env', 'Redacted environment'],
|
||
['doctor', 'Security / debug posture'],
|
||
['features', '/proc/bare_os_features'],
|
||
['rlimits', 'Resource limits']
|
||
]) &&
|
||
discordCmdAddSubs(svc, [
|
||
['list', 'systemctl list'],
|
||
['status', 'Unit status', function (s) {
|
||
discordCmdOpt(s, 'unit', 'Unit name', true, true)
|
||
}],
|
||
['start', 'Start a unit', function (s) {
|
||
discordCmdOpt(s, 'unit', 'Unit name', true, true)
|
||
}],
|
||
['stop', 'Stop a unit', function (s) {
|
||
discordCmdOpt(s, 'unit', 'Unit name', true, true)
|
||
}],
|
||
['restart', 'Restart a unit', function (s) {
|
||
discordCmdOpt(s, 'unit', 'Unit name', true, true)
|
||
}],
|
||
['logs', 'Unit logs', function (s) {
|
||
discordCmdOpt(s, 'unit', 'Unit name', true, true)
|
||
}]
|
||
]) &&
|
||
discordCmdAddSubs(fsCmd, [
|
||
['ls', 'List a directory', function (s) {
|
||
discordCmdOpt(s, 'path', 'VFS path', false, true)
|
||
}],
|
||
['cat', 'Read a file', function (s) {
|
||
discordCmdOpt(s, 'path', 'VFS path', true, true)
|
||
}],
|
||
['stat', 'Stat a path', function (s) {
|
||
discordCmdOpt(s, 'path', 'VFS path', true, true)
|
||
}],
|
||
['head', 'First lines of a file', function (s) {
|
||
discordCmdOpt(s, 'path', 'VFS path', true, true)
|
||
discordCmdOpt(s, 'lines', 'Line count', false, false)
|
||
}]
|
||
]) &&
|
||
discordCmdAddSubs(net, [
|
||
['peers', 'Swarm peers'],
|
||
['swarm', 'Swarm snapshot'],
|
||
['summary', 'net_summary']
|
||
]) &&
|
||
discordCmdAddSubs(plugins, [
|
||
['list', 'Loaded plugins'],
|
||
['reload', 'Rescan ~/.discord/plugins'],
|
||
['info', 'Plugin details', function (s) {
|
||
discordCmdOpt(s, 'name', 'Plugin name', true, false)
|
||
}],
|
||
['disable', 'Disable a plugin', function (s) {
|
||
discordCmdOpt(s, 'name', 'Plugin name', true, false)
|
||
}],
|
||
['enable', 'Enable a plugin', function (s) {
|
||
discordCmdOpt(s, 'name', 'Plugin name', true, false)
|
||
}]
|
||
]) &&
|
||
discordCmdAddSubs(hdms, [
|
||
['list', 'Interactive drive manager'],
|
||
['help', 'HDMS command map'],
|
||
['health', 'hdms_health.json snapshot'],
|
||
['hints', 'Pairing hints (no secrets)'],
|
||
['show', 'Show a drive record', function (s) {
|
||
discordCmdOpt(s, 'label', 'Drive label', true, true)
|
||
}],
|
||
['create', 'Create a writable drive', function (s) {
|
||
discordCmdOpt(s, 'label', 'New label (mounted at /mnt/<label>)', true, false)
|
||
}],
|
||
['add', 'Mount a read-only z32 key', function (s) {
|
||
discordCmdOpt(s, 'label', 'Mount label', true, false)
|
||
discordCmdOpt(s, 'key', 'Hyperdrive public key (z32)', true, false)
|
||
}],
|
||
['remove', 'Unmount and drop a registry entry', function (s) {
|
||
discordCmdOpt(s, 'label', 'Drive label', true, true)
|
||
}],
|
||
['invite', 'Mint an Autopass invite', function (s) {
|
||
discordCmdOpt(s, 'label', 'Optional writable drive to share', false, true)
|
||
discordCmdOpt(s, 'mode', 'pair (default), readonly, or rw', false, false)
|
||
}],
|
||
['pair', 'Accept an invite', function (s) {
|
||
discordCmdOpt(s, 'invite', 'Invite token from the peer', true, false)
|
||
discordCmdOpt(s, 'persist', 'yes (default) or ephemeral', false, false)
|
||
}]
|
||
]) &&
|
||
discordCmdAddSubs(holesail, [
|
||
['list', 'Interactive tunnel manager'],
|
||
['help', 'Holesail command map'],
|
||
['status', 'Daemon + connection counts'],
|
||
['path', 'state.json path'],
|
||
['logs', 'bare-holesail journal'],
|
||
['show', 'Show a connection (no seed)', function (s) {
|
||
discordCmdOpt(s, 'id', 'Connection id', true, true)
|
||
}],
|
||
['url', 'Shareable hs:// URL', function (s) {
|
||
discordCmdOpt(s, 'id', 'Connection id', true, true)
|
||
}],
|
||
['add', 'Add a server or client', function (s) {
|
||
discordCmdOpt(s, 'id', 'New connection id', false, false)
|
||
discordCmdOpt(s, 'mode', 'server or client', false, false)
|
||
discordCmdOpt(s, 'port', 'Local port', false, false)
|
||
discordCmdOpt(s, 'host', 'Bind / connect host', false, false)
|
||
discordCmdOpt(s, 'key', 'hs:// URL (client)', false, false)
|
||
discordCmdOpt(s, 'udp', 'yes or no', false, false)
|
||
discordCmdOpt(s, 'secure', 'yes or no', false, false)
|
||
}],
|
||
['edit', 'Update fields in place', function (s) {
|
||
discordCmdOpt(s, 'id', 'Connection id', false, true)
|
||
discordCmdOpt(s, 'port', 'Local port', false, false)
|
||
discordCmdOpt(s, 'host', 'Host, or clear', false, false)
|
||
discordCmdOpt(s, 'key', 'hs:// URL', false, false)
|
||
discordCmdOpt(s, 'udp', 'yes or no', false, false)
|
||
discordCmdOpt(s, 'secure', 'yes or no', false, false)
|
||
}],
|
||
['remove', 'Drop a connection', function (s) {
|
||
discordCmdOpt(s, 'id', 'Connection id', true, true)
|
||
}],
|
||
['start', 'Start a live tunnel', function (s) {
|
||
discordCmdOpt(s, 'id', 'Connection id', true, true)
|
||
}],
|
||
['stop', 'Stop a live tunnel', function (s) {
|
||
discordCmdOpt(s, 'id', 'Connection id', true, true)
|
||
}],
|
||
['restart', 'Restart a live tunnel', function (s) {
|
||
discordCmdOpt(s, 'id', 'Connection id', true, true)
|
||
}],
|
||
['enable', 'Enable and start if the daemon is up', function (s) {
|
||
discordCmdOpt(s, 'id', 'Connection id', true, true)
|
||
}],
|
||
['disable', 'Disable and stop if live', function (s) {
|
||
discordCmdOpt(s, 'id', 'Connection id', true, true)
|
||
}],
|
||
['service', 'Control the bare-holesail unit', function (s) {
|
||
discordCmdOpt(s, 'action', 'start, stop, restart, or status', false, false)
|
||
}]
|
||
]) &&
|
||
discordCmdAddSubs(agent, [
|
||
['list', 'Interactive agent manager'],
|
||
['help', 'Agent command map'],
|
||
['status', 'Ready / backend / model / QVAC / plan / autonomous'],
|
||
['config', 'Non-secret ~/.agent/config.json'],
|
||
['models', 'Pick QVAC or REST model'],
|
||
['ask', 'Run one agent turn', function (s) {
|
||
discordCmdOpt(
|
||
s,
|
||
'prompt',
|
||
'What should the agent do? Required — cannot be empty.',
|
||
true,
|
||
false,
|
||
{ min: 1, max: 4000 }
|
||
)
|
||
discordCmdBool(s, 'new', 'Start a fresh chat (clear history first)')
|
||
discordCmdBool(s, 'plan', 'Start in plan mode (read-only until exit_plan_mode)')
|
||
discordCmdBool(s, 'auto', 'Autonomous until task_complete / timebox')
|
||
discordCmdBool(s, 'compact', 'Compact history before this turn')
|
||
discordCmdInt(s, 'max_turns', 'Cap ReAct iterations for this run', { min: 1, max: 200 })
|
||
discordCmdOpt(s, 'model', 'Override QVAC / REST model for this run', false, false)
|
||
}],
|
||
['skills', 'List discovered SKILL.md ids'],
|
||
['todos', 'Print session todos'],
|
||
['plan', 'Print ~/.agent/plan.md'],
|
||
['hooks', 'List ~/.agent/hooks/*.json'],
|
||
['history', 'Search or tail chat history', function (s) {
|
||
discordCmdOpt(s, 'query', 'Optional keyword filter', false, false)
|
||
}],
|
||
['recap', 'Last user + assistant pair'],
|
||
['undo', 'Restore the last file edit snapshot'],
|
||
['rewind', 'Drop the last N user turns', function (s) {
|
||
discordCmdInt(s, 'steps', 'How many user turns to drop (default 1)', { min: 1, max: 50 })
|
||
}],
|
||
['compact', 'Compact history now'],
|
||
['export', 'Write a Markdown transcript', function (s) {
|
||
discordCmdOpt(s, 'path', 'Dest path (default ~/.agent/export.md)', false, false)
|
||
}],
|
||
['remember', 'Append a FACT to MEMORY.md', function (s) {
|
||
discordCmdOpt(s, 'text', 'What to remember', true, false, { min: 1, max: 1000 })
|
||
}],
|
||
['reset', 'Clear chat history (keeps config)'],
|
||
['stop', 'Abort the in-flight turn']
|
||
])
|
||
if (!ok) {
|
||
return [ping.toJSON()]
|
||
}
|
||
discordCmdOpt(man, 'page', 'Command name (e.g. uname)', true, true)
|
||
discordCmdOpt(say, 'text', 'Text to box (omit to open a form)', false, false)
|
||
discordCmdOpt(run, 'cmd', 'Shell line (cd, pipes, &&, redirects, $VAR)', true, true)
|
||
discordCmdOpt(journal, 'unit', 'Unit name', false, true)
|
||
discordCmdOpt(edit, 'path', 'File under ~ or /tmp (omit to pick)', false, true)
|
||
discordCmdOpt(create, 'path', 'New file under ~ or /tmp (omit to pick)', false, true)
|
||
discordCmdOpt(files, 'path', 'Directory to open', false, true)
|
||
discordCmdAtt(upload, 'file', 'File to upload (Discord CDN, then wget into the VFS)', true)
|
||
discordCmdOpt(upload, 'path', 'VFS dest (file or dir; default: /r cwd + name)', false, true)
|
||
const stock = [bare, sys, svc, fsCmd, net, man, say, run, journal, edit, create, files, settings, plugins, panel, ping, upload, hdms, holesail, agent].map(
|
||
function (c) {
|
||
return discordStampUserInstallCommand(c.toJSON(), ctx)
|
||
}
|
||
)
|
||
return stock.concat(discordPluginsSlash(dj, ctx))
|
||
}
|
||
if (ctx && typeof ctx.then !== 'function') {
|
||
return Promise.resolve(discordPluginsEnsure(ctx)).then(finish)
|
||
}
|
||
return finish()
|
||
}
|
||
|
||
function discordShowForm(interaction, spec) {
|
||
if (!interaction || typeof interaction.showModal !== 'function') return false
|
||
const fields = spec.fields && spec.fields.length
|
||
? spec.fields
|
||
: [
|
||
{
|
||
id: spec.field || 'text',
|
||
label: spec.label || 'Value',
|
||
paragraph: spec.paragraph,
|
||
max: spec.max,
|
||
placeholder: spec.placeholder,
|
||
required: spec.required
|
||
}
|
||
]
|
||
const components = []
|
||
for (let i = 0; i < fields.length && components.length < 5; i++) {
|
||
const f = fields[i]
|
||
if (!f) continue
|
||
const input = {
|
||
type: 4,
|
||
custom_id: String(f.id || 'text').slice(0, 100),
|
||
label: String(f.label || 'Value').slice(0, 45),
|
||
style: f.paragraph ? 2 : 1,
|
||
required: f.required !== false,
|
||
max_length: f.max || 200,
|
||
placeholder: f.placeholder || ''
|
||
}
|
||
if (f.min > 0) input.min_length = Math.floor(Number(f.min))
|
||
components.push({
|
||
type: 1,
|
||
components: [input]
|
||
})
|
||
}
|
||
if (!components.length) return false
|
||
return interaction.showModal({
|
||
custom_id: spec.id,
|
||
title: String(spec.title || 'Bare OS').slice(0, 45),
|
||
components: components
|
||
})
|
||
}
|
||
|
||
function discordShowModal(interaction, spec) {
|
||
return discordShowForm(interaction, spec)
|
||
}
|
||
|
||
async function discordRouteCommand(ctx, name, sub, opt) {
|
||
if (name === 'panel') return discordPanel(ctx)
|
||
if (name === 'ping' || (name === 'bare' && (sub === 'ping' || !sub))) {
|
||
return discordCmdHandleBare(ctx, 'ping')
|
||
}
|
||
if (name === 'bare') return discordCmdHandleBare(ctx, sub)
|
||
if (name === 'sys') return discordCmdHandleSys(ctx, sub)
|
||
if (name === 'svc') return discordCmdHandleSvc(ctx, sub, opt('unit'))
|
||
if (name === 'fs') return discordCmdHandleFs(ctx, sub, opt('path'), opt('lines'))
|
||
if (name === 'net') return discordCmdHandleNet(ctx, sub)
|
||
if (name === 'man') return discordCmdHandleMan(ctx, opt('page'))
|
||
if (name === 'say') {
|
||
const text = opt('text')
|
||
if (!text) return { modal: 'say' }
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'say',
|
||
desc: discordCmdFence(discordCmdSayBox(text))
|
||
})
|
||
)
|
||
}
|
||
if (name === 'r' || name === 'run') return { run: opt('cmd') || '' }
|
||
if (name === 'journal') return discordCmdHandleJournal(ctx, opt('unit'))
|
||
if (name === 'edit') {
|
||
const path = opt('path')
|
||
if (!path) return { modal: 'edit:path' }
|
||
return { edit: path }
|
||
}
|
||
if (name === 'create') {
|
||
const path = opt('path')
|
||
if (!path) return { createPick: true }
|
||
return { create: path }
|
||
}
|
||
if (name === 'files' || name === 'browse') {
|
||
return { files: opt('path') || '' }
|
||
}
|
||
if (name === 'settings') return { settings: true }
|
||
return { text: 'unknown command: /' + name, ephemeral: true }
|
||
}
|
||
|
||
async function discordBeginEdit(ctx, interaction, rawPath) {
|
||
const p = discordCmdPathWriteOk(ctx, rawPath)
|
||
if (!p) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text:
|
||
'Cannot edit that path. Writable locations: `~/…`, `/home/' +
|
||
discordSessionUser(ctx) +
|
||
'/…`, `/tmp/…`. Token files are blocked.',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
if (!ctx.vfs || typeof ctx.vfs.writeFile !== 'function') {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'vfs.writeFile is unavailable in this session',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
let text = await discordCmdReadText(ctx, p)
|
||
const created = !text
|
||
if (text && discordLooksBinary(text)) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Refusing to open a binary file in a Discord modal: `' + p + '`',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
const split = discordEditChunks(text)
|
||
discordEditPut(interaction, {
|
||
path: p,
|
||
origLen: split.origLen,
|
||
truncated: split.truncated,
|
||
created: created
|
||
})
|
||
if (typeof interaction.showModal !== 'function') {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'This client cannot show Discord modals.',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
try {
|
||
await interaction.showModal(discordEditModalPayload(p, split.chunks))
|
||
} catch (err) {
|
||
if (ctx.console && typeof ctx.console.error === 'function') {
|
||
ctx.console.error(
|
||
'discord-bot: edit modal failed: ' + ((err && err.message) || err)
|
||
)
|
||
}
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Could not open the editor modal: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
})
|
||
}
|
||
}
|
||
|
||
async function discordEditSaveFromModal(ctx, interaction) {
|
||
const rec = discordEditGet(interaction)
|
||
if (!rec || !rec.path) {
|
||
return {
|
||
text: 'Edit session expired (15 minutes). Run `/edit` again.',
|
||
ephemeral: true
|
||
}
|
||
}
|
||
const p = discordCmdPathWriteOk(ctx, rec.path)
|
||
if (!p) {
|
||
return { text: 'Write not allowed for `' + rec.path + '`', ephemeral: true }
|
||
}
|
||
if (!ctx.vfs || typeof ctx.vfs.writeFile !== 'function') {
|
||
return { text: 'vfs.writeFile is unavailable', ephemeral: true }
|
||
}
|
||
const parts = []
|
||
for (let i = 0; i < BARE_OS_DISCORD_EDIT_MAX_CHUNKS; i++) {
|
||
try {
|
||
if (interaction.fields && typeof interaction.fields.getTextInputValue === 'function') {
|
||
parts.push(String(interaction.fields.getTextInputValue('c' + i) || ''))
|
||
}
|
||
} catch {
|
||
/* missing chunk */
|
||
}
|
||
}
|
||
const body = parts.join('')
|
||
if (rec.created && (await discordFileExists(ctx, p))) {
|
||
return {
|
||
text: '`' + p + '` already exists. Use `/edit` to change it.',
|
||
ephemeral: true
|
||
}
|
||
}
|
||
try {
|
||
await ctx.vfs.writeFile(p, discordTextToBuf(ctx, body))
|
||
} catch (err) {
|
||
return {
|
||
text: 'Save failed: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
}
|
||
}
|
||
discordEditClear(interaction)
|
||
const lines = body ? body.split(/\r?\n/).length : 0
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: rec.created ? 'Created ' + p : 'Saved ' + p,
|
||
color: BARE_OS_DISCORD_COLOR_OK,
|
||
fields: [
|
||
discordField('Path', p, false),
|
||
discordField('Bytes', String(body.length), true),
|
||
discordField('Lines', String(lines), true),
|
||
discordField('User', discordSessionUser(ctx), true)
|
||
],
|
||
desc: rec.truncated
|
||
? 'Original file was larger than the Discord modal cap (20 000 characters). Only the loaded window was saved.'
|
||
: 'Written through `ctx.vfs.writeFile` as **' +
|
||
discordSessionUser(ctx) +
|
||
'**.'
|
||
}),
|
||
{
|
||
components: [
|
||
discordButtons([
|
||
{ id: 'edit:open', label: 'Edit again', style: 1 },
|
||
{ id: 'edit:new', label: 'Edit another…' },
|
||
{ id: 'create:new', label: 'Create another…', style: 3 }
|
||
])
|
||
].filter(Boolean),
|
||
editPath: p
|
||
}
|
||
)
|
||
}
|
||
|
||
function discordCreatePicker(ctx) {
|
||
const choices = discordSuggestEditPaths(ctx, '')
|
||
const sel = discordSelect(
|
||
'create:pick',
|
||
'Choose a new file path…',
|
||
choices.map(function (c) {
|
||
return { label: c.name, value: c.value, description: 'Create this path' }
|
||
})
|
||
)
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Create a file',
|
||
desc:
|
||
'Pick a suggested path, or **Custom path…** to type one. Then enter the contents in a Discord modal.\nWritable: `~/…`, `/tmp/…`, `/home/' +
|
||
discordSessionUser(ctx) +
|
||
'/…`.'
|
||
}),
|
||
{
|
||
components: [
|
||
sel,
|
||
discordButtons([
|
||
{ id: 'create:custom', label: 'Custom path…', style: 1 }
|
||
])
|
||
].filter(Boolean)
|
||
}
|
||
)
|
||
}
|
||
|
||
var BARE_OS_DISCORD_SET_SESSIONS = Object.create(null)
|
||
var BARE_OS_DISCORD_SET_TTL_MS = BARE_OS_DISCORD_IDLE_MS
|
||
var BARE_OS_DISCORD_THEME_FALLBACK = [
|
||
'catppuccin_mocha',
|
||
'default',
|
||
'dracula',
|
||
'github_dark',
|
||
'gruvbox_dark',
|
||
'nord',
|
||
'solarized_dark',
|
||
'tokyo_night'
|
||
]
|
||
var BARE_OS_DISCORD_SET_PAGE = 8
|
||
var BARE_OS_DISCORD_SET_SECRET_RE = /token|secret|password|passwd|api[_-]?key|authorization|sasl/i
|
||
var BARE_OS_DISCORD_SET_GROUPS = [
|
||
{ id: 'appearance', label: 'Appearance', description: 'Theme, colors, TUI' },
|
||
{ id: 'shell', label: 'Shell', description: 'Live flags + completion' },
|
||
{ id: 'session', label: 'Session', description: 'Hostname, locale, editor' },
|
||
{ id: 'discord', label: 'Discord', description: 'Guild, whitelist, user-install (not token)' },
|
||
{ id: 'agent', label: 'Agent', description: '~/.agent/config.json' },
|
||
{ id: 'irc', label: 'IRC', description: '~/.irc/config.json' },
|
||
{ id: 'aliases', label: 'Aliases', description: 'shellAliases + ~/.barerc' }
|
||
]
|
||
var BARE_OS_DISCORD_SETTINGS = [
|
||
{
|
||
id: 'theme',
|
||
group: 'appearance',
|
||
label: 'Theme',
|
||
kind: 'theme',
|
||
env: 'BARE_OS_THEME',
|
||
persist: 'theme',
|
||
live: 'now (prompt / ls)'
|
||
},
|
||
{
|
||
id: 'color_depth',
|
||
group: 'appearance',
|
||
label: 'Color depth',
|
||
kind: 'enum',
|
||
env: 'BARE_OS_COLOR_DEPTH',
|
||
values: ['truecolor', '256', '16', '8'],
|
||
persist: 'export',
|
||
live: 'now (reapply theme)'
|
||
},
|
||
{
|
||
id: 'ls_lock',
|
||
group: 'appearance',
|
||
label: 'Lock LS_COLORS',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_LS_COLORS_LOCKED',
|
||
persist: 'export',
|
||
live: 'now (reapply theme)'
|
||
},
|
||
{
|
||
id: 'tui_noalt',
|
||
group: 'appearance',
|
||
label: 'TUI no altscreen',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_TUI_NO_ALTSCREEN',
|
||
persist: 'export',
|
||
live: 'next TUI'
|
||
},
|
||
{
|
||
id: 'no_color',
|
||
group: 'appearance',
|
||
label: 'NO_COLOR',
|
||
kind: 'bool',
|
||
env: 'NO_COLOR',
|
||
persist: 'export',
|
||
live: 'now (reapply theme)'
|
||
},
|
||
{
|
||
id: 'dircolors',
|
||
group: 'appearance',
|
||
label: 'dircolors file',
|
||
kind: 'string',
|
||
env: 'BARE_OS_DIRCOLORS',
|
||
persist: 'export',
|
||
live: 'now (reapply theme)'
|
||
},
|
||
{
|
||
id: 'compact',
|
||
group: 'shell',
|
||
label: 'Compact completion',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_COMPACT_MENU',
|
||
persist: 'export',
|
||
live: 'next Tab'
|
||
},
|
||
{
|
||
id: 'errexit',
|
||
group: 'shell',
|
||
label: 'errexit (set -e)',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_ERREXIT',
|
||
persist: 'export',
|
||
live: 'next command'
|
||
},
|
||
{
|
||
id: 'nounset',
|
||
group: 'shell',
|
||
label: 'nounset (set -u)',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_NOUNSET',
|
||
persist: 'export',
|
||
live: 'next command'
|
||
},
|
||
{
|
||
id: 'noglob',
|
||
group: 'shell',
|
||
label: 'noglob (set -f)',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_NOGLOB',
|
||
persist: 'export',
|
||
live: 'next command'
|
||
},
|
||
{
|
||
id: 'pipefail',
|
||
group: 'shell',
|
||
label: 'pipefail',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_PIPEFAIL',
|
||
persist: 'export',
|
||
live: 'next pipeline'
|
||
},
|
||
{
|
||
id: 'pipestatus',
|
||
group: 'shell',
|
||
label: 'PIPESTATUS',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_PIPESTATUS',
|
||
persist: 'export',
|
||
live: 'next pipeline'
|
||
},
|
||
{
|
||
id: 'posix',
|
||
group: 'shell',
|
||
label: 'POSIX mode',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_POSIX_MODE',
|
||
persist: 'export',
|
||
live: 'next command'
|
||
},
|
||
{
|
||
id: 'grouping',
|
||
group: 'shell',
|
||
label: 'Grouping ( … )',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_GROUPING',
|
||
persist: 'export',
|
||
live: 'next command'
|
||
},
|
||
{
|
||
id: 'dbracket',
|
||
group: 'shell',
|
||
label: '[[ … ]] tests',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_DOUBLE_BRACKET',
|
||
persist: 'export',
|
||
live: 'next command'
|
||
},
|
||
{
|
||
id: 'cmdsubst',
|
||
group: 'shell',
|
||
label: 'Command subst',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_CMDSUBST',
|
||
persist: 'export',
|
||
live: 'next $(…)'
|
||
},
|
||
{
|
||
id: 'streaming',
|
||
group: 'shell',
|
||
label: 'Pipeline streaming',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_STREAMING',
|
||
persist: 'export',
|
||
live: 'next pipeline'
|
||
},
|
||
{
|
||
id: 'brace',
|
||
group: 'shell',
|
||
label: 'Brace expansion',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_BRACE_EXPANSION',
|
||
persist: 'export',
|
||
live: 'next command'
|
||
},
|
||
{
|
||
id: 'paramexp',
|
||
group: 'shell',
|
||
label: 'Param expansion',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_PARAM_EXPANSION',
|
||
persist: 'export',
|
||
live: 'next ${…}'
|
||
},
|
||
{
|
||
id: 'until',
|
||
group: 'shell',
|
||
label: 'until loops',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_UNTIL',
|
||
persist: 'export',
|
||
live: 'next command'
|
||
},
|
||
{
|
||
id: 'loopctl',
|
||
group: 'shell',
|
||
label: 'break / continue',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_LOOP_CONTROL',
|
||
persist: 'export',
|
||
live: 'next loop'
|
||
},
|
||
{
|
||
id: 'readb',
|
||
group: 'shell',
|
||
label: 'read builtin',
|
||
kind: 'bool',
|
||
env: 'BARE_OS_SHELL_READ_BUILTIN',
|
||
persist: 'export',
|
||
live: 'next read'
|
||
},
|
||
{
|
||
id: 'hostname',
|
||
group: 'session',
|
||
label: 'Hostname',
|
||
kind: 'string',
|
||
env: 'HOSTNAME',
|
||
persist: 'hostname',
|
||
live: 'now (needs BARE_OS_HOSTNAME_SET=1)'
|
||
},
|
||
{
|
||
id: 'tz',
|
||
group: 'session',
|
||
label: 'Timezone (TZ)',
|
||
kind: 'string',
|
||
env: 'TZ',
|
||
persist: 'export',
|
||
live: 'next date'
|
||
},
|
||
{
|
||
id: 'lang',
|
||
group: 'session',
|
||
label: 'Locale (LANG)',
|
||
kind: 'string',
|
||
env: 'LANG',
|
||
persist: 'export',
|
||
live: 'next command'
|
||
},
|
||
{
|
||
id: 'editor',
|
||
group: 'session',
|
||
label: 'EDITOR',
|
||
kind: 'string',
|
||
env: 'EDITOR',
|
||
persist: 'export',
|
||
live: 'next editor'
|
||
},
|
||
{
|
||
id: 'pager',
|
||
group: 'session',
|
||
label: 'PAGER',
|
||
kind: 'string',
|
||
env: 'PAGER',
|
||
persist: 'export',
|
||
live: 'next pager'
|
||
},
|
||
{
|
||
id: 'guild',
|
||
group: 'discord',
|
||
label: 'Guild id',
|
||
kind: 'string',
|
||
env: 'DISCORD_GUILD_ID',
|
||
persist: 'discord',
|
||
live: 'next slash register'
|
||
},
|
||
{
|
||
id: 'whitelist',
|
||
group: 'discord',
|
||
label: 'User id whitelist',
|
||
kind: 'string',
|
||
env: 'DISCORD_ID_WHITELIST',
|
||
persist: 'discord',
|
||
live: 'next command'
|
||
},
|
||
{
|
||
id: 'user_install',
|
||
group: 'discord',
|
||
label: 'User-installable app',
|
||
kind: 'bool',
|
||
env: 'DISCORD_USER_INSTALL',
|
||
persist: 'discord',
|
||
live: 'next bot start'
|
||
},
|
||
{
|
||
id: 'dbg',
|
||
group: 'discord',
|
||
label: 'Debug logs',
|
||
kind: 'bool',
|
||
env: 'DISCORD_DEBUG',
|
||
persist: 'discord',
|
||
live: 'next bot start'
|
||
},
|
||
{
|
||
id: 'msgc',
|
||
group: 'discord',
|
||
label: 'Message Content',
|
||
kind: 'bool',
|
||
env: 'DISCORD_MESSAGE_CONTENT',
|
||
persist: 'discord',
|
||
live: 'next bot start'
|
||
},
|
||
{
|
||
id: 'loginms',
|
||
group: 'discord',
|
||
label: 'Login timeout ms',
|
||
kind: 'number',
|
||
env: 'DISCORD_LOGIN_TIMEOUT_MS',
|
||
persist: 'discord',
|
||
live: 'next bot start'
|
||
},
|
||
{
|
||
id: 'ag_backend',
|
||
group: 'agent',
|
||
label: 'Backend',
|
||
kind: 'enum',
|
||
jsonKey: 'backend',
|
||
values: ['qvac', 'rest'],
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_provider',
|
||
group: 'agent',
|
||
label: 'REST provider',
|
||
kind: 'enum',
|
||
jsonKey: 'provider',
|
||
values: ['groq', 'xai', 'openai', 'custom'],
|
||
persist: 'agent',
|
||
backend: 'rest',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_profile',
|
||
group: 'agent',
|
||
label: 'QVAC profile',
|
||
kind: 'enum',
|
||
jsonKey: 'qvac_profile',
|
||
values: ['recommended', 'strong', 'tool-tiny', 'lite'],
|
||
persist: 'agent',
|
||
backend: 'qvac',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_device',
|
||
group: 'agent',
|
||
label: 'QVAC device',
|
||
kind: 'enum',
|
||
jsonKey: 'qvac_device',
|
||
values: ['auto', 'cpu', 'gpu'],
|
||
persist: 'agent',
|
||
backend: 'qvac',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_ctx',
|
||
group: 'agent',
|
||
label: 'QVAC context size',
|
||
kind: 'enum',
|
||
jsonKey: 'qvac_ctx_size',
|
||
values: ['0', '8192', '16384', '32768', '131072'],
|
||
persist: 'agent',
|
||
backend: 'qvac',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_model',
|
||
group: 'agent',
|
||
label: 'Model',
|
||
kind: 'model',
|
||
jsonKey: 'model',
|
||
persist: 'agent',
|
||
live: 'opens model picker'
|
||
},
|
||
{
|
||
id: 'ag_access',
|
||
group: 'agent',
|
||
label: 'Access policy',
|
||
kind: 'enum',
|
||
jsonKey: 'access_policy',
|
||
values: ['full', 'restricted'],
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_compact',
|
||
group: 'agent',
|
||
label: 'Compaction',
|
||
kind: 'enum',
|
||
jsonKey: 'context_compaction',
|
||
values: ['auto', 'aggressive', 'off'],
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_auto',
|
||
group: 'agent',
|
||
label: 'Autonomous mode',
|
||
kind: 'bool',
|
||
jsonKey: 'autonomous_mode_enabled',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_owner',
|
||
group: 'agent',
|
||
label: 'Owner name',
|
||
kind: 'string',
|
||
jsonKey: 'owner_name',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_label',
|
||
group: 'agent',
|
||
label: 'Agent label',
|
||
kind: 'string',
|
||
jsonKey: 'agent_label',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_url',
|
||
group: 'agent',
|
||
label: 'REST base URL',
|
||
kind: 'string',
|
||
jsonKey: 'rest_base_url',
|
||
persist: 'agent',
|
||
backend: 'rest',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_temp',
|
||
group: 'agent',
|
||
label: 'Temperature',
|
||
kind: 'number',
|
||
jsonKey: 'temperature',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_maxtok',
|
||
group: 'agent',
|
||
label: 'Max tokens',
|
||
kind: 'number',
|
||
jsonKey: 'max_tokens',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_maxiter',
|
||
group: 'agent',
|
||
label: 'Max iterations',
|
||
kind: 'number',
|
||
jsonKey: 'max_iterations',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_stream',
|
||
group: 'agent',
|
||
label: 'Stream',
|
||
kind: 'bool',
|
||
jsonKey: 'stream',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_del',
|
||
group: 'agent',
|
||
label: 'Allow delete',
|
||
kind: 'bool',
|
||
jsonKey: 'allow_delete',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_reason',
|
||
group: 'agent',
|
||
label: 'Show reasoning',
|
||
kind: 'bool',
|
||
jsonKey: 'show_reasoning',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_reason_mode',
|
||
group: 'agent',
|
||
label: 'Reasoning mode',
|
||
kind: 'enum',
|
||
jsonKey: 'reasoning_mode',
|
||
values: ['off', 'summary', 'trace'],
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_bridge',
|
||
group: 'agent',
|
||
label: 'Bridge mutations',
|
||
kind: 'bool',
|
||
jsonKey: 'allow_bridge_mutations',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_notify',
|
||
group: 'agent',
|
||
label: 'Host notifications',
|
||
kind: 'bool',
|
||
jsonKey: 'allow_host_notifications',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_hostact',
|
||
group: 'agent',
|
||
label: 'Host actions',
|
||
kind: 'bool',
|
||
jsonKey: 'allow_host_actions',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_emerg',
|
||
group: 'agent',
|
||
label: 'Emergency stop mutations',
|
||
kind: 'bool',
|
||
jsonKey: 'emergency_stop_mutations',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_nudge',
|
||
group: 'agent',
|
||
label: 'Todo nudge',
|
||
kind: 'bool',
|
||
jsonKey: 'todo_nudge_enabled',
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_parallel',
|
||
group: 'agent',
|
||
label: 'Tool parallelism',
|
||
kind: 'enum',
|
||
jsonKey: 'tool_parallelism',
|
||
values: ['1', '2', '4'],
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_timeout',
|
||
group: 'agent',
|
||
label: 'Request timeout ms',
|
||
kind: 'enum',
|
||
jsonKey: 'request_timeout_ms',
|
||
values: ['60000', '120000', '180000', '300000'],
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_keep',
|
||
group: 'agent',
|
||
label: 'Compaction keep recent',
|
||
kind: 'enum',
|
||
jsonKey: 'compaction_keep_recent',
|
||
values: ['4', '8', '12', '16'],
|
||
persist: 'agent',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_gpulayers',
|
||
group: 'agent',
|
||
label: 'QVAC GPU layers',
|
||
kind: 'enum',
|
||
jsonKey: 'qvac_gpu_layers',
|
||
values: ['-1', '0', '8', '16', '32', '64'],
|
||
persist: 'agent',
|
||
backend: 'qvac',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'ag_maingpu',
|
||
group: 'agent',
|
||
label: 'QVAC main GPU',
|
||
kind: 'enum',
|
||
jsonKey: 'qvac_main_gpu',
|
||
values: ['auto', '0', '1', '2'],
|
||
persist: 'agent',
|
||
backend: 'qvac',
|
||
live: 'next agent'
|
||
},
|
||
{
|
||
id: 'irc_nick',
|
||
group: 'irc',
|
||
label: 'Nick',
|
||
kind: 'string',
|
||
jsonKey: 'nick',
|
||
persist: 'irc',
|
||
live: 'next irc'
|
||
},
|
||
{
|
||
id: 'irc_join',
|
||
group: 'irc',
|
||
label: 'Autojoin',
|
||
kind: 'string',
|
||
jsonKey: 'autojoin',
|
||
persist: 'irc',
|
||
live: 'next irc'
|
||
},
|
||
{
|
||
id: 'irc_share',
|
||
group: 'irc',
|
||
label: 'Share channels',
|
||
kind: 'bool',
|
||
jsonKey: 'shareChannels',
|
||
persist: 'irc',
|
||
live: 'next irc'
|
||
}
|
||
]
|
||
|
||
function discordSetKey(interaction) {
|
||
return discordInteractionUserId(interaction) || 'anon'
|
||
}
|
||
|
||
function discordSetGet(interaction) {
|
||
const now = Date.now()
|
||
for (const k in BARE_OS_DISCORD_SET_SESSIONS) {
|
||
const r = BARE_OS_DISCORD_SET_SESSIONS[k]
|
||
if (!r || now - r.atMs > BARE_OS_DISCORD_SET_TTL_MS) delete BARE_OS_DISCORD_SET_SESSIONS[k]
|
||
}
|
||
let rec = BARE_OS_DISCORD_SET_SESSIONS[discordSetKey(interaction)]
|
||
if (!rec) {
|
||
rec = { group: 'appearance', sel: '', page: 0, pending: '', atMs: now }
|
||
BARE_OS_DISCORD_SET_SESSIONS[discordSetKey(interaction)] = rec
|
||
}
|
||
rec.atMs = now
|
||
if (rec.page == null) rec.page = 0
|
||
if (rec.pending == null) rec.pending = ''
|
||
return rec
|
||
}
|
||
|
||
function discordSettingsGet(interaction) {
|
||
return discordSetGet(interaction)
|
||
}
|
||
|
||
function discordSettingsSpec(id) {
|
||
for (let i = 0; i < BARE_OS_DISCORD_SETTINGS.length; i++) {
|
||
if (BARE_OS_DISCORD_SETTINGS[i].id === id) return BARE_OS_DISCORD_SETTINGS[i]
|
||
}
|
||
return null
|
||
}
|
||
|
||
function discordSettingsFind(id) {
|
||
return discordSettingsSpec(id)
|
||
}
|
||
|
||
function discordSettingsInGroup(group, backend) {
|
||
const out = []
|
||
const be = backend === 'rest' || backend === 'qvac' ? backend : ''
|
||
for (let i = 0; i < BARE_OS_DISCORD_SETTINGS.length; i++) {
|
||
const spec = BARE_OS_DISCORD_SETTINGS[i]
|
||
if (spec.group !== group) continue
|
||
if (be && spec.backend && spec.backend !== be) continue
|
||
out.push(spec)
|
||
}
|
||
return out
|
||
}
|
||
|
||
function discordSanitizeAgentConfig(cfg) {
|
||
const next = cfg && typeof cfg === 'object' ? cfg : {}
|
||
const backend = String(next.backend || '').trim().toLowerCase() === 'rest' ? 'rest' : 'qvac'
|
||
next.backend = backend
|
||
if (backend === 'rest') {
|
||
delete next.qvac_model
|
||
delete next.qvac_profile
|
||
delete next.qvac_ctx_size
|
||
delete next.qvac_device
|
||
delete next.qvac_main_gpu
|
||
delete next.qvac_gpu_layers
|
||
const p = String(next.provider || '').trim().toLowerCase()
|
||
if (!p || p === 'qvac') next.provider = 'groq'
|
||
if (!next.rest_base_url) next.rest_base_url = 'https://api.groq.com/openai/v1'
|
||
} else {
|
||
delete next.rest_api_key
|
||
delete next.rest_base_url
|
||
next.provider = 'qvac'
|
||
}
|
||
return next
|
||
}
|
||
|
||
function discordSettingsPageOf(items, page) {
|
||
const size = BARE_OS_DISCORD_SET_PAGE
|
||
const pages = Math.max(1, Math.ceil(items.length / size) || 1)
|
||
const p = Math.min(Math.max(0, page | 0), pages - 1)
|
||
return { slice: items.slice(p * size, p * size + size), page: p, pages: pages }
|
||
}
|
||
|
||
function discordThemeNames(ctx) {
|
||
if (ctx && typeof ctx.bareOsListThemes === 'function') {
|
||
try {
|
||
const n = ctx.bareOsListThemes()
|
||
if (Array.isArray(n) && n.length) return n.slice().sort()
|
||
} catch {
|
||
/* fall through */
|
||
}
|
||
}
|
||
return BARE_OS_DISCORD_THEME_FALLBACK.slice()
|
||
}
|
||
|
||
function discordEnvOn(raw) {
|
||
if (raw === true) return true
|
||
const v = String(raw == null ? '' : raw).trim().toLowerCase()
|
||
return v === '1' || v === 'true' || v === 'yes' || v === 'on'
|
||
}
|
||
|
||
function discordSettingsIsSecretKey(key) {
|
||
return BARE_OS_DISCORD_SET_SECRET_RE.test(String(key || ''))
|
||
}
|
||
|
||
function discordSettingsAliasName(raw) {
|
||
const t = String(raw || '').trim()
|
||
const eq = t.indexOf('=')
|
||
const name = (eq >= 0 ? t.slice(0, eq) : t).trim()
|
||
if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(name)) return ''
|
||
return name
|
||
}
|
||
|
||
function discordSettingsAliasValue(raw) {
|
||
const t = String(raw || '').trim()
|
||
const eq = t.indexOf('=')
|
||
if (eq < 0) return ''
|
||
let v = t.slice(eq + 1).trim()
|
||
if (
|
||
(v.charAt(0) === "'" && v.charAt(v.length - 1) === "'") ||
|
||
(v.charAt(0) === '"' && v.charAt(v.length - 1) === '"')
|
||
) {
|
||
v = v.slice(1, -1)
|
||
}
|
||
return v
|
||
}
|
||
|
||
function discordSettingsQuoteAlias(value) {
|
||
const v = String(value == null ? '' : value)
|
||
if (/^[A-Za-z0-9_./:+@%-]+$/.test(v)) return v
|
||
return "'" + v.replace(/'/g, "'\\''") + "'"
|
||
}
|
||
|
||
async function discordEnsureDir(ctx, path) {
|
||
if (!ctx || !ctx.vfs || typeof ctx.vfs.mkdir !== 'function') return
|
||
try {
|
||
await ctx.vfs.mkdir(path, { recursive: true })
|
||
} catch {
|
||
/* already exists or vfs without mkdir */
|
||
}
|
||
}
|
||
|
||
async function discordReadJsonFile(ctx, path) {
|
||
const t = await discordCmdReadText(ctx, path)
|
||
const j = discordTryJson(t)
|
||
return j && typeof j === 'object' && !Array.isArray(j) ? j : {}
|
||
}
|
||
|
||
async function discordWriteJsonFile(ctx, path, obj) {
|
||
if (!ctx.vfs || typeof ctx.vfs.writeFile !== 'function') {
|
||
throw new Error('writeFile unavailable')
|
||
}
|
||
const parent = discordParentPath(path)
|
||
if (parent && parent !== path) await discordEnsureDir(ctx, parent)
|
||
const sanitized = {}
|
||
const keys = Object.keys(obj || {})
|
||
for (let i = 0; i < keys.length; i++) {
|
||
const k = keys[i]
|
||
if (discordSettingsIsSecretKey(k)) {
|
||
sanitized[k] = obj[k]
|
||
continue
|
||
}
|
||
sanitized[k] = obj[k]
|
||
}
|
||
const body = JSON.stringify(sanitized, null, 2) + '\n'
|
||
await ctx.vfs.writeFile(path, discordTextToBuf(ctx, body))
|
||
}
|
||
|
||
async function discordPersistBarercLine(ctx, matcher, line) {
|
||
if (!ctx.vfs || typeof ctx.vfs.writeFile !== 'function') {
|
||
throw new Error('writeFile unavailable')
|
||
}
|
||
const text = await discordCmdReadText(ctx, '~/.barerc')
|
||
const lines = text ? text.split(/\r?\n/) : []
|
||
const drop = line == null || line === ''
|
||
let replaced = false
|
||
const out = []
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (matcher.test(lines[i])) {
|
||
if (!replaced && !drop) {
|
||
out.push(line)
|
||
replaced = true
|
||
}
|
||
} else out.push(lines[i])
|
||
}
|
||
if (!replaced && !drop) {
|
||
if (out.length && String(out[out.length - 1]).trim() !== '') out.push('')
|
||
out.push(line)
|
||
}
|
||
while (out.length && String(out[out.length - 1]).trim() === '') out.pop()
|
||
let next = out.join('\n')
|
||
if (next) next += '\n'
|
||
await ctx.vfs.writeFile('~/.barerc', discordTextToBuf(ctx, next))
|
||
}
|
||
|
||
async function discordPersistDiscordEnvKey(ctx, key, value) {
|
||
if (discordSettingsIsSecretKey(key) || key === 'DISCORD_TOKEN') {
|
||
throw new Error('refusing to write a secret key')
|
||
}
|
||
const path = '~/.discord/.env'
|
||
let text = await discordCmdReadText(ctx, path)
|
||
const line = key + '=' + String(value)
|
||
const re = new RegExp('^\\s*(?:export\\s+)?' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=.*$', 'm')
|
||
if (re.test(text)) text = text.replace(re, line)
|
||
else text = (text && !/\n$/.test(text) ? text + '\n' : text || '') + line + '\n'
|
||
await discordEnsureDir(ctx, '~/.discord')
|
||
await ctx.vfs.writeFile(path, discordTextToBuf(ctx, text))
|
||
}
|
||
|
||
async function discordSettingsCurrent(ctx, spec) {
|
||
const env = discordCmdEnv(ctx)
|
||
if (spec.persist === 'agent') {
|
||
const cfg = await discordReadJsonFile(ctx, '~/.agent/config.json')
|
||
return cfg[spec.jsonKey]
|
||
}
|
||
if (spec.persist === 'irc') {
|
||
const cfg = await discordReadJsonFile(ctx, '~/.irc/config.json')
|
||
const v = cfg[spec.jsonKey]
|
||
if (Array.isArray(v)) return v.join(',')
|
||
return v
|
||
}
|
||
if (spec.env) return env[spec.env]
|
||
return ''
|
||
}
|
||
|
||
function discordSettingsDisplay(spec, raw) {
|
||
if (spec.kind === 'bool') return discordEnvOn(raw) ? 'on' : 'off'
|
||
if (raw == null || raw === '') return '(unset)'
|
||
return String(raw)
|
||
}
|
||
|
||
function discordSettingsCoerce(spec, value) {
|
||
if (spec.kind === 'bool') {
|
||
const on = value === true || value === 'on' || discordEnvOn(value)
|
||
if (spec.persist === 'agent' || spec.persist === 'irc') return on
|
||
return on ? '1' : '0'
|
||
}
|
||
if (spec.kind === 'number') {
|
||
const n = Number(value)
|
||
if (!Number.isFinite(n)) throw new Error('not a number')
|
||
return n
|
||
}
|
||
if (spec.kind === 'theme') {
|
||
const name = String(value || '')
|
||
.toLowerCase()
|
||
.trim()
|
||
.replace(/\s+/g, '_')
|
||
if (!/^[a-z0-9_.-]+$/.test(name)) throw new Error('invalid theme name')
|
||
return name
|
||
}
|
||
if (spec.kind === 'enum') {
|
||
const want = String(value == null ? '' : value)
|
||
const ok = spec.values && spec.values.indexOf(want) >= 0
|
||
if (!ok) throw new Error('invalid value')
|
||
return want
|
||
}
|
||
return String(value == null ? '' : value)
|
||
}
|
||
|
||
async function discordSettingsApply(ctx, spec, value) {
|
||
if (!spec) throw new Error('unknown setting')
|
||
if (spec.env && discordSettingsIsSecretKey(spec.env)) {
|
||
throw new Error('refusing to write a secret')
|
||
}
|
||
if (spec.jsonKey && discordSettingsIsSecretKey(spec.jsonKey)) {
|
||
throw new Error('refusing to write a secret')
|
||
}
|
||
const env = discordCmdEnv(ctx)
|
||
let v = discordSettingsCoerce(spec, value)
|
||
if (spec.kind === 'hostname' || spec.persist === 'hostname') {
|
||
const host = String(v).trim()
|
||
if (!/^[A-Za-z0-9][A-Za-z0-9.-]{0,62}$/.test(host)) throw new Error('invalid hostname')
|
||
v = host
|
||
}
|
||
if (spec.env) {
|
||
env[spec.env] = String(v)
|
||
if (ctx.env && ctx.env !== env) ctx.env[spec.env] = String(v)
|
||
}
|
||
if (spec.persist === 'export') {
|
||
await discordPersistBarercLine(
|
||
ctx,
|
||
new RegExp('^\\s*export\\s+' + spec.env + '='),
|
||
'export ' + spec.env + '=' + v
|
||
)
|
||
} else if (spec.persist === 'theme') {
|
||
await discordPersistBarercLine(ctx, /^\s*theme\s+/, 'theme ' + v)
|
||
if (typeof ctx.bareOsApplyTheme === 'function') await ctx.bareOsApplyTheme()
|
||
} else if (spec.persist === 'discord') {
|
||
await discordPersistDiscordEnvKey(ctx, spec.env, String(v))
|
||
} else if (spec.persist === 'hostname') {
|
||
try {
|
||
if (typeof ctx.bareOsSetSessionHostname === 'function') {
|
||
ctx.bareOsSetSessionHostname(String(v))
|
||
} else {
|
||
env.HOSTNAME = String(v)
|
||
env.COMPUTERNAME = String(v)
|
||
if (ctx.env && ctx.env !== env) {
|
||
ctx.env.HOSTNAME = String(v)
|
||
ctx.env.COMPUTERNAME = String(v)
|
||
}
|
||
}
|
||
} catch {
|
||
env.HOSTNAME = String(v)
|
||
env.COMPUTERNAME = String(v)
|
||
if (ctx.env && ctx.env !== env) {
|
||
ctx.env.HOSTNAME = String(v)
|
||
ctx.env.COMPUTERNAME = String(v)
|
||
}
|
||
}
|
||
await discordPersistBarercLine(ctx, /^\s*export\s+HOSTNAME=/, 'export HOSTNAME=' + v)
|
||
} else if (spec.persist === 'agent') {
|
||
const cfg = await discordReadJsonFile(ctx, '~/.agent/config.json')
|
||
if (spec.jsonKey === 'backend') cfg.backend = String(v) === 'rest' ? 'rest' : 'qvac'
|
||
else if (spec.jsonKey === 'qvac_ctx_size') cfg.qvac_ctx_size = Number(v) || 0
|
||
else if (
|
||
spec.jsonKey === 'tool_parallelism' ||
|
||
spec.jsonKey === 'request_timeout_ms' ||
|
||
spec.jsonKey === 'compaction_keep_recent' ||
|
||
spec.jsonKey === 'qvac_gpu_layers'
|
||
) {
|
||
cfg[spec.jsonKey] = Number(v)
|
||
} else if (spec.jsonKey === 'qvac_profile') {
|
||
cfg.qvac_profile = String(v)
|
||
const mapped = discordQvacModelForProfile(v)
|
||
if (mapped && mapped.id) {
|
||
cfg.qvac_model = mapped.id
|
||
cfg.model = mapped.id
|
||
}
|
||
} else cfg[spec.jsonKey] = v
|
||
const clean = discordSanitizeAgentConfig(cfg)
|
||
await discordWriteJsonFile(ctx, '~/.agent/config.json', clean)
|
||
} else if (spec.persist === 'irc') {
|
||
const cfg = await discordReadJsonFile(ctx, '~/.irc/config.json')
|
||
if (spec.jsonKey === 'autojoin') {
|
||
cfg.autojoin = String(v)
|
||
.split(',')
|
||
.map(function (s) {
|
||
return s.trim()
|
||
})
|
||
.filter(Boolean)
|
||
} else cfg[spec.jsonKey] = v
|
||
await discordWriteJsonFile(ctx, '~/.irc/config.json', cfg)
|
||
}
|
||
if (
|
||
spec.persist === 'theme' ||
|
||
spec.id === 'color_depth' ||
|
||
spec.id === 'ls_lock' ||
|
||
spec.id === 'no_color' ||
|
||
spec.id === 'dircolors'
|
||
) {
|
||
if (spec.persist !== 'theme' && typeof ctx.bareOsApplyTheme === 'function') {
|
||
await ctx.bareOsApplyTheme()
|
||
}
|
||
}
|
||
}
|
||
|
||
async function discordSettingsSetAlias(ctx, raw) {
|
||
const name = discordSettingsAliasName(raw)
|
||
const value = discordSettingsAliasValue(raw)
|
||
if (!name || !value) throw new Error('use name=command')
|
||
if (!ctx.shellAliases || typeof ctx.shellAliases !== 'object') ctx.shellAliases = {}
|
||
ctx.shellAliases[name] = value
|
||
await discordPersistBarercLine(
|
||
ctx,
|
||
new RegExp('^\\s*unalias\\s+' + name + '\\b'),
|
||
''
|
||
)
|
||
await discordPersistBarercLine(
|
||
ctx,
|
||
new RegExp('^\\s*alias\\s+' + name + '='),
|
||
'alias ' + name + '=' + discordSettingsQuoteAlias(value)
|
||
)
|
||
}
|
||
|
||
async function discordSettingsRemoveAlias(ctx, name) {
|
||
const n = discordSettingsAliasName(name)
|
||
if (!n) throw new Error('bad alias name')
|
||
if (ctx.shellAliases && n) delete ctx.shellAliases[n]
|
||
await discordPersistBarercLine(ctx, new RegExp('^\\s*alias\\s+' + n + '='), '')
|
||
await discordPersistBarercLine(ctx, new RegExp('^\\s*unalias\\s+' + n + '\\b'), 'unalias ' + n)
|
||
}
|
||
|
||
function discordSettingsChoices(ctx, spec) {
|
||
if (spec.kind === 'theme') {
|
||
return discordThemeNames(ctx)
|
||
.slice(0, 25)
|
||
.map(function (n) {
|
||
return { label: n, value: n }
|
||
})
|
||
}
|
||
if (spec.kind === 'enum' && spec.values) {
|
||
return spec.values.map(function (n) {
|
||
return { label: String(n), value: String(n) }
|
||
})
|
||
}
|
||
if (spec.kind === 'bool') {
|
||
return [
|
||
{ label: 'on', value: '1' },
|
||
{ label: 'off', value: '0' }
|
||
]
|
||
}
|
||
return []
|
||
}
|
||
|
||
function discordSettingsGroupMeta(id) {
|
||
for (let i = 0; i < BARE_OS_DISCORD_SET_GROUPS.length; i++) {
|
||
if (BARE_OS_DISCORD_SET_GROUPS[i].id === id) return BARE_OS_DISCORD_SET_GROUPS[i]
|
||
}
|
||
return { id: id, label: id, description: '' }
|
||
}
|
||
|
||
function discordSettingsAliasEntries(ctx) {
|
||
const al = (ctx.shellAliases && typeof ctx.shellAliases === 'object' && ctx.shellAliases) || {}
|
||
return Object.keys(al)
|
||
.sort()
|
||
.map(function (k) {
|
||
return { name: k, value: String(al[k]) }
|
||
})
|
||
}
|
||
|
||
async function discordSettingsView(ctx, interaction) {
|
||
const rec = discordSetGet(interaction)
|
||
const group = rec.group || 'appearance'
|
||
const meta = discordSettingsGroupMeta(group)
|
||
const fields = []
|
||
let pages = 1
|
||
let page = 0
|
||
let itemOpts = []
|
||
if (group === 'aliases') {
|
||
const entries = discordSettingsAliasEntries(ctx)
|
||
const pg = discordSettingsPageOf(entries, rec.page)
|
||
rec.page = pg.page
|
||
pages = pg.pages
|
||
page = pg.page
|
||
if (!entries.length) fields.push(discordField('Aliases', 'None defined. Add one from the menu.'))
|
||
for (let i = 0; i < pg.slice.length; i++) {
|
||
const e = pg.slice[i]
|
||
fields.push(discordField((rec.sel === e.name ? '▸ ' : '') + e.name, '`' + (e.value || '') + '`'))
|
||
}
|
||
itemOpts = [{ label: 'Add alias', value: 'alias_add', description: 'name=command' }].concat(
|
||
pg.slice.map(function (e) {
|
||
return { label: 'Remove ' + e.name, value: 'alias_rm:' + e.name, description: e.value.slice(0, 80) }
|
||
})
|
||
)
|
||
} else {
|
||
let items = discordSettingsInGroup(group)
|
||
if (group === 'agent') {
|
||
const cfg = await discordReadJsonFile(ctx, '~/.agent/config.json')
|
||
const be = String(cfg.backend || '').toLowerCase() === 'rest' ? 'rest' : 'qvac'
|
||
items = discordSettingsInGroup(group, be)
|
||
}
|
||
const pg = discordSettingsPageOf(items, rec.page)
|
||
rec.page = pg.page
|
||
pages = pg.pages
|
||
page = pg.page
|
||
for (let i = 0; i < pg.slice.length; i++) {
|
||
const spec = pg.slice[i]
|
||
const cur = await discordSettingsCurrent(ctx, spec)
|
||
fields.push(
|
||
discordField(
|
||
(rec.sel === spec.id ? '▸ ' : '') + spec.label,
|
||
'**' + discordSettingsDisplay(spec, cur) + '**\n' + spec.live
|
||
)
|
||
)
|
||
}
|
||
itemOpts = pg.slice.map(function (s) {
|
||
return { label: s.label, value: s.id, description: s.live }
|
||
})
|
||
}
|
||
const gsel = discordSelect(
|
||
'set:group',
|
||
'Category',
|
||
BARE_OS_DISCORD_SET_GROUPS.map(function (g) {
|
||
return { label: g.label, value: g.id, description: g.description }
|
||
})
|
||
)
|
||
const isel = discordSelect('set:item', pages > 1 ? 'Setting… p' + (page + 1) + '/' + pages : 'Setting…', itemOpts)
|
||
const acts = discordButtons([
|
||
{ id: 'set:edit', label: 'Edit', style: 1 },
|
||
{ id: 'set:toggle', label: 'Toggle' },
|
||
{ id: 'set:reload', label: 'Reload barerc' },
|
||
{ id: 'nav:panel', label: 'Panel' }
|
||
])
|
||
const pageRow =
|
||
pages > 1
|
||
? discordButtons([
|
||
{ id: 'set:prev', label: '← Prev', style: 2 },
|
||
{ id: 'set:next', label: 'Next →', style: 2 }
|
||
])
|
||
: null
|
||
return discordResult(
|
||
discordEmbed({
|
||
title: 'Settings · ' + meta.label + (pages > 1 ? ' · ' + (page + 1) + '/' + pages : ''),
|
||
desc: meta.description + '. Secrets are never shown. Pick a row, then **Edit** or **Toggle**.',
|
||
fields: fields.slice(0, 20),
|
||
footer: discordSessionUser(ctx) + ' · expires after 2m idle'
|
||
}),
|
||
{ components: [gsel, isel, acts, pageRow].filter(Boolean), nav: false }
|
||
)
|
||
}
|
||
|
||
async function discordSettingsOpenEditor(ctx, interaction, spec) {
|
||
if (!spec) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Pick a setting from the menu first.',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
const rec = discordSetGet(interaction)
|
||
rec.sel = spec.id
|
||
rec.pending = spec.id
|
||
if (spec.kind === 'model') {
|
||
return discordSendResult(ctx, interaction, await discordAgentModelView(ctx, interaction, ''))
|
||
}
|
||
if (spec.kind === 'enum' || spec.kind === 'theme' || spec.kind === 'bool') {
|
||
const opts = discordSettingsChoices(ctx, spec)
|
||
const cur = await discordSettingsCurrent(ctx, spec)
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
discordResult(
|
||
discordEmbed({
|
||
title: spec.label,
|
||
desc:
|
||
'Current: **' +
|
||
discordSettingsDisplay(spec, cur) +
|
||
'**. Applies live where possible and persists. ' +
|
||
spec.live +
|
||
'.'
|
||
}),
|
||
{
|
||
components: [discordSelect('set:val', spec.label, opts), discordButtons([{ id: 'set:back', label: 'Back' }])],
|
||
nav: false
|
||
}
|
||
)
|
||
)
|
||
}
|
||
const cur = await discordSettingsCurrent(ctx, spec)
|
||
await discordShowModal(interaction, {
|
||
id: 'set:str',
|
||
title: spec.label.slice(0, 45),
|
||
label: spec.label.slice(0, 45),
|
||
placeholder: discordSettingsDisplay(spec, cur).slice(0, 80),
|
||
max: 200
|
||
})
|
||
}
|
||
|
||
async function discordSettingsAction(ctx, interaction, id) {
|
||
const rec = discordSetGet(interaction)
|
||
if (id === 'set:home') {
|
||
rec.group = 'appearance'
|
||
rec.sel = ''
|
||
rec.page = 0
|
||
rec.pending = ''
|
||
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
|
||
}
|
||
if (id === 'set:group') {
|
||
rec.group = String((interaction.values && interaction.values[0]) || rec.group)
|
||
rec.sel = ''
|
||
rec.page = 0
|
||
rec.pending = ''
|
||
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
|
||
}
|
||
if (id === 'set:prev') {
|
||
rec.page = Math.max(0, (rec.page | 0) - 1)
|
||
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
|
||
}
|
||
if (id === 'set:next') {
|
||
rec.page = (rec.page | 0) + 1
|
||
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
|
||
}
|
||
if (id === 'set:item') {
|
||
const v = String((interaction.values && interaction.values[0]) || '')
|
||
if (v === 'alias_add') {
|
||
rec.sel = 'alias_add'
|
||
rec.pending = 'alias_add'
|
||
await discordShowModal(interaction, {
|
||
id: 'set:alias',
|
||
title: 'Add alias',
|
||
label: 'name=command',
|
||
placeholder: 'll=ls -la',
|
||
max: 80
|
||
})
|
||
return
|
||
}
|
||
if (v.indexOf('alias_rm:') === 0) {
|
||
try {
|
||
await discordSettingsRemoveAlias(ctx, v.slice(9))
|
||
} catch (err) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Remove alias failed: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
})
|
||
}
|
||
rec.sel = ''
|
||
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
|
||
}
|
||
rec.sel = v
|
||
rec.pending = v
|
||
return discordSettingsOpenEditor(ctx, interaction, discordSettingsSpec(v))
|
||
}
|
||
if (id === 'set:val') {
|
||
const spec = discordSettingsSpec(rec.sel)
|
||
const v = String((interaction.values && interaction.values[0]) || '')
|
||
if (spec) {
|
||
try {
|
||
await discordSettingsApply(ctx, spec, v)
|
||
} catch (err) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Apply failed: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
})
|
||
}
|
||
}
|
||
rec.pending = ''
|
||
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
|
||
}
|
||
if (id === 'set:toggle') {
|
||
const spec = discordSettingsSpec(rec.sel)
|
||
if (!spec || spec.kind !== 'bool') {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Select a boolean setting first, then Toggle.',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
const cur = await discordSettingsCurrent(ctx, spec)
|
||
const next = discordEnvOn(cur) ? '0' : '1'
|
||
try {
|
||
await discordSettingsApply(ctx, spec, next)
|
||
} catch (err) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Toggle failed: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
})
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
|
||
}
|
||
if (id === 'set:edit') {
|
||
if (rec.group === 'aliases') {
|
||
rec.pending = 'alias_add'
|
||
await discordShowModal(interaction, {
|
||
id: 'set:alias',
|
||
title: 'Add alias',
|
||
label: 'name=command',
|
||
placeholder: 'll=ls -la',
|
||
max: 80
|
||
})
|
||
return
|
||
}
|
||
return discordSettingsOpenEditor(ctx, interaction, discordSettingsSpec(rec.sel))
|
||
}
|
||
if (id === 'set:reload') {
|
||
if (typeof ctx.execLine === 'function') {
|
||
try {
|
||
await ctx.execLine('barerc reload')
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
|
||
}
|
||
if (id === 'set:back') {
|
||
rec.pending = ''
|
||
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
|
||
}
|
||
|
||
async function discordBeginCreate(ctx, interaction, rawPath) {
|
||
const p = discordCmdPathWriteOk(ctx, rawPath)
|
||
if (!p) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text:
|
||
'Cannot create that path. Use `~/file`, `/tmp/file`, or `/home/' +
|
||
discordSessionUser(ctx) +
|
||
'/file`. Token files are blocked.',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
if (!ctx.vfs || typeof ctx.vfs.writeFile !== 'function') {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'vfs.writeFile is unavailable in this session',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
if (await discordFileExists(ctx, p)) {
|
||
discordEditPut(interaction, { path: p })
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
discordResult(
|
||
discordEmbed({
|
||
title: 'Already exists',
|
||
color: BARE_OS_DISCORD_COLOR_WARN,
|
||
desc:
|
||
'`' +
|
||
p +
|
||
'` is already on disk. Use **Edit** to change it, or pick another path.'
|
||
}),
|
||
{
|
||
components: [
|
||
discordButtons([
|
||
{ id: 'edit:open', label: 'Edit instead', style: 1 },
|
||
{ id: 'create:new', label: 'Different path…', style: 3 }
|
||
])
|
||
],
|
||
editPath: p
|
||
}
|
||
)
|
||
)
|
||
}
|
||
const split = discordEditChunks('')
|
||
discordEditPut(interaction, {
|
||
path: p,
|
||
origLen: 0,
|
||
truncated: false,
|
||
created: true
|
||
})
|
||
if (typeof interaction.showModal !== 'function') {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'This client cannot show Discord modals.',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
try {
|
||
const modal = discordEditModalPayload(p, split.chunks)
|
||
modal.custom_id = 'create:save'
|
||
modal.title = ('Create ' + (String(p).split('/').pop() || p)).slice(0, 45)
|
||
await interaction.showModal(modal)
|
||
} catch (err) {
|
||
if (ctx.console && typeof ctx.console.error === 'function') {
|
||
ctx.console.error(
|
||
'discord-bot: create modal failed: ' + ((err && err.message) || err)
|
||
)
|
||
}
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Could not open the create modal: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
})
|
||
}
|
||
}
|
||
|
||
function discordIdleNow() {
|
||
return Date.now()
|
||
}
|
||
|
||
function discordIdleSchedule(fn, ms) {
|
||
const tfn = typeof setTimeout === 'function' ? setTimeout : globalThis.setTimeout
|
||
if (typeof tfn !== 'function') return null
|
||
const t = tfn(fn, ms)
|
||
if (t && typeof t.unref === 'function') t.unref()
|
||
return t
|
||
}
|
||
|
||
function discordIdleClearTimer(t) {
|
||
const cfn = typeof clearTimeout === 'function' ? clearTimeout : globalThis.clearTimeout
|
||
if (t != null && typeof cfn === 'function') cfn(t)
|
||
}
|
||
|
||
function discordIdleExpiredEmbeds(embeds) {
|
||
const out = []
|
||
if (!Array.isArray(embeds)) return out
|
||
for (let i = 0; i < embeds.length; i++) {
|
||
const e = embeds[i] || {}
|
||
const n = {}
|
||
for (const k in e) n[k] = e[k]
|
||
n.footer = { text: 'Expired after 2 minutes idle' }
|
||
n.color = BARE_OS_DISCORD_COLOR_WARN
|
||
out.push(n)
|
||
}
|
||
return out
|
||
}
|
||
|
||
function discordIdleForgetSessions(userId) {
|
||
if (!userId) return
|
||
for (const k in BARE_OS_DISCORD_LIVE) {
|
||
const r = BARE_OS_DISCORD_LIVE[k]
|
||
if (r && r.userId === userId) return
|
||
}
|
||
delete BARE_OS_DISCORD_EDIT_SESSIONS[userId]
|
||
delete BARE_OS_DISCORD_FM_SESSIONS[userId]
|
||
delete BARE_OS_DISCORD_SET_SESSIONS[userId]
|
||
delete BARE_OS_DISCORD_MORE_SESSIONS[userId]
|
||
delete BARE_OS_DISCORD_SH_SESSIONS[userId]
|
||
delete BARE_OS_DISCORD_HDMS_SESSIONS[userId]
|
||
delete BARE_OS_DISCORD_HOLESAIL_SESSIONS[userId]
|
||
}
|
||
|
||
function discordUnwrapMessage(sent) {
|
||
if (!sent || typeof sent !== 'object') return null
|
||
if (sent.resource && sent.resource.message) return sent.resource.message
|
||
if (sent.message && (sent.message.id || typeof sent.message.edit === 'function')) {
|
||
return sent.message
|
||
}
|
||
if (sent.id || typeof sent.delete === 'function' || typeof sent.edit === 'function') return sent
|
||
return null
|
||
}
|
||
|
||
async function discordResolvePostedMessage(interaction, sent) {
|
||
const unwrapped = discordUnwrapMessage(sent)
|
||
if (unwrapped) return unwrapped
|
||
if (interaction && interaction.message && (interaction.message.id || typeof interaction.message.delete === 'function')) {
|
||
return interaction.message
|
||
}
|
||
if (interaction && typeof interaction.fetchReply === 'function') {
|
||
try {
|
||
const m = await interaction.fetchReply()
|
||
return discordUnwrapMessage(m) || m
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function discordIdleArm(msg, payload, userId) {
|
||
if (!msg) return null
|
||
const hasUi =
|
||
(payload && payload.embeds && payload.embeds.length) ||
|
||
(payload && payload.components && payload.components.length)
|
||
if (!hasUi) return null
|
||
const id = String(msg.id || '') || 'anon:' + ++BARE_OS_DISCORD_IDLE_SEQ
|
||
const prev = BARE_OS_DISCORD_LIVE[id]
|
||
if (prev) discordIdleClearTimer(prev.timer)
|
||
const rec = {
|
||
id: id,
|
||
message: msg,
|
||
userId: userId || '',
|
||
embeds: payload && payload.embeds ? payload.embeds : null,
|
||
atMs: discordIdleNow(),
|
||
timer: null
|
||
}
|
||
BARE_OS_DISCORD_LIVE[id] = rec
|
||
rec.timer = discordIdleSchedule(function () {
|
||
Promise.resolve(discordIdleExpire(id)).catch(function () {})
|
||
}, BARE_OS_DISCORD_IDLE_MS)
|
||
return rec
|
||
}
|
||
|
||
async function discordIdleExpire(id) {
|
||
const rec = BARE_OS_DISCORD_LIVE[id]
|
||
if (!rec) return false
|
||
delete BARE_OS_DISCORD_LIVE[id]
|
||
discordIdleClearTimer(rec.timer)
|
||
rec.timer = null
|
||
const msg = rec.message
|
||
let cleaned = false
|
||
if (msg && typeof msg.delete === 'function') {
|
||
try {
|
||
await msg.delete()
|
||
cleaned = true
|
||
} catch {
|
||
cleaned = false
|
||
}
|
||
}
|
||
if (!cleaned && msg && typeof msg.edit === 'function') {
|
||
try {
|
||
const body = { components: [] }
|
||
if (rec.embeds && rec.embeds.length) body.embeds = discordIdleExpiredEmbeds(rec.embeds)
|
||
await msg.edit(body)
|
||
cleaned = true
|
||
} catch {
|
||
cleaned = false
|
||
}
|
||
}
|
||
discordIdleForgetSessions(rec.userId)
|
||
return cleaned
|
||
}
|
||
|
||
function discordIdleSweep() {
|
||
const now = discordIdleNow()
|
||
const due = []
|
||
for (const k in BARE_OS_DISCORD_LIVE) {
|
||
const r = BARE_OS_DISCORD_LIVE[k]
|
||
if (!r || now - r.atMs >= BARE_OS_DISCORD_IDLE_MS) due.push(k)
|
||
}
|
||
for (let i = 0; i < due.length; i++) {
|
||
Promise.resolve(discordIdleExpire(due[i])).catch(function () {})
|
||
}
|
||
}
|
||
|
||
function discordIdleTouch(interaction) {
|
||
const mid = interaction && interaction.message && interaction.message.id
|
||
if (!mid) return
|
||
const rec = BARE_OS_DISCORD_LIVE[String(mid)]
|
||
if (!rec) return
|
||
if (discordIdleNow() - rec.atMs >= BARE_OS_DISCORD_IDLE_MS) return
|
||
rec.atMs = discordIdleNow()
|
||
}
|
||
|
||
async function discordIdleWatch(interaction, payload, sent) {
|
||
if (!payload) return
|
||
if (
|
||
payload.flags === BARE_OS_DISCORD_FLAG_EPHEMERAL &&
|
||
!(payload.embeds && payload.embeds.length) &&
|
||
!(payload.components && payload.components.length)
|
||
) {
|
||
return
|
||
}
|
||
const msg = await discordResolvePostedMessage(interaction, sent)
|
||
discordIdleArm(msg, payload, discordInteractionUserId(interaction))
|
||
}
|
||
|
||
async function discordSendResult(ctx, interaction, result) {
|
||
if (result && result.run !== undefined) {
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleRun(ctx, interaction, result.run))
|
||
}
|
||
if (result && result.more) {
|
||
return discordSendResult(ctx, interaction, discordLongTextResult(interaction, result.more))
|
||
}
|
||
if (result && result.editPath) {
|
||
const prev = discordEditGet(interaction)
|
||
discordEditPut(interaction, {
|
||
path: result.editPath,
|
||
created: !!(result.created || (prev && prev.created))
|
||
})
|
||
}
|
||
if (result && result.edit) {
|
||
await discordBeginEdit(ctx, interaction, result.edit)
|
||
return
|
||
}
|
||
if (result && result.create) {
|
||
await discordBeginCreate(ctx, interaction, result.create)
|
||
return
|
||
}
|
||
if (result && result.createPick) {
|
||
return discordSendResult(ctx, interaction, discordCreatePicker(ctx))
|
||
}
|
||
if (result && result.settings) {
|
||
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
|
||
}
|
||
if (result && result.files !== undefined) {
|
||
const rec = discordFmGet(interaction, ctx)
|
||
const want = String(result.files || '').trim()
|
||
if (want) {
|
||
const p = discordCmdPathOk(want)
|
||
if (p) {
|
||
rec.cwd = p
|
||
rec.page = 0
|
||
rec.sel = ''
|
||
rec.confirm = ''
|
||
}
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordFmView(ctx, interaction))
|
||
}
|
||
if (result && result.modal === 'create:path') {
|
||
await discordShowModal(interaction, {
|
||
id: 'create:path',
|
||
title: 'New file path',
|
||
label: 'Path under ~ or /tmp',
|
||
placeholder: '~/notes.txt',
|
||
max: 200
|
||
})
|
||
return
|
||
}
|
||
if (result && result.modal === 'edit:path') {
|
||
await discordShowModal(interaction, {
|
||
id: 'edit:path',
|
||
title: 'Open a file to edit',
|
||
label: 'Path under ~ or /tmp',
|
||
placeholder: '~/notes.txt',
|
||
max: 200
|
||
})
|
||
return
|
||
}
|
||
if (result && result.modal === 'say') {
|
||
await discordShowModal(interaction, {
|
||
id: 'say:modal',
|
||
title: 'Say as Bare OS',
|
||
label: 'Message',
|
||
paragraph: true,
|
||
placeholder: 'Hello from Bare OS'
|
||
})
|
||
return
|
||
}
|
||
if (result && result.modal === 'run') {
|
||
return discordSendResult(ctx, interaction, discordShHud(ctx, interaction, ''))
|
||
}
|
||
if (result && result.modal === 'hdms:create') {
|
||
await discordShowForm(interaction, {
|
||
id: 'hdms:create',
|
||
title: 'Create HDMS drive',
|
||
fields: [
|
||
{
|
||
id: 'label',
|
||
label: 'Label (mounted at /mnt/<label>)',
|
||
placeholder: 'notes',
|
||
max: 63
|
||
}
|
||
]
|
||
})
|
||
return
|
||
}
|
||
if (result && result.modal === 'hdms:add') {
|
||
await discordShowForm(interaction, {
|
||
id: 'hdms:add',
|
||
title: 'Add read-only HDMS drive',
|
||
fields: [
|
||
{ id: 'label', label: 'Label', placeholder: 'peerdrive', max: 63 },
|
||
{ id: 'key', label: 'Hyperdrive key (z32)', placeholder: 'z32…', max: 80 }
|
||
]
|
||
})
|
||
return
|
||
}
|
||
if (result && result.modal === 'hdms:pair') {
|
||
await discordShowForm(interaction, {
|
||
id: 'hdms:pair',
|
||
title: 'Pair HDMS invite',
|
||
fields: [
|
||
{
|
||
id: 'invite',
|
||
label: 'Invite token',
|
||
paragraph: true,
|
||
placeholder: 'Paste the z32 invite from the peer',
|
||
max: 400
|
||
}
|
||
]
|
||
})
|
||
return
|
||
}
|
||
if (result && result.modal === 'holesail:addsrv') {
|
||
await discordShowForm(interaction, {
|
||
id: 'holesail:addsrv',
|
||
title: 'Add Holesail server',
|
||
fields: [
|
||
{ id: 'id', label: 'Connection id', placeholder: 'web-9000', max: 64 },
|
||
{ id: 'port', label: 'Local port', placeholder: '8088', max: 8 },
|
||
{ id: 'host', label: 'Bind host', placeholder: '127.0.0.1', max: 80, required: false },
|
||
{ id: 'udp', label: 'UDP (yes/no)', placeholder: 'no', max: 8, required: false },
|
||
{ id: 'secure', label: 'Secure (yes/no)', placeholder: 'no', max: 8, required: false }
|
||
]
|
||
})
|
||
return
|
||
}
|
||
if (result && result.modal === 'holesail:addcli') {
|
||
await discordShowForm(interaction, {
|
||
id: 'holesail:addcli',
|
||
title: 'Add Holesail client',
|
||
fields: [
|
||
{ id: 'id', label: 'Connection id', placeholder: 'peer-web', max: 64 },
|
||
{ id: 'key', label: 'hs:// URL', paragraph: true, placeholder: 'hs://…', max: 200 },
|
||
{ id: 'port', label: 'Local port', placeholder: '8088', max: 8 },
|
||
{ id: 'host', label: 'Connect host', placeholder: '127.0.0.1', max: 80, required: false }
|
||
]
|
||
})
|
||
return
|
||
}
|
||
if (result && result.modal === 'agent:ask') {
|
||
await discordShowForm(interaction, {
|
||
id: 'agent:ask',
|
||
title: 'Ask the agent',
|
||
fields: [
|
||
{
|
||
id: 'prompt',
|
||
label: 'What should the agent do?',
|
||
paragraph: true,
|
||
placeholder: 'e.g. list /bin and summarize /proc/bare_os/features',
|
||
min: 1,
|
||
max: 4000,
|
||
required: true
|
||
}
|
||
]
|
||
})
|
||
return
|
||
}
|
||
if (result && result.modal === 'agent:remember') {
|
||
await discordShowForm(interaction, {
|
||
id: 'agent:remember',
|
||
title: 'Remember a fact',
|
||
fields: [
|
||
{
|
||
id: 'text',
|
||
label: 'Fact to store in MEMORY.md',
|
||
paragraph: true,
|
||
placeholder: 'e.g. Holesail keys live in ~/.holesail',
|
||
min: 1,
|
||
max: 1000,
|
||
required: true
|
||
}
|
||
]
|
||
})
|
||
return
|
||
}
|
||
if (result && result.modal === 'agent:history') {
|
||
await discordShowForm(interaction, {
|
||
id: 'agent:history',
|
||
title: 'Search agent history',
|
||
fields: [
|
||
{
|
||
id: 'query',
|
||
label: 'Keyword (empty = count only)',
|
||
placeholder: 'holesail',
|
||
max: 200,
|
||
required: false
|
||
}
|
||
]
|
||
})
|
||
return
|
||
}
|
||
if (result && result.modal === 'agent:mdltype') {
|
||
await discordShowForm(interaction, {
|
||
id: 'agent:mdltype',
|
||
title: 'Custom model id',
|
||
fields: [
|
||
{
|
||
id: 'model',
|
||
label: 'Model id',
|
||
placeholder: 'QWEN3_8B_INST_Q4_K_M or llama-3.3-70b-versatile',
|
||
max: 120
|
||
}
|
||
]
|
||
})
|
||
return
|
||
}
|
||
if (result && result.modal === 'holesail:edit') {
|
||
await discordShowForm(interaction, {
|
||
id: 'holesail:edit',
|
||
title: 'Edit Holesail tunnel',
|
||
fields: [
|
||
{ id: 'port', label: 'Port (blank = keep)', placeholder: '8088', max: 8, required: false },
|
||
{ id: 'host', label: 'Host (clear to drop)', placeholder: '127.0.0.1', max: 80, required: false },
|
||
{ id: 'key', label: 'hs:// URL (blank = keep)', paragraph: true, placeholder: 'hs://…', max: 200, required: false },
|
||
{ id: 'udp', label: 'UDP (yes/no/blank)', placeholder: 'no', max: 8, required: false },
|
||
{ id: 'secure', label: 'Secure (yes/no/blank)', placeholder: 'no', max: 8, required: false }
|
||
]
|
||
})
|
||
return
|
||
}
|
||
const payload = discordCmdReplyPayload(result)
|
||
const isComp =
|
||
(typeof interaction.isButton === 'function' && interaction.isButton()) ||
|
||
(typeof interaction.isStringSelectMenu === 'function' &&
|
||
interaction.isStringSelectMenu())
|
||
let sent = null
|
||
try {
|
||
if (isComp && typeof interaction.update === 'function' && !interaction.replied && !interaction.deferred) {
|
||
sent = await interaction.update(payload)
|
||
} else if (interaction.deferred && typeof interaction.editReply === 'function') {
|
||
sent = await interaction.editReply(payload)
|
||
} else if (interaction.replied && typeof interaction.followUp === 'function') {
|
||
sent = await interaction.followUp(payload)
|
||
} else {
|
||
sent = await interaction.reply(payload)
|
||
}
|
||
} catch (err) {
|
||
if (ctx && ctx.console && typeof ctx.console.error === 'function') {
|
||
ctx.console.error(
|
||
'discord-bot: reply failed: ' + ((err && err.message) || err)
|
||
)
|
||
}
|
||
return
|
||
}
|
||
try {
|
||
await discordIdleWatch(interaction, payload, sent)
|
||
} catch {
|
||
/* tracking must not break the bot */
|
||
}
|
||
}
|
||
|
||
async function discordReplyEphemeralText(ctx, interaction, text) {
|
||
const payload = discordCmdReplyPayload({ text: text, ephemeral: true })
|
||
try {
|
||
if (interaction.deferred && typeof interaction.editReply === 'function') {
|
||
await interaction.editReply(payload)
|
||
return
|
||
}
|
||
if (interaction.replied && typeof interaction.followUp === 'function') {
|
||
await interaction.followUp(payload)
|
||
return
|
||
}
|
||
if (typeof interaction.reply === 'function') {
|
||
await interaction.reply(payload)
|
||
}
|
||
} catch (err) {
|
||
if (ctx && ctx.console && typeof ctx.console.error === 'function') {
|
||
ctx.console.error(
|
||
'discord-bot: deny reply failed: ' + ((err && err.message) || err)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
async function discordReplyWhitelistDenied(ctx, interaction) {
|
||
await discordReplyEphemeralText(ctx, interaction, BARE_OS_DISCORD_WHITELIST_DENY)
|
||
}
|
||
|
||
async function discordReplyOwnerDenied(ctx, interaction) {
|
||
await discordReplyEphemeralText(ctx, interaction, BARE_OS_DISCORD_OWNER_DENY)
|
||
}
|
||
|
||
async function discordDispatchAutocomplete(ctx, interaction) {
|
||
let focused = { name: '', value: '' }
|
||
try {
|
||
if (interaction.options && typeof interaction.options.getFocused === 'function') {
|
||
const f = interaction.options.getFocused(true)
|
||
if (f && typeof f === 'object') focused = f
|
||
else focused = { name: 'cmd', value: String(f || '') }
|
||
}
|
||
} catch {
|
||
focused = { name: '', value: '' }
|
||
}
|
||
const q = focused.value
|
||
const fname = String(focused.name || '')
|
||
let choices = []
|
||
try {
|
||
if (fname === 'unit') choices = await discordSuggestUnits(ctx, q)
|
||
else if (fname === 'page') choices = await discordSuggestMan(ctx, q)
|
||
else if (fname === 'cmd') choices = await discordSuggestRun(ctx, interaction, q)
|
||
else if (fname === 'path') {
|
||
const cmd = String(interaction.commandName || '')
|
||
choices =
|
||
cmd === 'edit' || cmd === 'create' || cmd === 'upload'
|
||
? discordSuggestEditPaths(ctx, q)
|
||
: cmd === 'hdms'
|
||
? await discordSuggestHdmsLabels(ctx, q)
|
||
: discordSuggestPaths(ctx, q)
|
||
} else if (fname === 'label' && String(interaction.commandName || '') === 'hdms') {
|
||
choices = await discordSuggestHdmsLabels(ctx, q)
|
||
} else if (fname === 'id' && String(interaction.commandName || '') === 'holesail') {
|
||
choices = await discordSuggestHolesailIds(ctx, q)
|
||
} else if (fname === 'name' && String(interaction.commandName || '') === 'plugins') {
|
||
await discordPluginsEnsure(ctx)
|
||
const names = []
|
||
for (let i = 0; i < BARE_OS_DISCORD_PLUGINS.length; i++) names.push(BARE_OS_DISCORD_PLUGINS[i].name)
|
||
choices = discordFilterChoices(names, q)
|
||
} else {
|
||
const plug = discordPluginFind(interaction.commandName)
|
||
if (plug && typeof plug.onAutocomplete === 'function') {
|
||
const ev = discordPluginEvent(ctx, interaction, plug, '', function () {
|
||
return q
|
||
})
|
||
ev.focused = fname
|
||
ev.query = q
|
||
const got = await plug.onAutocomplete(ev)
|
||
if (Array.isArray(got)) choices = discordFilterChoices(got, q)
|
||
}
|
||
}
|
||
} catch {
|
||
choices = []
|
||
}
|
||
try {
|
||
if (typeof interaction.respond === 'function') await interaction.respond(choices.slice(0, 25))
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return true
|
||
}
|
||
|
||
async function discordFmDelete(ctx, path) {
|
||
if (typeof ctx.vfs.rm === 'function') {
|
||
await ctx.vfs.rm(path, { recursive: true, force: true })
|
||
return
|
||
}
|
||
const st = await discordFmStat(ctx, path)
|
||
if (discordIsDirStat(st) && typeof ctx.vfs.rmdir === 'function') {
|
||
await ctx.vfs.rmdir(path)
|
||
return
|
||
}
|
||
if (typeof ctx.vfs.unlink === 'function') await ctx.vfs.unlink(path)
|
||
else throw new Error('unlink unavailable')
|
||
}
|
||
|
||
async function discordFmCopyFile(ctx, from, to) {
|
||
if (typeof ctx.vfs.readFile !== 'function' || typeof ctx.vfs.writeFile !== 'function') {
|
||
throw new Error('copy requires readFile/writeFile')
|
||
}
|
||
const buf = await ctx.vfs.readFile(from)
|
||
await ctx.vfs.writeFile(to, buf)
|
||
}
|
||
|
||
async function discordFmAction(ctx, interaction, id) {
|
||
const rec = discordFmGet(interaction, ctx)
|
||
if (id === 'fm:goto') {
|
||
await discordShowModal(interaction, {
|
||
id: 'fm:goto',
|
||
title: 'Go to path',
|
||
label: 'Directory',
|
||
placeholder: rec.cwd || '~',
|
||
max: 200
|
||
})
|
||
return
|
||
}
|
||
if (id === 'fm:mkdir') {
|
||
await discordShowModal(interaction, {
|
||
id: 'fm:mkdir',
|
||
title: 'New folder in ' + discordBaseName(rec.cwd || '~'),
|
||
label: 'Folder name',
|
||
placeholder: 'docs',
|
||
max: 80
|
||
})
|
||
return
|
||
}
|
||
if (id === 'fm:ren') {
|
||
if (!rec.sel) {
|
||
return discordSendResult(ctx, interaction, await discordFmView(ctx, interaction))
|
||
}
|
||
await discordShowModal(interaction, {
|
||
id: 'fm:ren',
|
||
title: 'Rename ' + rec.sel,
|
||
label: 'New name',
|
||
placeholder: rec.sel,
|
||
max: 80
|
||
})
|
||
return
|
||
}
|
||
if (id === 'fm:copy') {
|
||
if (!rec.sel) {
|
||
return discordSendResult(ctx, interaction, await discordFmView(ctx, interaction))
|
||
}
|
||
await discordShowModal(interaction, {
|
||
id: 'fm:copy',
|
||
title: 'Copy ' + rec.sel,
|
||
label: 'Destination path',
|
||
placeholder: discordJoinPath(rec.cwd, rec.sel + '.copy'),
|
||
max: 200
|
||
})
|
||
return
|
||
}
|
||
if (id === 'fm:new') {
|
||
const name = 'new-' + String(Date.now()).slice(-6) + '.txt'
|
||
await discordBeginCreate(ctx, interaction, discordJoinPath(rec.cwd, name))
|
||
return
|
||
}
|
||
if (id === 'fm:edit') {
|
||
if (!rec.sel) {
|
||
return discordSendResult(ctx, interaction, await discordFmView(ctx, interaction))
|
||
}
|
||
await discordBeginEdit(ctx, interaction, discordJoinPath(rec.cwd, rec.sel))
|
||
return
|
||
}
|
||
if (id === 'fm:up') {
|
||
rec.cwd = discordParentPath(rec.cwd)
|
||
rec.sel = ''
|
||
rec.page = 0
|
||
rec.confirm = ''
|
||
} else if (id === 'fm:home') {
|
||
rec.cwd = '~'
|
||
rec.sel = ''
|
||
rec.page = 0
|
||
rec.confirm = ''
|
||
} else if (id === 'fm:prev') {
|
||
rec.page--
|
||
rec.confirm = ''
|
||
} else if (id === 'fm:next') {
|
||
rec.page++
|
||
rec.confirm = ''
|
||
} else if (id === 'fm:ref' || id === 'fm:more' || id === 'fm:no') {
|
||
rec.confirm = ''
|
||
} else if (id === 'fm:pick') {
|
||
const v = String((interaction.values && interaction.values[0]) || '')
|
||
rec.confirm = ''
|
||
if (v === '..') {
|
||
rec.cwd = discordParentPath(rec.cwd)
|
||
rec.sel = ''
|
||
rec.page = 0
|
||
} else if (v) {
|
||
const full = discordJoinPath(rec.cwd, v)
|
||
const st = await discordFmStat(ctx, full)
|
||
if (discordIsDirStat(st)) {
|
||
rec.cwd = full
|
||
rec.sel = ''
|
||
rec.page = 0
|
||
} else {
|
||
rec.sel = v
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleFs(ctx, 'cat', full))
|
||
}
|
||
}
|
||
} else if (id === 'fm:open') {
|
||
if (!rec.sel) {
|
||
/* stay */
|
||
} else {
|
||
const full = discordJoinPath(rec.cwd, rec.sel)
|
||
const st = await discordFmStat(ctx, full)
|
||
if (discordIsDirStat(st)) {
|
||
rec.cwd = full
|
||
rec.sel = ''
|
||
rec.page = 0
|
||
} else {
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleFs(ctx, 'cat', full))
|
||
}
|
||
}
|
||
} else if (id === 'fm:del') {
|
||
rec.confirm = rec.sel || ''
|
||
} else if (id === 'fm:ok') {
|
||
const name = rec.confirm || rec.sel
|
||
rec.confirm = ''
|
||
if (name) {
|
||
const full = discordJoinPath(rec.cwd, name)
|
||
if (!discordCmdPathWriteOk(ctx, full)) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Delete not allowed: `' + full + '`',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
try {
|
||
await discordFmDelete(ctx, full)
|
||
rec.sel = ''
|
||
} catch (err) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Delete failed: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
})
|
||
}
|
||
}
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordFmView(ctx, interaction))
|
||
}
|
||
|
||
async function discordDispatchComponent(ctx, interaction) {
|
||
const id = String(interaction.customId || '')
|
||
const opt = function () {
|
||
return ''
|
||
}
|
||
if (id.indexOf('plug:') === 0) {
|
||
await discordPluginComponent(ctx, interaction, id)
|
||
return true
|
||
}
|
||
if (id.indexOf('sh:') === 0 || id === 'run:compose') {
|
||
await discordShAction(ctx, interaction, id)
|
||
return true
|
||
}
|
||
if (id.indexOf('hdms:') === 0) {
|
||
await discordHdmsAction(ctx, interaction, id)
|
||
return true
|
||
}
|
||
if (id.indexOf('holesail:') === 0) {
|
||
await discordHolesailAction(ctx, interaction, id)
|
||
return true
|
||
}
|
||
if (id.indexOf('agent:') === 0) {
|
||
await discordAgentAction(ctx, interaction, id)
|
||
return true
|
||
}
|
||
if (id === 'more:prev' || id === 'more:next') {
|
||
const rec = discordMoreGet(interaction)
|
||
if (!rec) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'This view expired after 2 minutes idle.',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
rec.page += id === 'more:next' ? 1 : -1
|
||
return discordSendResult(ctx, interaction, discordMoreResult(interaction))
|
||
}
|
||
if (id.indexOf('set:') === 0) {
|
||
await discordSettingsAction(ctx, interaction, id)
|
||
return true
|
||
}
|
||
if (id.indexOf('fm:') === 0) {
|
||
await discordFmAction(ctx, interaction, id)
|
||
return true
|
||
}
|
||
if (id === 'nav:menu') {
|
||
const msg = interaction.message || {}
|
||
const open = !discordNavIsExpanded(msg.components)
|
||
const payload = {
|
||
components: discordUniqueComponents(discordApplyNav(msg.components, open))
|
||
}
|
||
const embeds = discordNormalizeEmbeds(msg.embeds)
|
||
if (embeds) payload.embeds = embeds
|
||
let sent = null
|
||
try {
|
||
if (
|
||
typeof interaction.update === 'function' &&
|
||
!interaction.replied &&
|
||
!interaction.deferred
|
||
) {
|
||
sent = await interaction.update(payload)
|
||
} else if (typeof interaction.reply === 'function') {
|
||
sent = await interaction.reply(payload)
|
||
}
|
||
} catch (err) {
|
||
if (ctx && ctx.console && typeof ctx.console.error === 'function') {
|
||
ctx.console.error(
|
||
'discord-bot: menu toggle failed: ' + ((err && err.message) || err)
|
||
)
|
||
}
|
||
return true
|
||
}
|
||
try {
|
||
await discordIdleWatch(interaction, payload, sent)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return true
|
||
}
|
||
if (id === 'nav:panel') return discordSendResult(ctx, interaction, discordPanel(ctx))
|
||
if (id === 'say:compose') {
|
||
await discordShowModal(interaction, {
|
||
id: 'say:modal',
|
||
title: 'Say as Bare OS',
|
||
label: 'Message',
|
||
paragraph: true,
|
||
placeholder: 'Hello from Bare OS'
|
||
})
|
||
return true
|
||
}
|
||
if (id === 'edit:new') {
|
||
await discordShowModal(interaction, {
|
||
id: 'edit:path',
|
||
title: 'Open a file to edit',
|
||
label: 'Path under ~ or /tmp',
|
||
placeholder: '~/notes.txt',
|
||
max: 200
|
||
})
|
||
return true
|
||
}
|
||
if (id === 'create:new' || id === 'create:custom') {
|
||
await discordShowModal(interaction, {
|
||
id: 'create:path',
|
||
title: 'New file path',
|
||
label: 'Path under ~ or /tmp',
|
||
placeholder: '~/notes.txt',
|
||
max: 200
|
||
})
|
||
return true
|
||
}
|
||
if (id === 'create:pick') {
|
||
const values = interaction.values || []
|
||
const path = values[0] ? String(values[0]) : ''
|
||
if (!path) {
|
||
return discordSendResult(ctx, interaction, discordCreatePicker(ctx))
|
||
}
|
||
await discordBeginCreate(ctx, interaction, path)
|
||
return true
|
||
}
|
||
if (id === 'create:open') {
|
||
const rec = discordEditGet(interaction)
|
||
const path = rec && rec.path
|
||
if (!path) {
|
||
await discordShowModal(interaction, {
|
||
id: 'create:path',
|
||
title: 'New file path',
|
||
label: 'Path under ~ or /tmp',
|
||
placeholder: '~/notes.txt',
|
||
max: 200
|
||
})
|
||
return true
|
||
}
|
||
await discordBeginCreate(ctx, interaction, path)
|
||
return true
|
||
}
|
||
if (id === 'edit:open') {
|
||
const rec = discordEditGet(interaction)
|
||
const path = rec && rec.path
|
||
if (!path) {
|
||
await discordShowModal(interaction, {
|
||
id: 'edit:path',
|
||
title: 'Open a file to edit',
|
||
label: 'Path under ~ or /tmp',
|
||
placeholder: '~/notes.txt',
|
||
max: 200
|
||
})
|
||
return true
|
||
}
|
||
await discordBeginEdit(ctx, interaction, path)
|
||
return true
|
||
}
|
||
if (id.indexOf('bare:') === 0) {
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleBare(ctx, id.slice(5)))
|
||
}
|
||
if (id.indexOf('sys:') === 0) {
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleSys(ctx, id.slice(4)))
|
||
}
|
||
if (id.indexOf('net:') === 0) {
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleNet(ctx, id.slice(4)))
|
||
}
|
||
if (id === 'svc:list') {
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleSvc(ctx, 'list', ''))
|
||
}
|
||
if (id === 'svc:pick') {
|
||
const values = interaction.values || []
|
||
const unit = values[0] ? String(values[0]) : ''
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleSvc(ctx, 'status', unit))
|
||
}
|
||
const svcAct = /^svc:(status|start|stop|restart|logs):(.+)$/.exec(id)
|
||
if (svcAct) {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordCmdHandleSvc(ctx, svcAct[1], svcAct[2])
|
||
)
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordRouteCommand(ctx, 'panel', '', opt))
|
||
}
|
||
|
||
async function discordDispatchModal(ctx, interaction) {
|
||
const id = String(interaction.customId || '')
|
||
if (id.indexOf('plug:') === 0) {
|
||
const parts = id.split(':')
|
||
const plug = discordPluginFind(parts[1] || '')
|
||
if (plug && typeof plug.onModal === 'function') {
|
||
const ev = discordPluginEvent(ctx, interaction, plug, '', function () {
|
||
return ''
|
||
})
|
||
ev.id = parts.slice(2).join(':')
|
||
ev.value = ''
|
||
try {
|
||
if (interaction.fields && typeof interaction.fields.getTextInputValue === 'function') {
|
||
ev.value = String(interaction.fields.getTextInputValue('text') || '')
|
||
}
|
||
} catch {
|
||
ev.value = ''
|
||
}
|
||
const out = await plug.onModal(ev)
|
||
return discordSendResult(ctx, interaction, typeof out === 'string' ? { text: out } : out || { text: 'ok' })
|
||
}
|
||
}
|
||
let value = ''
|
||
try {
|
||
if (interaction.fields && typeof interaction.fields.getTextInputValue === 'function') {
|
||
value = String(interaction.fields.getTextInputValue('text') || '')
|
||
}
|
||
} catch {
|
||
value = ''
|
||
}
|
||
function field(name) {
|
||
try {
|
||
if (interaction.fields && typeof interaction.fields.getTextInputValue === 'function') {
|
||
return String(interaction.fields.getTextInputValue(name) || '')
|
||
}
|
||
} catch {
|
||
/* missing */
|
||
}
|
||
return ''
|
||
}
|
||
if (id === 'hdms:create') {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordCmdHandleHdms(ctx, interaction, 'create', function (k) {
|
||
return k === 'label' ? field('label') || value : ''
|
||
})
|
||
)
|
||
}
|
||
if (id === 'hdms:add') {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordCmdHandleHdms(ctx, interaction, 'add', function (k) {
|
||
if (k === 'label') return field('label')
|
||
if (k === 'key') return field('key') || value
|
||
return ''
|
||
})
|
||
)
|
||
}
|
||
if (id === 'hdms:pair') {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordCmdHandleHdms(ctx, interaction, 'pair', function (k) {
|
||
if (k === 'invite') return field('invite') || value
|
||
return ''
|
||
})
|
||
)
|
||
}
|
||
if (id === 'holesail:addsrv' || id === 'holesail:addcli') {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordCmdHandleHolesail(ctx, interaction, 'add', function (k) {
|
||
if (k === 'id') return field('id') || value
|
||
if (k === 'mode') return id === 'holesail:addcli' ? 'client' : 'server'
|
||
if (k === 'port') return field('port')
|
||
if (k === 'host') return field('host')
|
||
if (k === 'key') return field('key')
|
||
if (k === 'udp') return field('udp')
|
||
if (k === 'secure') return field('secure')
|
||
return ''
|
||
})
|
||
)
|
||
}
|
||
if (id === 'holesail:edit') {
|
||
const sel = discordHolesailGet(interaction).sel
|
||
const port = field('port')
|
||
const host = field('host')
|
||
const key = field('key')
|
||
const udp = field('udp')
|
||
const secure = field('secure')
|
||
if (!port && !host && !key && !udp && !secure) {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordHolesailHud(ctx, interaction, 'No fields changed.')
|
||
)
|
||
}
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordCmdHandleHolesail(ctx, interaction, 'edit', function (k) {
|
||
if (k === 'id') return sel
|
||
if (k === 'port') return port
|
||
if (k === 'host') return host
|
||
if (k === 'key') return key
|
||
if (k === 'udp') return udp
|
||
if (k === 'secure') return secure
|
||
return ''
|
||
})
|
||
)
|
||
}
|
||
if (id === 'agent:ask') {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordAgentAsk(ctx, interaction, field('prompt') || value)
|
||
)
|
||
}
|
||
if (id === 'agent:remember') {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordAgentInspect(ctx, interaction, 'remember', field('text') || value)
|
||
)
|
||
}
|
||
if (id === 'agent:history') {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordAgentInspect(ctx, interaction, 'history', field('query') || value)
|
||
)
|
||
}
|
||
if (id === 'agent:mdltype') {
|
||
const mid = field('model') || value
|
||
try {
|
||
await discordAgentApplyModel(ctx, mid)
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
await discordAgentModelView(ctx, interaction, 'Now using **' + String(mid).trim() + '**.')
|
||
)
|
||
} catch (err) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Could not set model: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
})
|
||
}
|
||
}
|
||
if (id === 'fm:goto') {
|
||
const rec = discordFmGet(interaction, ctx)
|
||
const p = discordCmdPathOk(value)
|
||
if (p) {
|
||
rec.cwd = p
|
||
rec.page = 0
|
||
rec.sel = ''
|
||
rec.confirm = ''
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordFmView(ctx, interaction))
|
||
}
|
||
if (id === 'fm:mkdir') {
|
||
const rec = discordFmGet(interaction, ctx)
|
||
const name = String(value || '').replace(/[\/\0]/g, '')
|
||
const dest = discordJoinPath(rec.cwd, name)
|
||
if (!name || !discordCmdPathWriteOk(ctx, dest)) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Cannot create folder `' + (name || '?') + '` here.',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
try {
|
||
if (typeof ctx.vfs.mkdir !== 'function') throw new Error('mkdir unavailable')
|
||
await ctx.vfs.mkdir(dest, { recursive: true })
|
||
rec.sel = name
|
||
} catch (err) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'mkdir failed: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
})
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordFmView(ctx, interaction))
|
||
}
|
||
if (id === 'fm:ren') {
|
||
const rec = discordFmGet(interaction, ctx)
|
||
const name = String(value || '').replace(/[\/\0]/g, '')
|
||
const from = rec.sel ? discordJoinPath(rec.cwd, rec.sel) : ''
|
||
const to = discordJoinPath(rec.cwd, name)
|
||
if (!from || !name || !discordCmdPathWriteOk(ctx, from) || !discordCmdPathWriteOk(ctx, to)) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Rename not allowed.',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
try {
|
||
const st = await discordFmStat(ctx, from)
|
||
if (discordIsDirStat(st)) throw new Error('renaming folders is not supported')
|
||
if (await discordFileExists(ctx, to)) throw new Error('destination exists')
|
||
await discordFmCopyFile(ctx, from, to)
|
||
await discordFmDelete(ctx, from)
|
||
rec.sel = name
|
||
} catch (err) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Rename failed: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
})
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordFmView(ctx, interaction))
|
||
}
|
||
if (id === 'fm:copy') {
|
||
const rec = discordFmGet(interaction, ctx)
|
||
const to = discordCmdPathWriteOk(ctx, value)
|
||
const from = rec.sel ? discordJoinPath(rec.cwd, rec.sel) : ''
|
||
if (!from || !to || !discordCmdPathWriteOk(ctx, from)) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Copy not allowed.',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
try {
|
||
const st = await discordFmStat(ctx, from)
|
||
if (discordIsDirStat(st)) throw new Error('copying folders is not supported')
|
||
if (await discordFileExists(ctx, to)) throw new Error('destination exists')
|
||
await discordFmCopyFile(ctx, from, to)
|
||
} catch (err) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Copy failed: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
})
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordFmView(ctx, interaction))
|
||
}
|
||
if (id === 'set:str') {
|
||
const rec = discordSettingsGet(interaction)
|
||
const spec = discordSettingsFind(rec.pending || rec.sel)
|
||
rec.pending = ''
|
||
if (!spec || (spec.kind !== 'string' && spec.kind !== 'number')) {
|
||
return discordSendResult(ctx, interaction, { text: 'No pending setting.', ephemeral: true })
|
||
}
|
||
try {
|
||
await discordSettingsApply(ctx, spec, value)
|
||
} catch (err) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Could not apply `' + spec.id + '`: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
})
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
|
||
}
|
||
if (id === 'set:alias') {
|
||
const rec = discordSettingsGet(interaction)
|
||
rec.pending = ''
|
||
rec.group = 'aliases'
|
||
try {
|
||
await discordSettingsSetAlias(ctx, value)
|
||
rec.sel = discordSettingsAliasName(value)
|
||
} catch (err) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text: 'Alias failed: ' + ((err && err.message) || err),
|
||
ephemeral: true
|
||
})
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordSettingsView(ctx, interaction))
|
||
}
|
||
if (id === 'edit:save' || id === 'create:save') {
|
||
if (id === 'create:save') {
|
||
const rec = discordEditGet(interaction)
|
||
if (rec) rec.created = true
|
||
}
|
||
return discordSendResult(ctx, interaction, await discordEditSaveFromModal(ctx, interaction))
|
||
}
|
||
if (id === 'create:path') {
|
||
const p = discordCmdPathWriteOk(ctx, value)
|
||
if (!p) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text:
|
||
'Not writable: `' +
|
||
(value || '(empty)') +
|
||
'`. Use `~/file`, `/tmp/file`, or `/home/' +
|
||
discordSessionUser(ctx) +
|
||
'/file`.',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
if (await discordFileExists(ctx, p)) {
|
||
discordEditPut(interaction, { path: p })
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
discordResult(
|
||
discordEmbed({
|
||
title: 'Already exists',
|
||
color: BARE_OS_DISCORD_COLOR_WARN,
|
||
desc: '`' + p + '` is already on disk. Edit it, or choose another name.'
|
||
}),
|
||
{
|
||
components: [
|
||
discordButtons([
|
||
{ id: 'edit:open', label: 'Edit instead', style: 1 },
|
||
{ id: 'create:custom', label: 'Different path…', style: 3 }
|
||
])
|
||
],
|
||
editPath: p
|
||
}
|
||
)
|
||
)
|
||
}
|
||
discordEditPut(interaction, { path: p, created: true })
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
discordResult(
|
||
discordEmbed({
|
||
title: 'Ready to create',
|
||
desc:
|
||
'`' +
|
||
p +
|
||
'`\nDiscord cannot open a second modal from this form. Click **Compose contents**.',
|
||
color: BARE_OS_DISCORD_COLOR
|
||
}),
|
||
{
|
||
components: [
|
||
discordButtons([{ id: 'create:open', label: 'Compose contents', style: 3 }])
|
||
],
|
||
editPath: p,
|
||
created: true
|
||
}
|
||
)
|
||
)
|
||
}
|
||
if (id === 'edit:path') {
|
||
const p = discordCmdPathWriteOk(ctx, value)
|
||
if (!p) {
|
||
return discordSendResult(ctx, interaction, {
|
||
text:
|
||
'Not writable: `' +
|
||
(value || '(empty)') +
|
||
'`. Use `~/file`, `/tmp/file`, or `/home/' +
|
||
discordSessionUser(ctx) +
|
||
'/file`.',
|
||
ephemeral: true
|
||
})
|
||
}
|
||
discordEditPut(interaction, { path: p })
|
||
const exists = Boolean(await discordCmdReadText(ctx, p))
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
discordResult(
|
||
discordEmbed({
|
||
title: exists ? 'Ready to edit' : 'New file',
|
||
desc:
|
||
'`' +
|
||
p +
|
||
'`\nDiscord cannot open a second modal from this form. Click **Open editor**.',
|
||
color: BARE_OS_DISCORD_COLOR
|
||
}),
|
||
{
|
||
components: [
|
||
discordButtons([{ id: 'edit:open', label: 'Open editor', style: 1 }])
|
||
],
|
||
editPath: p
|
||
}
|
||
)
|
||
)
|
||
}
|
||
if (id === 'say:modal') {
|
||
return discordSendResult(
|
||
ctx,
|
||
interaction,
|
||
discordResult(
|
||
discordEmbed({
|
||
title: 'say',
|
||
desc: discordCmdFence(discordCmdSayBox(value))
|
||
})
|
||
)
|
||
)
|
||
}
|
||
if (id === 'run:modal') {
|
||
return discordSendResult(ctx, interaction, await discordCmdHandleRun(ctx, interaction, value))
|
||
}
|
||
return discordSendResult(ctx, interaction, discordPanel(ctx))
|
||
}
|
||
|
||
async function discordDispatchInteraction(ctx, interaction) {
|
||
if (!interaction) return false
|
||
discordSyncSessionIdentity(ctx)
|
||
discordIdleSweep()
|
||
const uid = discordInteractionUserId(interaction)
|
||
if (discordIsAutocomplete(interaction)) {
|
||
if (!discordUserAllowed(ctx, uid, interaction)) {
|
||
try {
|
||
if (typeof interaction.respond === 'function') await interaction.respond([])
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return true
|
||
}
|
||
await discordPluginsEnsure(ctx)
|
||
return discordDispatchAutocomplete(ctx, interaction)
|
||
}
|
||
if (!discordUserAllowed(ctx, uid, interaction)) {
|
||
if (ctx && ctx.console && typeof ctx.console.log === 'function') {
|
||
ctx.console.log('discord-bot: denied user ' + (uid || '?'))
|
||
}
|
||
await discordReplyWhitelistDenied(ctx, interaction)
|
||
return true
|
||
}
|
||
const isComp = discordIsMessageComponent(interaction)
|
||
const isModal = discordIsModalSubmit(interaction)
|
||
if (isComp || isModal) {
|
||
const owner = discordInteractionOwnerId(interaction)
|
||
if (owner && owner !== uid) {
|
||
if (ctx && ctx.console && typeof ctx.console.log === 'function') {
|
||
ctx.console.log('discord-bot: denied owner mismatch ' + (uid || '?'))
|
||
}
|
||
await discordReplyOwnerDenied(ctx, interaction)
|
||
return true
|
||
}
|
||
discordIdleTouch(interaction)
|
||
if (isComp) {
|
||
await discordDispatchComponent(ctx, interaction)
|
||
return true
|
||
}
|
||
await discordDispatchModal(ctx, interaction)
|
||
return true
|
||
}
|
||
if (typeof interaction.isChatInputCommand !== 'function' || !interaction.isChatInputCommand()) {
|
||
return false
|
||
}
|
||
const name = String(interaction.commandName || '')
|
||
let sub = ''
|
||
let opt = function () {
|
||
return ''
|
||
}
|
||
if (interaction.options) {
|
||
if (typeof interaction.options.getSubcommand === 'function') {
|
||
try {
|
||
sub = String(interaction.options.getSubcommand(false) || '')
|
||
} catch {
|
||
sub = ''
|
||
}
|
||
}
|
||
opt = function (key) {
|
||
if (typeof interaction.options.getBoolean === 'function') {
|
||
try {
|
||
const b = interaction.options.getBoolean(key)
|
||
if (b === true) return 'true'
|
||
if (b === false) return 'false'
|
||
} catch {
|
||
/* not a boolean option */
|
||
}
|
||
}
|
||
if (typeof interaction.options.getInteger === 'function') {
|
||
try {
|
||
const n = interaction.options.getInteger(key)
|
||
if (n != null) return String(n)
|
||
} catch {
|
||
/* not an integer option */
|
||
}
|
||
}
|
||
if (typeof interaction.options.getString === 'function') {
|
||
const v = interaction.options.getString(key)
|
||
return v == null ? '' : String(v)
|
||
}
|
||
return ''
|
||
}
|
||
}
|
||
let result
|
||
try {
|
||
await discordPluginsEnsure(ctx)
|
||
if (name === 'plugins') {
|
||
result = await discordPluginsAdmin(ctx, interaction, sub, opt)
|
||
} else if (name === 'upload') {
|
||
result = await discordCmdHandleUpload(ctx, interaction, opt('path'))
|
||
} else if (name === 'hdms') {
|
||
result = await discordCmdHandleHdms(ctx, interaction, sub, opt)
|
||
} else if (name === 'holesail') {
|
||
result = await discordCmdHandleHolesail(ctx, interaction, sub, opt)
|
||
} else if (name === 'agent') {
|
||
result = await discordCmdHandleAgent(ctx, interaction, sub, opt)
|
||
} else {
|
||
const plug = discordPluginFind(name)
|
||
if (plug) result = await discordPluginInvoke(ctx, interaction, plug, sub, opt)
|
||
else result = await discordRouteCommand(ctx, name, sub, opt)
|
||
}
|
||
} catch (err) {
|
||
result = {
|
||
text: 'error: ' + ((err && err.message) || String(err)),
|
||
ephemeral: true
|
||
}
|
||
}
|
||
await discordSendResult(ctx, interaction, result)
|
||
return true
|
||
}
|
||
|
||
var bareOsDiscordCommands = {
|
||
buildSlashCommands: discordBuildSlashCommands,
|
||
dispatchInteraction: discordDispatchInteraction,
|
||
captureOutput: discordCmdCapture,
|
||
stripAnsi: discordStripAnsi,
|
||
parseIdWhitelist: discordParseIdWhitelist,
|
||
whitelistCount: discordWhitelistCount,
|
||
userAllowed: discordUserAllowed,
|
||
userInstallEnabled: discordUserInstallEnabled,
|
||
isUserInstallInteraction: discordIsUserInstallInteraction,
|
||
stampUserInstallCommands: discordStampUserInstallCommands,
|
||
userInstallAuthorizeUrl: discordUserInstallAuthorizeUrl,
|
||
userInstallAppConfig: discordUserInstallAppConfig,
|
||
enableUserInstallApp: discordEnableUserInstallApp,
|
||
putSlashCommands: discordPutSlashCommands,
|
||
interactionUserId: discordInteractionUserId,
|
||
interactionOwnerId: discordInteractionOwnerId,
|
||
isMessageComponent: discordIsMessageComponent,
|
||
installProcessEmitWarning: discordInstallProcessEmitWarning,
|
||
replyPayload: discordCmdReplyPayload,
|
||
syncSessionIdentity: discordSyncSessionIdentity,
|
||
sessionUser: discordSessionUser,
|
||
FLAG_EPHEMERAL: BARE_OS_DISCORD_FLAG_EPHEMERAL,
|
||
prettySnapshot: discordPrettySnapshot,
|
||
prettyBytes: discordPrettyBytes,
|
||
parseMeminfo: discordParseMeminfo,
|
||
parseSystemctlList: discordParseSystemctlList,
|
||
formatRlimits: discordFormatRlimits,
|
||
formatFeatures: discordFormatFeatures,
|
||
formatDoctor: discordFormatDoctor,
|
||
formatSwarm: discordFormatSwarm,
|
||
formatNetSummary: discordFormatNetSummary,
|
||
formatHostDf: discordFormatHostDf,
|
||
pathWriteOk: discordCmdPathWriteOk,
|
||
editChunks: discordEditChunks,
|
||
editPut: discordEditPut,
|
||
editGet: discordEditGet,
|
||
editModalPayload: discordEditModalPayload,
|
||
joinPath: discordJoinPath,
|
||
parentPath: discordParentPath,
|
||
settingsSpecs: BARE_OS_DISCORD_SETTINGS,
|
||
qvacChatModels: BARE_OS_DISCORD_QVAC_MODELS,
|
||
parseOpenAiModels: discordParseOpenAiModels,
|
||
restModelsFallback: discordRestModelsFallback,
|
||
filterModels: discordFilterModels,
|
||
applyAgentModel: discordAgentApplyModel,
|
||
fetchRestModels: discordFetchRestModels,
|
||
parseAgentProgress: discordAgentParseProgress,
|
||
splitAgentStdout: discordAgentSplitStdout,
|
||
formatAgentProcess: discordAgentFormatProcess,
|
||
inspectAgent: discordAgentInspect,
|
||
agentReady: discordAgentReady,
|
||
markdownPages: discordMarkdownPages,
|
||
settingsApply: discordSettingsApply,
|
||
settingsCurrent: discordSettingsCurrent,
|
||
uniqueComponents: discordUniqueComponents,
|
||
navRows: discordNavRows,
|
||
applyNav: discordApplyNav,
|
||
parseHdmsList: discordParseHdmsList,
|
||
parseHolesailList: discordParseHolesailList,
|
||
parseHolesailShow: discordParseHolesailShow,
|
||
pathOk: discordCmdPathOk,
|
||
result: discordResult,
|
||
packEmbed: discordPackEmbed,
|
||
textPages: discordTextPages,
|
||
clipSmart: discordClipSmart,
|
||
embedSize: discordEmbedSize,
|
||
LIMIT: BARE_OS_DISCORD_LIMIT,
|
||
idleMs: BARE_OS_DISCORD_IDLE_MS,
|
||
idleExpire: discordIdleExpire,
|
||
idleSweep: discordIdleSweep,
|
||
idleArm: discordIdleArm,
|
||
idleLive: function () {
|
||
return BARE_OS_DISCORD_LIVE
|
||
},
|
||
loadPlugins: discordPluginsLoad,
|
||
listPlugins: function () {
|
||
return BARE_OS_DISCORD_PLUGINS
|
||
},
|
||
setPluginRegistrar: discordPluginsSetRegistrar
|
||
}
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
module.exports = bareOsDiscordCommands
|
||
}
|