SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+933
View File
@@ -0,0 +1,933 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
assertValidOneBotConfig,
loadOneBotConfig,
makeDefaultOneBotConfig,
prepareOneBotConfigForRestore,
saveOneBotConfig,
STATUS_COMMAND_TRIGGER_MAX_LENGTH,
} from '../src/config';
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
describe('makeDefaultOneBotConfig', () => {
it('returns default unified networks structure', () => {
const config = makeDefaultOneBotConfig();
expect(config.networks.httpServers).toHaveLength(1);
expect(config.networks.httpServers[0].name).toBe('http-default');
expect(config.networks.httpServers[0].host).toBe('127.0.0.1');
expect(config.networks.httpServers[0].port).toBe(3000);
expect(config.networks.httpServers[0].accessToken).toMatch(TOKEN_PATTERN);
expect(config.networks.httpServers[0].messageFormat).toBe('array');
expect(config.networks.httpServers[0].reportSelfMessage).toBe(false);
expect(config.networks.httpClients).toEqual([]);
expect(config.networks.wsServers).toHaveLength(1);
expect(config.networks.wsServers[0].host).toBe('127.0.0.1');
expect(config.networks.wsServers[0].port).toBe(3001);
expect(config.networks.wsServers[0].role).toBe('Universal');
expect(config.networks.wsServers[0].accessToken).toMatch(TOKEN_PATTERN);
expect(config.networks.wsServers[0].messageFormat).toBe('array');
expect(config.networks.wsServers[0].reportSelfMessage).toBe(false);
expect(config.networks.wsClients).toEqual([]);
expect(config.statusCommand).toEqual({
enabled: true,
swallow: false,
cooldownSeconds: 5,
trigger: '#sl',
});
expect(config.historySync).toEqual({ enabled: false });
expect(config.notifications).toEqual({ channelIds: [] });
});
});
describe('assertValidOneBotConfig', () => {
it('rejects duplicate adapter names across different kinds', () => {
const config = makeDefaultOneBotConfig();
config.networks.httpClients.push({
name: 'http-default',
url: 'http://127.0.0.1:5700',
messageFormat: 'array',
reportSelfMessage: false,
});
expect(() => assertValidOneBotConfig(config)).toThrow(
/duplicated in httpServers and httpClients/,
);
});
it('allows a disabled client draft with an empty URL', () => {
const config = makeDefaultOneBotConfig();
config.networks.httpClients.push({
name: 'disabled-draft',
enabled: false,
url: '',
messageFormat: 'array',
reportSelfMessage: false,
});
expect(() => assertValidOneBotConfig(config)).not.toThrow();
});
it('rejects enabled clients with malformed or wrong-protocol URLs', () => {
const http = makeDefaultOneBotConfig();
http.networks.httpClients.push({
name: 'bad-http',
url: 'not a url',
messageFormat: 'array',
reportSelfMessage: false,
});
expect(() => assertValidOneBotConfig(http)).toThrow(/valid absolute URL/);
const ws = makeDefaultOneBotConfig();
ws.networks.wsClients.push({
name: 'bad-ws',
url: 'https://example.com/socket',
messageFormat: 'array',
reportSelfMessage: false,
});
expect(() => assertValidOneBotConfig(ws)).toThrow(/protocol must be one of ws:, wss:/);
});
it('rejects enabled servers with the same normalized host and port', () => {
const config = makeDefaultOneBotConfig();
config.networks.httpServers[0].host = '0.0.0.0';
config.networks.wsServers.push({
name: 'conflicting-ws',
host: '0.0.0.0',
port: 3000,
path: '/different-path',
messageFormat: 'array',
reportSelfMessage: false,
});
expect(() => assertValidOneBotConfig(config)).toThrow(
/conflicts with networks\.httpServers\[0\] on server binding 0\.0\.0\.0:3000/,
);
});
it('rejects blank hosts and wildcard/specific listeners on the same port', () => {
const blank = makeDefaultOneBotConfig();
blank.networks.httpServers[0].host = ' ';
expect(() => assertValidOneBotConfig(blank)).toThrow(/\.host/);
const wildcard = makeDefaultOneBotConfig();
wildcard.networks.httpServers[0].host = '0.0.0.0';
wildcard.networks.wsServers.push({
name: 'specific-ws',
host: '127.0.0.1',
port: 3000,
path: '/ws',
messageFormat: 'array',
reportSelfMessage: false,
});
expect(() => assertValidOneBotConfig(wildcard)).toThrow(/wildcard server port 3000/);
});
it('rejects timer values that Node would clamp to a 1ms loop', () => {
const http = makeDefaultOneBotConfig();
http.networks.httpClients.push({
name: 'overflow-http',
url: 'http://127.0.0.1:5700',
timeoutMs: 1e100,
messageFormat: 'array',
reportSelfMessage: false,
});
expect(() => assertValidOneBotConfig(http)).toThrow(/timeoutMs/);
const ws = makeDefaultOneBotConfig();
ws.networks.wsClients.push({
name: 'overflow-ws',
url: 'ws://127.0.0.1:5700',
reconnectIntervalMs: 2_147_483_648,
messageFormat: 'array',
reportSelfMessage: false,
});
expect(() => assertValidOneBotConfig(ws)).toThrow(/reconnectIntervalMs/);
});
it('bounds maxPayloadBytes when one is configured', () => {
for (const invalid of [0, 1023, 2 * 1024 * 1024 * 1024 + 1, 1024.5, '104857600']) {
const config = makeDefaultOneBotConfig();
config.networks.wsServers[0].maxPayloadBytes = invalid as unknown as number;
expect(() => assertValidOneBotConfig(config), String(invalid)).toThrow(
/maxPayloadBytes/,
);
}
const accepted = makeDefaultOneBotConfig();
accepted.networks.wsServers[0].maxPayloadBytes = 1024;
expect(() => assertValidOneBotConfig(accepted)).not.toThrow();
// The third WebSocket-capable adapter: httpServers with the embedded WS.
const httpWs = makeDefaultOneBotConfig();
httpWs.networks.httpServers[0].enableWebSocket = true;
httpWs.networks.httpServers[0].maxPayloadBytes = 2048;
expect(() => assertValidOneBotConfig(httpWs)).not.toThrow();
});
it('rejects maxPayloadBytes on the pure HTTP client adapter', () => {
const config = makeDefaultOneBotConfig();
config.networks.httpClients.push({
name: 'mirror',
url: 'http://127.0.0.1:9000',
messageFormat: 'array',
reportSelfMessage: false,
});
// HttpPostAdapter has no WebSocket transport, so the value could only be
// saved and silently ignored.
(config.networks.httpClients[0] as unknown as Record<string, unknown>).maxPayloadBytes =
1024;
expect(() => assertValidOneBotConfig(config)).toThrow(
/maxPayloadBytes is only supported by WebSocket adapters/,
);
});
it('rejects server paths that cannot match a URL pathname', () => {
for (const invalidPath of ['api', '/api?token=x', '/api#fragment', ' /api']) {
const config = makeDefaultOneBotConfig();
config.networks.httpServers[0].path = invalidPath;
expect(() => assertValidOneBotConfig(config), invalidPath).toThrow(/\.path/);
}
const valid = makeDefaultOneBotConfig();
valid.networks.httpServers[0].path = '';
expect(() => assertValidOneBotConfig(valid)).not.toThrow();
});
});
describe('saveOneBotConfig validation boundary', () => {
it('does not write an invalid enabled-client URL to disk', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-invalid-save-'));
const previous = process.cwd();
process.chdir(tempDir);
try {
const config = makeDefaultOneBotConfig();
config.networks.wsClients.push({
name: 'bad-client',
url: 'javascript:alert(1)',
messageFormat: 'array',
reportSelfMessage: false,
});
expect(() => saveOneBotConfig('10001', config)).toThrow(
/protocol must be one of ws:, wss:/,
);
expect(fs.existsSync(path.join(tempDir, 'config', 'onebot_10001.json'))).toBe(false);
} finally {
process.chdir(previous);
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('does not write a deterministic server bind conflict to disk', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-bind-conflict-'));
const previous = process.cwd();
process.chdir(tempDir);
try {
const config = makeDefaultOneBotConfig();
config.networks.wsServers.push({
name: 'same-bind',
host: '127.0.0.1',
port: 3000,
path: '/ws',
messageFormat: 'array',
reportSelfMessage: false,
});
expect(() => saveOneBotConfig('10001', config)).toThrow(
/server binding 127\.0\.0\.1:3000/,
);
expect(fs.existsSync(path.join(tempDir, 'config', 'onebot_10001.json'))).toBe(false);
} finally {
process.chdir(previous);
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('preserves the previous desired config when the atomic rename fails', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-atomic-save-'));
const previous = process.cwd();
process.chdir(tempDir);
try {
const original = makeDefaultOneBotConfig();
saveOneBotConfig('10001', original);
const changed = structuredClone(original);
changed.networks.httpServers[0].port = 3999;
const rename = vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => {
throw new Error('injected rename failure');
});
expect(() => saveOneBotConfig('10001', changed)).toThrow(/injected rename failure/);
rename.mockRestore();
expect(loadOneBotConfig('10001').networks.httpServers[0].port).toBe(3000);
expect(
fs.readdirSync(path.join(tempDir, 'config')).some((name) => name.endsWith('.tmp')),
).toBe(false);
} finally {
vi.restoreAllMocks();
process.chdir(previous);
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe('loadOneBotConfig', () => {
let tempDir: string;
let prevCwd: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-config-'));
prevCwd = process.cwd();
process.chdir(tempDir);
});
afterEach(() => {
process.chdir(prevCwd);
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('creates and persists default config when none exists', () => {
const uin = '10001';
const config = loadOneBotConfig(uin, { persistDefaults: true });
expect(config.networks.httpServers).toHaveLength(1);
const onDisk = JSON.parse(
fs.readFileSync(path.join(tempDir, 'config', `onebot_${uin}.json`), 'utf8'),
);
expect(onDisk.networks).toBeDefined();
expect(Array.isArray(onDisk.networks.httpServers)).toBe(true);
expect(onDisk.networks.httpServers[0].name).toBe('http-default');
expect(onDisk.networks.httpServers[0].accessToken).toMatch(TOKEN_PATTERN);
expect(onDisk.networks.wsServers[0].accessToken).toMatch(TOKEN_PATTERN);
// Legacy keys must not appear in the persisted file.
expect(onDisk.httpServers).toBeUndefined();
expect(onDisk.wsServers).toBeUndefined();
// statusCommand is materialised with defaults on a fresh install.
expect(onDisk.statusCommand).toEqual({
enabled: true,
swallow: false,
cooldownSeconds: 5,
trigger: '#sl',
});
});
it('fills statusCommand defaults and clamps a negative cooldown', () => {
const uin = '10042';
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, `onebot_${uin}.json`),
JSON.stringify({
networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] },
statusCommand: { swallow: true, cooldownSeconds: -3 },
}),
);
const config = loadOneBotConfig(uin);
expect(config.statusCommand.enabled).toBe(true); // default filled (absent in file)
expect(config.statusCommand.swallow).toBe(true); // taken from file
expect(config.statusCommand.cooldownSeconds).toBe(0); // negative clamped to 0
});
it('migrates legacy per-type arrays into networks groups', () => {
const uin = '10002';
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
const legacy = {
httpServers: [{ host: '0.0.0.0', port: 3100, path: '/', accessToken: 'tok' }],
httpPostEndpoints: [{ name: 'main-bot', url: 'http://127.0.0.1:5700' }],
wsServers: [{ host: '0.0.0.0', port: 3101 }],
wsClients: [{ url: 'ws://127.0.0.1:8080' }],
musicSignUrl: 'https://example.com/sign',
messageFormat: 'string',
reportSelfMessage: true,
};
fs.writeFileSync(path.join(dir, `onebot_${uin}.json`), JSON.stringify(legacy), 'utf8');
const config = loadOneBotConfig(uin, { persistDefaults: true });
expect(config.networks.httpServers).toHaveLength(1);
expect(config.networks.httpServers[0].port).toBe(3100);
expect(config.networks.httpServers[0].accessToken).toBe('tok');
expect(config.networks.httpServers[0].messageFormat).toBe('string');
expect(config.networks.httpServers[0].reportSelfMessage).toBe(true);
expect(config.networks.httpClients).toHaveLength(1);
expect(config.networks.httpClients[0].name).toBe('main-bot');
expect(config.networks.httpClients[0].url).toBe('http://127.0.0.1:5700');
expect(config.networks.httpClients[0].messageFormat).toBe('string');
expect(config.networks.httpClients[0].reportSelfMessage).toBe(true);
expect(config.networks.wsServers).toHaveLength(1);
expect(config.networks.wsServers[0].port).toBe(3101);
expect(config.networks.wsServers[0].messageFormat).toBe('string');
expect(config.networks.wsServers[0].reportSelfMessage).toBe(true);
expect(config.networks.wsClients).toHaveLength(1);
expect(config.networks.wsClients[0].url).toBe('ws://127.0.0.1:8080');
expect(config.networks.wsClients[0].messageFormat).toBe('string');
expect(config.networks.wsClients[0].reportSelfMessage).toBe(true);
// File should now be in unified format on disk.
const onDisk = JSON.parse(fs.readFileSync(path.join(dir, `onebot_${uin}.json`), 'utf8'));
expect(onDisk.networks).toBeDefined();
expect(onDisk.httpServers).toBeUndefined();
expect(onDisk.httpPostEndpoints).toBeUndefined();
expect(onDisk.messageFormat).toBeUndefined();
expect(onDisk.reportSelfMessage).toBeUndefined();
expect(onDisk.networks.httpServers[0].messageFormat).toBe('string');
expect(onDisk.networks.httpServers[0].reportSelfMessage).toBe(true);
});
it('round-trips per-adapter overrides through save/load', () => {
const uin = '10003';
const config = makeDefaultOneBotConfig();
config.networks.httpClients.push({
name: 'self-mirror',
url: 'http://127.0.0.1:9000',
messageFormat: 'string',
reportSelfMessage: true,
});
saveOneBotConfig(uin, config);
const reloaded = loadOneBotConfig(uin);
expect(reloaded.networks.httpClients).toHaveLength(1);
expect(reloaded.networks.httpClients[0].name).toBe('self-mirror');
expect(reloaded.networks.httpClients[0].messageFormat).toBe('string');
expect(reloaded.networks.httpClients[0].reportSelfMessage).toBe(true);
});
it('round-trips maxPayloadBytes (both directions) and omits it when unset', () => {
const uin = '10020';
const config = makeDefaultOneBotConfig();
config.networks.wsServers[0].maxPayloadBytes = 512 * 1024 * 1024;
config.networks.wsClients.push({
name: 'astrbot',
url: 'ws://127.0.0.1:6199/ws',
messageFormat: 'array',
reportSelfMessage: false,
maxPayloadBytes: 256 * 1024 * 1024,
});
saveOneBotConfig(uin, config);
const onDisk = JSON.parse(
fs.readFileSync(path.join(tempDir, 'config', `onebot_${uin}.json`), 'utf8'),
);
expect(onDisk.networks.wsServers[0].maxPayloadBytes).toBe(512 * 1024 * 1024);
expect(onDisk.networks.wsClients[0].maxPayloadBytes).toBe(256 * 1024 * 1024);
// Unset adapters must not gain the key, so existing configs stay byte-stable.
expect(onDisk.networks.httpServers[0].maxPayloadBytes).toBeUndefined();
const reloaded = loadOneBotConfig(uin);
expect(reloaded.networks.wsServers[0].maxPayloadBytes).toBe(512 * 1024 * 1024);
expect(reloaded.networks.wsClients[0]!.maxPayloadBytes).toBe(256 * 1024 * 1024);
const prepared = prepareOneBotConfigForRestore(onDisk, 'per-uin');
const preparedNetworks = prepared.value.networks as {
wsClients: Array<Record<string, unknown>>;
};
expect(preparedNetworks.wsClients[0]!.maxPayloadBytes).toBe(256 * 1024 * 1024);
const junk = structuredClone(onDisk) as {
networks: { wsClients: Array<Record<string, unknown>> };
};
for (const value of [0, '268435456', 1024.5, null]) {
junk.networks.wsClients[0]!.maxPayloadBytes = value;
expect(() => prepareOneBotConfigForRestore(junk, 'per-uin')).toThrow(/maxPayloadBytes/);
}
});
it.each(['httpServers', 'wsServers', 'wsClients'] as const)(
'rejects invalid on-disk maxPayloadBytes for %s without rewriting the file',
(kind) => {
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'onebot_10023.json');
for (const value of [-1, 0, 1023, 1024.5, 2 * 1024 ** 3 + 1, '2048', null, true]) {
const raw = JSON.stringify({
mode: 'snapshot',
networks: {
[kind]: [
{
name: 'invalid-limit',
port: 3001,
url: 'ws://127.0.0.1:3001',
enableWebSocket: true,
maxPayloadBytes: value,
},
],
},
});
fs.writeFileSync(file, raw, 'utf8');
expect(() => loadOneBotConfig('10023'), String(value)).toThrow(/maxPayloadBytes/);
expect(fs.readFileSync(file, 'utf8')).toBe(raw);
}
},
);
it('persists maxPayloadBytes for httpServers and rejects it for httpClients', () => {
const uin = '10021';
const config = makeDefaultOneBotConfig();
config.networks.httpServers[0].enableWebSocket = true;
config.networks.httpServers[0].maxPayloadBytes = 4096;
saveOneBotConfig(uin, config);
const onDisk = JSON.parse(
fs.readFileSync(path.join(tempDir, 'config', `onebot_${uin}.json`), 'utf8'),
);
expect(onDisk.networks.httpServers[0].maxPayloadBytes).toBe(4096);
expect(loadOneBotConfig(uin).networks.httpServers[0].maxPayloadBytes).toBe(4096);
expect(() => prepareOneBotConfigForRestore(onDisk, 'per-uin')).not.toThrow();
// A restore source that carries the key on a non-WS adapter is rejected
// rather than accepted into a config where it can never apply.
const withHttpClient = structuredClone(onDisk) as {
networks: { httpClients: Array<Record<string, unknown>> };
};
withHttpClient.networks.httpClients.push({
name: 'mirror',
url: 'http://127.0.0.1:9000',
maxPayloadBytes: 4096,
});
expect(() => prepareOneBotConfigForRestore(withHttpClient, 'per-uin')).toThrow(
/maxPayloadBytes is not supported/,
);
// A hand-written file must reject the same unsupported field instead
// of discarding it before the canonical validation runs.
const dir = path.join(tempDir, 'config');
fs.writeFileSync(
path.join(dir, 'onebot_10022.json'),
JSON.stringify({
mode: 'snapshot',
networks: {
httpServers: [],
httpClients: [
{
name: 'mirror',
url: 'http://127.0.0.1:9000',
messageFormat: 'array',
reportSelfMessage: false,
maxPayloadBytes: 4096,
},
],
wsServers: [],
wsClients: [],
},
}),
'utf8',
);
expect(() => loadOneBotConfig('10022')).toThrow(
/maxPayloadBytes is only supported by WebSocket adapters/,
);
});
it('replays a saved per-UIN snapshot without reviving deleted global adapters', () => {
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
const global = makeDefaultOneBotConfig();
global.networks.httpServers[0].name = 'inherited';
fs.writeFileSync(path.join(dir, 'onebot.json'), JSON.stringify(global), 'utf8');
const config = loadOneBotConfig('10004');
expect(config.networks.httpServers.map((item) => item.name)).toContain('inherited');
config.networks.httpServers = [];
config.networks.wsServers.push({
name: 'inherited',
host: '127.0.0.1',
port: 3998,
path: '/',
role: 'Universal',
messageFormat: 'array',
reportSelfMessage: false,
});
saveOneBotConfig('10004', config);
const reloaded = loadOneBotConfig('10004');
expect(reloaded.networks.httpServers).toEqual([]);
expect(reloaded.networks.wsServers.map((item) => item.name)).toContain('inherited');
const disk = JSON.parse(fs.readFileSync(path.join(dir, 'onebot_10004.json'), 'utf8'));
expect(disk.mode).toBe('snapshot');
});
it('fails fast instead of replacing a corrupt per-UIN desired config with defaults', () => {
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'onebot_10005.json'), '{broken', 'utf8');
expect(() => loadOneBotConfig('10005', { persistDefaults: true })).toThrow(/is corrupt/);
expect(fs.readFileSync(path.join(dir, 'onebot_10005.json'), 'utf8')).toBe('{broken');
});
it('treats a valid JSON non-object root as corrupt desired state', () => {
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'onebot_10006.json'), '[]', 'utf8');
expect(() => loadOneBotConfig('10006', { persistDefaults: true })).toThrow(
/root must be an object/,
);
expect(fs.readFileSync(path.join(dir, 'onebot_10006.json'), 'utf8')).toBe('[]');
});
it('fills new statusCommand fields with defaults when absent', () => {
const uin = '10042';
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, `onebot_${uin}.json`),
JSON.stringify({
networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] },
statusCommand: { enabled: false },
}),
);
const config = loadOneBotConfig(uin);
expect(config.statusCommand.enabled).toBe(false); // from file
expect(config.statusCommand.trigger).toBe('#sl'); // default
});
it('clamps trigger length and rejects empty trigger', () => {
const uin = '10043';
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, `onebot_${uin}.json`),
JSON.stringify({
networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] },
statusCommand: { trigger: '' },
}),
);
const config = loadOneBotConfig(uin);
expect(config.statusCommand.trigger).toBe('#sl'); // empty → default
});
it('truncates trigger to max 32 chars', () => {
const uin = '10049';
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, `onebot_${uin}.json`),
JSON.stringify({
networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] },
statusCommand: { trigger: 'a'.repeat(100) },
}),
);
const config = loadOneBotConfig(uin);
expect(config.statusCommand.trigger.length).toBe(32);
});
it('rejects trigger containing newline, falls back to default', () => {
const uin = '10050';
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, `onebot_${uin}.json`),
JSON.stringify({
networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] },
statusCommand: { trigger: '#sl\nbot' },
}),
);
const config = loadOneBotConfig(uin);
expect(config.statusCommand.trigger).toBe('#sl');
});
it('aligns MAX_LENGTH constant with expected value', () => {
expect(STATUS_COMMAND_TRIGGER_MAX_LENGTH).toBe(32);
});
it('does not write to disk by default (read-only contract)', () => {
const uin = '10006';
const cfgPath = path.join(tempDir, 'config', `onebot_${uin}.json`);
// Default call must not materialize the file or mint a fresh access token.
const config = loadOneBotConfig(uin);
expect(config.networks.httpServers).toHaveLength(1);
expect(fs.existsSync(cfgPath)).toBe(false);
// Explicit opt-in still writes.
loadOneBotConfig(uin, { persistDefaults: true });
expect(fs.existsSync(cfgPath)).toBe(true);
});
it('respects an operator-emptied adapter list instead of seeding defaults back', () => {
const uin = '10007';
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, `onebot_${uin}.json`),
JSON.stringify({
networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] },
musicSignUrl: '',
}),
'utf8',
);
const config = loadOneBotConfig(uin, { persistDefaults: true });
expect(config.networks.httpServers).toEqual([]);
expect(config.networks.wsServers).toEqual([]);
});
it('migrates legacy lowercase WsRole values into canonical uppercase form', () => {
const uin = '10005';
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, `onebot_${uin}.json`),
JSON.stringify({
networks: {
httpServers: [],
httpClients: [],
wsServers: [
{
name: 'ws-legacy',
host: '0.0.0.0',
port: 3201,
path: '/',
role: 'universal',
messageFormat: 'array',
reportSelfMessage: false,
},
],
wsClients: [
{
name: 'wsc-legacy',
url: 'ws://127.0.0.1:8080',
role: 'event',
messageFormat: 'array',
reportSelfMessage: false,
},
],
},
}),
'utf8',
);
const config = loadOneBotConfig(uin);
expect(config.networks.wsServers).toHaveLength(1);
expect(config.networks.wsServers[0].role).toBe('Universal');
expect(config.networks.wsClients).toHaveLength(1);
expect(config.networks.wsClients[0].role).toBe('Event');
});
it('auto-generates names for legacy entries that lack one', () => {
const uin = '10004';
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, `onebot_${uin}.json`),
JSON.stringify({
httpServers: [{ port: 3000 }, { port: 3001 }],
}),
'utf8',
);
const config = loadOneBotConfig(uin);
const names = config.networks.httpServers.map((n) => n.name);
expect(names).toHaveLength(2);
expect(new Set(names).size).toBe(2); // unique
expect(names.every((n) => n.length > 0)).toBe(true);
expect(config.networks.httpServers.every((n) => n.messageFormat === 'array')).toBe(true);
expect(config.networks.httpServers.every((n) => n.reportSelfMessage === false)).toBe(true);
});
});
describe('OneBotConfig.notifications (per-UIN channel opt-in)', () => {
let tempDir: string;
let prevCwd: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-notif-'));
prevCwd = process.cwd();
process.chdir(tempDir);
});
afterEach(() => {
process.chdir(prevCwd);
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('defaults to an empty channelIds list when absent', () => {
const config = loadOneBotConfig('20001');
expect(config.notifications).toEqual({ channelIds: [] });
});
it('validates, dedupes and drops bad channel ids', () => {
const uin = '20002';
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, `onebot_${uin}.json`),
JSON.stringify({
networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] },
notifications: {
channelIds: ['dingtalk', 'dingtalk', 'bad id!', '', 42, 'feishu'],
},
}),
);
const config = loadOneBotConfig(uin);
expect(config.notifications?.channelIds).toEqual(['dingtalk', 'feishu']);
});
it('round-trips channelIds through save/load and persists them', () => {
const uin = '20003';
const config = makeDefaultOneBotConfig();
config.notifications = { channelIds: ['discord', 'serverchan'] };
saveOneBotConfig(uin, config);
const reloaded = loadOneBotConfig(uin);
expect(reloaded.notifications?.channelIds).toEqual(['discord', 'serverchan']);
const onDisk = JSON.parse(
fs.readFileSync(path.join(tempDir, 'config', `onebot_${uin}.json`), 'utf8'),
);
expect(onDisk.notifications).toEqual({ channelIds: ['discord', 'serverchan'] });
});
});
describe('OneBotConfig login history sync', () => {
let tempDir: string;
let prevCwd: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-history-sync-'));
prevCwd = process.cwd();
process.chdir(tempDir);
});
afterEach(() => {
process.chdir(prevCwd);
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('defaults old configurations to disabled', () => {
const config = loadOneBotConfig('30001');
expect(config.historySync).toEqual({ enabled: false });
});
it('round-trips the per-account opt-in', () => {
const config = makeDefaultOneBotConfig();
config.historySync.enabled = true;
saveOneBotConfig('30002', config);
expect(loadOneBotConfig('30002').historySync).toEqual({ enabled: true });
const onDisk = JSON.parse(
fs.readFileSync(path.join(tempDir, 'config', 'onebot_30002.json'), 'utf8'),
);
expect(onDisk.historySync).toEqual({ enabled: true });
});
it('rejects malformed history sync settings during save and restore', () => {
const malformed = makeDefaultOneBotConfig() as unknown as Record<string, unknown>;
malformed.historySync = { enabled: 'yes' };
expect(() => assertValidOneBotConfig(malformed)).toThrow(/historySync\.enabled/);
const source = {
networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] },
historySync: { enabled: true, interval: 1 },
};
expect(() => prepareOneBotConfigForRestore(source, 'per-uin')).toThrow(
/\$\.historySync\.interval is not supported/,
);
});
it('rejects malformed on-disk settings instead of inheriting an enabled value', () => {
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'onebot.json'),
JSON.stringify({
historySync: { enabled: true },
}),
'utf8',
);
fs.writeFileSync(
path.join(dir, 'onebot_30003.json'),
JSON.stringify({
networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] },
historySync: { enabled: 'yes' },
}),
'utf8',
);
expect(() => loadOneBotConfig('30003')).toThrow(/historySync\.enabled must be a boolean/);
});
});
describe('OneBot listen port environment overrides', () => {
let tempDir = '';
let previous = '';
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-port-env-'));
previous = process.cwd();
process.chdir(tempDir);
for (const name of ['SNOWLUMA_ONEBOT_HTTP_PORT', 'SNOWLUMA_ONEBOT_WS_PORT']) {
saved[name] = process.env[name];
delete process.env[name];
}
});
afterEach(() => {
for (const name of ['SNOWLUMA_ONEBOT_HTTP_PORT', 'SNOWLUMA_ONEBOT_WS_PORT']) {
if (saved[name] === undefined) {
delete process.env[name];
} else {
process.env[name] = saved[name];
}
}
process.chdir(previous);
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('overrides only the factory listeners and does not write the port back', () => {
process.env.SNOWLUMA_ONEBOT_HTTP_PORT = '3800';
process.env.SNOWLUMA_ONEBOT_WS_PORT = '3801';
const loaded = loadOneBotConfig('10001', { persistDefaults: true });
expect(loaded.networks.httpServers[0]?.port).toBe(3800);
expect(loaded.networks.wsServers[0]?.port).toBe(3801);
const onDisk = JSON.parse(
fs.readFileSync(path.join(tempDir, 'config', 'onebot_10001.json'), 'utf8'),
) as {
networks: { httpServers: Array<{ port: number }>; wsServers: Array<{ port: number }> };
};
expect(onDisk.networks.httpServers[0]?.port).toBe(3000);
expect(onDisk.networks.wsServers[0]?.port).toBe(3001);
});
it('leaves a custom listener alone', () => {
const config = makeDefaultOneBotConfig();
config.networks.httpServers = [
{
...config.networks.httpServers[0]!,
name: 'public-http',
port: 4000,
},
];
saveOneBotConfig('10001', config);
process.env.SNOWLUMA_ONEBOT_HTTP_PORT = '3800';
const loaded = loadOneBotConfig('10001');
expect(loaded.networks.httpServers.map((server) => server.port)).toEqual([4000]);
});
it('keeps the saved port when the override is not a port', () => {
process.env.SNOWLUMA_ONEBOT_HTTP_PORT = 'nope';
const loaded = loadOneBotConfig('10001', { persistDefaults: true });
expect(loaded.networks.httpServers[0]?.port).toBe(3000);
});
});