472 lines
14 KiB
JavaScript
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)
|
|
}
|