chore: prepare GoodBuddy 0.8.2
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ChannelDriver } from './channel-driver'
|
||||
import {
|
||||
ChannelManager,
|
||||
type ManagedChannelService
|
||||
} from './channel-manager'
|
||||
import {
|
||||
ChannelSettingsStore,
|
||||
type ChannelCredentialCipher,
|
||||
type ResolvedChannelSettings
|
||||
} from './channel-settings-store'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
function cipher(): ChannelCredentialCipher {
|
||||
return {
|
||||
isAvailable: () => true,
|
||||
encrypt: (value) => Buffer.from(value),
|
||||
decrypt: (value) => value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
async function store(): Promise<ChannelSettingsStore> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-manager-'))
|
||||
roots.push(root)
|
||||
return new ChannelSettingsStore(
|
||||
join(root, 'channel-settings.json'),
|
||||
cipher(),
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
function inertDriver(channel: string): ChannelDriver {
|
||||
return {
|
||||
channel,
|
||||
start: async () => undefined,
|
||||
send: async () => undefined,
|
||||
stop: async () => undefined
|
||||
}
|
||||
}
|
||||
|
||||
const executor = async () => ({
|
||||
status: 'completed',
|
||||
output: 'ok'
|
||||
})
|
||||
|
||||
type ServiceRecord = {
|
||||
settings: ResolvedChannelSettings
|
||||
start: ReturnType<typeof vi.fn<() => Promise<void>>>
|
||||
stop: ReturnType<typeof vi.fn<() => Promise<void>>>
|
||||
}
|
||||
|
||||
function managerHarness(
|
||||
settingsStore: ChannelSettingsStore,
|
||||
failSecret?: string
|
||||
): {
|
||||
manager: ChannelManager
|
||||
services: ServiceRecord[]
|
||||
} {
|
||||
const drivers = new WeakMap<ChannelDriver, ResolvedChannelSettings>()
|
||||
const services: ServiceRecord[] = []
|
||||
const manager = new ChannelManager(settingsStore, executor, {
|
||||
createDriver: (settings) => {
|
||||
const driver = inertDriver(settings.channel)
|
||||
drivers.set(driver, settings)
|
||||
return driver
|
||||
},
|
||||
createService: (driver): ManagedChannelService => {
|
||||
const settings = drivers.get(driver)
|
||||
if (settings === undefined) {
|
||||
throw new Error('missing test settings')
|
||||
}
|
||||
const record: ServiceRecord = {
|
||||
settings,
|
||||
start: vi.fn(async () => {
|
||||
if (settings.secret === failSecret) {
|
||||
throw new Error(
|
||||
`Authorization secret=${settings.secret} connection failed`
|
||||
)
|
||||
}
|
||||
}),
|
||||
stop: vi.fn(async () => undefined)
|
||||
}
|
||||
services.push(record)
|
||||
return record
|
||||
}
|
||||
})
|
||||
return { manager, services }
|
||||
}
|
||||
|
||||
describe('ChannelManager', () => {
|
||||
it('applies settings and dynamically starts, replaces, and disables services', async () => {
|
||||
const settingsStore = await store()
|
||||
const { manager, services } = managerHarness(settingsStore)
|
||||
|
||||
let snapshot = await manager.apply({
|
||||
wecom: {
|
||||
enabled: true,
|
||||
botId: 'bot-1',
|
||||
secret: { action: 'replace', value: 'secret-1' },
|
||||
allowedSenderIds: ['sender-1'],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
expect(snapshot.wecom.status).toEqual({ state: 'running' })
|
||||
expect(services[0]?.start).toHaveBeenCalledOnce()
|
||||
|
||||
snapshot = await manager.apply({
|
||||
wecom: {
|
||||
enabled: true,
|
||||
botId: 'bot-2',
|
||||
secret: { action: 'replace', value: 'secret-2' },
|
||||
allowedSenderIds: ['sender-2'],
|
||||
allowGroupMessages: true
|
||||
}
|
||||
})
|
||||
expect(snapshot.wecom.status.state).toBe('running')
|
||||
expect(services[0]?.stop).toHaveBeenCalledOnce()
|
||||
expect(services[1]?.settings).toMatchObject({
|
||||
botId: 'bot-2',
|
||||
secret: 'secret-2',
|
||||
allowGroupMessages: true
|
||||
})
|
||||
|
||||
snapshot = await manager.apply({
|
||||
wecom: {
|
||||
enabled: false,
|
||||
botId: 'bot-2',
|
||||
secret: { action: 'keep' },
|
||||
allowedSenderIds: ['sender-2'],
|
||||
allowGroupMessages: true
|
||||
}
|
||||
})
|
||||
expect(snapshot.wecom.status.state).toBe('disabled')
|
||||
expect(services[1]?.stop).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retires the old service when a persisted replacement fails', async () => {
|
||||
const settingsStore = await store()
|
||||
const leakedSecret = 'new-super-secret'
|
||||
const { manager, services } = managerHarness(
|
||||
settingsStore,
|
||||
leakedSecret
|
||||
)
|
||||
await manager.apply({
|
||||
dingtalk: {
|
||||
enabled: true,
|
||||
clientId: 'client-1',
|
||||
secret: { action: 'replace', value: 'old-secret' },
|
||||
allowedSenderIds: ['staff-1'],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
manager.apply({
|
||||
dingtalk: {
|
||||
enabled: true,
|
||||
clientId: 'client-2',
|
||||
secret: { action: 'replace', value: leakedSecret },
|
||||
allowedSenderIds: ['staff-2'],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
).rejects.not.toThrow(leakedSecret)
|
||||
expect(services[0]?.stop).toHaveBeenCalledOnce()
|
||||
expect(services[1]?.stop).toHaveBeenCalledOnce()
|
||||
const snapshot = await manager.snapshot()
|
||||
expect(snapshot.dingtalk.clientId).toBe('client-2')
|
||||
expect(snapshot.dingtalk.allowedSenderIds).toEqual(['staff-2'])
|
||||
expect(snapshot.dingtalk.status.state).toBe('error')
|
||||
expect(snapshot.dingtalk.status.lastError).not.toContain(leakedSecret)
|
||||
expect(snapshot.dingtalk.status.lastError).toContain('[已隐藏]')
|
||||
})
|
||||
|
||||
it('tests temporary settings without persisting or installing the service', async () => {
|
||||
const settingsStore = await store()
|
||||
const { manager, services } = managerHarness(settingsStore)
|
||||
const result = await manager.test('wecom', {
|
||||
enabled: true,
|
||||
botId: 'temporary-bot',
|
||||
secret: { action: 'replace', value: 'temporary-secret' },
|
||||
allowedSenderIds: ['sender'],
|
||||
allowGroupMessages: false
|
||||
})
|
||||
|
||||
expect(result).toEqual({ channel: 'wecom', ok: true })
|
||||
expect(services[0]?.start).toHaveBeenCalledOnce()
|
||||
expect(services[0]?.stop).toHaveBeenCalledOnce()
|
||||
expect((await settingsStore.snapshot()).wecom.botId).toBe('')
|
||||
expect((await manager.snapshot()).wecom.status.state).toBe('disabled')
|
||||
})
|
||||
|
||||
it('starts stored channels and stops all active services', async () => {
|
||||
const settingsStore = await store()
|
||||
await settingsStore.apply({
|
||||
wecom: {
|
||||
enabled: true,
|
||||
botId: 'bot',
|
||||
secret: { action: 'replace', value: 'secret' },
|
||||
allowedSenderIds: ['sender'],
|
||||
allowGroupMessages: false
|
||||
},
|
||||
dingtalk: {
|
||||
enabled: true,
|
||||
clientId: 'client',
|
||||
secret: { action: 'replace', value: 'client-secret' },
|
||||
allowedSenderIds: ['staff'],
|
||||
allowGroupMessages: true
|
||||
}
|
||||
})
|
||||
const { manager, services } = managerHarness(settingsStore)
|
||||
|
||||
const running = await manager.initialize()
|
||||
expect(running.wecom.status.state).toBe('running')
|
||||
expect(running.dingtalk.status.state).toBe('running')
|
||||
await manager.stopAll()
|
||||
expect(services).toHaveLength(2)
|
||||
expect(services.every((service) => service.stop.mock.calls.length === 1))
|
||||
.toBe(true)
|
||||
const stopped = await manager.snapshot()
|
||||
expect(stopped.wecom.status.state).toBe('stopped')
|
||||
expect(stopped.dingtalk.status.state).toBe('stopped')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,410 @@
|
||||
import {
|
||||
CHANNEL_SETTINGS_LIMITS,
|
||||
channelConnectionTestResultSchema,
|
||||
dingTalkChannelSettingsInputSchema,
|
||||
weComChannelSettingsInputSchema,
|
||||
type ChannelConnectionTestResult,
|
||||
type ChannelRuntimeStatus,
|
||||
type ChannelSettingsApply,
|
||||
type ChannelSettingsSnapshot,
|
||||
type DingTalkChannelSettingsInput,
|
||||
type ManagedChannel,
|
||||
type WeComChannelSettingsInput
|
||||
} from '../../shared/channel-settings-contracts'
|
||||
import type {
|
||||
ChannelDriver,
|
||||
ChannelExecutor
|
||||
} from './channel-driver'
|
||||
import { ChannelService } from './channel-service'
|
||||
import { redactChannelError } from './channel-service'
|
||||
import {
|
||||
ChannelSettingsStore,
|
||||
type ResolvedChannelSettings
|
||||
} from './channel-settings-store'
|
||||
import { DingTalkChannelDriver } from './dingtalk-channel-driver'
|
||||
import { WeComChannelDriver } from './wecom-channel-driver'
|
||||
|
||||
export type ManagedChannelService = Pick<
|
||||
ChannelService,
|
||||
'start' | 'stop'
|
||||
>
|
||||
|
||||
export type ChannelDriverFactory = (
|
||||
settings: ResolvedChannelSettings
|
||||
) => ChannelDriver | Promise<ChannelDriver>
|
||||
|
||||
export type ChannelServiceFactory = (
|
||||
driver: ChannelDriver,
|
||||
executor: ChannelExecutor,
|
||||
options: {
|
||||
allowedSenderIds: readonly string[]
|
||||
allowGroupMessages: boolean
|
||||
}
|
||||
) => ManagedChannelService | Promise<ManagedChannelService>
|
||||
|
||||
export type ChannelManagerOptions = {
|
||||
createDriver?: ChannelDriverFactory
|
||||
createService?: ChannelServiceFactory
|
||||
}
|
||||
|
||||
type TestSettingsInput =
|
||||
| {
|
||||
channel: 'wecom'
|
||||
settings?: WeComChannelSettingsInput
|
||||
}
|
||||
| {
|
||||
channel: 'dingtalk'
|
||||
settings?: DingTalkChannelSettingsInput
|
||||
}
|
||||
|
||||
function defaultDriverFactory(
|
||||
settings: ResolvedChannelSettings
|
||||
): ChannelDriver {
|
||||
if (settings.secret === undefined) {
|
||||
throw new Error('通道 Secret 尚未配置')
|
||||
}
|
||||
return settings.channel === 'wecom'
|
||||
? new WeComChannelDriver({
|
||||
botId: settings.botId,
|
||||
secret: settings.secret
|
||||
})
|
||||
: new DingTalkChannelDriver({
|
||||
clientId: settings.clientId,
|
||||
clientSecret: settings.secret,
|
||||
allowedSenderIds: settings.allowedSenderIds
|
||||
})
|
||||
}
|
||||
|
||||
function defaultServiceFactory(
|
||||
driver: ChannelDriver,
|
||||
executor: ChannelExecutor,
|
||||
options: {
|
||||
allowedSenderIds: readonly string[]
|
||||
allowGroupMessages: boolean
|
||||
}
|
||||
): ChannelService {
|
||||
return new ChannelService(driver, executor, options)
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return typeof error === 'string' ? error : '未知错误'
|
||||
}
|
||||
|
||||
function redactManagerError(
|
||||
error: unknown,
|
||||
secrets: readonly (string | undefined)[]
|
||||
): string {
|
||||
let message = errorText(error)
|
||||
for (const secret of secrets) {
|
||||
if (secret !== undefined && secret.length > 0) {
|
||||
message = message.split(secret).join('[凭据已隐藏]')
|
||||
}
|
||||
}
|
||||
const redacted = redactChannelError(message).trim()
|
||||
const bounded = redacted.slice(
|
||||
0,
|
||||
CHANNEL_SETTINGS_LIMITS.maximumStatusMessageLength
|
||||
)
|
||||
return bounded || '通道操作失败'
|
||||
}
|
||||
|
||||
function sanitizedManagerFailure(message: string): Error {
|
||||
return new Error(message)
|
||||
}
|
||||
|
||||
function validateResolved(settings: ResolvedChannelSettings): void {
|
||||
const identifier =
|
||||
settings.channel === 'wecom' ? settings.botId : settings.clientId
|
||||
if (
|
||||
identifier.length === 0 ||
|
||||
settings.secret === undefined ||
|
||||
settings.allowedSenderIds.length === 0
|
||||
) {
|
||||
throw new Error(
|
||||
settings.channel === 'wecom'
|
||||
? '企业微信需要机器人 ID、Secret 和允许的发送者'
|
||||
: '钉钉需要 Client ID、Secret 和允许的发送者'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class ChannelManager {
|
||||
private readonly services = new Map<
|
||||
ManagedChannel,
|
||||
ManagedChannelService
|
||||
>()
|
||||
private readonly statuses = new Map<
|
||||
ManagedChannel,
|
||||
ChannelRuntimeStatus
|
||||
>()
|
||||
private readonly createDriver: ChannelDriverFactory
|
||||
private readonly createService: ChannelServiceFactory
|
||||
private operationQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
private readonly store: ChannelSettingsStore,
|
||||
private readonly executor: ChannelExecutor,
|
||||
options: ChannelManagerOptions = {}
|
||||
) {
|
||||
this.createDriver = options.createDriver ?? defaultDriverFactory
|
||||
this.createService = options.createService ?? defaultServiceFactory
|
||||
}
|
||||
|
||||
snapshot(): Promise<ChannelSettingsSnapshot> {
|
||||
return this.store.snapshot(Object.fromEntries(this.statuses))
|
||||
}
|
||||
|
||||
getSnapshot(): Promise<ChannelSettingsSnapshot> {
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
initialize(): Promise<ChannelSettingsSnapshot> {
|
||||
return this.enqueue(async () => {
|
||||
const settings = await this.store.resolveAll()
|
||||
for (const channelSettings of settings) {
|
||||
if (!channelSettings.enabled) {
|
||||
this.statuses.set(channelSettings.channel, {
|
||||
state: 'disabled'
|
||||
})
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await this.replaceService(channelSettings)
|
||||
} catch {
|
||||
// Each channel is isolated; its sanitized error is kept in status.
|
||||
}
|
||||
}
|
||||
return this.snapshot()
|
||||
})
|
||||
}
|
||||
|
||||
apply(input: ChannelSettingsApply): Promise<ChannelSettingsSnapshot> {
|
||||
return this.enqueue(async () => {
|
||||
await this.store.apply(input)
|
||||
const channels: ManagedChannel[] = [
|
||||
...(input.wecom === undefined ? [] : (['wecom'] as const)),
|
||||
...(input.dingtalk === undefined ? [] : (['dingtalk'] as const))
|
||||
]
|
||||
for (const channel of channels) {
|
||||
const settings = await this.store.resolve(channel)
|
||||
if (!settings.enabled) {
|
||||
await this.disableService(channel)
|
||||
continue
|
||||
}
|
||||
await this.replaceService(settings)
|
||||
}
|
||||
return this.snapshot()
|
||||
})
|
||||
}
|
||||
|
||||
test(
|
||||
channel: 'wecom',
|
||||
settings?: WeComChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult>
|
||||
test(
|
||||
channel: 'dingtalk',
|
||||
settings?: DingTalkChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult>
|
||||
async test(
|
||||
channel: ManagedChannel,
|
||||
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult> {
|
||||
let resolved: ResolvedChannelSettings | undefined
|
||||
try {
|
||||
resolved = await this.settingsForTest({
|
||||
channel,
|
||||
...(settings === undefined ? {} : { settings })
|
||||
} as TestSettingsInput)
|
||||
validateResolved(resolved)
|
||||
const service = await this.buildService(resolved)
|
||||
try {
|
||||
await service.start()
|
||||
} finally {
|
||||
await Promise.resolve(service.stop()).catch(() => undefined)
|
||||
}
|
||||
return channelConnectionTestResultSchema.parse({
|
||||
channel,
|
||||
ok: true
|
||||
})
|
||||
} catch (error) {
|
||||
return channelConnectionTestResultSchema.parse({
|
||||
channel,
|
||||
ok: false,
|
||||
error: redactManagerError(error, [
|
||||
resolved?.secret,
|
||||
settings?.secret.action === 'replace'
|
||||
? settings.secret.value
|
||||
: undefined
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
testConnection(
|
||||
channel: 'wecom',
|
||||
settings?: WeComChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult>
|
||||
testConnection(
|
||||
channel: 'dingtalk',
|
||||
settings?: DingTalkChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult>
|
||||
testConnection(
|
||||
channel: ManagedChannel,
|
||||
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult> {
|
||||
return channel === 'wecom'
|
||||
? this.test(
|
||||
channel,
|
||||
settings as WeComChannelSettingsInput | undefined
|
||||
)
|
||||
: this.test(
|
||||
channel,
|
||||
settings as DingTalkChannelSettingsInput | undefined
|
||||
)
|
||||
}
|
||||
|
||||
stopAll(): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
const active = [...this.services.entries()]
|
||||
this.services.clear()
|
||||
const results = await Promise.allSettled(
|
||||
active.map(([, service]) => Promise.resolve(service.stop()))
|
||||
)
|
||||
const resolved = await this.store.resolveAll()
|
||||
for (const settings of resolved) {
|
||||
this.statuses.set(settings.channel, {
|
||||
state: settings.enabled ? 'stopped' : 'disabled'
|
||||
})
|
||||
}
|
||||
const failure = results.find((result) => result.status === 'rejected')
|
||||
if (failure?.status === 'rejected') {
|
||||
throw new Error(redactManagerError(failure.reason, []))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async replaceService(
|
||||
settings: ResolvedChannelSettings
|
||||
): Promise<void> {
|
||||
const channel = settings.channel
|
||||
const previous = this.services.get(channel)
|
||||
this.statuses.set(channel, { state: 'starting' })
|
||||
let replacement: ManagedChannelService | undefined
|
||||
try {
|
||||
validateResolved(settings)
|
||||
replacement = await this.buildService(settings)
|
||||
if (previous !== undefined) {
|
||||
await previous.stop()
|
||||
this.services.delete(channel)
|
||||
}
|
||||
await replacement.start()
|
||||
} catch (error) {
|
||||
await Promise.resolve(replacement?.stop()).catch(() => undefined)
|
||||
if (
|
||||
previous !== undefined &&
|
||||
this.services.get(channel) === previous
|
||||
) {
|
||||
this.services.delete(channel)
|
||||
await Promise.resolve(previous.stop()).catch(() => undefined)
|
||||
}
|
||||
const redacted = redactManagerError(error, [settings.secret])
|
||||
this.statuses.set(channel, {
|
||||
state: 'error',
|
||||
lastError: redacted
|
||||
})
|
||||
throw sanitizedManagerFailure(redacted)
|
||||
}
|
||||
|
||||
this.services.set(channel, replacement)
|
||||
this.statuses.set(channel, { state: 'running' })
|
||||
}
|
||||
|
||||
private async disableService(channel: ManagedChannel): Promise<void> {
|
||||
const previous = this.services.get(channel)
|
||||
if (previous !== undefined) {
|
||||
await previous.stop()
|
||||
this.services.delete(channel)
|
||||
}
|
||||
this.statuses.set(channel, { state: 'disabled' })
|
||||
}
|
||||
|
||||
private async buildService(
|
||||
settings: ResolvedChannelSettings
|
||||
): Promise<ManagedChannelService> {
|
||||
const driver = await this.createDriver(settings)
|
||||
return this.createService(driver, this.executor, {
|
||||
allowedSenderIds: settings.allowedSenderIds,
|
||||
allowGroupMessages: settings.allowGroupMessages
|
||||
})
|
||||
}
|
||||
|
||||
private async settingsForTest(
|
||||
input: TestSettingsInput
|
||||
): Promise<ResolvedChannelSettings> {
|
||||
const current = await this.store.resolve(input.channel)
|
||||
if (input.settings === undefined) {
|
||||
return current
|
||||
}
|
||||
if (current.readOnly) {
|
||||
throw new Error('环境变量通道配置为只读,不能使用临时设置')
|
||||
}
|
||||
|
||||
if (input.channel === 'wecom') {
|
||||
const parsed = weComChannelSettingsInputSchema.parse(input.settings)
|
||||
return {
|
||||
channel: 'wecom',
|
||||
enabled: parsed.enabled,
|
||||
botId: parsed.botId,
|
||||
...this.testCommonSettings(current.secret, parsed)
|
||||
}
|
||||
}
|
||||
const parsed = dingTalkChannelSettingsInputSchema.parse(input.settings)
|
||||
return {
|
||||
channel: 'dingtalk',
|
||||
enabled: parsed.enabled,
|
||||
clientId: parsed.clientId,
|
||||
...this.testCommonSettings(current.secret, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
private testCommonSettings(
|
||||
currentSecret: string | undefined,
|
||||
input: WeComChannelSettingsInput | DingTalkChannelSettingsInput
|
||||
): {
|
||||
secret?: string
|
||||
allowedSenderIds: readonly string[]
|
||||
allowGroupMessages: boolean
|
||||
source: 'none' | 'encrypted'
|
||||
readOnly: false
|
||||
} {
|
||||
const secret =
|
||||
input.secret.action === 'keep'
|
||||
? currentSecret
|
||||
: input.secret.action === 'replace'
|
||||
? input.secret.value
|
||||
: undefined
|
||||
return {
|
||||
...(secret === undefined ? {} : { secret }),
|
||||
allowedSenderIds: input.allowedSenderIds,
|
||||
allowGroupMessages: input.allowGroupMessages,
|
||||
source: secret === undefined ? 'none' : 'encrypted',
|
||||
readOnly: false
|
||||
}
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
let value!: T
|
||||
const run = async (): Promise<void> => {
|
||||
value = await operation()
|
||||
}
|
||||
const result = this.operationQueue.then(run, run)
|
||||
this.operationQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return result.then(() => value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ChannelSettingsStore,
|
||||
type ChannelCredentialCipher
|
||||
} from './channel-settings-store'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
async function settingsPath(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-channels-'))
|
||||
roots.push(root)
|
||||
return join(root, 'channel-settings.json')
|
||||
}
|
||||
|
||||
function createCipher(available = true): ChannelCredentialCipher {
|
||||
return {
|
||||
isAvailable: () => available,
|
||||
encrypt: (value) =>
|
||||
Buffer.from(`protected:${Buffer.from(value).toString('base64')}`),
|
||||
decrypt: (value) => {
|
||||
const encoded = value.toString().replace(/^protected:/u, '')
|
||||
return Buffer.from(encoded, 'base64').toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('ChannelSettingsStore', () => {
|
||||
it('encrypts secrets and supports keep, replace, and clear', async () => {
|
||||
const filePath = await settingsPath()
|
||||
const store = new ChannelSettingsStore(filePath, createCipher(), {})
|
||||
|
||||
let snapshot = await store.apply({
|
||||
wecom: {
|
||||
enabled: true,
|
||||
botId: 'bot-1',
|
||||
secret: { action: 'replace', value: 'first-secret' },
|
||||
allowedSenderIds: ['sender-1'],
|
||||
allowGroupMessages: true
|
||||
}
|
||||
})
|
||||
expect(snapshot.wecom).toMatchObject({
|
||||
enabled: true,
|
||||
botId: 'bot-1',
|
||||
secretConfigured: true,
|
||||
source: 'encrypted'
|
||||
})
|
||||
expect(await readFile(filePath, 'utf8')).not.toContain('first-secret')
|
||||
|
||||
snapshot = await store.apply({
|
||||
wecom: {
|
||||
enabled: true,
|
||||
botId: 'bot-2',
|
||||
secret: { action: 'keep' },
|
||||
allowedSenderIds: ['sender-2'],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
expect((await store.resolve('wecom')).secret).toBe('first-secret')
|
||||
expect(snapshot.wecom.botId).toBe('bot-2')
|
||||
|
||||
await store.apply({
|
||||
wecom: {
|
||||
enabled: true,
|
||||
botId: 'bot-2',
|
||||
secret: { action: 'replace', value: 'second-secret' },
|
||||
allowedSenderIds: ['sender-2'],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
expect((await store.resolve('wecom')).secret).toBe('second-secret')
|
||||
|
||||
snapshot = await store.apply({
|
||||
wecom: {
|
||||
enabled: false,
|
||||
botId: 'bot-2',
|
||||
secret: { action: 'clear' },
|
||||
allowedSenderIds: ['sender-2'],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
expect(snapshot.wecom).toMatchObject({
|
||||
secretConfigured: false,
|
||||
source: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('requires safe storage and complete fields for enabled channels', async () => {
|
||||
const unavailable = new ChannelSettingsStore(
|
||||
await settingsPath(),
|
||||
createCipher(false),
|
||||
{}
|
||||
)
|
||||
await expect(
|
||||
unavailable.apply({
|
||||
dingtalk: {
|
||||
enabled: false,
|
||||
clientId: 'client',
|
||||
secret: { action: 'replace', value: 'secret' },
|
||||
allowedSenderIds: [],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('安全存储不可用')
|
||||
|
||||
const store = new ChannelSettingsStore(
|
||||
await settingsPath(),
|
||||
createCipher(),
|
||||
{}
|
||||
)
|
||||
await expect(
|
||||
store.apply({
|
||||
dingtalk: {
|
||||
enabled: true,
|
||||
clientId: 'client',
|
||||
secret: { action: 'replace', value: 'secret' },
|
||||
allowedSenderIds: [],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('允许的发送者')
|
||||
})
|
||||
|
||||
it('gives complete environment configuration read-only priority', async () => {
|
||||
const filePath = await settingsPath()
|
||||
const originalStore = new ChannelSettingsStore(
|
||||
filePath,
|
||||
createCipher(),
|
||||
{}
|
||||
)
|
||||
await originalStore.apply({
|
||||
wecom: {
|
||||
enabled: true,
|
||||
botId: 'stored-bot',
|
||||
secret: { action: 'replace', value: 'stored-secret' },
|
||||
allowedSenderIds: ['stored-sender'],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
|
||||
const store = new ChannelSettingsStore(filePath, createCipher(), {
|
||||
GOODBUDDY_WECOM_BOT_ID: 'environment-bot',
|
||||
GOODBUDDY_WECOM_SECRET: 'environment-secret',
|
||||
GOODBUDDY_WECOM_ALLOWED_SENDERS: 'sender-a,sender-b',
|
||||
GOODBUDDY_WECOM_ALLOW_GROUPS: 'true'
|
||||
})
|
||||
expect(await store.resolve('wecom')).toEqual({
|
||||
channel: 'wecom',
|
||||
enabled: true,
|
||||
botId: 'environment-bot',
|
||||
secret: 'environment-secret',
|
||||
allowedSenderIds: ['sender-a', 'sender-b'],
|
||||
allowGroupMessages: true,
|
||||
source: 'environment',
|
||||
readOnly: true
|
||||
})
|
||||
await expect(
|
||||
store.apply({
|
||||
wecom: {
|
||||
enabled: false,
|
||||
botId: '',
|
||||
secret: { action: 'clear' },
|
||||
allowedSenderIds: [],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('环境变量配置')
|
||||
})
|
||||
|
||||
it('isolates corrupt files and recovers with an atomic persisted file', async () => {
|
||||
const filePath = await settingsPath()
|
||||
await writeFile(filePath, '{invalid-json', 'utf8')
|
||||
const store = new ChannelSettingsStore(
|
||||
filePath,
|
||||
createCipher(),
|
||||
{},
|
||||
() => 1234
|
||||
)
|
||||
|
||||
const initial = await store.snapshot()
|
||||
expect(initial.warning).toContain('已损坏')
|
||||
expect(
|
||||
await readdir(join(filePath, '..'))
|
||||
).toContain('channel-settings.json.corrupt-1234')
|
||||
|
||||
await store.apply({
|
||||
dingtalk: {
|
||||
enabled: false,
|
||||
clientId: 'client-id',
|
||||
secret: { action: 'replace', value: 'client-secret' },
|
||||
allowedSenderIds: [' Staff-A '],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
})
|
||||
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as {
|
||||
version: number
|
||||
dingtalk: { allowedSenderIds: string[] }
|
||||
}
|
||||
expect(persisted.version).toBe(1)
|
||||
expect(persisted.dingtalk.allowedSenderIds).toEqual(['staff-a'])
|
||||
expect((await readdir(join(filePath, '..'))).some(
|
||||
(name) => name.endsWith('.tmp')
|
||||
)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,595 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
CHANNEL_SETTINGS_LIMITS,
|
||||
allowedSenderIdsSchema,
|
||||
channelSettingsApplySchema,
|
||||
type ChannelRuntimeStatus,
|
||||
type ChannelSettingsApply,
|
||||
type ChannelSettingsSnapshot,
|
||||
type DingTalkChannelSettingsInput,
|
||||
type ManagedChannel,
|
||||
type WeComChannelSettingsInput
|
||||
} from '../../shared/channel-settings-contracts'
|
||||
|
||||
export interface ChannelCredentialCipher {
|
||||
isAvailable(): boolean
|
||||
encrypt(value: string): Buffer
|
||||
decrypt(value: Buffer): string
|
||||
}
|
||||
|
||||
const encryptedCredentialSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
scheme: z.literal('electron-safe-storage'),
|
||||
ciphertextBase64: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(CHANNEL_SETTINGS_LIMITS.maximumSecretLength * 8)
|
||||
.regex(/^[a-z0-9+/]+={0,2}$/iu)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const storedChannelFields = {
|
||||
enabled: z.boolean(),
|
||||
credential: encryptedCredentialSchema.optional(),
|
||||
allowedSenderIds: allowedSenderIdsSchema,
|
||||
allowGroupMessages: z.boolean()
|
||||
} as const
|
||||
|
||||
const storedSettingsSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
wecom: z
|
||||
.object({
|
||||
...storedChannelFields,
|
||||
botId: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength)
|
||||
})
|
||||
.strict(),
|
||||
dingtalk: z
|
||||
.object({
|
||||
...storedChannelFields,
|
||||
clientId: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength)
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
.strict()
|
||||
|
||||
type StoredSettings = z.infer<typeof storedSettingsSchema>
|
||||
type StoredChannel = StoredSettings['wecom'] | StoredSettings['dingtalk']
|
||||
|
||||
const credentialPayloadSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
channel: z.enum(['wecom', 'dingtalk']),
|
||||
secret: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(CHANNEL_SETTINGS_LIMITS.maximumSecretLength)
|
||||
})
|
||||
.strict()
|
||||
|
||||
type EnvironmentChannel = {
|
||||
owned: boolean
|
||||
enabled: boolean
|
||||
id: string
|
||||
secret?: string
|
||||
allowedSenderIds: readonly string[]
|
||||
allowGroupMessages: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type ResolvedChannelSettings =
|
||||
| {
|
||||
channel: 'wecom'
|
||||
enabled: boolean
|
||||
botId: string
|
||||
secret?: string
|
||||
allowedSenderIds: readonly string[]
|
||||
allowGroupMessages: boolean
|
||||
source: 'none' | 'encrypted' | 'environment'
|
||||
readOnly: boolean
|
||||
}
|
||||
| {
|
||||
channel: 'dingtalk'
|
||||
enabled: boolean
|
||||
clientId: string
|
||||
secret?: string
|
||||
allowedSenderIds: readonly string[]
|
||||
allowGroupMessages: boolean
|
||||
source: 'none' | 'encrypted' | 'environment'
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
const defaultStoredSettings: StoredSettings = {
|
||||
version: 1,
|
||||
wecom: {
|
||||
enabled: false,
|
||||
botId: '',
|
||||
allowedSenderIds: [],
|
||||
allowGroupMessages: false
|
||||
},
|
||||
dingtalk: {
|
||||
enabled: false,
|
||||
clientId: '',
|
||||
allowedSenderIds: [],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
}
|
||||
|
||||
const defaultStatus = (enabled: boolean): ChannelRuntimeStatus => ({
|
||||
state: enabled ? 'stopped' : 'disabled'
|
||||
})
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return (
|
||||
error !== null &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
|
||||
function boundedEnvironmentValue(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
name: string,
|
||||
maximum: number
|
||||
): { value?: string; invalid: boolean } {
|
||||
const raw = environment[name]
|
||||
if (raw === undefined || raw.trim() === '') {
|
||||
return { invalid: false }
|
||||
}
|
||||
const value = raw.trim()
|
||||
return value.length <= maximum
|
||||
? { value, invalid: false }
|
||||
: { invalid: true }
|
||||
}
|
||||
|
||||
function environmentBoolean(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
name: string,
|
||||
fallback: boolean
|
||||
): { value: boolean; invalid: boolean } {
|
||||
const raw = environment[name]
|
||||
if (raw === undefined || raw.trim() === '') {
|
||||
return { value: fallback, invalid: false }
|
||||
}
|
||||
if (raw === 'true') {
|
||||
return { value: true, invalid: false }
|
||||
}
|
||||
if (raw === 'false') {
|
||||
return { value: false, invalid: false }
|
||||
}
|
||||
return { value: false, invalid: true }
|
||||
}
|
||||
|
||||
function environmentSenders(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
name: string,
|
||||
normalize: (value: string) => string
|
||||
): { value: readonly string[]; invalid: boolean } {
|
||||
const raw = environment[name]
|
||||
if (raw === undefined || raw.trim() === '') {
|
||||
return { value: [], invalid: false }
|
||||
}
|
||||
const parsed = allowedSenderIdsSchema.safeParse(
|
||||
raw.split(',').map((value) => normalize(value.trim()))
|
||||
)
|
||||
return parsed.success
|
||||
? { value: parsed.data, invalid: false }
|
||||
: { value: [], invalid: true }
|
||||
}
|
||||
|
||||
function normalizeDingTalkSender(value: string): string {
|
||||
return value.normalize('NFKC').trim().toLocaleLowerCase('en-US')
|
||||
}
|
||||
|
||||
function cloneStored(settings: StoredSettings): StoredSettings {
|
||||
return structuredClone(settings)
|
||||
}
|
||||
|
||||
export class ChannelSettingsStore {
|
||||
private settings?: StoredSettings
|
||||
private warning?: string
|
||||
private updateQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly cipher: ChannelCredentialCipher,
|
||||
private readonly environment: NodeJS.ProcessEnv = process.env,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
async snapshot(
|
||||
statuses: Partial<Record<ManagedChannel, ChannelRuntimeStatus>> = {}
|
||||
): Promise<ChannelSettingsSnapshot> {
|
||||
const [wecom, dingtalk] = await Promise.all([
|
||||
this.resolve('wecom'),
|
||||
this.resolve('dingtalk')
|
||||
])
|
||||
const weComEnvironment = this.environmentChannel('wecom')
|
||||
const dingTalkEnvironment = this.environmentChannel('dingtalk')
|
||||
const environmentWarning =
|
||||
weComEnvironment.error ?? dingTalkEnvironment.error
|
||||
const warning = this.warning ?? environmentWarning
|
||||
return {
|
||||
wecom: {
|
||||
enabled: wecom.enabled,
|
||||
botId: wecom.botId,
|
||||
secretConfigured: wecom.secret !== undefined,
|
||||
source: wecom.source,
|
||||
readOnly: wecom.readOnly,
|
||||
allowedSenderIds: [...wecom.allowedSenderIds],
|
||||
allowGroupMessages: wecom.allowGroupMessages,
|
||||
status:
|
||||
statuses.wecom ??
|
||||
(weComEnvironment.error === undefined
|
||||
? defaultStatus(wecom.enabled)
|
||||
: {
|
||||
state: 'error',
|
||||
lastError: weComEnvironment.error
|
||||
})
|
||||
},
|
||||
dingtalk: {
|
||||
enabled: dingtalk.enabled,
|
||||
clientId: dingtalk.clientId,
|
||||
secretConfigured: dingtalk.secret !== undefined,
|
||||
source: dingtalk.source,
|
||||
readOnly: dingtalk.readOnly,
|
||||
allowedSenderIds: [...dingtalk.allowedSenderIds],
|
||||
allowGroupMessages: dingtalk.allowGroupMessages,
|
||||
status:
|
||||
statuses.dingtalk ??
|
||||
(dingTalkEnvironment.error === undefined
|
||||
? defaultStatus(dingtalk.enabled)
|
||||
: {
|
||||
state: 'error',
|
||||
lastError: dingTalkEnvironment.error
|
||||
})
|
||||
},
|
||||
...(warning === undefined ? {} : { warning })
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot(
|
||||
statuses?: Partial<Record<ManagedChannel, ChannelRuntimeStatus>>
|
||||
): Promise<ChannelSettingsSnapshot> {
|
||||
return this.snapshot(statuses)
|
||||
}
|
||||
|
||||
resolve(channel: 'wecom'): Promise<Extract<ResolvedChannelSettings, {
|
||||
channel: 'wecom'
|
||||
}>>
|
||||
resolve(channel: 'dingtalk'): Promise<Extract<ResolvedChannelSettings, {
|
||||
channel: 'dingtalk'
|
||||
}>>
|
||||
resolve(channel: ManagedChannel): Promise<ResolvedChannelSettings>
|
||||
async resolve(channel: ManagedChannel): Promise<ResolvedChannelSettings> {
|
||||
const environment = this.environmentChannel(channel)
|
||||
if (environment.owned) {
|
||||
const common = {
|
||||
enabled: environment.enabled,
|
||||
secret: environment.secret,
|
||||
allowedSenderIds: environment.allowedSenderIds,
|
||||
allowGroupMessages: environment.allowGroupMessages,
|
||||
source: 'environment' as const,
|
||||
readOnly: true
|
||||
}
|
||||
return channel === 'wecom'
|
||||
? {
|
||||
channel,
|
||||
botId: environment.id,
|
||||
...common
|
||||
}
|
||||
: {
|
||||
channel,
|
||||
clientId: environment.id,
|
||||
...common
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await this.load()
|
||||
const stored = settings[channel]
|
||||
const secret = this.decryptCredential(channel, stored)
|
||||
const common = {
|
||||
enabled: stored.enabled,
|
||||
...(secret === undefined ? {} : { secret }),
|
||||
allowedSenderIds: [...stored.allowedSenderIds],
|
||||
allowGroupMessages: stored.allowGroupMessages,
|
||||
source: secret === undefined ? ('none' as const) : ('encrypted' as const),
|
||||
readOnly: false
|
||||
}
|
||||
return channel === 'wecom'
|
||||
? { channel, botId: settings.wecom.botId, ...common }
|
||||
: { channel, clientId: settings.dingtalk.clientId, ...common }
|
||||
}
|
||||
|
||||
resolveAll(): Promise<readonly [
|
||||
Extract<ResolvedChannelSettings, { channel: 'wecom' }>,
|
||||
Extract<ResolvedChannelSettings, { channel: 'dingtalk' }>
|
||||
]> {
|
||||
return Promise.all([this.resolve('wecom'), this.resolve('dingtalk')])
|
||||
}
|
||||
|
||||
apply(input: ChannelSettingsApply): Promise<ChannelSettingsSnapshot> {
|
||||
const parsed = channelSettingsApplySchema.parse(input)
|
||||
let snapshot!: ChannelSettingsSnapshot
|
||||
const update = async (): Promise<void> => {
|
||||
snapshot = await this.applyNow(parsed)
|
||||
}
|
||||
const operation = this.updateQueue.then(update, update)
|
||||
this.updateQueue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return operation.then(() => snapshot)
|
||||
}
|
||||
|
||||
private async applyNow(
|
||||
input: ChannelSettingsApply
|
||||
): Promise<ChannelSettingsSnapshot> {
|
||||
const current = cloneStored(await this.load())
|
||||
if (input.wecom !== undefined) {
|
||||
if (this.environmentChannel('wecom').owned) {
|
||||
throw new Error('企业微信由环境变量配置,不能在设置中修改')
|
||||
}
|
||||
current.wecom = this.updateStoredChannel(
|
||||
'wecom',
|
||||
current.wecom,
|
||||
input.wecom
|
||||
)
|
||||
}
|
||||
if (input.dingtalk !== undefined) {
|
||||
if (this.environmentChannel('dingtalk').owned) {
|
||||
throw new Error('钉钉由环境变量配置,不能在设置中修改')
|
||||
}
|
||||
current.dingtalk = this.updateStoredChannel(
|
||||
'dingtalk',
|
||||
current.dingtalk,
|
||||
input.dingtalk
|
||||
)
|
||||
}
|
||||
|
||||
this.validateEnabledChannel('wecom', current.wecom)
|
||||
this.validateEnabledChannel('dingtalk', current.dingtalk)
|
||||
await this.persist(current)
|
||||
this.settings = current
|
||||
this.warning = undefined
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
private updateStoredChannel(
|
||||
channel: 'wecom',
|
||||
current: StoredSettings['wecom'],
|
||||
input: WeComChannelSettingsInput
|
||||
): StoredSettings['wecom']
|
||||
private updateStoredChannel(
|
||||
channel: 'dingtalk',
|
||||
current: StoredSettings['dingtalk'],
|
||||
input: DingTalkChannelSettingsInput
|
||||
): StoredSettings['dingtalk']
|
||||
private updateStoredChannel(
|
||||
channel: ManagedChannel,
|
||||
current: StoredChannel,
|
||||
input: WeComChannelSettingsInput | DingTalkChannelSettingsInput
|
||||
): StoredChannel {
|
||||
const credential =
|
||||
input.secret.action === 'keep'
|
||||
? current.credential
|
||||
: input.secret.action === 'clear'
|
||||
? undefined
|
||||
: this.encryptCredential(channel, input.secret.value)
|
||||
const allowedSenderIds =
|
||||
channel === 'dingtalk'
|
||||
? [...new Set(input.allowedSenderIds.map(normalizeDingTalkSender))]
|
||||
: [...input.allowedSenderIds]
|
||||
const common = {
|
||||
enabled: input.enabled,
|
||||
...(credential === undefined ? {} : { credential }),
|
||||
allowedSenderIds,
|
||||
allowGroupMessages: input.allowGroupMessages
|
||||
}
|
||||
return channel === 'wecom'
|
||||
? {
|
||||
...common,
|
||||
botId: (input as WeComChannelSettingsInput).botId
|
||||
}
|
||||
: {
|
||||
...common,
|
||||
clientId: (input as DingTalkChannelSettingsInput).clientId
|
||||
}
|
||||
}
|
||||
|
||||
private validateEnabledChannel(
|
||||
channel: ManagedChannel,
|
||||
stored: StoredChannel
|
||||
): void {
|
||||
if (!stored.enabled) {
|
||||
return
|
||||
}
|
||||
const identifier =
|
||||
channel === 'wecom'
|
||||
? (stored as StoredSettings['wecom']).botId
|
||||
: (stored as StoredSettings['dingtalk']).clientId
|
||||
if (
|
||||
identifier.length === 0 ||
|
||||
stored.allowedSenderIds.length === 0 ||
|
||||
this.decryptCredential(channel, stored) === undefined
|
||||
) {
|
||||
throw new Error(
|
||||
channel === 'wecom'
|
||||
? '启用企业微信前需要配置机器人 ID、Secret 和允许的发送者'
|
||||
: '启用钉钉前需要配置 Client ID、Secret 和允许的发送者'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private encryptCredential(
|
||||
channel: ManagedChannel,
|
||||
secret: string
|
||||
): StoredChannel['credential'] {
|
||||
if (!this.cipher.isAvailable()) {
|
||||
throw new Error('系统安全存储不可用,无法保存通道 Secret')
|
||||
}
|
||||
const encrypted = this.cipher.encrypt(
|
||||
JSON.stringify({ version: 1, channel, secret })
|
||||
)
|
||||
return {
|
||||
formatVersion: 1,
|
||||
scheme: 'electron-safe-storage',
|
||||
ciphertextBase64: encrypted.toString('base64')
|
||||
}
|
||||
}
|
||||
|
||||
private decryptCredential(
|
||||
channel: ManagedChannel,
|
||||
stored: StoredChannel
|
||||
): string | undefined {
|
||||
if (stored.credential === undefined || !this.cipher.isAvailable()) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const payload = credentialPayloadSchema.parse(
|
||||
JSON.parse(
|
||||
this.cipher.decrypt(
|
||||
Buffer.from(stored.credential.ciphertextBase64, 'base64')
|
||||
)
|
||||
)
|
||||
)
|
||||
return payload.channel === channel ? payload.secret : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async load(): Promise<StoredSettings> {
|
||||
if (this.settings !== undefined) {
|
||||
return this.settings
|
||||
}
|
||||
try {
|
||||
this.settings = storedSettingsSchema.parse(
|
||||
JSON.parse(await readFile(this.filePath, 'utf8'))
|
||||
)
|
||||
} catch (error) {
|
||||
if (!isMissingFile(error)) {
|
||||
this.warning = '通道设置文件已损坏,已隔离原文件并恢复默认设置'
|
||||
await rename(
|
||||
this.filePath,
|
||||
`${this.filePath}.corrupt-${this.now()}`
|
||||
).catch(() => undefined)
|
||||
}
|
||||
this.settings = cloneStored(defaultStoredSettings)
|
||||
}
|
||||
return this.settings
|
||||
}
|
||||
|
||||
private async persist(settings: StoredSettings): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true })
|
||||
const temporaryPath = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`
|
||||
try {
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(settings, null, 2)}\n`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx'
|
||||
}
|
||||
)
|
||||
await rename(temporaryPath, this.filePath)
|
||||
} finally {
|
||||
await rm(temporaryPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
private environmentChannel(channel: ManagedChannel): EnvironmentChannel {
|
||||
const prefix =
|
||||
channel === 'wecom' ? 'GOODBUDDY_WECOM' : 'GOODBUDDY_DINGTALK'
|
||||
const idName =
|
||||
channel === 'wecom'
|
||||
? `${prefix}_BOT_ID`
|
||||
: `${prefix}_CLIENT_ID`
|
||||
const secretName =
|
||||
channel === 'wecom'
|
||||
? `${prefix}_SECRET`
|
||||
: `${prefix}_CLIENT_SECRET`
|
||||
const id = boundedEnvironmentValue(
|
||||
this.environment,
|
||||
idName,
|
||||
CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength
|
||||
)
|
||||
const secret = boundedEnvironmentValue(
|
||||
this.environment,
|
||||
secretName,
|
||||
CHANNEL_SETTINGS_LIMITS.maximumSecretLength
|
||||
)
|
||||
const owned = id.value !== undefined || secret.value !== undefined ||
|
||||
id.invalid || secret.invalid
|
||||
if (!owned) {
|
||||
return {
|
||||
owned: false,
|
||||
enabled: false,
|
||||
id: '',
|
||||
allowedSenderIds: [],
|
||||
allowGroupMessages: false
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = environmentBoolean(
|
||||
this.environment,
|
||||
`${prefix}_ENABLED`,
|
||||
true
|
||||
)
|
||||
const allowGroups = environmentBoolean(
|
||||
this.environment,
|
||||
`${prefix}_ALLOW_GROUPS`,
|
||||
false
|
||||
)
|
||||
const senders = environmentSenders(
|
||||
this.environment,
|
||||
`${prefix}_ALLOWED_SENDERS`,
|
||||
channel === 'dingtalk'
|
||||
? normalizeDingTalkSender
|
||||
: (value) => value
|
||||
)
|
||||
const invalid =
|
||||
id.invalid ||
|
||||
secret.invalid ||
|
||||
enabled.invalid ||
|
||||
allowGroups.invalid ||
|
||||
senders.invalid
|
||||
return {
|
||||
owned: true,
|
||||
enabled: invalid ? false : enabled.value,
|
||||
id: id.value ?? '',
|
||||
...(secret.value === undefined ? {} : { secret: secret.value }),
|
||||
allowedSenderIds: senders.value,
|
||||
allowGroupMessages: allowGroups.value,
|
||||
...(!invalid &&
|
||||
id.value !== undefined &&
|
||||
secret.value !== undefined &&
|
||||
senders.value.length > 0
|
||||
? {}
|
||||
: {
|
||||
error:
|
||||
channel === 'wecom'
|
||||
? '企业微信环境变量配置无效或不完整'
|
||||
: '钉钉环境变量配置无效或不完整'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,32 +4,42 @@ import type { WeComSdkTransport } from './wecom-driver'
|
||||
|
||||
type MessageListener = (frame: unknown) => void
|
||||
type ErrorListener = (error: Error) => void
|
||||
type AuthenticatedListener = () => void
|
||||
|
||||
class FakeTransport implements WeComSdkTransport {
|
||||
readonly connect = vi.fn()
|
||||
readonly connect = vi.fn(() => {
|
||||
this.authenticatedListener?.()
|
||||
})
|
||||
readonly disconnect = vi.fn()
|
||||
readonly replyStream = vi.fn<WeComSdkTransport['replyStream']>(
|
||||
async () => ({})
|
||||
)
|
||||
private messageListener?: MessageListener
|
||||
private authenticatedListener?: AuthenticatedListener
|
||||
|
||||
on(event: 'message', listener: MessageListener): unknown
|
||||
on(event: 'error', listener: ErrorListener): unknown
|
||||
on(event: 'authenticated', listener: AuthenticatedListener): unknown
|
||||
on(
|
||||
event: 'message' | 'error',
|
||||
listener: MessageListener | ErrorListener
|
||||
event: 'message' | 'error' | 'authenticated',
|
||||
listener: MessageListener | ErrorListener | AuthenticatedListener
|
||||
): unknown {
|
||||
if (event === 'message') {
|
||||
this.messageListener = listener as MessageListener
|
||||
} else if (event === 'authenticated') {
|
||||
this.authenticatedListener = listener as AuthenticatedListener
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
off(event: 'message', listener: MessageListener): unknown
|
||||
off(event: 'error', listener: ErrorListener): unknown
|
||||
off(event: 'message' | 'error'): unknown {
|
||||
off(event: 'authenticated', listener: AuthenticatedListener): unknown
|
||||
off(event: 'message' | 'error' | 'authenticated'): unknown {
|
||||
if (event === 'message') {
|
||||
this.messageListener = undefined
|
||||
} else if (event === 'authenticated') {
|
||||
this.authenticatedListener = undefined
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -10,9 +10,14 @@ import {
|
||||
|
||||
type MessageListener = (frame: unknown) => void
|
||||
type ErrorListener = (error: Error) => void
|
||||
type AuthenticatedListener = () => void
|
||||
|
||||
class FakeTransport implements WeComSdkTransport {
|
||||
readonly connect = vi.fn(() => undefined)
|
||||
readonly connect = vi.fn(() => {
|
||||
if (this.autoAuthenticate) {
|
||||
this.emitAuthenticated()
|
||||
}
|
||||
})
|
||||
readonly disconnect = vi.fn(() => undefined)
|
||||
readonly replyStream = vi.fn<WeComSdkTransport['replyStream']>(
|
||||
async () => ({})
|
||||
@@ -20,35 +25,54 @@ class FakeTransport implements WeComSdkTransport {
|
||||
|
||||
readonly #messageListeners = new Set<MessageListener>()
|
||||
readonly #errorListeners = new Set<ErrorListener>()
|
||||
readonly #authenticatedListeners = new Set<AuthenticatedListener>()
|
||||
|
||||
constructor(private readonly autoAuthenticate = true) {}
|
||||
|
||||
on(event: 'message', listener: MessageListener): unknown
|
||||
on(event: 'error', listener: ErrorListener): unknown
|
||||
on(event: 'authenticated', listener: AuthenticatedListener): unknown
|
||||
on(
|
||||
event: 'message' | 'error',
|
||||
listener: MessageListener | ErrorListener
|
||||
event: 'message' | 'error' | 'authenticated',
|
||||
listener: MessageListener | ErrorListener | AuthenticatedListener
|
||||
): unknown {
|
||||
if (event === 'message') {
|
||||
this.#messageListeners.add(listener as MessageListener)
|
||||
} else {
|
||||
} else if (event === 'error') {
|
||||
this.#errorListeners.add(listener as ErrorListener)
|
||||
} else {
|
||||
this.#authenticatedListeners.add(
|
||||
listener as AuthenticatedListener
|
||||
)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
off(event: 'message', listener: MessageListener): unknown
|
||||
off(event: 'error', listener: ErrorListener): unknown
|
||||
off(event: 'authenticated', listener: AuthenticatedListener): unknown
|
||||
off(
|
||||
event: 'message' | 'error',
|
||||
listener: MessageListener | ErrorListener
|
||||
event: 'message' | 'error' | 'authenticated',
|
||||
listener: MessageListener | ErrorListener | AuthenticatedListener
|
||||
): unknown {
|
||||
if (event === 'message') {
|
||||
this.#messageListeners.delete(listener as MessageListener)
|
||||
} else {
|
||||
} else if (event === 'error') {
|
||||
this.#errorListeners.delete(listener as ErrorListener)
|
||||
} else {
|
||||
this.#authenticatedListeners.delete(
|
||||
listener as AuthenticatedListener
|
||||
)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
emitAuthenticated(): void {
|
||||
for (const listener of this.#authenticatedListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
emitMessage(frame: unknown): void {
|
||||
for (const listener of this.#messageListeners) {
|
||||
listener(frame)
|
||||
@@ -61,10 +85,15 @@ class FakeTransport implements WeComSdkTransport {
|
||||
}
|
||||
}
|
||||
|
||||
get listenerCounts(): { message: number; error: number } {
|
||||
get listenerCounts(): {
|
||||
message: number
|
||||
error: number
|
||||
authenticated: number
|
||||
} {
|
||||
return {
|
||||
message: this.#messageListeners.size,
|
||||
error: this.#errorListeners.size
|
||||
error: this.#errorListeners.size,
|
||||
authenticated: this.#authenticatedListeners.size
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,19 +373,105 @@ describe('WeComDriver', () => {
|
||||
|
||||
await Promise.all([driver.start(), driver.start(), driver.start()])
|
||||
expect(transport.connect).toHaveBeenCalledOnce()
|
||||
expect(transport.listenerCounts).toEqual({ message: 1, error: 1 })
|
||||
expect(transport.listenerCounts).toEqual({
|
||||
message: 1,
|
||||
error: 1,
|
||||
authenticated: 0
|
||||
})
|
||||
expect(driver.started).toBe(true)
|
||||
|
||||
await driver.stop()
|
||||
await driver.stop()
|
||||
expect(transport.disconnect).toHaveBeenCalledOnce()
|
||||
expect(transport.listenerCounts).toEqual({ message: 0, error: 0 })
|
||||
expect(transport.listenerCounts).toEqual({
|
||||
message: 0,
|
||||
error: 0,
|
||||
authenticated: 0
|
||||
})
|
||||
expect(driver.started).toBe(false)
|
||||
|
||||
transport.emitMessage(textFrame())
|
||||
expect(messages).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not finish starting until the SDK authenticates', async () => {
|
||||
const transport = new FakeTransport(false)
|
||||
const driver = new WeComDriver({
|
||||
botId: 'bot-main',
|
||||
secret: 'main-process-secret',
|
||||
transportFactory: () => transport,
|
||||
authenticationTimeoutMs: 100,
|
||||
onMessage: () => undefined
|
||||
})
|
||||
let completed = false
|
||||
|
||||
const start = driver.start().then(() => {
|
||||
completed = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(completed).toBe(false)
|
||||
|
||||
transport.emitAuthenticated()
|
||||
await start
|
||||
expect(completed).toBe(true)
|
||||
await driver.stop()
|
||||
})
|
||||
|
||||
it('fails startup when the SDK reports an authentication error', async () => {
|
||||
const transport = new FakeTransport(false)
|
||||
const driver = new WeComDriver({
|
||||
botId: 'bot-main',
|
||||
secret: 'main-process-secret',
|
||||
transportFactory: () => transport,
|
||||
authenticationTimeoutMs: 100,
|
||||
onMessage: () => undefined
|
||||
})
|
||||
|
||||
const start = driver.start()
|
||||
await Promise.resolve()
|
||||
transport.emitError(new Error('invalid credentials'))
|
||||
|
||||
await expect(start).rejects.toMatchObject({
|
||||
code: 'transport_error'
|
||||
})
|
||||
expect(transport.disconnect).toHaveBeenCalledOnce()
|
||||
expect(driver.started).toBe(false)
|
||||
})
|
||||
|
||||
it('shares an in-flight authentication failure with later start calls', async () => {
|
||||
const transport = new FakeTransport(false)
|
||||
const driver = new WeComDriver({
|
||||
botId: 'bot-main',
|
||||
secret: 'main-process-secret',
|
||||
transportFactory: () => transport,
|
||||
authenticationTimeoutMs: 100,
|
||||
onMessage: () => undefined
|
||||
})
|
||||
|
||||
const first = driver.start()
|
||||
await vi.waitFor(() =>
|
||||
expect(transport.connect).toHaveBeenCalledOnce()
|
||||
)
|
||||
const second = driver.start()
|
||||
transport.emitError(new Error('invalid credentials'))
|
||||
|
||||
const results = await Promise.allSettled([first, second])
|
||||
expect(results.map((result) => result.status)).toEqual([
|
||||
'rejected',
|
||||
'rejected'
|
||||
])
|
||||
expect(
|
||||
results.map((result) =>
|
||||
result.status === 'rejected' ? result.reason : undefined
|
||||
)
|
||||
).toEqual([
|
||||
expect.objectContaining({ code: 'transport_error' }),
|
||||
expect.objectContaining({ code: 'transport_error' })
|
||||
])
|
||||
expect(transport.connect).toHaveBeenCalledOnce()
|
||||
expect(transport.disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('invalidates reply contexts when restarted with another transport', async () => {
|
||||
const first = new FakeTransport()
|
||||
const second = new FakeTransport()
|
||||
|
||||
@@ -5,6 +5,8 @@ export const WECOM_TEXT_MAX_BYTES = 20_480
|
||||
const IDENTIFIER_MAX_BYTES = 1_024
|
||||
const WECOM_MESSAGE_EVENT = 'message'
|
||||
const WECOM_ERROR_EVENT = 'error'
|
||||
const WECOM_AUTHENTICATED_EVENT = 'authenticated'
|
||||
const DEFAULT_AUTHENTICATION_TIMEOUT_MS = 15_000
|
||||
|
||||
export type WeComChatType = 'single' | 'group'
|
||||
|
||||
@@ -77,8 +79,10 @@ interface WeComFrameHeaders {
|
||||
export interface WeComSdkTransport {
|
||||
on(event: 'message', listener: (frame: unknown) => void): unknown
|
||||
on(event: 'error', listener: (error: Error) => void): unknown
|
||||
on(event: 'authenticated', listener: () => void): unknown
|
||||
off(event: 'message', listener: (frame: unknown) => void): unknown
|
||||
off(event: 'error', listener: (error: Error) => void): unknown
|
||||
off(event: 'authenticated', listener: () => void): unknown
|
||||
connect(): unknown
|
||||
disconnect(): unknown
|
||||
replyStream(
|
||||
@@ -108,6 +112,7 @@ export interface WeComDriverOptions extends WeComTransportCredentials {
|
||||
readonly onError?: (error: WeComDriverError) => void
|
||||
readonly transportFactory?: WeComTransportFactory
|
||||
readonly streamIdFactory?: () => string
|
||||
readonly authenticationTimeoutMs?: number
|
||||
}
|
||||
|
||||
interface NormalizedWeComPayload {
|
||||
@@ -329,6 +334,7 @@ export class WeComDriver {
|
||||
readonly #onError: WeComDriverOptions['onError']
|
||||
readonly #transportFactory: WeComTransportFactory
|
||||
readonly #streamIdFactory: () => string
|
||||
readonly #authenticationTimeoutMs: number
|
||||
readonly #replyRecords = new WeakMap<WeComReplyContext, ReplyRecord>()
|
||||
|
||||
#transport: WeComSdkTransport | undefined
|
||||
@@ -354,6 +360,17 @@ export class WeComDriver {
|
||||
options.transportFactory ?? createOfficialWeComTransport
|
||||
this.#streamIdFactory =
|
||||
options.streamIdFactory ?? (() => `goodbuddy_${randomUUID()}`)
|
||||
this.#authenticationTimeoutMs =
|
||||
options.authenticationTimeoutMs ?? DEFAULT_AUTHENTICATION_TIMEOUT_MS
|
||||
if (
|
||||
!Number.isSafeInteger(this.#authenticationTimeoutMs) ||
|
||||
this.#authenticationTimeoutMs < 1
|
||||
) {
|
||||
throw new WeComDriverError(
|
||||
'invalid_credentials',
|
||||
'企业微信认证等待时间无效'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
get started(): boolean {
|
||||
@@ -361,23 +378,23 @@ export class WeComDriver {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.#transport !== undefined) {
|
||||
return
|
||||
}
|
||||
if (this.#startPromise !== undefined) {
|
||||
return this.#startPromise
|
||||
}
|
||||
if (this.#transport !== undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const version = ++this.#lifecycleVersion
|
||||
const startPromise = this.#createAndConnect(version)
|
||||
this.#startPromise = startPromise
|
||||
try {
|
||||
await startPromise
|
||||
} catch {
|
||||
const startPromise = this.#createAndConnect(version).catch(() => {
|
||||
throw new WeComDriverError(
|
||||
'transport_error',
|
||||
'企业微信长连接启动失败'
|
||||
)
|
||||
})
|
||||
this.#startPromise = startPromise
|
||||
try {
|
||||
await startPromise
|
||||
} finally {
|
||||
if (this.#startPromise === startPromise) {
|
||||
this.#startPromise = undefined
|
||||
@@ -471,7 +488,7 @@ export class WeComDriver {
|
||||
this.#transport = transport
|
||||
this.#attachTransport(transport)
|
||||
try {
|
||||
await transport.connect()
|
||||
await this.#connectAndAuthenticate(transport)
|
||||
} catch (error) {
|
||||
if (this.#transport === transport) {
|
||||
this.#transport = undefined
|
||||
@@ -490,6 +507,46 @@ export class WeComDriver {
|
||||
}
|
||||
}
|
||||
|
||||
async #connectAndAuthenticate(
|
||||
transport: WeComSdkTransport
|
||||
): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
const finish = (error?: Error): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
transport.off(WECOM_AUTHENTICATED_EVENT, authenticated)
|
||||
transport.off(WECOM_ERROR_EVENT, failed)
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
const authenticated = (): void => finish()
|
||||
const failed = (): void =>
|
||||
finish(new Error('企业微信认证失败'))
|
||||
const timeout = setTimeout(
|
||||
() => finish(new Error('企业微信认证超时')),
|
||||
this.#authenticationTimeoutMs
|
||||
)
|
||||
transport.on(WECOM_AUTHENTICATED_EVENT, authenticated)
|
||||
transport.on(WECOM_ERROR_EVENT, failed)
|
||||
try {
|
||||
transport.connect()
|
||||
} catch (error) {
|
||||
finish(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('企业微信长连接启动失败')
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
readonly #handleMessage = (frame: unknown): void => {
|
||||
const transport = this.#transport
|
||||
if (transport === undefined) {
|
||||
|
||||
Reference in New Issue
Block a user