412 lines
12 KiB
JavaScript
412 lines
12 KiB
JavaScript
/**
|
|
* Host .env injection + guest-script contract for ctx.bare.discordJS bots.
|
|
*/
|
|
import test from 'brittle'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import {
|
|
normalizeDiscordToken,
|
|
parseDotEnvText,
|
|
applyDiscordHostEnvToShellEnv
|
|
} from './lib/services/bare-discord-js-loader.js'
|
|
import {
|
|
registerPackedDiscordJs,
|
|
takePackedDiscordJs
|
|
} from './lib/ctx/bare-os-ctx-discord-registry.js'
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
|
const examplePath = path.join(root, 'examples/discord-ping-pong/index.js')
|
|
const binSrcPath = path.join(
|
|
root,
|
|
'packages/bare-os-coreutils/src/discord-bot.js'
|
|
)
|
|
|
|
test('packed discord registry is what standalone app.bundle uses', (t) => {
|
|
const prev = takePackedDiscordJs()
|
|
t.is(registerPackedDiscordJs({}), null)
|
|
const fake = { Client: function Client() {} }
|
|
t.is(registerPackedDiscordJs(fake), fake)
|
|
t.is(takePackedDiscordJs(), fake)
|
|
registerPackedDiscordJs(prev)
|
|
})
|
|
|
|
test('normalizeDiscordToken strips BOM, quotes leftovers, zwsp', (t) => {
|
|
t.is(normalizeDiscordToken(' tok.en \n'), 'tok.en')
|
|
t.is(normalizeDiscordToken('\uFEFFabc'), 'abc')
|
|
t.is(normalizeDiscordToken('ab\u200Bc'), 'abc')
|
|
t.is(normalizeDiscordToken(null), '')
|
|
})
|
|
|
|
test('parseDotEnvText handles export, quotes, comments', (t) => {
|
|
const parsed = parseDotEnvText(
|
|
'# comment\nexport DISCORD_TOKEN="abc.def"\nDISCORD_GUILD_ID=99\n'
|
|
)
|
|
t.is(parsed.DISCORD_TOKEN, 'abc.def')
|
|
t.is(parsed.DISCORD_GUILD_ID, '99')
|
|
})
|
|
|
|
test('applyDiscordHostEnvToShellEnv reads host .env file path', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bare-os-discord-env-'))
|
|
const envPath = path.join(dir, '.env')
|
|
fs.writeFileSync(
|
|
envPath,
|
|
'DISCORD_TOKEN=host.file.token\nDISCORD_GUILD_ID=42\nDISCORD_ID_WHITELIST=111,222\n'
|
|
)
|
|
const shellEnv = {}
|
|
applyDiscordHostEnvToShellEnv(
|
|
{ DISCORD_ENV_FILE: envPath, BARE_OS_DISCORD: '1' },
|
|
shellEnv
|
|
)
|
|
t.is(shellEnv.DISCORD_TOKEN, 'host.file.token')
|
|
t.is(shellEnv.DISCORD_GUILD_ID, '42')
|
|
t.is(shellEnv.DISCORD_ID_WHITELIST, '111,222')
|
|
t.is(shellEnv.DISCORD_ENV_FILE, envPath)
|
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
})
|
|
|
|
test('applyDiscordHostEnvToShellEnv copies DISCORD_ID_WHITELIST from host env', (t) => {
|
|
const shellEnv = {}
|
|
applyDiscordHostEnvToShellEnv(
|
|
{ DISCORD_ID_WHITELIST: ' 123 , 456 ', DISCORD_TOKEN: 't.ok' },
|
|
shellEnv
|
|
)
|
|
t.is(shellEnv.DISCORD_ID_WHITELIST, ' 123 , 456 ')
|
|
t.is(shellEnv.DISCORD_TOKEN, 't.ok')
|
|
})
|
|
|
|
test('applyDiscordHostEnvToShellEnv does not overwrite existing token', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bare-os-discord-env-'))
|
|
const envPath = path.join(dir, '.env')
|
|
fs.writeFileSync(envPath, 'DISCORD_TOKEN=from.file\n')
|
|
const shellEnv = { DISCORD_TOKEN: 'already.set' }
|
|
applyDiscordHostEnvToShellEnv({ DISCORD_ENV_FILE: envPath }, shellEnv)
|
|
t.is(shellEnv.DISCORD_TOKEN, 'already.set')
|
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
})
|
|
|
|
test('guest --check resolves token from env and VFS .env path', async (t) => {
|
|
const src = fs.readFileSync(examplePath, 'utf8')
|
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
|
const files = {
|
|
'/home/guest/.discord.env': 'DISCORD_TOKEN=vfs.file.token\n'
|
|
}
|
|
function makeCtx(env) {
|
|
const logs = []
|
|
const errors = []
|
|
return {
|
|
logs,
|
|
errors,
|
|
ctx: {
|
|
bare: { discordJS: { Client: function Client() {} } },
|
|
env,
|
|
console: {
|
|
log: (s) => logs.push(String(s)),
|
|
error: (s) => errors.push(String(s))
|
|
},
|
|
vfs: {
|
|
readFile: async (p) => {
|
|
const text = files[p]
|
|
if (text == null) return null
|
|
return Buffer.from(text)
|
|
}
|
|
},
|
|
b4a: { toString: (b) => Buffer.from(b).toString('utf8') },
|
|
exitCode: 0
|
|
}
|
|
}
|
|
}
|
|
|
|
const missing = makeCtx({})
|
|
const fn = new AsyncFunction(
|
|
'ctx',
|
|
'argv',
|
|
src + '\nif (typeof run === "function") await run(ctx, argv)\n'
|
|
)
|
|
await fn(missing.ctx, ['discord-bot', '--check'])
|
|
t.is(missing.ctx.exitCode, 1)
|
|
const missingJson = JSON.parse(missing.logs.join('\n'))
|
|
t.is(missingJson.discordJS, true)
|
|
t.is(missingJson.tokenPresent, false)
|
|
|
|
const fromEnv = makeCtx({ DISCORD_TOKEN: 'session.token' })
|
|
await fn(fromEnv.ctx, ['discord-bot', '--check'])
|
|
t.is(fromEnv.ctx.exitCode, 0)
|
|
t.is(JSON.parse(fromEnv.logs.join('\n')).tokenSource, 'DISCORD_TOKEN')
|
|
|
|
const fromFile = makeCtx({})
|
|
await fn(fromFile.ctx, [
|
|
'discord-bot',
|
|
'--check',
|
|
'--env',
|
|
'/home/guest/.discord.env'
|
|
])
|
|
t.is(fromFile.ctx.exitCode, 0)
|
|
t.is(
|
|
JSON.parse(fromFile.logs.join('\n')).tokenSource,
|
|
'/home/guest/.discord.env'
|
|
)
|
|
})
|
|
|
|
test('example and /bin discord-bot are guest-safe (no import/require)', (t) => {
|
|
const example = fs.readFileSync(examplePath, 'utf8')
|
|
const binSrc = fs.readFileSync(binSrcPath, 'utf8')
|
|
for (const [label, src] of [
|
|
['example', example],
|
|
['bin', binSrc]
|
|
]) {
|
|
t.ok(/async function run\s*\(\s*ctx/.test(src), label + ' defines run(ctx)')
|
|
t.ok(
|
|
/ctx\.bare/.test(src) && /discordJS/.test(src),
|
|
label + ' uses ctx.bare.discordJS'
|
|
)
|
|
t.absent(
|
|
/^\s*import\s/m.test(src) || /^\s*export\s/m.test(src),
|
|
label + ' has no ESM import/export'
|
|
)
|
|
t.absent(
|
|
/\brequire\s*\(/.test(src),
|
|
label + ' has no require() (guest AsyncFunction)'
|
|
)
|
|
t.ok(/--env/.test(src), label + ' documents --env')
|
|
t.ok(
|
|
/--message-content/.test(src),
|
|
label + ' documents --message-content (privileged intent is opt-in)'
|
|
)
|
|
}
|
|
})
|
|
|
|
function makeLoginHangClient(emitAfterMs, eventName, payload) {
|
|
function Client(opts) {
|
|
this.opts = opts || {}
|
|
this.handlers = Object.create(null)
|
|
}
|
|
Client.prototype.on = function (ev, fn) {
|
|
if (!this.handlers[ev]) this.handlers[ev] = []
|
|
this.handlers[ev].push(fn)
|
|
return this
|
|
}
|
|
Client.prototype.once = function (ev, fn) {
|
|
return this.on(ev, fn)
|
|
}
|
|
Client.prototype.emit = function (ev, arg) {
|
|
const list = this.handlers[ev] || []
|
|
for (let i = 0; i < list.length; i++) list[i](arg)
|
|
}
|
|
Client.prototype.login = function () {
|
|
const self = this
|
|
setTimeout(function () {
|
|
self.emit(eventName, payload)
|
|
}, emitAfterMs)
|
|
return new Promise(function () {})
|
|
}
|
|
Client.prototype.destroy = async function () {}
|
|
return Client
|
|
}
|
|
|
|
const loginEvents = {
|
|
ClientReady: 'clientReady',
|
|
InteractionCreate: 'interactionCreate',
|
|
MessageCreate: 'messageCreate',
|
|
Error: 'error',
|
|
ShardError: 'shardError',
|
|
ShardDisconnect: 'shardDisconnect',
|
|
Debug: 'debug',
|
|
Warn: 'warn',
|
|
Invalidated: 'invalidated'
|
|
}
|
|
|
|
function loginBits() {
|
|
return {
|
|
Guilds: 1,
|
|
GuildMessages: 2,
|
|
DirectMessages: 4,
|
|
MessageContent: 8
|
|
}
|
|
}
|
|
|
|
function loginSlash() {
|
|
function SlashCommandBuilder() {}
|
|
SlashCommandBuilder.prototype.setName = function () {
|
|
return this
|
|
}
|
|
SlashCommandBuilder.prototype.setDescription = function () {
|
|
return this
|
|
}
|
|
SlashCommandBuilder.prototype.toJSON = function () {
|
|
return { name: 'ping' }
|
|
}
|
|
return SlashCommandBuilder
|
|
}
|
|
|
|
test('hanging login fails on gateway 4014 instead of waiting forever', async (t) => {
|
|
const src = fs.readFileSync(binSrcPath, 'utf8')
|
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
|
const Client = makeLoginHangClient(5, 'shardDisconnect', { code: 4014 })
|
|
const errors = []
|
|
const logs = []
|
|
const ctx = {
|
|
bare: {
|
|
discordJS: {
|
|
Client,
|
|
GatewayIntentBits: loginBits(),
|
|
Events: loginEvents,
|
|
REST: function REST() {
|
|
this.setToken = function () {
|
|
return this
|
|
}
|
|
},
|
|
Routes: {},
|
|
SlashCommandBuilder: loginSlash()
|
|
}
|
|
},
|
|
env: { DISCORD_TOKEN: 'fake.token.value', DISCORD_LOGIN_TIMEOUT_MS: '2000' },
|
|
console: {
|
|
log: (s) => logs.push(String(s)),
|
|
error: (s) => errors.push(String(s))
|
|
},
|
|
exitCode: 0
|
|
}
|
|
const fn = new AsyncFunction(
|
|
'ctx',
|
|
'argv',
|
|
src + '\nif (typeof run === "function") await run(ctx, argv)\n'
|
|
)
|
|
await fn(ctx, ['discord-bot'])
|
|
t.is(ctx.exitCode, 1)
|
|
t.ok(
|
|
errors.some((e) => /4014|disallowed intents/i.test(e)),
|
|
'reports gateway 4014: ' + errors.join(' | ')
|
|
)
|
|
t.ok(
|
|
logs.some((l) => /logging in/.test(l)),
|
|
'printed logging in'
|
|
)
|
|
})
|
|
|
|
test('standalone pack loads Discord WS bootstrap before discord.js', (t) => {
|
|
const packImports = fs.readFileSync(
|
|
path.join(root, 'packages/bare-os-booter/lib/ctx/bare-os-standalone-pack-imports.mjs'),
|
|
'utf8'
|
|
)
|
|
const standalone = fs.readFileSync(
|
|
path.join(root, 'packages/bare-os-booter/standalone.mjs'),
|
|
'utf8'
|
|
)
|
|
const bootIdx = packImports.indexOf('bare-os-discord-ws-bootstrap')
|
|
const discordIdx = packImports.indexOf('bare-os-ctx-discord-packed')
|
|
t.ok(bootIdx >= 0, 'pack-imports bootstraps Discord WS')
|
|
t.ok(discordIdx > bootIdx, 'WS bootstrap is imported before packed discord.js')
|
|
t.ok(
|
|
standalone.includes('bare-os-discord-ws-bootstrap'),
|
|
'standalone.mjs bootstraps Discord WS first'
|
|
)
|
|
t.ok(
|
|
/bare-os-discord-commands-guest\.cjs/.test(packImports),
|
|
'pack-imports includes the Discord command catalog as CJS'
|
|
)
|
|
const initd = fs.readFileSync(
|
|
path.join(root, 'packages/bare-os-booter/lib/services/bare-os-discord-initd.js'),
|
|
'utf8'
|
|
)
|
|
t.ok(
|
|
/import\s+discordCommandsNs\s+from\s+['"]\.\/bare-os-discord-commands-guest\.cjs['"]/.test(
|
|
initd
|
|
),
|
|
'initd statically imports the catalog (bare-pack rewrites this)'
|
|
)
|
|
t.absent(
|
|
/require\s*\(\s*['"]\.\/bare-os-discord-commands-guest/.test(initd),
|
|
'initd must not require() the catalog (MODULE_NOT_FOUND under app.bundle)'
|
|
)
|
|
})
|
|
|
|
test('WHATWG bare-ws wrapper exposes send (not Duplex-only Socket)', (t) => {
|
|
const src = fs.readFileSync(
|
|
path.join(
|
|
root,
|
|
'packages/bare-os-booter/vendor/bare-discord-js/src/adapters/whatwg-ws.cjs'
|
|
),
|
|
'utf8'
|
|
)
|
|
t.ok(/bare-os-discord-gateway-ws/.test(src), 'unique pack marker present')
|
|
t.ok(/class WhatwgWebSocket/.test(src), 'defines WhatwgWebSocket')
|
|
t.ok(/send\s*\(/.test(src), 'implements send()')
|
|
})
|
|
|
|
test('zlib-sync pack stub is not truthy (empty {} would hang login)', (t) => {
|
|
const stubPath = path.join(root, 'build/stubs/zlib-sync.cjs')
|
|
const stub = fs.readFileSync(stubPath, 'utf8')
|
|
t.ok(/module\.exports = null/.test(stub), 'stub exports null')
|
|
const wsMgr = fs.readFileSync(
|
|
path.join(root, 'node_modules/discord.js/src/client/websocket/WebSocketManager.js'),
|
|
'utf8'
|
|
)
|
|
t.ok(
|
|
/typeof zlib\.Inflate !== 'function'/.test(wsMgr),
|
|
'discord.js ignores zlib-sync without Inflate'
|
|
)
|
|
const wsJs = fs.readFileSync(
|
|
path.join(root, 'node_modules/@discordjs/ws/dist/index.js'),
|
|
'utf8'
|
|
)
|
|
t.ok(
|
|
/yielding before identify/.test(wsJs),
|
|
'@discordjs/ws defers identify after HELLO'
|
|
)
|
|
t.ok(
|
|
/Warmup gateway heartbeat before identify/.test(wsJs),
|
|
'@discordjs/ws sends a warmup heartbeat so IDENTIFY is not the first client frame'
|
|
)
|
|
})
|
|
|
|
test('default login does not request Message Content Intent', async (t) => {
|
|
const src = fs.readFileSync(binSrcPath, 'utf8')
|
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
|
let seenIntents = null
|
|
function Client(opts) {
|
|
seenIntents = opts && opts.intents
|
|
this.on = function () {
|
|
return this
|
|
}
|
|
this.once = function () {
|
|
return this
|
|
}
|
|
this.login = async function () {
|
|
throw Object.assign(new Error('stop after construct'), {
|
|
code: 'TokenInvalid'
|
|
})
|
|
}
|
|
this.destroy = async function () {}
|
|
}
|
|
const ctx = {
|
|
bare: {
|
|
discordJS: {
|
|
Client,
|
|
GatewayIntentBits: loginBits(),
|
|
Events: loginEvents,
|
|
REST: function REST() {},
|
|
Routes: {},
|
|
SlashCommandBuilder: loginSlash()
|
|
}
|
|
},
|
|
env: { DISCORD_TOKEN: 'fake.token.value' },
|
|
console: { log: () => {}, error: () => {} },
|
|
exitCode: 0
|
|
}
|
|
const fn = new AsyncFunction(
|
|
'ctx',
|
|
'argv',
|
|
src + '\nif (typeof run === "function") await run(ctx, argv)\n'
|
|
)
|
|
await fn(ctx, ['discord-bot'])
|
|
t.ok(Array.isArray(seenIntents), 'Client received intents')
|
|
t.absent(
|
|
seenIntents.includes(8),
|
|
'MessageContent bit is not requested by default'
|
|
)
|
|
t.ok(seenIntents.includes(1), 'Guilds intent is requested')
|
|
t.ok(seenIntents.includes(4), 'DirectMessages intent is requested for DM agent chat')
|
|
})
|