/** * 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 '../initd/bare-initd.js' import { appendVarLog, BARE_OS_VAR_LOG_DIR } from '../vfs/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} 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} ctx */ export async function syncBareOsDiscordInitd(ctx) { const env = ctx && ctx.env && typeof ctx.env === 'object' ? /** @type {Record} */ (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} 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) }