Files
goodbuddy/src/main/channels/channel-manager.ts
T
lofyer b8fc7bc86e
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
chore: prepare GoodBuddy 0.8.2
2026-08-06 22:47:13 +08:00

411 lines
12 KiB
TypeScript

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)
}
}