Files
goodbuddy/src/main/channels/channel-settings-store.ts
T

914 lines
25 KiB
TypeScript

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 CredentialChannel,
type DingTalkChannelSettingsInput,
type ManagedChannel,
type WeComChannelSettingsInput
} from '../../shared/channel-settings-contracts'
import { weixinAccountDisplay } from '../../shared/weixin-channel-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 legacyStoredSettingsSchema = 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()
const legacyWeixinStoredChannelSchema = z
.object({
enabled: z.boolean(),
credential: encryptedCredentialSchema.optional(),
accountId: z
.string()
.trim()
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength),
userId: z
.string()
.trim()
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength),
baseUrl: z.union([
z.literal(''),
z.string().url().max(2_048)
])
})
.strict()
const storedSettingsSchema = z
.object({
version: z.literal(3),
weixin: z
.object({
enabled: z.boolean(),
credential: encryptedCredentialSchema.optional()
})
.strict(),
wecom: legacyStoredSettingsSchema.shape.wecom,
dingtalk: legacyStoredSettingsSchema.shape.dingtalk
})
.strict()
type StoredSettings = z.infer<typeof storedSettingsSchema>
type StoredCredentialChannel =
| StoredSettings['wecom']
| StoredSettings['dingtalk']
type StoredEncryptedCredential = z.infer<
typeof encryptedCredentialSchema
>
const credentialPayloadSchema = z
.object({
version: z.literal(1),
channel: z.enum(['weixin', 'wecom', 'dingtalk']),
secret: z
.string()
.min(1)
.max(CHANNEL_SETTINGS_LIMITS.maximumSecretLength)
})
.strict()
const weixinCredentialPayloadSchema = z
.object({
version: z.literal(2),
channel: z.literal('weixin'),
accountId: z
.string()
.trim()
.min(1)
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength),
userId: z
.string()
.trim()
.min(1)
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength),
baseUrl: z.string().url().max(2_048),
token: z
.string()
.min(1)
.max(CHANNEL_SETTINGS_LIMITS.maximumSecretLength)
})
.strict()
const versionTwoStoredSettingsSchema = z
.object({
version: z.literal(2),
weixin: legacyWeixinStoredChannelSchema,
wecom: legacyStoredSettingsSchema.shape.wecom,
dingtalk: legacyStoredSettingsSchema.shape.dingtalk
})
.strict()
type EnvironmentChannel = {
owned: boolean
enabled: boolean
id: string
secret?: string
allowedSenderIds: readonly string[]
allowGroupMessages: boolean
error?: string
}
export type ResolvedChannelSettings =
| {
channel: 'weixin'
enabled: boolean
accountId: string
userId: string
baseUrl: string
token?: string
allowedSenderIds: readonly string[]
allowGroupMessages: false
source: 'none' | 'encrypted'
readOnly: false
}
| {
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: 3,
weixin: {
enabled: false
},
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)
}
const weixinBindingSchema = z
.object({
accountId: z
.string()
.trim()
.min(1)
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength),
userId: z
.string()
.trim()
.min(1)
.max(CHANNEL_SETTINGS_LIMITS.maximumIdentifierLength),
baseUrl: z
.string()
.url()
.max(2_048)
.refine((value) => new URL(value).protocol === 'https:', {
message: '微信服务地址必须使用 HTTPS'
}),
token: z
.string()
.trim()
.min(1)
.max(CHANNEL_SETTINGS_LIMITS.maximumSecretLength)
})
.strict()
export type WeixinBinding = z.infer<typeof weixinBindingSchema>
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 [weixin, wecom, dingtalk] = await Promise.all([
this.resolve('weixin'),
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 {
weixin: {
enabled: weixin.enabled,
bindingConfigured: weixin.token !== undefined,
source: weixin.source,
accountDisplay: weixinAccountDisplay(weixin.userId),
status: statuses.weixin ?? defaultStatus(weixin.enabled)
},
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: 'weixin'): Promise<Extract<ResolvedChannelSettings, {
channel: 'weixin'
}>>
resolve(channel: ManagedChannel): Promise<ResolvedChannelSettings>
async resolve(channel: ManagedChannel): Promise<ResolvedChannelSettings> {
if (channel === 'weixin') {
const settings = await this.load()
const stored = settings.weixin
const binding = this.decryptWeixinBinding(stored)
return {
channel,
enabled: stored.enabled,
accountId: binding?.accountId ?? '',
userId: binding?.userId ?? '',
baseUrl: binding?.baseUrl ?? '',
...(binding === undefined ? {} : { token: binding.token }),
allowedSenderIds: binding ? [binding.userId] : [],
allowGroupMessages: false,
source: binding === undefined ? 'none' : 'encrypted',
readOnly: false
}
}
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: 'weixin' }>,
Extract<ResolvedChannelSettings, { channel: 'wecom' }>,
Extract<ResolvedChannelSettings, { channel: 'dingtalk' }>
]> {
return Promise.all([
this.resolve('weixin'),
this.resolve('wecom'),
this.resolve('dingtalk')
])
}
async saveWeixinBinding(input: WeixinBinding): Promise<ChannelSettingsSnapshot> {
const parsed = weixinBindingSchema.parse(input)
let snapshot!: ChannelSettingsSnapshot
const update = async (): Promise<void> => {
const current = cloneStored(await this.load())
current.weixin = {
enabled: true,
credential: this.encryptWeixinBinding(parsed)
}
await this.persist(current)
this.settings = current
this.warning = undefined
snapshot = await this.snapshot()
}
const operation = this.updateQueue.then(update, update)
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation.then(() => snapshot)
}
async clearWeixinBinding(): Promise<ChannelSettingsSnapshot> {
let snapshot!: ChannelSettingsSnapshot
const update = async (): Promise<void> => {
const current = cloneStored(await this.load())
current.weixin = {
enabled: false
}
await this.persist(current)
this.settings = current
this.warning = undefined
snapshot = await this.snapshot()
}
const operation = this.updateQueue.then(update, update)
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation.then(() => snapshot)
}
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.weixin !== undefined) {
current.weixin.enabled = input.weixin.enabled
}
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.validateEnabledWeixin(current.weixin)
this.validateEnabledCredentialChannel('wecom', current.wecom)
this.validateEnabledCredentialChannel('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: CredentialChannel,
current: StoredCredentialChannel,
input: WeComChannelSettingsInput | DingTalkChannelSettingsInput
): StoredCredentialChannel {
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 validateEnabledWeixin(
stored: StoredSettings['weixin']
): void {
if (!stored.enabled) {
return
}
if (
this.decryptWeixinBinding(stored) === undefined
) {
throw new Error('启用微信 ClawBot 前需要先完成扫码绑定')
}
}
private validateEnabledCredentialChannel(
channel: CredentialChannel,
stored: StoredCredentialChannel
): 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: CredentialChannel,
secret: string
): StoredEncryptedCredential {
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: CredentialChannel,
stored: StoredCredentialChannel
): 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 encryptWeixinBinding(
binding: WeixinBinding
): StoredEncryptedCredential {
if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,无法保存微信绑定')
}
const encrypted = this.cipher.encrypt(
JSON.stringify({
version: 2,
channel: 'weixin',
accountId: binding.accountId,
userId: binding.userId,
baseUrl: binding.baseUrl,
token: binding.token
})
)
return {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: encrypted.toString('base64')
}
}
private decryptWeixinBinding(
stored: StoredSettings['weixin']
): WeixinBinding | undefined {
if (stored.credential === undefined || !this.cipher.isAvailable()) {
return undefined
}
try {
return weixinCredentialPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(stored.credential.ciphertextBase64, 'base64')
)
)
)
} catch {
return undefined
}
}
private async load(): Promise<StoredSettings> {
if (this.settings !== undefined) {
return this.settings
}
try {
const raw: unknown = JSON.parse(await readFile(this.filePath, 'utf8'))
const current = storedSettingsSchema.safeParse(raw)
if (current.success) {
this.settings = current.data
} else {
const versionTwo = versionTwoStoredSettingsSchema.safeParse(raw)
if (versionTwo.success) {
const legacyWeixin = versionTwo.data.weixin
let token: string | undefined
if (
legacyWeixin.credential &&
this.cipher.isAvailable()
) {
try {
const payload = credentialPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(
legacyWeixin.credential.ciphertextBase64,
'base64'
)
)
)
)
token =
payload.channel === 'weixin'
? payload.secret
: undefined
} catch {
token = undefined
}
}
const binding =
token &&
legacyWeixin.accountId &&
legacyWeixin.userId &&
legacyWeixin.baseUrl
? {
accountId: legacyWeixin.accountId,
userId: legacyWeixin.userId,
baseUrl: legacyWeixin.baseUrl,
token
}
: undefined
this.settings = {
version: 3,
weixin: {
enabled: binding ? legacyWeixin.enabled : false,
...(binding
? { credential: this.encryptWeixinBinding(binding) }
: {})
},
wecom: versionTwo.data.wecom,
dingtalk: versionTwo.data.dingtalk
}
if (legacyWeixin.enabled && !binding) {
this.warning =
'旧版微信绑定无法安全迁移,请重新扫码绑定'
}
} else {
const legacy = legacyStoredSettingsSchema.parse(raw)
this.settings = {
version: 3,
weixin: {
enabled: false
},
wecom: legacy.wecom,
dingtalk: legacy.dingtalk
}
}
await this.persist(this.settings)
}
} 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: CredentialChannel): 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'
? '企业微信环境变量配置无效或不完整'
: '钉钉环境变量配置无效或不完整'
})
}
}
}