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
+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'
? '企业微信环境变量配置无效或不完整'
: '钉钉环境变量配置无效或不完整'
})
}
}
}