From f95d247e732833ee23e54bad04251ea101997aef Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 6 Oct 2026 00:48:34 +0800 Subject: [PATCH] feat(runtime): override deploy settings from the environment SNOWLUMA_ variables can set the factory OneBot listen ports, WebUI TLS, packet and hook timeouts, the stream directory, file download timeout, and notification HTTP timeout. The values apply in memory and are not written back to saved config. --- packages/bridge/package.json | 4 ++ packages/bridge/src/hook-packet-client.ts | 3 +- packages/bridge/src/packet-timeout.ts | 8 +++ packages/bridge/src/qq-hook-client.ts | 3 +- packages/bridge/tests/packet-timeout.test.ts | 30 +++++++++ packages/common/package.json | 4 ++ packages/common/src/env.ts | 28 ++++++++ packages/common/src/runtime.ts | 3 + packages/common/tests/env.test.ts | 26 ++++++++ packages/common/tests/runtime.test.ts | 11 ++++ packages/core/src/bridge/apis/group-album.ts | 13 ++-- packages/core/src/bridge/bridge.ts | 3 +- packages/core/src/notifications/manager.ts | 5 +- .../core/tests/notifications-manager.test.ts | 26 ++++++++ packages/onebot/src/actions/extended.ts | 9 ++- packages/onebot/src/config.ts | 65 ++++++++++++++++++- packages/onebot/src/stream-storage.ts | 21 +++++- packages/onebot/tests/config.test.ts | 58 +++++++++++++++++ .../onebot/tests/stream-storage-env.test.ts | 36 ++++++++++ 19 files changed, 342 insertions(+), 14 deletions(-) create mode 100644 packages/bridge/src/packet-timeout.ts create mode 100644 packages/bridge/tests/packet-timeout.test.ts create mode 100644 packages/common/src/env.ts create mode 100644 packages/common/tests/env.test.ts create mode 100644 packages/onebot/tests/stream-storage-env.test.ts 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