/** * Stock Discord ping-pong bot via ctx.bare.discordJS (vendored bare-discord-js). * Token: --token, DISCORD_TOKEN, --env PATH, DISCORD_ENV_FILE, ~/.discord.env. * Optional DISCORD_ID_WHITELIST=id,id in .env restricts who may use the bot. */ function discordNormalizeToken(raw) { if (typeof raw !== 'string') return '' let t = raw.trim() if (t.charCodeAt(0) === 0xfeff) t = t.slice(1) t = t.replace(/[\u200B-\u200D\uFEFF]/g, '') return t.trim() } function discordParseDotEnvLine(line) { let s = String(line) .replace(/^\uFEFF/, '') .trim() if (!s || s.startsWith('#')) return null if (s.startsWith('export ')) s = s.slice(7).trim() const eq = s.indexOf('=') if (eq === -1) return null const key = s.slice(0, eq).trim() if (!key) return null let val = s.slice(eq + 1).trim() if ( (val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")) ) { val = val.slice(1, -1) } return { key: key, value: val } } function discordParseDotEnvText(text) { const out = {} if (typeof text !== 'string' || !text) return out let body = text if (body.charCodeAt(0) === 0xfeff) body = body.slice(1) const lines = body.split(/\r?\n/) for (let i = 0; i < lines.length; i++) { const parsed = discordParseDotEnvLine(lines[i]) if (!parsed) continue out[parsed.key] = parsed.value } return out } function discordArgValue(argv, names) { for (let i = 1; i < argv.length; i++) { const a = String(argv[i] || '') for (let n = 0; n < names.length; n++) { const name = names[n] if (a === name) return argv[i + 1] ? String(argv[i + 1]) : '' if (a.startsWith(name + '=')) return a.slice(name.length + 1) } } return '' } function discordHasFlag(argv, names) { for (let i = 1; i < argv.length; i++) { const a = String(argv[i] || '') for (let n = 0; n < names.length; n++) { if (a === names[n]) return true } } return false } function discordEnvFlag(ctx, keys) { if (!ctx.env) return false for (let i = 0; i < keys.length; i++) { const v = String(ctx.env[keys[i]] || '') .trim() .toLowerCase() if (v === '1' || v === 'true' || v === 'yes') return true } return false } function discordWantsMessageContent(ctx, argv) { return ( discordHasFlag(argv, ['--message-content', '--privileged-intents']) || discordEnvFlag(ctx, [ 'DISCORD_MESSAGE_CONTENT', 'BARE_OS_DISCORD_MESSAGE_CONTENT' ]) ) } function discordDebugOn(ctx, argv) { return ( discordHasFlag(argv, ['--debug', '-v', '--verbose']) || discordEnvFlag(ctx, ['DISCORD_DEBUG', 'BARE_OS_DISCORD_DEBUG']) ) } function discordLoginTimeoutMs(ctx, argv) { const raw = discordArgValue(argv, ['--login-timeout']) || (ctx.env && (ctx.env.DISCORD_LOGIN_TIMEOUT_MS || ctx.env.BARE_OS_DISCORD_LOGIN_TIMEOUT_MS)) || '45000' const n = Number(raw) return Number.isFinite(n) && n > 0 ? n : 45000 } function discordErrText(err) { return (err && err.message) || String(err) } function discordAttachInterrupt(ctx, onStop) { const proc = typeof globalThis.process !== 'undefined' ? globalThis.process : null if (!proc || typeof proc.on !== 'function') { return function () {} } let fired = false function fire() { if (fired) return fired = true ctx.exitCode = 130 try { ctx.console.log('discord-bot: stopped (^C)') } catch { /* ignore */ } try { onStop() } catch { /* ignore */ } } proc.on('SIGINT', fire) proc.on('SIGTERM', fire) proc.on('bare-os:host-sigint', fire) return function detach() { try { proc.off('SIGINT', fire) } catch { /* ignore */ } try { proc.off('SIGTERM', fire) } catch { /* ignore */ } try { proc.off('bare-os:host-sigint', fire) } catch { /* ignore */ } } } function discordUsage(argv0) { return ( 'usage: ' + (argv0 || 'discord-bot') + ' [--env PATH] [--token TOKEN] [--guild ID] [--check]\n' + ' [--debug] [--message-content] [--login-timeout MS]\n' + ' [--no-user-install]\n' + 'Ping-pong bot via ctx.bare.discordJS.\n' + 'Token from --token, DISCORD_TOKEN, or a .env file (--env, DISCORD_ENV_FILE,\n' + 'host DISCORD_ENV_FILE, ~/.discord/.env, ~/.discord.env).\n' + 'Ctrl+C stops the foreground bot. Initd unit bare-os-discord appears in\n' + 'systemctl only when ~/.discord/.env exists with DISCORD_TOKEN=.\n' + 'Slash commands: /panel /files /edit /create /upload /hdms /holesail /agent /bare /sys /svc /fs /net /man /say /r /journal /ping.\n' + 'Commands run as the unlocked session user. Options use Discord autocomplete.\n' + 'Channel "ping" still replies pong when Message Content Intent is enabled.\n' + 'User-installable (profile app) is on by default so operators can use slash\n' + 'commands in DMs and any server. DISCORD_ID_WHITELIST is always enforced on\n' + 'those surfaces (empty list denies user-install / DM use). Guild-only mode:\n' + '--no-user-install or DISCORD_USER_INSTALL=0.' ) } async function discordReadVfsText(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 discordLoadToken(ctx, argv) { const fromFlag = discordNormalizeToken(discordArgValue(argv, ['--token'])) if (fromFlag) return { token: fromFlag, source: '--token' } const fromEnv = discordNormalizeToken(ctx.env && ctx.env.DISCORD_TOKEN) if (fromEnv) return { token: fromEnv, source: 'DISCORD_TOKEN' } const envPaths = [] const flagPath = discordArgValue(argv, ['--env', '--env-file']) if (flagPath) envPaths.push(flagPath) if (ctx.env && ctx.env.DISCORD_ENV_FILE) envPaths.push(ctx.env.DISCORD_ENV_FILE) if (ctx.env && ctx.env.BARE_OS_DISCORD_ENV_FILE) { envPaths.push(ctx.env.BARE_OS_DISCORD_ENV_FILE) } envPaths.push( '~/.discord/.env', '~/.discord.env', '~/discord.env', './.env', '~/.env' ) const seen = {} for (let i = 0; i < envPaths.length; i++) { const p = String(envPaths[i] || '').trim() if (!p || seen[p]) continue seen[p] = true const text = await discordReadVfsText(ctx, p) if (!text) continue const parsed = discordParseDotEnvText(text) const token = discordNormalizeToken(parsed.DISCORD_TOKEN || '') if (token) { discordApplyDotEnvExtras(ctx, parsed) return { token: token, source: p } } } return { token: '', source: '' } } function discordApplyDotEnvExtras(ctx, parsed) { if (!parsed || !ctx) return if (!ctx.env) ctx.env = {} const extras = [ 'DISCORD_GUILD_ID', 'DISCORD_ID_WHITELIST', 'DISCORD_USER_INSTALL' ] 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) } const alias = { DISCORD_ID_WHITELIST: parsed.DISCORD_ID_WHITELIST || parsed.BARE_OS_DISCORD_ID_WHITELIST } for (let i = 0; i < extras.length; i++) { const k = extras[i] const v = k === 'DISCORD_ID_WHITELIST' ? alias.DISCORD_ID_WHITELIST : parsed[k] if (v == null || String(v).trim() === '') continue const next = String(v).trim() for (let j = 0; j < maps.length; j++) { if (!maps[j][k] || String(maps[j][k]).trim() === '') maps[j][k] = next } } } async function discordHydrateEnvFromFiles(ctx, argv) { const envPaths = [] const flagPath = discordArgValue(argv, ['--env', '--env-file']) if (flagPath) envPaths.push(flagPath) if (ctx.env && ctx.env.DISCORD_ENV_FILE) envPaths.push(ctx.env.DISCORD_ENV_FILE) if (ctx.env && ctx.env.BARE_OS_DISCORD_ENV_FILE) { envPaths.push(ctx.env.BARE_OS_DISCORD_ENV_FILE) } envPaths.push( '~/.discord/.env', '~/.discord.env', '~/discord.env', './.env', '~/.env' ) const seen = {} for (let i = 0; i < envPaths.length; i++) { const p = String(envPaths[i] || '').trim() if (!p || seen[p]) continue seen[p] = true const text = await discordReadVfsText(ctx, p) if (!text) continue discordApplyDotEnvExtras(ctx, discordParseDotEnvText(text)) } } async function run(ctx, argv) { if (discordHasFlag(argv, ['-h', '--help'])) { ctx.console.log(discordUsage(argv[0])) return } const dj = ctx.bare && ctx.bare.discordJS if (!dj || typeof dj.Client !== 'function') { const bareKeys = ctx.bare && typeof ctx.bare === 'object' ? Object.keys(ctx.bare).join(',') : '' ctx.console.error('discord-bot: ctx.bare.discordJS is unavailable') ctx.console.error( ' ctx.bare: ' + (ctx.bare ? 'present (' + (bareKeys || 'no keys') + ')' : 'missing') + ' BARE_OS_BARE_MODULES=' + String((ctx.env && ctx.env.BARE_OS_BARE_MODULES) || '') + ' BARE_OS_DISCORD=' + String((ctx.env && ctx.env.BARE_OS_DISCORD) || '') ) if (ctx.env && ctx.env.BARE_OS_DISCORD_LOAD_ERROR) { ctx.console.error(' load error: ' + ctx.env.BARE_OS_DISCORD_LOAD_ERROR) } else { ctx.console.error( ' Restart the Bare OS booter after vendoring (image /bin is not enough).' ) } ctx.exitCode = 1 return } if (typeof discordInstallProcessEmitWarning === 'function') { discordInstallProcessEmitWarning() } const loaded = await discordLoadToken(ctx, argv) await discordHydrateEnvFromFiles(ctx, argv) const checkOnly = discordHasFlag(argv, ['--check', '--dry-run']) || (argv[1] && String(argv[1]) === 'check') if (checkOnly) { ctx.console.log( JSON.stringify( { ok: Boolean(loaded.token), discordJS: true, client: typeof dj.Client === 'function', tokenSource: loaded.source || null, tokenPresent: Boolean(loaded.token) }, null, 2 ) ) if (!loaded.token) ctx.exitCode = 1 return } if (!loaded.token) { ctx.console.error( 'discord-bot: missing DISCORD_TOKEN (set --token, export DISCORD_TOKEN, or --env PATH)' ) ctx.exitCode = 1 return } const guildId = discordArgValue(argv, ['--guild', '--guild-id']) || (ctx.env && ctx.env.DISCORD_GUILD_ID) || '' if (discordHasFlag(argv, ['--no-user-install', '--guild-only'])) { if (!ctx.env) ctx.env = {} ctx.env.DISCORD_USER_INSTALL = '0' } const userInstallOn = typeof discordUserInstallEnabled === 'function' ? discordUserInstallEnabled(ctx) : true const Client = dj.Client const GatewayIntentBits = dj.GatewayIntentBits const Events = dj.Events const REST = dj.REST const Routes = dj.Routes const SlashCommandBuilder = dj.SlashCommandBuilder const wantsMessageContent = discordWantsMessageContent(ctx, argv) const debugOn = discordDebugOn(ctx, argv) const loginMs = discordLoginTimeoutMs(ctx, argv) const intents = [GatewayIntentBits.Guilds] if (wantsMessageContent && GatewayIntentBits.MessageContent) { intents.push(GatewayIntentBits.MessageContent) } const osName = (typeof process !== 'undefined' && process.platform) || 'darwin' const client = new Client({ intents: intents, ws: { identifyProperties: { os: osName, browser: 'bare-os', device: 'bare-os' } } }) const slashBody = await Promise.resolve( typeof discordBuildSlashCommands === 'function' ? discordBuildSlashCommands(dj, ctx) : [ new SlashCommandBuilder() .setName('ping') .setDescription('Replies with pong.') .toJSON() ] ) async function discordRegisterSlashBody(body) { const rest = new REST().setToken(loaded.token) const appId = client.user && client.user.id if (!appId) return if (userInstallOn && typeof discordEnableUserInstallApp === 'function') { try { await discordEnableUserInstallApp(rest, Routes) } catch (err) { ctx.console.error( 'discord-bot: user-install app config failed: ' + ((err && err.message) || String(err)) + ' (enable User Install under Developer Portal → Installation)' ) } } if (typeof discordPutSlashCommands === 'function') { const put = await discordPutSlashCommands(rest, Routes, appId, body, { guildId: guildId, userInstall: userInstallOn }) if (put.global) { ctx.console.log( 'Registered ' + body.length + ' global slash commands' + (userInstallOn ? ' (user-install + guild)' : '') + (guildId ? '' : ' (may take up to ~1 hour)') ) } if (put.guild) { ctx.console.log( 'Registered ' + body.length + ' slash commands for guild ' + guildId ) } return } if (guildId) { await rest.put(Routes.applicationGuildCommands(appId, guildId), { body: body }) } else { await rest.put(Routes.applicationCommands(appId), { body: body }) } } if (typeof discordPluginsSetRegistrar === 'function') { discordPluginsSetRegistrar(async function () { const body = await Promise.resolve(discordBuildSlashCommands(dj, ctx)) await discordRegisterSlashBody(body) }) } client.once(Events.ClientReady, async function (readyClient) { ctx.console.log('Logged in as ' + readyClient.user.tag) if (typeof REST !== 'function' || !Routes) return try { await discordRegisterSlashBody(slashBody) if (userInstallOn && typeof discordUserInstallAuthorizeUrl === 'function') { const url = discordUserInstallAuthorizeUrl(readyClient.user.id) if (url) { ctx.console.log('discord-bot: add to your Discord profile: ' + url) } } } catch (err) { ctx.console.error( 'discord-bot: slash command registration failed: ' + ((err && err.message) || String(err)) ) } }) client.on(Events.InteractionCreate, async function (interaction) { if (typeof discordDispatchInteraction === 'function') { await discordDispatchInteraction(ctx, interaction) return } if (!interaction.isChatInputCommand || !interaction.isChatInputCommand()) return if (interaction.commandName !== 'ping') return const uid = interaction.user && interaction.user.id ? interaction.user.id : '' if ( typeof discordUserAllowed === 'function' && !discordUserAllowed(ctx, uid, interaction) ) { try { await interaction.reply({ content: typeof BARE_OS_DISCORD_WHITELIST_DENY === 'string' ? BARE_OS_DISCORD_WHITELIST_DENY : 'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.', flags: typeof BARE_OS_DISCORD_FLAG_EPHEMERAL === 'number' ? BARE_OS_DISCORD_FLAG_EPHEMERAL : 64 }) } catch (err) { ctx.console.error( 'discord-bot: deny reply failed: ' + ((err && err.message) || String(err)) ) } return } try { await interaction.reply({ content: 'pong' }) } catch (err) { ctx.console.error( 'discord-bot: /ping reply failed: ' + ((err && err.message) || String(err)) ) } }) if (Events.MessageCreate) { client.on(Events.MessageCreate, async function (message) { if (!message || (message.author && message.author.bot)) return const text = String(message.content || '') .trim() .toLowerCase() if (text !== 'ping') return const uid = message.author && message.author.id ? message.author.id : '' if (typeof discordUserAllowed === 'function' && !discordUserAllowed(ctx, uid)) { try { await message.reply( typeof BARE_OS_DISCORD_WHITELIST_DENY === 'string' ? BARE_OS_DISCORD_WHITELIST_DENY : 'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.' ) } catch (err) { ctx.console.error( 'discord-bot: deny reply failed: ' + discordErrText(err) ) } return } try { await message.reply('pong') } catch (err) { ctx.console.error( 'discord-bot: message reply failed: ' + discordErrText(err) ) } }) } client.on(Events.Error, function (error) { ctx.console.error('discord-bot: client error: ' + discordErrText(error)) }) if (Events.ShardError) { client.on(Events.ShardError, function (error) { ctx.console.error('discord-bot: shard error: ' + discordErrText(error)) }) } if (Events.Raw) { client.on(Events.Raw, function (packet) { if (!loggingIn) return const t = packet && packet.t if (t) ctx.console.log('discord-bot: dispatch ' + t) }) } function discordRedactWs(s) { return String(s || '').replace(/"token"\s*:\s*"[^"]*"/g, '"token":"***"') } function discordHookGateway(c) { let tries = 0 const iv = setInterval(function () { tries++ const mgr = c.ws && c.ws._ws const shards = mgr && mgr.shards let hooked = false if (shards && typeof shards.values === 'function') { const it = shards.values() let n = it.next() while (!n.done) { const conn = n.value && n.value.connection if (conn && !conn.__bareOsHooked) { conn.__bareOsHooked = true hooked = true if (typeof conn.send === 'function') { const origSend = conn.send.bind(conn) conn.send = function (data) { ctx.console.log( 'discord-bot: ws send ' + discordRedactWs(data).slice(0, 180) ) return origSend(data) } } const prev = conn.onmessage conn.onmessage = function (ev) { const d = ev && ev.data const text = typeof d === 'string' ? d : d && typeof d.toString === 'function' ? d.toString() : String(d) ctx.console.log('discord-bot: ws recv ' + text.slice(0, 180)) if (typeof prev === 'function') return prev.apply(this, arguments) } } n = it.next() } } if (hooked || tries > 80) clearInterval(iv) }, 25) } discordHookGateway(client) let loggingIn = true let loginTimer = null let loginTick = null let interruptStop = null const detachInterrupt = discordAttachInterrupt(ctx, function () { if (typeof interruptStop === 'function') interruptStop() Promise.resolve(client.destroy()).catch(function () {}) }) if (Events.Warn) { client.on(Events.Warn, function (message) { ctx.console.error('discord-bot: ' + String(message || '')) }) } if (Events.Debug) { client.on(Events.Debug, function (message) { if (loggingIn || debugOn) { ctx.console.log('discord-bot: ' + String(message || '')) } }) } ctx.console.log( 'discord-bot: logging in (token from ' + loaded.source + ')...' ) if (typeof discordParseIdWhitelist === 'function') { const wl = discordParseIdWhitelist( (ctx.env && (ctx.env.DISCORD_ID_WHITELIST || ctx.env.BARE_OS_DISCORD_ID_WHITELIST)) || '' ) let wlN = 0 for (const k in wl) { if (Object.prototype.hasOwnProperty.call(wl, k)) wlN++ } if (wlN) { ctx.console.log( 'discord-bot: DISCORD_ID_WHITELIST active (' + wlN + ' user id(s))' ) } else if (userInstallOn) { ctx.console.log( 'discord-bot: user-install is on and DISCORD_ID_WHITELIST is empty — user-install / DM commands are denied until you add your Discord user id' ) } } if (!wantsMessageContent) { ctx.console.log( 'discord-bot: /ping only (pass --message-content after enabling Message Content Intent for channel ping/pong)' ) } if (typeof REST === 'function' && Routes && Routes.gatewayBot) { ctx.console.log('discord-bot: checking Discord REST /gateway/bot...') try { const restProbe = new REST({ timeout: 15000 }).setToken(loaded.token) const gw = await restProbe.get(Routes.gatewayBot()) ctx.console.log( 'discord-bot: REST ok gateway=' + String((gw && gw.url) || '?') + ' shards=' + String((gw && gw.shards) || '?') ) } catch (err) { ctx.console.error( 'discord-bot: REST /gateway/bot failed: ' + discordErrText(err) ) const status = err && (err.status || err.statusCode) if (status === 401 || (err && err.code === 'TokenInvalid')) { ctx.console.error( 'Invalid token: Developer Portal → Bot → Reset Token (not the OAuth2 client secret).' ) } ctx.exitCode = 1 await Promise.resolve(client.destroy()).catch(function () {}) return } } function discordGatewayFatal(event) { const code = event && typeof event === 'object' ? event.code : event if (code === 4014) { const err = new Error( 'gateway closed 4014 (disallowed intents). Enable Message Content Intent in the Developer Portal, or omit --message-content (default).' ) err.code = 4014 return err } if (code === 4013) { const err = new Error('gateway closed 4013 (invalid intents)') err.code = 4013 return err } if (code === 4004) { const err = new Error( 'gateway closed 4004 (authentication failed). Check the bot token.' ) err.code = 4004 return err } return null } try { await Promise.race([ client.login(loaded.token), new Promise(function (resolve) { client.once(Events.ClientReady, function () { resolve('ready') }) }), new Promise(function (resolve, reject) { interruptStop = function () { resolve('interrupt') } }), new Promise(function (_, reject) { let waited = 0 loginTick = setInterval(function () { waited += 5 if (!loggingIn) return ctx.console.log( 'discord-bot: still connecting (' + waited + 's / ' + loginMs / 1000 + 's)...' ) }, 5000) loginTimer = setTimeout(function () { reject( new Error( 'login timed out after ' + loginMs + 'ms (gateway ready). Last lines above are discord.js debug.' ) ) }, loginMs) if (Events.ShardError) { client.on(Events.ShardError, function (error) { if (!loggingIn) return reject( error instanceof Error ? error : new Error(discordErrText(error)) ) }) } if (Events.ShardDisconnect) { client.on(Events.ShardDisconnect, function (event) { if (!loggingIn) return const fatal = discordGatewayFatal(event) if (fatal) reject(fatal) }) } }) ]) } catch (err) { ctx.console.error('discord-bot: login failed: ' + discordErrText(err)) if (err && err.code === 'TokenInvalid') { ctx.console.error( 'Invalid token: Developer Portal → Bot → Token (not the OAuth2 client secret).' ) } if (err && (err.code === 4014 || /disallowed intents/i.test(String(err)))) { ctx.console.error( 'Privileged intents: Developer Portal → Bot → Privileged Gateway Intents.' ) } ctx.exitCode = 1 loggingIn = false if (loginTimer) clearTimeout(loginTimer) if (loginTick) clearInterval(loginTick) detachInterrupt() await Promise.resolve(client.destroy()).catch(function () {}) return } loggingIn = false if (loginTimer) clearTimeout(loginTimer) if (loginTick) clearInterval(loginTick) if (ctx.exitCode === 130) { detachInterrupt() await Promise.resolve(client.destroy()).catch(function () {}) return } await new Promise(function (resolve) { function shutdown() { detachInterrupt() Promise.resolve(client.destroy()) .catch(function () {}) .then(resolve) } interruptStop = shutdown if (typeof ctx.registerKernelShutdownHook === 'function') { ctx.registerKernelShutdownHook(shutdown) } if (Events.Invalidated) client.once(Events.Invalidated, shutdown) }) }