Files
bare-operating-system/packages/bare-os-booter/lib/services/bare-os-discord-initd.js
T
2026-08-18 18:11:28 -04:00

472 lines
14 KiB
JavaScript

/**
* Optional Discord bot initd unit (`bare-os-discord`).
* Registered only when the guest VFS has ~/.discord/.env with DISCORD_TOKEN=.
* Hidden from systemctl when that file is missing.
*/
import {
findBareServiceDefinition,
registerBareService,
startBareService,
stopBareService,
unregisterBareService
} from './bare-initd.js'
import { appendVarLog, BARE_OS_VAR_LOG_DIR } from './bare-os-var-log.js'
import {
normalizeDiscordToken,
parseDotEnvText
} from './bare-discord-js-loader.js'
// Static CJS import so bare-pack rewrites the binding into app.bundle.
// Runtime require of a sibling path is MODULE_NOT_FOUND under bare:/app.bundle/.
import discordCommandsNs from './bare-os-discord-commands-guest.cjs'
const discordCommands =
discordCommandsNs &&
(discordCommandsNs.dispatchInteraction || discordCommandsNs.buildSlashCommands)
? discordCommandsNs
: discordCommandsNs && discordCommandsNs.default
? discordCommandsNs.default
: discordCommandsNs
export const BARE_OS_DISCORD_SERVICE_ENV = '~/.discord/.env'
export const BARE_OS_DISCORD_LOG = `${BARE_OS_VAR_LOG_DIR}/discord.log`
export const BARE_OS_DISCORD_UNIT = 'bare-os-discord'
/** Extra guest files scanned for DISCORD_ID_WHITELIST / guild / user-install. */
export const BARE_OS_DISCORD_EXTRA_ENV_FILES = [
'~/.discord/.env',
'~/.discord.env',
'~/discord.env',
'~/.env',
'./.env'
]
/** @type {import('discord.js').Client | null} */
let discordServiceClient = null
/** Live session ctx (updated after login so commands are not stuck on guest). */
let discordServiceCtx = null
export function bindBareOsDiscordSessionCtx(ctx) {
if (ctx) discordServiceCtx = ctx
return discordServiceCtx
}
/** @param {Record<string, string | undefined>} env */
export function bareOsDiscordInitdEnabled(env) {
const v = env && (env.BARE_OS_DISCORD_INITD || env.BARE_OS_DISCORD)
if (v === '0' || v === 'false') return false
return true
}
function discordVfsText(ctx, buf) {
if (!buf) return ''
if (typeof ctx.b4a?.toString === 'function') return ctx.b4a.toString(buf)
return Buffer.from(buf).toString('utf8')
}
async function readBareOsDiscordDotEnvFile(ctx, path) {
const vfs = ctx && ctx.vfs
if (!vfs || typeof vfs.readFile !== 'function') return null
let buf
try {
buf = await vfs.readFile(path)
} catch {
return null
}
if (!buf) return null
return parseDotEnvText(discordVfsText(ctx, buf))
}
function discordSessionEnvMaps(ctx) {
if (!ctx.env || typeof ctx.env !== 'object') ctx.env = {}
const maps = [ctx.env]
if (ctx.vfs && ctx.vfs.env && typeof ctx.vfs.env === 'object' && ctx.vfs.env !== ctx.env) {
maps.push(ctx.vfs.env)
}
return maps
}
/**
* Copy non-empty Discord extras onto both `ctx.env` and `ctx.vfs.env`.
* Session values win; empty strings do not block a file value.
*/
export function applyDiscordExtrasToSession(ctx, parsed) {
if (!parsed || !ctx) return
const maps = discordSessionEnvMaps(ctx)
const pairs = [
['DISCORD_GUILD_ID', parsed.DISCORD_GUILD_ID],
[
'DISCORD_ID_WHITELIST',
parsed.DISCORD_ID_WHITELIST || parsed.BARE_OS_DISCORD_ID_WHITELIST
],
[
'DISCORD_USER_INSTALL',
parsed.DISCORD_USER_INSTALL || parsed.BARE_OS_DISCORD_USER_INSTALL
]
]
for (const [key, raw] of pairs) {
const v = raw == null ? '' : String(raw).trim()
if (!v) continue
for (const env of maps) {
if (!String(env[key] || '').trim()) env[key] = v
}
}
}
function discordSessionWhitelist(ctx) {
const maps = []
if (ctx && ctx.env && typeof ctx.env === 'object') maps.push(ctx.env)
if (ctx && ctx.vfs && ctx.vfs.env && typeof ctx.vfs.env === 'object') {
maps.push(ctx.vfs.env)
}
for (const env of maps) {
const v = String(
env.DISCORD_ID_WHITELIST || env.BARE_OS_DISCORD_ID_WHITELIST || ''
).trim()
if (v) return v
}
return ''
}
export async function readBareOsDiscordServiceEnv(ctx) {
const parsed = await readBareOsDiscordDotEnvFile(ctx, BARE_OS_DISCORD_SERVICE_ENV)
if (!parsed) return null
const token = normalizeDiscordToken(
parsed.DISCORD_TOKEN || parsed.BOT_TOKEN || ''
)
if (!token) return null
return {
token,
guildId: String(parsed.DISCORD_GUILD_ID || '').trim(),
whitelist: String(
parsed.DISCORD_ID_WHITELIST || parsed.BARE_OS_DISCORD_ID_WHITELIST || ''
).trim(),
userInstall: String(
parsed.DISCORD_USER_INSTALL || parsed.BARE_OS_DISCORD_USER_INSTALL || ''
).trim()
}
}
/**
* Load token from ~/.discord/.env, then fill whitelist / guild / user-install
* from that file, sibling env files (~/.env, …), and both session env maps.
*/
export async function hydrateBareOsDiscordSessionEnv(ctx) {
const creds = await readBareOsDiscordServiceEnv(ctx)
if (creds) applyDiscordExtrasToSession(ctx, creds)
const seen = Object.create(null)
for (const path of BARE_OS_DISCORD_EXTRA_ENV_FILES) {
if (!path || seen[path]) continue
seen[path] = true
const parsed = await readBareOsDiscordDotEnvFile(ctx, path)
if (parsed) applyDiscordExtrasToSession(ctx, parsed)
}
if (creds) {
const wl = discordSessionWhitelist(ctx)
if (wl) creds.whitelist = wl
const guild = String(
(ctx.env && ctx.env.DISCORD_GUILD_ID) || creds.guildId || ''
).trim()
if (guild) creds.guildId = guild
const ui = String(
(ctx.env && ctx.env.DISCORD_USER_INSTALL) || creds.userInstall || ''
).trim()
if (ui) creds.userInstall = ui
}
return creds
}
function discordServiceLog(ctx, line) {
try {
ctx.console?.log?.('[bare-os-discord] ' + line)
} catch {
/* ignore */
}
void appendVarLog(ctx, BARE_OS_DISCORD_LOG, 'discord', line)
}
async function startBareOsDiscord(ctx) {
const creds = await hydrateBareOsDiscordSessionEnv(ctx)
if (!creds) {
throw new Error('missing ' + BARE_OS_DISCORD_SERVICE_ENV + ' (DISCORD_TOKEN=)')
}
const dj = ctx.bare && ctx.bare.discordJS
if (!dj || typeof dj.Client !== 'function') {
throw new Error('ctx.bare.discordJS is unavailable')
}
discordServiceCtx = ctx
if (discordServiceClient) return
applyDiscordExtrasToSession(ctx, creds)
const osName =
(typeof process !== 'undefined' && process.platform) || 'darwin'
const client = new dj.Client({
intents: [dj.GatewayIntentBits.Guilds],
ws: {
identifyProperties: {
os: osName,
browser: 'bare-os',
device: 'bare-os'
}
}
})
if (dj.Events && dj.Events.Error) {
client.on(dj.Events.Error, function (err) {
discordServiceLog(ctx, 'client error: ' + ((err && err.message) || err))
})
}
if (dj.Events && dj.Events.InteractionCreate) {
client.on(dj.Events.InteractionCreate, async function (interaction) {
const live = discordServiceCtx || ctx
if (
discordCommands &&
typeof discordCommands.syncSessionIdentity === 'function'
) {
discordCommands.syncSessionIdentity(live)
}
const dispatch =
discordCommands &&
(discordCommands.dispatchInteraction ||
(discordCommands.default &&
discordCommands.default.dispatchInteraction))
if (typeof dispatch === 'function') {
await dispatch(live, interaction)
return
}
if (
!interaction.isChatInputCommand ||
!interaction.isChatInputCommand()
) {
return
}
if (interaction.commandName !== 'ping') return
const uid =
interaction.user && interaction.user.id ? String(interaction.user.id) : ''
const allowed =
discordCommands && typeof discordCommands.userAllowed === 'function'
? discordCommands.userAllowed(live, uid, interaction)
: true
if (!allowed) {
try {
await interaction.reply({
content:
'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.',
flags: 64
})
} catch (err) {
discordServiceLog(
ctx,
'deny failed: ' + ((err && err.message) || err)
)
}
return
}
try {
await interaction.reply({ content: 'pong' })
} catch (err) {
discordServiceLog(
ctx,
'/ping failed: ' + ((err && err.message) || err)
)
}
})
}
await client.login(creds.token)
discordServiceClient = client
const tag =
client.user && client.user.tag ? client.user.tag : String(client.user)
discordServiceLog(ctx, 'logged in as ' + tag)
if (typeof dj.REST === 'function' && dj.Routes) {
try {
const rest = new dj.REST().setToken(creds.token)
const build =
discordCommands &&
(discordCommands.buildSlashCommands ||
(discordCommands.default &&
discordCommands.default.buildSlashCommands))
const body =
typeof build === 'function'
? await Promise.resolve(build(dj, ctx))
: [
new dj.SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with pong.')
.toJSON()
]
const setReg =
discordCommands &&
(discordCommands.setPluginRegistrar ||
(discordCommands.default && discordCommands.default.setPluginRegistrar))
const userInstallOn =
discordCommands &&
typeof discordCommands.userInstallEnabled === 'function'
? discordCommands.userInstallEnabled(ctx)
: true
async function registerBody(next) {
if (
userInstallOn &&
discordCommands &&
typeof discordCommands.enableUserInstallApp === 'function'
) {
try {
await discordCommands.enableUserInstallApp(rest, dj.Routes)
} catch (err) {
discordServiceLog(
ctx,
'user-install app config failed: ' +
((err && err.message) || err) +
' (enable User Install under Developer Portal → Installation)'
)
}
}
if (
discordCommands &&
typeof discordCommands.putSlashCommands === 'function'
) {
const put = await discordCommands.putSlashCommands(
rest,
dj.Routes,
client.user.id,
next,
{ guildId: creds.guildId, userInstall: userInstallOn }
)
if (put.global) {
discordServiceLog(
ctx,
'registered ' +
next.length +
' global commands' +
(userInstallOn ? ' (user-install + guild)' : '')
)
}
if (put.guild) {
discordServiceLog(
ctx,
'registered ' +
next.length +
' commands for guild ' +
creds.guildId
)
}
return
}
if (creds.guildId) {
await rest.put(
dj.Routes.applicationGuildCommands(client.user.id, creds.guildId),
{ body: next }
)
} else {
await rest.put(dj.Routes.applicationCommands(client.user.id), {
body: next
})
}
}
if (typeof setReg === 'function') {
setReg(async function () {
const next = await Promise.resolve(build(dj, ctx))
await registerBody(next)
})
}
await registerBody(body)
if (userInstallOn) {
const url =
discordCommands &&
typeof discordCommands.userInstallAuthorizeUrl === 'function'
? discordCommands.userInstallAuthorizeUrl(client.user.id)
: ''
if (url) {
discordServiceLog(ctx, 'add to your Discord profile: ' + url)
}
const wlN =
discordCommands &&
typeof discordCommands.whitelistCount === 'function'
? discordCommands.whitelistCount(ctx)
: 0
if (wlN) {
discordServiceLog(
ctx,
'DISCORD_ID_WHITELIST active (' + wlN + ' user id(s))'
)
} else {
discordServiceLog(
ctx,
'user-install is on and DISCORD_ID_WHITELIST is empty — user-install / DM commands are denied'
)
}
}
} catch (err) {
discordServiceLog(
ctx,
'slash register failed: ' + ((err && err.message) || err)
)
}
}
}
async function stopBareOsDiscord(_ctx) {
const c = discordServiceClient
discordServiceClient = null
discordServiceCtx = null
if (!c) return
await Promise.resolve(c.destroy()).catch(function () {})
}
/**
* Register or drop the unit from systemctl based on ~/.discord/.env.
* @param {Record<string, unknown>} ctx
*/
export async function syncBareOsDiscordInitd(ctx) {
const env =
ctx && ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string | undefined>} */ (ctx.env)
: {}
const want =
bareOsDiscordInitdEnabled(env) && (await readBareOsDiscordServiceEnv(ctx))
const have = Boolean(findBareServiceDefinition(BARE_OS_DISCORD_UNIT))
if (!want) {
if (have) {
try {
await stopBareService(ctx, BARE_OS_DISCORD_UNIT)
} catch {
/* may already be inactive */
}
unregisterBareService(BARE_OS_DISCORD_UNIT)
}
return false
}
registerBareService({
name: BARE_OS_DISCORD_UNIT,
description:
'Bare OS Discord bot (/bare /sys /svc /fs /net …); requires ~/.discord/.env',
logPath: BARE_OS_DISCORD_LOG,
start: startBareOsDiscord,
stop: stopBareOsDiscord
})
return true
}
/**
* After identity unlock: show+start the unit only when ~/.discord/.env exists.
* @param {Record<string, unknown>} ctx
*/
export async function maybeStartBareOsDiscordAfterIdentity(ctx) {
bindBareOsDiscordSessionCtx(ctx)
const shown = await syncBareOsDiscordInitd(ctx)
if (!shown) return
try {
await startBareService(ctx, BARE_OS_DISCORD_UNIT)
} catch (e) {
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
discordServiceLog(ctx, 'start failed: ' + msg)
}
}
/** Drop the unit on logout so it does not appear for the next session. */
export async function stopBareOsDiscordInitd(ctx) {
if (!findBareServiceDefinition(BARE_OS_DISCORD_UNIT)) return
try {
await stopBareService(ctx, BARE_OS_DISCORD_UNIT)
} catch {
/* ignore */
}
unregisterBareService(BARE_OS_DISCORD_UNIT)
}