/** * Guest Discord ping-pong bot. * * Uses ctx.bare.discordJS (vendored bare-discord-js). No import/require. * * Token (first match wins): * --token TOKEN * ctx.env.DISCORD_TOKEN (host DISCORD_TOKEN / host DISCORD_ENV_FILE, or `export`) * --env / --env-file PATH (VFS path, e.g. ~/.discord.env) * ctx.env.DISCORD_ENV_FILE or BARE_OS_DISCORD_ENV_FILE (VFS) * ~/.discord.env, ~/discord.env, $PWD/.env, ~/.env * * .env file: * DISCORD_TOKEN=your-bot-token * DISCORD_GUILD_ID=optional-guild-for-instant-slash-commands * DISCORD_ID_WHITELIST=comma-separated-user-ids (optional; unset = allow all) * * `/ping` works with Guilds only. Channel "ping" → "pong" needs * `--message-content` and Message Content Intent in the Developer Portal. * Requesting that privileged intent without enabling it closes the gateway * with 4014 and used to hang login forever. * * Run inside Bare OS: * discord-bot --env ~/.discord.env * ./discord-ping-pong.js --env ~/.discord.env * ./discord-ping-pong.js --check --env ~/.discord.env */ 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 discordUserAllowed(ctx, userId) { const raw = String( (ctx.env && (ctx.env.DISCORD_ID_WHITELIST || ctx.env.BARE_OS_DISCORD_ID_WHITELIST)) || '' ).trim() if (!raw) return true const want = String(userId == null ? '' : userId).trim() if (!want) return false const parts = raw.split(',') for (let i = 0; i < parts.length; i++) { if (parts[i].trim() === want) return true } return false } function discordUsage(argv0) { return ( 'usage: ' + (argv0 || 'discord-bot') + ' [--env PATH] [--token TOKEN] [--guild ID] [--check]\n' + ' [--debug] [--message-content] [--login-timeout MS]\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).\n' + 'Replies pong to /ping. Message "ping" needs --message-content and the\n' + 'Message Content Intent in the Developer Portal (privileged).\n' + 'See man discord-bot.' ) } 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) { if (parsed.DISCORD_GUILD_ID && ctx.env && !ctx.env.DISCORD_GUILD_ID) { ctx.env.DISCORD_GUILD_ID = String(parsed.DISCORD_GUILD_ID).trim() } if ( parsed.DISCORD_ID_WHITELIST && ctx.env && !ctx.env.DISCORD_ID_WHITELIST ) { ctx.env.DISCORD_ID_WHITELIST = String(parsed.DISCORD_ID_WHITELIST).trim() } return { token: token, source: p } } } return { token: '', source: '' } } 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 } const loaded = await discordLoadToken(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) || '' 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 (GatewayIntentBits.GuildMessages) intents.push(GatewayIntentBits.GuildMessages) if (GatewayIntentBits.DirectMessages) intents.push(GatewayIntentBits.DirectMessages) 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' }, buildIdentifyThrottler: async function () { return { waitForIdentify: function (_id, signal) { return new Promise(function (resolve, reject) { const t = setTimeout(resolve, 200) if (signal && typeof signal.addEventListener === 'function') { signal.addEventListener('abort', function onAbort() { clearTimeout(t) reject(signal.reason) }) } }) } } } } }) const pingCommand = new SlashCommandBuilder() .setName('ping') .setDescription('Replies with pong.') .toJSON() client.once(Events.ClientReady, async function (readyClient) { ctx.console.log('Logged in as ' + readyClient.user.tag) if (typeof REST !== 'function' || !Routes) return try { const rest = new REST().setToken(loaded.token) const appId = readyClient.user.id if (guildId) { await rest.put(Routes.applicationGuildCommands(appId, guildId), { body: [pingCommand] }) ctx.console.log('Registered /ping for guild ' + guildId) } else { await rest.put(Routes.applicationCommands(appId), { body: [pingCommand] }) ctx.console.log( 'Registered global /ping (may take up to ~1 hour). Set DISCORD_GUILD_ID or --guild for instant updates.' ) } } catch (err) { ctx.console.error( 'discord-bot: slash command registration failed: ' + ((err && err.message) || String(err)) ) } }) client.on(Events.InteractionCreate, async function (interaction) { if (!interaction.isChatInputCommand || !interaction.isChatInputCommand()) return if (interaction.commandName !== 'ping') return const uid = interaction.user && interaction.user.id ? interaction.user.id : '' if (!discordUserAllowed(ctx, uid)) { try { await interaction.reply({ content: 'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.', flags: 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 (!discordUserAllowed(ctx, uid)) { try { await message.reply( '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) }) } let loggingIn = true let loginTimer = null let loginTick = null 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 (!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 (_, 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) await Promise.resolve(client.destroy()).catch(function () {}) return } loggingIn = false if (loginTimer) clearTimeout(loginTimer) if (loginTick) clearInterval(loginTick) await new Promise(function (resolve) { function shutdown() { Promise.resolve(client.destroy()) .catch(function () {}) .then(resolve) } if (typeof ctx.registerKernelShutdownHook === 'function') { ctx.registerKernelShutdownHook(shutdown) } if (Events.Invalidated) client.once(Events.Invalidated, shutdown) }) }