diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 365dfb56..316c4a4b 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -24,6 +24,10 @@ "types": "./src/hook-packet-client.ts", "default": "./src/hook-packet-client.ts" }, + "./packet-timeout": { + "types": "./src/packet-timeout.ts", + "default": "./src/packet-timeout.ts" + }, "./injector": { "types": "./src/injector.ts", "default": "./src/injector.ts" diff --git a/packages/bridge/src/hook-packet-client.ts b/packages/bridge/src/hook-packet-client.ts index 3898bc4e..7078b5a0 100644 --- a/packages/bridge/src/hook-packet-client.ts +++ b/packages/bridge/src/hook-packet-client.ts @@ -1,4 +1,5 @@ import type { PacketSender, SendPacketResult } from '@snowluma/common/packet-sender'; +import { packetTimeoutMs } from './packet-timeout'; import { HookPipeRequestError, PIPE_STATUS_CONNECTION_UNAVAILABLE, @@ -16,7 +17,7 @@ export class HookPacketClient implements PacketSender { private readonly onOutboundHealthChanged?: OutboundHealthListener, ) { } - async sendPacket(serviceCmd: string, body: Buffer, timeoutMs = 15000): Promise { + async sendPacket(serviceCmd: string, body: Buffer, timeoutMs = packetTimeoutMs()): Promise { if (!this.client.isLoggedIn) { return { success: false, gotResponse: false, errorCode: -1, errorMessage: 'qq_hook client is not logged in', responseData: null }; } diff --git a/packages/bridge/src/packet-timeout.ts b/packages/bridge/src/packet-timeout.ts new file mode 100644 index 00000000..264682ba --- /dev/null +++ b/packages/bridge/src/packet-timeout.ts @@ -0,0 +1,8 @@ +import { positiveIntEnv } from '@snowluma/common/env'; + +/** Default wait for a QQ reply when the caller does not pass one. */ +export const DEFAULT_PACKET_TIMEOUT_MS = 15_000; + +export function packetTimeoutMs(env?: NodeJS.ProcessEnv): number { + return positiveIntEnv('SNOWLUMA_PACKET_TIMEOUT_MS', DEFAULT_PACKET_TIMEOUT_MS, { env }); +} diff --git a/packages/bridge/src/qq-hook-client.ts b/packages/bridge/src/qq-hook-client.ts index 1573c23c..6271cc2f 100644 --- a/packages/bridge/src/qq-hook-client.ts +++ b/packages/bridge/src/qq-hook-client.ts @@ -10,6 +10,7 @@ import { isRealUin } from '@snowluma/common/uin'; import { readdirSync, promises as fs } from 'fs'; import net from 'net'; import path from 'path'; +import { positiveIntEnv } from '@snowluma/common/env'; import { resolveHookRuntimeDir } from './hook-runtime-dir'; export const PIPE_MAGIC = 0x31504851; @@ -471,7 +472,7 @@ export class QqHookClient extends EventEmitter { private loginWaiters: Deferred[] = []; constructor(pid: number, { - ackTimeoutMs = DEFAULT_ACK_TIMEOUT_MS, + ackTimeoutMs = positiveIntEnv('SNOWLUMA_HOOK_ACK_TIMEOUT_MS', DEFAULT_ACK_TIMEOUT_MS), replyTimeoutMs = DEFAULT_REPLY_TIMEOUT_MS, runtimeDir = resolveHookRuntimeDir(pid), }: QqHookClientOptions = {}) { diff --git a/packages/bridge/tests/packet-timeout.test.ts b/packages/bridge/tests/packet-timeout.test.ts new file mode 100644 index 00000000..587dc4b0 --- /dev/null +++ b/packages/bridge/tests/packet-timeout.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { QqHookClient } from '../src/qq-hook-client'; +import { packetTimeoutMs } from '../src/packet-timeout'; + +const PACKET = 'SNOWLUMA_PACKET_TIMEOUT_MS'; +const ACK = 'SNOWLUMA_HOOK_ACK_TIMEOUT_MS'; + +afterEach(() => { + delete process.env[PACKET]; + delete process.env[ACK]; +}); + +describe('packet and hook ack timeouts', () => { + it('keeps the 15 second packet default', () => { + expect(packetTimeoutMs({})).toBe(15_000); + }); + + it('reads SNOWLUMA_PACKET_TIMEOUT_MS', () => { + expect(packetTimeoutMs({ [PACKET]: '8000' })).toBe(8000); + expect(packetTimeoutMs({ [PACKET]: '0' })).toBe(15_000); + }); + + it('uses SNOWLUMA_HOOK_ACK_TIMEOUT_MS when the client is not given one', () => { + process.env[ACK] = '1200'; + const client = new QqHookClient(1, { runtimeDir: '/tmp' }); + expect(client.defaultAckTimeoutMs).toBe(1200); + const explicit = new QqHookClient(1, { runtimeDir: '/tmp', ackTimeoutMs: 50 }); + expect(explicit.defaultAckTimeoutMs).toBe(50); + }); +}); \ No newline at end of file diff --git a/packages/common/package.json b/packages/common/package.json index b01a9ef0..2bb2d7fb 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -20,6 +20,10 @@ "types": "./src/coerce.ts", "default": "./src/coerce.ts" }, + "./env": { + "types": "./src/env.ts", + "default": "./src/env.ts" + }, "./single-flight-cache": { "types": "./src/single-flight-cache.ts", "default": "./src/single-flight-cache.ts" diff --git a/packages/common/src/env.ts b/packages/common/src/env.ts new file mode 100644 index 00000000..2b02cce2 --- /dev/null +++ b/packages/common/src/env.ts @@ -0,0 +1,28 @@ +import { createLogger } from './logger'; + +const log = createLogger('Env'); + +/** Node's setTimeout delay is a signed 32-bit integer. */ +const MAX_POSITIVE_INT = 2_147_483_647; + +/** Read a positive integer environment variable. + * + * A blank value is absent and returns `fallback`. A present but unusable + * value is ignored, with a warning, and also returns `fallback`. + */ +export function positiveIntEnv( + name: string, + fallback: number, + options?: { max?: number; env?: NodeJS.ProcessEnv }, +): number { + const max = options?.max ?? MAX_POSITIVE_INT; + const raw = (options?.env ?? process.env)[name]; + if (typeof raw !== 'string' || !raw.trim()) return fallback; + const value = raw.trim(); + const parsed = /^[1-9]\d*$/.test(value) ? Number(value) : Number.NaN; + if (!Number.isSafeInteger(parsed) || parsed > max) { + log.warn('%s=%j is ignored; expected a positive integer up to %d', name, raw, max); + return fallback; + } + return parsed; +} diff --git a/packages/common/src/runtime.ts b/packages/common/src/runtime.ts index cad1f984..e0f6392b 100644 --- a/packages/common/src/runtime.ts +++ b/packages/common/src/runtime.ts @@ -108,6 +108,9 @@ export function resolveRuntimeEnvOverrides(env: NodeJS.ProcessEnv): Partial { + it('returns the fallback when the variable is absent or blank', () => { + expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: {} })).toBe(15); + expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: { SNOWLUMA_TEST_INT: ' ' } })).toBe(15); + }); + + it('reads a positive integer and honors the max', () => { + expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: { SNOWLUMA_TEST_INT: '2500' } })).toBe(2500); + expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { + max: 65535, + env: { SNOWLUMA_TEST_INT: '80' }, + })).toBe(80); + }); + + it('ignores zero, negatives, fractions, and values above the max', () => { + for (const raw of ['0', '-5', '1.5', 'nope', '70000']) { + expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { + max: 65535, + env: { SNOWLUMA_TEST_INT: raw }, + })).toBe(15); + } + }); +}); diff --git a/packages/common/tests/runtime.test.ts b/packages/common/tests/runtime.test.ts index 46439d5f..878fc3a8 100644 --- a/packages/common/tests/runtime.test.ts +++ b/packages/common/tests/runtime.test.ts @@ -124,6 +124,17 @@ describe('resolveRuntimeEnvOverrides', () => { })).toThrow(/SNOWLUMA_LOG_RETAIN_DAYS/); }); + it('parses SNOWLUMA_WEBUI_TLS over the saved switch', () => { + expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: '1' })).toEqual({ + webuiTls: { enabled: true }, + }); + expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: '0' })).toEqual({ + webuiTls: { enabled: false }, + }); + expect(() => resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: 'maybe' })) + .toThrow(/SNOWLUMA_WEBUI_TLS/); + }); + it('ignores an out-of-range / non-numeric port env', () => { expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_PORT: '0' })).toEqual({}); expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_PORT: 'abc' })).toEqual({}); diff --git a/packages/core/src/bridge/apis/group-album.ts b/packages/core/src/bridge/apis/group-album.ts index e4e84429..47fcde46 100644 --- a/packages/core/src/bridge/apis/group-album.ts +++ b/packages/core/src/bridge/apis/group-album.ts @@ -1,4 +1,5 @@ import type { JsonObject, JsonValue } from '@snowluma/common/json'; +import { packetTimeoutMs } from '@snowluma/bridge/packet-timeout'; import { createLogger } from '@snowluma/common/logger'; import type { AlbumCreator, @@ -179,7 +180,7 @@ export class GroupAlbumApi { body.length, ); - const result = await this.ctx.sendRawPacket(GET_ALBUM_LIST_CMD, body, 15000); + const result = await this.ctx.sendRawPacket(GET_ALBUM_LIST_CMD, body, packetTimeoutMs()); if (!result.success || !result.gotResponse || !result.responseData) { throw new Error( `get group album list transport failed: ${result.errorMessage || `code ${result.errorCode}`}`, @@ -297,7 +298,7 @@ export class GroupAlbumApi { const result = await this.ctx.sendRawPacket( 'QunAlbum.trpc.qzone.webapp_qun_media.QunMedia.GetMediaList', body, - 15000, + packetTimeoutMs(), ); if (!result.success || !result.gotResponse || !result.responseData) { @@ -359,7 +360,7 @@ export class GroupAlbumApi { extMap: [{ key: 'fc-appid', value: '100' }], }); - const result = await this.ctx.sendRawPacket(DO_QUN_COMMENT_CMD, body, 15000); + const result = await this.ctx.sendRawPacket(DO_QUN_COMMENT_CMD, body, packetTimeoutMs()); if (!result.success || !result.gotResponse || !result.responseData) { throw new Error(result.errorMessage || 'failed to comment on album media'); @@ -431,7 +432,7 @@ export class GroupAlbumApi { const result = await this.ctx.sendRawPacket( 'QunAlbum.trpc.qzone.webapp_qun_operation.FeedsWriter.DoQunLike', body, - 15000, + packetTimeoutMs(), ); if (!result.success || !result.gotResponse || !result.responseData) { @@ -470,7 +471,7 @@ export class GroupAlbumApi { const result = await this.ctx.sendRawPacket( 'QunAlbum.trpc.qzone.webapp_qun_media.QunMedia.DeleteMedias', body, - 15000, + packetTimeoutMs(), ); if (!result.success || !result.gotResponse || !result.responseData) { @@ -571,7 +572,7 @@ export class GroupAlbumApi { extMap: [{ key: 'fc-appid', value: '100' }], }); - const result = await this.ctx.sendRawPacket(GET_QUN_FEED_DETAIL_CMD, body, 15000); + const result = await this.ctx.sendRawPacket(GET_QUN_FEED_DETAIL_CMD, body, packetTimeoutMs()); if (!result.success || !result.gotResponse || !result.responseData) { throw new Error(result.errorMessage || 'failed to fetch album feed'); } diff --git a/packages/core/src/bridge/bridge.ts b/packages/core/src/bridge/bridge.ts index 0cb8607f..185817db 100644 --- a/packages/core/src/bridge/bridge.ts +++ b/packages/core/src/bridge/bridge.ts @@ -4,6 +4,7 @@ import { runWithTraceRequest, } from '@snowluma/common/logger'; import type { PacketSender, SendPacketResult } from '@snowluma/common/packet-sender'; +import { packetTimeoutMs } from '@snowluma/bridge/packet-timeout'; import type { PacketInfo } from '@snowluma/common/protocol-types'; import { BridgeEventBus } from '@snowluma/protocol/event-bus'; import { IdentityService } from '@snowluma/protocol/identity-service'; @@ -296,7 +297,7 @@ export class Bridge implements BridgeInterface { this.msgRandom_ = (this.msgRandom_ + 0x9E3779B9) >>> 0; return this.msgRandom_ & 0x7FFFFFFF; } - async sendRawPacket(serviceCmd: string, body: Uint8Array, timeoutMs = 15000): Promise { + async sendRawPacket(serviceCmd: string, body: Uint8Array, timeoutMs = packetTimeoutMs()): Promise { if (!this.packetClient_) { return runWithTraceRequest(() => { const startedAt = Date.now(); diff --git a/packages/core/src/notifications/manager.ts b/packages/core/src/notifications/manager.ts index d07d6ba9..92eebde5 100644 --- a/packages/core/src/notifications/manager.ts +++ b/packages/core/src/notifications/manager.ts @@ -8,6 +8,7 @@ // Side-effecting collaborators (config load, per-UIN channel ids, webhook POST, // SMTP send, the clock) are injected so the class is fully unit-testable; the // real wiring lives in `createNotificationManager()` at the bottom. +import { positiveIntEnv } from '@snowluma/common/env'; import { createLogger } from '@snowluma/common/logger'; import { loadOneBotConfig } from '@snowluma/onebot/config'; import type { BridgeManager } from '../bridge/manager'; @@ -262,7 +263,9 @@ export class NotificationManager { /** Default outbound POST: a single attempt with a timeout, JSON content-type * (the common case for 钉钉/Discord/飞书). Never throws. */ -export function createDefaultPost(timeoutMs = DEFAULT_POST_TIMEOUT_MS): NotificationManagerDeps['post'] { +export function createDefaultPost( + timeoutMs = positiveIntEnv('SNOWLUMA_NOTIFICATION_HTTP_TIMEOUT_MS', DEFAULT_POST_TIMEOUT_MS), +): NotificationManagerDeps['post'] { return async (url, body, headers) => { try { const res = await fetch(url, { diff --git a/packages/core/tests/notifications-manager.test.ts b/packages/core/tests/notifications-manager.test.ts index 95eb7263..ac2c1d06 100644 --- a/packages/core/tests/notifications-manager.test.ts +++ b/packages/core/tests/notifications-manager.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NotificationManager, + createDefaultPost, selectChannels, type MailPayload, type NotificationManagerDeps, @@ -321,3 +322,28 @@ describe('NotificationManager — debounce integration (fake timers)', () => { expect(events).toEqual([]); }); }); + +describe('createDefaultPost timeout', () => { + const name = 'SNOWLUMA_NOTIFICATION_HTTP_TIMEOUT_MS'; + const saved = process.env[name]; + + afterEach(() => { + if (saved === undefined) delete process.env[name]; + else process.env[name] = saved; + vi.unstubAllGlobals(); + }); + + it('uses SNOWLUMA_NOTIFICATION_HTTP_TIMEOUT_MS', async () => { + process.env[name] = '1234'; + const seen: number[] = []; + const timeout = AbortSignal.timeout.bind(AbortSignal); + vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms: number) => { + seen.push(ms); + return timeout(50); + }); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200 }))); + const post = createDefaultPost(); + await post('http://127.0.0.1/hook', '{}'); + expect(seen).toEqual([1234]); + }); +}); diff --git a/packages/onebot/src/actions/extended.ts b/packages/onebot/src/actions/extended.ts index 2fdc48cb..a59cdd28 100644 --- a/packages/onebot/src/actions/extended.ts +++ b/packages/onebot/src/actions/extended.ts @@ -1,3 +1,4 @@ +import { positiveIntEnv } from '@snowluma/common/env'; import { readFile } from 'node:fs/promises'; import { FriendDressError } from '@snowluma/protocol/web/friend-dress'; import { formatGroupRequestFlag } from '@snowluma/protocol/qq-info'; @@ -21,7 +22,11 @@ import { groupInfoReturnsSchema } from './group-info'; import { GROUP_MESSAGE_EVENT, hashMessageIdInt32 } from '../message-id'; const DOWNLOAD_FILE_MAX_BYTES = 1024 * 1024 * 1024; // 1 GiB -const DOWNLOAD_FILE_TIMEOUT_MS = 60_000; +const DEFAULT_DOWNLOAD_FILE_TIMEOUT_MS = 60_000; + +function downloadFileTimeoutMs(): number { + return positiveIntEnv('SNOWLUMA_DOWNLOAD_FILE_TIMEOUT_MS', DEFAULT_DOWNLOAD_FILE_TIMEOUT_MS); +} const profileLikeUserSchema = { type: 'object', @@ -2592,7 +2597,7 @@ export const actions = [ buf = Buffer.from(base64, 'base64'); if (buf.length > DOWNLOAD_FILE_MAX_BYTES) return failedResponse(RETCODE.BAD_REQUEST, `base64 payload too large: ${buf.length} > ${DOWNLOAD_FILE_MAX_BYTES} bytes`); } else { - buf = await fetchDownloadFile(url, parseDownloadHeaders(p.headers), DOWNLOAD_FILE_MAX_BYTES, DOWNLOAD_FILE_TIMEOUT_MS); + buf = await fetchDownloadFile(url, parseDownloadHeaders(p.headers), DOWNLOAD_FILE_MAX_BYTES, downloadFileTimeoutMs()); } try { const safe = await saveDownloadBuffer(buf, name); diff --git a/packages/onebot/src/config.ts b/packages/onebot/src/config.ts index 4598dcd4..592a9cb3 100644 --- a/packages/onebot/src/config.ts +++ b/packages/onebot/src/config.ts @@ -1,3 +1,4 @@ +import { positiveIntEnv } from '@snowluma/common/env'; import { createLogger } from '@snowluma/common/logger'; import { isRealUin } from '@snowluma/common/uin'; import { randomBytes } from 'crypto'; @@ -110,7 +111,69 @@ export function loadOneBotConfig(uin: string, options: LoadOneBotConfigOptions = saveOneBotConfig(uin, config, { mode: globalRaw ? 'overlay' : 'snapshot' }); } - return config; + // Listen-port overrides stay in memory. Persisting them would bake a + // machine-specific port into onebot_.json. + return applyOneBotListenPortOverrides(config); +} + +const HTTP_DEFAULT_SERVER = 'http-default'; +const WS_DEFAULT_SERVER = 'ws-default'; + +/** Override only the factory listeners. Other adapters keep their saved ports. */ +export function applyOneBotListenPortOverrides( + config: OneBotConfig, + env: NodeJS.ProcessEnv = process.env, +): OneBotConfig { + const httpPort = readListenPort('SNOWLUMA_ONEBOT_HTTP_PORT', env); + const wsPort = readListenPort('SNOWLUMA_ONEBOT_WS_PORT', env); + if (httpPort === undefined && wsPort === undefined) return config; + + const next = structuredClone(config); + const httpApplied = httpPort !== undefined && assignFactoryPort( + next.networks.httpServers, + HTTP_DEFAULT_SERVER, + httpPort, + 'SNOWLUMA_ONEBOT_HTTP_PORT', + ); + const wsApplied = wsPort !== undefined && assignFactoryPort( + next.networks.wsServers, + WS_DEFAULT_SERVER, + wsPort, + 'SNOWLUMA_ONEBOT_WS_PORT', + ); + if (!httpApplied && !wsApplied) return config; + try { + assertValidOneBotConfig(next); + } catch (error) { + log.warn( + 'OneBot listen port override ignored: %s', + error instanceof Error ? error.message : String(error), + ); + return config; + } + return next; +} + +function readListenPort(name: string, env: NodeJS.ProcessEnv): number | undefined { + const raw = env[name]; + if (typeof raw !== 'string' || !raw.trim()) return undefined; + const port = positiveIntEnv(name, 0, { max: 65535, env }); + return port > 0 ? port : undefined; +} + +function assignFactoryPort( + servers: Array<{ name: string; port: number }>, + name: string, + port: number, + envName: string, +): boolean { + const server = servers.find((entry) => entry.name === name); + if (!server) { + log.warn('%s is ignored; no %s listener', envName, name); + return false; + } + server.port = port; + return true; } export interface SaveOneBotConfigOptions { diff --git a/packages/onebot/src/stream-storage.ts b/packages/onebot/src/stream-storage.ts index 59196d23..557ede35 100644 --- a/packages/onebot/src/stream-storage.ts +++ b/packages/onebot/src/stream-storage.ts @@ -1,7 +1,10 @@ +import { createLogger } from '@snowluma/common/logger'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +const log = createLogger('OneBot.Stream'); + export interface StreamStorageSnapshot { totalBytes: number; fileCount: number; @@ -273,7 +276,23 @@ function resolveManagedRoot(input: string): string { return path.join(fs.realpathSync(parent), path.basename(absolute)); } -export const streamStorage = new StreamStorage(path.join(os.tmpdir(), 'onebot-stream')); +export function createStreamStorage(env: NodeJS.ProcessEnv = process.env): StreamStorage { + const fallback = path.join(os.tmpdir(), 'onebot-stream'); + const raw = env.SNOWLUMA_STREAM_DIR?.trim(); + if (!raw) return new StreamStorage(fallback); + try { + return new StreamStorage(raw); + } catch (error) { + log.warn( + 'SNOWLUMA_STREAM_DIR=%j is ignored: %s', + raw, + error instanceof Error ? error.message : String(error), + ); + return new StreamStorage(fallback); + } +} + +export const streamStorage = createStreamStorage(); export const STREAM_ROOT = streamStorage.root; export const STREAM_UPLOAD_DIR = streamStorage.uploadDir; export const STREAM_DOWNLOAD_DIR = streamStorage.downloadDir; diff --git a/packages/onebot/tests/config.test.ts b/packages/onebot/tests/config.test.ts index 26ca11dd..109b00a3 100644 --- a/packages/onebot/tests/config.test.ts +++ b/packages/onebot/tests/config.test.ts @@ -640,3 +640,61 @@ describe('OneBotConfig login history sync', () => { .toThrow(/historySync\.enabled must be a boolean/); }); }); + +describe('OneBot listen port environment overrides', () => { + let tempDir = ''; + let previous = ''; + const saved: Record = {}; + + 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); + }); +}); diff --git a/packages/onebot/tests/stream-storage-env.test.ts b/packages/onebot/tests/stream-storage-env.test.ts new file mode 100644 index 00000000..51ab959a --- /dev/null +++ b/packages/onebot/tests/stream-storage-env.test.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createStreamStorage } from '../src/stream-storage'; + +describe('SNOWLUMA_STREAM_DIR', () => { + const created: string[] = []; + + afterEach(() => { + for (const directory of created.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it('uses the system temp directory by default', () => { + const storage = createStreamStorage({}); + expect(storage.root.endsWith(`${path.sep}onebot-stream`)).toBe(true); + created.push(storage.root); + }); + + it('uses a caller directory when the variable is set', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-stream-env-')); + created.push(root); + const storage = createStreamStorage({ SNOWLUMA_STREAM_DIR: root }); + expect(storage.root).toBe(fs.realpathSync(root)); + }); + + it('falls back when the variable does not name a usable directory', () => { + const file = path.join(os.tmpdir(), `snowluma-stream-not-dir-${process.pid}`); + fs.writeFileSync(file, 'x'); + created.push(file); + const storage = createStreamStorage({ SNOWLUMA_STREAM_DIR: file }); + expect(storage.root.endsWith(`${path.sep}onebot-stream`)).toBe(true); + }); +}); \ No newline at end of file