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

This commit is contained in:
lofyer
2026-08-06 22:47:13 +08:00
parent 8d00e6371d
commit b8fc7bc86e
114 changed files with 22916 additions and 1560 deletions
+234
View File
@@ -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')
})
})
+410
View File
@@ -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)
})
})
+595
View File
@@ -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'
? '企业微信环境变量配置无效或不完整'
: '钉钉环境变量配置无效或不完整'
})
}
}
}
+14 -4
View File
@@ -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
}
+126 -11
View File
@@ -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()
+66 -9
View File
@@ -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) {