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
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:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user