Connect a user-created Telegram bot to channel projects with encrypted credentials, numeric sender allowlists, unified execution and text replies. Use Electron networking for system proxy support, with polling recovery, cancellable delivery retries and live status updates. Add schema 61 channel support, lifecycle regression tests, Electron integration probes and feature documentation. Require the performance principles in AGENTS.md and record three alternating baseline/current Main benchmark rounds. Validation: typecheck, lint and diff checks passed. Full suite: 6087 passed, 87 skipped, one intermittent inline-help Electron failure that passed unchanged in isolation. Real Bot plus model acceptance remains pending; the local TLS proxy probe is opt-in. Release note: 新增 Telegram 私聊通道,可配置 Bot Token 和用户白名单,通过通道项目处理文字请求并回复结果。支持系统代理、断线重连和停用取消;真实 Bot 与模型的完整联调仍待完成。
620 lines
19 KiB
TypeScript
620 lines
19 KiB
TypeScript
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('persists encrypted Telegram settings with empty whitelist and secret actions', async () => {
|
|
const filePath = await settingsPath()
|
|
const store = new ChannelSettingsStore(filePath, createCipher(), {})
|
|
const input = { enabled: true, allowedSenderIds: [], allowGroupMessages: false as const }
|
|
const snapshot = await store.apply({ telegram: {
|
|
...input, secret: { action: 'replace', value: 'test-only-secret' }
|
|
} })
|
|
expect(snapshot.telegram).toMatchObject({ enabled: true, secretConfigured: true, source: 'encrypted' })
|
|
expect(JSON.stringify(snapshot)).not.toContain('test-only-secret')
|
|
expect(snapshot.telegram).not.toHaveProperty('botId')
|
|
expect(await readFile(filePath, 'utf8')).not.toContain('test-only-secret')
|
|
const reloaded = new ChannelSettingsStore(filePath, createCipher(), {})
|
|
expect(await reloaded.resolve('telegram')).toEqual({
|
|
channel: 'telegram', ...input, secret: 'test-only-secret', source: 'encrypted', readOnly: false
|
|
})
|
|
await reloaded.apply({ telegram: { ...input, secret: { action: 'keep' }, allowedSenderIds: ['123'] } })
|
|
expect((await reloaded.resolve('telegram')).secret).toBe('test-only-secret')
|
|
await reloaded.apply({ telegram: { ...input, secret: { action: 'replace', value: 'replacement-test-secret' } } })
|
|
expect((await reloaded.resolve('telegram')).secret).toBe('replacement-test-secret')
|
|
const unreadable = new ChannelSettingsStore(filePath, createCipher(false), {})
|
|
expect((await unreadable.snapshot()).telegram).toMatchObject({ source: 'unreadable', secretConfigured: false })
|
|
await reloaded.apply({ telegram: { ...input, enabled: false, secret: { action: 'clear' } } })
|
|
expect((await reloaded.snapshot()).telegram).toMatchObject({ enabled: false, source: 'none', secretConfigured: false })
|
|
await expect(reloaded.apply({ telegram: { ...input, secret: { action: 'keep' } } })).rejects.toThrow('Bot Token')
|
|
})
|
|
|
|
it('loads existing version 3 settings with Telegram defaults without rewriting', async () => {
|
|
const filePath = await settingsPath()
|
|
const original = JSON.stringify({
|
|
version: 3,
|
|
weixin: { enabled: false },
|
|
wecom: { enabled: false, botId: '', allowedSenderIds: [], allowGroupMessages: false },
|
|
dingtalk: { enabled: false, clientId: '', allowedSenderIds: [], allowGroupMessages: false }
|
|
})
|
|
await writeFile(filePath, original)
|
|
const store = new ChannelSettingsStore(filePath, createCipher(), {})
|
|
expect((await store.snapshot()).telegram).toEqual({
|
|
enabled: false, secretConfigured: false, source: 'none', readOnly: false,
|
|
allowedSenderIds: [], allowGroupMessages: false, status: { state: 'disabled' }
|
|
})
|
|
expect(await readFile(filePath, 'utf8')).toBe(original)
|
|
})
|
|
|
|
it('uses read-only Telegram environment credentials, including help-only startup', async () => {
|
|
const filePath = await settingsPath()
|
|
const environment = { GOODBUDDY_TELEGRAM_BOT_TOKEN: 'environment-test-secret' }
|
|
const store = new ChannelSettingsStore(filePath, createCipher(), environment)
|
|
expect(await store.resolve('telegram')).toEqual({
|
|
channel: 'telegram', enabled: true, secret: 'environment-test-secret',
|
|
allowedSenderIds: [], allowGroupMessages: false, source: 'environment', readOnly: true
|
|
})
|
|
await expect(store.apply({ telegram: {
|
|
enabled: false, secret: { action: 'clear' }, allowedSenderIds: [], allowGroupMessages: false
|
|
} })).rejects.toThrow('环境变量配置')
|
|
const configured = new ChannelSettingsStore(filePath, createCipher(), {
|
|
...environment, GOODBUDDY_TELEGRAM_ALLOWED_SENDERS: '123, 456,123', GOODBUDDY_TELEGRAM_ENABLED: 'false'
|
|
})
|
|
expect(await configured.resolve('telegram')).toMatchObject({ enabled: false, allowedSenderIds: ['123', '456'] })
|
|
for (const invalid of [
|
|
{ GOODBUDDY_TELEGRAM_ALLOWED_SENDERS: '@user' },
|
|
{ GOODBUDDY_TELEGRAM_ALLOW_GROUPS: 'true' },
|
|
{ GOODBUDDY_TELEGRAM_ENABLED: 'invalid' }
|
|
]) {
|
|
const invalidStore = new ChannelSettingsStore(filePath, createCipher(), { ...environment, ...invalid })
|
|
expect((await invalidStore.snapshot()).telegram).toMatchObject({
|
|
enabled: false, readOnly: true, allowGroupMessages: false, status: { state: 'error' }
|
|
})
|
|
}
|
|
})
|
|
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.warnings).toContainEqual({
|
|
code: 'channel-settings-recovered'
|
|
})
|
|
expect(
|
|
(await readdir(join(filePath, '..'))).some((name) =>
|
|
name.startsWith('channel-settings.json.corrupt-1234-')
|
|
)
|
|
).toBe(true)
|
|
|
|
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(3)
|
|
expect(persisted.dingtalk.allowedSenderIds).toEqual(['staff-a'])
|
|
expect((await readdir(join(filePath, '..'))).some(
|
|
(name) => name.endsWith('.tmp')
|
|
)).toBe(false)
|
|
await expect(store.snapshot()).resolves.not.toHaveProperty(
|
|
'warnings'
|
|
)
|
|
})
|
|
|
|
it('encrypts Weixin binding credentials and removes them on disconnect', async () => {
|
|
const filePath = await settingsPath()
|
|
const store = new ChannelSettingsStore(filePath, createCipher(), {})
|
|
|
|
const bound = await store.saveWeixinBinding({
|
|
accountId: 'account-123456',
|
|
userId: 'user-654321',
|
|
baseUrl: 'http://ilinkai.weixin.qq.com',
|
|
token: 'weixin-private-token'
|
|
})
|
|
expect(bound.weixin).toMatchObject({
|
|
enabled: true,
|
|
bindingConfigured: true,
|
|
source: 'encrypted',
|
|
accountDisplay: '微信用户 ****4321'
|
|
})
|
|
const raw = await readFile(filePath, 'utf8')
|
|
expect(raw).not.toContain('weixin-private-token')
|
|
expect(raw).not.toContain('user-654321')
|
|
expect(await store.resolve('weixin')).toMatchObject({
|
|
enabled: true,
|
|
accountId: 'account-123456',
|
|
userId: 'user-654321',
|
|
token: 'weixin-private-token'
|
|
})
|
|
|
|
const disconnected = await store.clearWeixinBinding()
|
|
expect(disconnected.weixin).toMatchObject({
|
|
enabled: false,
|
|
bindingConfigured: false,
|
|
source: 'none'
|
|
})
|
|
expect((await store.resolve('weixin')).token).toBeUndefined()
|
|
})
|
|
|
|
it('defers version 2 Weixin migration until safe storage recovers', async () => {
|
|
const filePath = await settingsPath()
|
|
let available = false
|
|
const cipher = createCipher()
|
|
const dynamicCipher: ChannelCredentialCipher = {
|
|
...cipher,
|
|
isAvailable: () => available
|
|
}
|
|
const legacyCredential = {
|
|
formatVersion: 1,
|
|
scheme: 'electron-safe-storage',
|
|
ciphertextBase64: cipher
|
|
.encrypt(
|
|
JSON.stringify({
|
|
version: 1,
|
|
channel: 'weixin',
|
|
secret: 'legacy-weixin-token'
|
|
})
|
|
)
|
|
.toString('base64')
|
|
}
|
|
const legacySettings = JSON.stringify({
|
|
version: 2,
|
|
weixin: {
|
|
enabled: true,
|
|
credential: legacyCredential,
|
|
accountId: 'account-legacy',
|
|
userId: 'user-legacy',
|
|
baseUrl: 'https://ilinkai.weixin.qq.com'
|
|
},
|
|
wecom: {
|
|
enabled: false,
|
|
botId: '',
|
|
allowedSenderIds: [],
|
|
allowGroupMessages: false
|
|
},
|
|
dingtalk: {
|
|
enabled: false,
|
|
clientId: '',
|
|
allowedSenderIds: [],
|
|
allowGroupMessages: false
|
|
}
|
|
})
|
|
await writeFile(filePath, legacySettings, 'utf8')
|
|
const store = new ChannelSettingsStore(filePath, dynamicCipher, {})
|
|
|
|
await expect(store.snapshot()).rejects.toThrow(
|
|
'安全存储暂不可用'
|
|
)
|
|
expect(await readFile(filePath, 'utf8')).toBe(legacySettings)
|
|
expect(
|
|
(await readdir(join(filePath, '..'))).some((name) =>
|
|
name.startsWith('channel-settings.json.corrupt-')
|
|
)
|
|
).toBe(false)
|
|
|
|
available = true
|
|
await expect(store.snapshot()).resolves.toMatchObject({
|
|
weixin: {
|
|
enabled: true,
|
|
bindingConfigured: true,
|
|
source: 'encrypted'
|
|
}
|
|
})
|
|
await expect(store.resolve('weixin')).resolves.toMatchObject({
|
|
accountId: 'account-legacy',
|
|
userId: 'user-legacy',
|
|
token: 'legacy-weixin-token'
|
|
})
|
|
expect(
|
|
JSON.parse(await readFile(filePath, 'utf8'))
|
|
).toMatchObject({
|
|
version: 3,
|
|
weixin: {
|
|
enabled: true,
|
|
credential: expect.any(Object)
|
|
}
|
|
})
|
|
})
|
|
|
|
it('preserves settings created by a newer unsupported version', async () => {
|
|
const filePath = await settingsPath()
|
|
const futureSettings = JSON.stringify({
|
|
version: 99,
|
|
futureField: 'keep-me'
|
|
})
|
|
await writeFile(filePath, futureSettings, 'utf8')
|
|
const store = new ChannelSettingsStore(
|
|
filePath,
|
|
createCipher(),
|
|
{}
|
|
)
|
|
|
|
await expect(store.snapshot()).rejects.toThrow(
|
|
'不支持通道设置版本 99'
|
|
)
|
|
expect(await readFile(filePath, 'utf8')).toBe(futureSettings)
|
|
expect(
|
|
(await readdir(join(filePath, '..'))).some((name) =>
|
|
name.startsWith('channel-settings.json.corrupt-')
|
|
)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('does not start Weixin with a temporarily unavailable credential', async () => {
|
|
const filePath = await settingsPath()
|
|
const availableStore = new ChannelSettingsStore(
|
|
filePath,
|
|
createCipher(),
|
|
{}
|
|
)
|
|
await availableStore.saveWeixinBinding({
|
|
accountId: 'account-123',
|
|
userId: 'user-123',
|
|
baseUrl: 'https://ilinkai.weixin.qq.com',
|
|
token: 'private-token'
|
|
})
|
|
|
|
const unavailableStore = new ChannelSettingsStore(
|
|
filePath,
|
|
createCipher(false),
|
|
{}
|
|
)
|
|
await expect(unavailableStore.resolve('weixin')).resolves.toMatchObject({
|
|
enabled: false,
|
|
source: 'none'
|
|
})
|
|
await expect(unavailableStore.snapshot()).resolves.toMatchObject({
|
|
weixin: {
|
|
enabled: false,
|
|
bindingConfigured: false
|
|
},
|
|
warnings: expect.arrayContaining([
|
|
{ code: 'channel-weixin-secure-storage-unavailable' }
|
|
])
|
|
})
|
|
expect(
|
|
JSON.parse(await readFile(filePath, 'utf8'))
|
|
).toMatchObject({
|
|
version: 3,
|
|
weixin: {
|
|
enabled: true,
|
|
credential: expect.any(Object)
|
|
}
|
|
})
|
|
|
|
await unavailableStore.apply({
|
|
wecom: {
|
|
enabled: false,
|
|
botId: 'bot-id',
|
|
secret: { action: 'keep' },
|
|
allowedSenderIds: [],
|
|
allowGroupMessages: false
|
|
}
|
|
})
|
|
expect(
|
|
JSON.parse(await readFile(filePath, 'utf8'))
|
|
).toMatchObject({
|
|
weixin: {
|
|
enabled: true,
|
|
credential: expect.any(Object)
|
|
}
|
|
})
|
|
})
|
|
|
|
it('distinguishes unreadable channel credentials from missing secrets', async () => {
|
|
const filePath = await settingsPath()
|
|
const availableStore = new ChannelSettingsStore(
|
|
filePath,
|
|
createCipher(),
|
|
{}
|
|
)
|
|
await availableStore.apply({
|
|
wecom: {
|
|
enabled: false,
|
|
botId: 'bot-id',
|
|
secret: { action: 'replace', value: 'private-secret' },
|
|
allowedSenderIds: ['sender-a'],
|
|
allowGroupMessages: false
|
|
}
|
|
})
|
|
const unreadableStore = new ChannelSettingsStore(
|
|
filePath,
|
|
{
|
|
...createCipher(),
|
|
decrypt: () => {
|
|
throw new Error('cannot decrypt')
|
|
}
|
|
},
|
|
{}
|
|
)
|
|
|
|
await expect(unreadableStore.snapshot()).resolves.toMatchObject({
|
|
wecom: {
|
|
secretConfigured: false,
|
|
source: 'unreadable'
|
|
},
|
|
warnings: expect.arrayContaining([
|
|
{ code: 'channel-wecom-credential-unreadable' }
|
|
])
|
|
})
|
|
|
|
await unreadableStore.apply({
|
|
wecom: {
|
|
enabled: false,
|
|
botId: 'replacement-bot',
|
|
secret: { action: 'clear' },
|
|
allowedSenderIds: ['sender-a'],
|
|
allowGroupMessages: false
|
|
}
|
|
})
|
|
await expect(unreadableStore.snapshot()).resolves.toMatchObject({
|
|
wecom: {
|
|
source: 'none'
|
|
}
|
|
})
|
|
expect(
|
|
(await unreadableStore.snapshot()).warnings ?? []
|
|
).not.toContainEqual({
|
|
code: 'channel-wecom-credential-unreadable'
|
|
})
|
|
})
|
|
})
|
|
|
|
it.each(['wecom', 'dingtalk'] as const)(
|
|
'clears an unreadable %s credential warning after decryption recovers',
|
|
async (channel) => {
|
|
const filePath = await settingsPath()
|
|
const availableCipher = createCipher()
|
|
const availableStore = new ChannelSettingsStore(
|
|
filePath,
|
|
availableCipher,
|
|
{}
|
|
)
|
|
await availableStore.apply(
|
|
channel === 'wecom'
|
|
? {
|
|
wecom: {
|
|
enabled: false,
|
|
botId: 'bot-id',
|
|
secret: { action: 'replace', value: 'private-secret' },
|
|
allowedSenderIds: ['sender-a'],
|
|
allowGroupMessages: false
|
|
}
|
|
}
|
|
: {
|
|
dingtalk: {
|
|
enabled: false,
|
|
clientId: 'client-id',
|
|
secret: { action: 'replace', value: 'private-secret' },
|
|
allowedSenderIds: ['sender-a'],
|
|
allowGroupMessages: false
|
|
}
|
|
}
|
|
)
|
|
let decryptAvailable = false
|
|
const recoveringStore = new ChannelSettingsStore(
|
|
filePath,
|
|
{
|
|
...availableCipher,
|
|
decrypt: (value) => {
|
|
if (!decryptAvailable) {
|
|
throw new Error('secure storage is temporarily unavailable')
|
|
}
|
|
return availableCipher.decrypt(value)
|
|
}
|
|
},
|
|
{}
|
|
)
|
|
const warningCode =
|
|
channel === 'wecom'
|
|
? 'channel-wecom-credential-unreadable'
|
|
: 'channel-dingtalk-credential-unreadable'
|
|
|
|
await expect(recoveringStore.snapshot()).resolves.toMatchObject({
|
|
[channel]: { source: 'unreadable' },
|
|
warnings: expect.arrayContaining([{ code: warningCode }])
|
|
})
|
|
|
|
decryptAvailable = true
|
|
await expect(recoveringStore.resolve(channel)).resolves.toMatchObject({
|
|
source: 'encrypted',
|
|
secret: 'private-secret'
|
|
})
|
|
expect((await recoveringStore.snapshot()).warnings ?? []).not.toContainEqual(
|
|
{ code: warningCode }
|
|
)
|
|
}
|
|
)
|