Files
bare-operating-system/examples/discord-ping-pong/index.js
T
Raven Scott 657010f953
Release rolling / release (push) Successful in 11m6s
Updates
2026-08-13 12:30:42 -04:00

614 lines
18 KiB
JavaScript

/**
* 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)
})
}