feat: add Telegram private messaging channel

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 与模型的完整联调仍待完成。
This commit is contained in:
mesalogo
2026-10-05 02:42:28 +08:00
parent 78afec7775
commit cf598121b1
56 changed files with 4144 additions and 194 deletions
@@ -2841,6 +2841,14 @@ describe('AssistantDatabase', () => {
expect.objectContaining({
kind: 'channel',
channel: 'dingtalk'
}),
expect.objectContaining({
name: 'Telegram',
kind: 'channel',
channel: 'telegram',
rootPath: 'C:\\Users\\test',
executionSpace: { kind: 'local', rootPath: 'C:\\Users\\test' },
runtimeSelection: { provider: 'model' }
})
])
expect(second.map((project) => project.id)).toEqual(
@@ -2891,6 +2899,101 @@ describe('AssistantDatabase', () => {
database.close()
})
it('relaxes schema 60 channel checks without rewriting or losing existing records', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-telegram-migration-'))
temporaryDirectories.push(directory)
const path = join(directory, 'assistant.sqlite')
const original = new AssistantDatabase(path)
original.initialize('C:\\Workspace')
const projects = original.ensureChannelProjects('C:\\Channels')
for (const project of projects.filter(project => project.channel !== 'telegram')) {
const conversation = original.getOrCreateRemoteConversation({
projectId: project.id, channel: project.channel!, accountId: 'legacy-account',
externalConversationId: 'legacy-chat', conversationType: 'direct',
title: 'Legacy conversation', accountDisplay: 'Legacy account',
runtimeSelection: { provider: 'model' }
})
original.appendRemoteConversationMessage({ conversationId: conversation.id, role: 'user', content: 'Preserve this message' })
}
original.close()
const legacy = new DatabaseSync(path)
legacy.enableDefensive(false)
legacy.exec(`PRAGMA foreign_keys = ON; DELETE FROM projects WHERE channel = 'telegram';
PRAGMA writable_schema = ON;
UPDATE sqlite_schema SET sql = replace(sql,
'channel IN (''weixin'', ''wecom'', ''dingtalk'', ''telegram'')',
'channel IN (''weixin'', ''wecom'', ''dingtalk'')')
WHERE type = 'table' AND name IN ('projects', 'conversations');
PRAGMA writable_schema = RESET; PRAGMA user_version = 60;`)
const tables = ['projects', 'project_execution_spaces', 'conversations', 'messages']
const rows = tables.map(table => legacy.prepare(`SELECT rowid, * FROM ${table} ORDER BY rowid`).all())
const schema = legacy.prepare('SELECT type, name, tbl_name, rootpage, sql FROM sqlite_schema ORDER BY name').all()
expect(() => legacy.prepare("UPDATE projects SET channel = 'telegram' WHERE channel = 'weixin'").run()).toThrow(/CHECK/)
legacy.close()
const migrated = new AssistantDatabase(path)
const progress = vi.fn()
migrated.upgradeStorage(progress, () => false, { migrateNotes: false, reclaimSpace: false })
expect(progress).not.toHaveBeenCalled()
const check = new DatabaseSync(path)
expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(61)
expect(tables.map(table => check.prepare(`SELECT rowid, * FROM ${table} ORDER BY rowid`).all())).toEqual(rows)
expect(check.prepare('SELECT type, name, tbl_name, rootpage, sql FROM sqlite_schema ORDER BY name').all()).toEqual(
schema.map(row => ['projects', 'conversations'].includes(row.name as string) && row.type === 'table'
? { ...row, sql: (row.sql as string).replace("channel IN ('weixin', 'wecom', 'dingtalk')", "channel IN ('weixin', 'wecom', 'dingtalk', 'telegram')") }
: row)
)
expect(check.prepare('PRAGMA integrity_check').get()!.integrity_check).toBe('ok')
expect(check.prepare('PRAGMA foreign_key_check').all()).toEqual([])
expect(() => check.prepare("UPDATE projects SET channel = 'invalid' WHERE channel = 'weixin'").run()).toThrow(/CHECK/)
expect(() => check.prepare("UPDATE conversations SET channel = 'invalid' WHERE channel = 'weixin'").run()).toThrow(/CHECK/)
check.close()
migrated.initialize('C:\\Workspace')
const telegram = migrated.ensureChannelProjects('C:\\Telegram').find(project => project.channel === 'telegram')!
const input = {
projectId: telegram.id, channel: 'telegram' as const, accountId: 'bot',
externalConversationId: '-100123', conversationType: 'group' as const,
title: 'Telegram chat', accountDisplay: 'Telegram', runtimeSelection: { provider: 'model' as const }
}
const conversation = migrated.getOrCreateRemoteConversation(input)
migrated.close()
const reopened = new AssistantDatabase(path)
reopened.initialize('C:\\Workspace')
expect(reopened.ensureChannelProjects('C:\\Ignored').find(project => project.channel === 'telegram')).toEqual(telegram)
expect(reopened.getOrCreateRemoteConversation(input).id).toBe(conversation.id)
expect(reopened.getConversation(conversation.id).remote?.channel).toBe('telegram')
reopened.close()
})
it('rolls back the channel CHECK migration if a schema definition is unexpected', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-telegram-rollback-'))
temporaryDirectories.push(directory)
const path = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(path)
database.initialize('C:\\Workspace')
database.close()
const legacy = new DatabaseSync(path)
legacy.enableDefensive(false)
legacy.exec(`PRAGMA writable_schema = ON;
UPDATE sqlite_schema SET sql = replace(sql,
'channel IN (''weixin'', ''wecom'', ''dingtalk'', ''telegram'')',
'channel IN (''weixin'', ''wecom'', ''dingtalk'')') WHERE name = 'projects';
UPDATE sqlite_schema SET sql = replace(sql,
'channel IN (''weixin'', ''wecom'', ''dingtalk'', ''telegram'')',
'channel IN (''weixin'')') WHERE name = 'conversations';
PRAGMA writable_schema = RESET; PRAGMA user_version = 60;`)
const before = legacy.prepare('SELECT * FROM sqlite_schema ORDER BY name').all()
legacy.close()
expect(() => database.upgradeStorage(() => undefined, () => false, { migrateNotes: false, reclaimSpace: false }))
.toThrow('Unrecognized channel constraint: conversations')
const check = new DatabaseSync(path)
expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(60)
expect(check.prepare('SELECT * FROM sqlite_schema ORDER BY name').all()).toEqual(before)
expect(check.prepare('PRAGMA integrity_check').get()!.integrity_check).toBe('ok')
check.close()
})
it('persists one protected remote conversation per channel identity', async () => {
const database = await createDatabase()
const project = database.ensureChannelProjects(
@@ -3099,6 +3202,31 @@ describe('AssistantDatabase', () => {
database.close()
})
it('persists an immediate terminal result without attachments or repeated attempt counts', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-channel-terminal-'))
temporaryDirectories.push(directory)
const path = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(path)
database.initialize('C:\\Workspace')
const entry = database.enqueueChannelResult({
channel: 'telegram', eventId: 'permanent-failure', conversationId: 'chat',
recipientId: 'user', status: 'completed', output: 'Keep result text',
attachments: [{ name: 'result.txt', mimeType: 'text/plain', size: 1, kind: 'file', dataBase64: 'eA==' }]
})
database.markChannelResult(entry.id, 'terminal')
database.close()
const reopened = new AssistantDatabase(path)
reopened.initialize('C:\\Workspace')
reopened.markChannelResult(entry.id, 'terminal')
reopened.markChannelResult(entry.id, 'failed')
expect(reopened.listUndeliveredChannelResults('telegram')).toEqual([
expect.objectContaining({ id: entry.id, state: 'terminal', attempts: 1,
message: expect.objectContaining({ output: 'Keep result text' }) })
])
expect(reopened.listUndeliveredChannelResults('telegram')[0]!.message).not.toHaveProperty('attachments')
reopened.close()
})
it('migrates exhausted legacy outbox failures to terminal state', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-channel-terminal-migration-')
+44 -9
View File
@@ -136,7 +136,7 @@ import {
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 60
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 61
function withoutHistoricalWorkMode(value: unknown): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
@@ -2623,6 +2623,11 @@ export class AssistantDatabase {
channel: 'dingtalk',
name: '钉钉',
description: '钉钉远程消息与受控任务'
},
{
channel: 'telegram',
name: 'Telegram',
description: 'Telegram 远程消息与受控任务'
}
]
const find = database.prepare(
@@ -4084,7 +4089,7 @@ export class AssistantDatabase {
markChannelResult(
id: string,
state: 'delivered' | 'failed'
state: 'delivered' | 'failed' | 'terminal'
): void {
this.requireDatabase()
.prepare(
@@ -4096,11 +4101,11 @@ export class AssistantDatabase {
END,
attempts = attempts + 1,
message_json = CASE
WHEN ? = 'delivered' OR attempts + 1 >= 5
WHEN ? IN ('delivered', 'terminal') OR attempts + 1 >= 5
THEN json_remove(message_json, '$.attachments')
ELSE message_json
END
WHERE id = ?`
WHERE id = ? AND state NOT IN ('delivered', 'terminal')`
)
.run(state, state, state, id)
}
@@ -4124,10 +4129,10 @@ export class AssistantDatabase {
`WITH pending AS (
SELECT id, message_json, state, attempts, created_at,
ROW_NUMBER() OVER (
ORDER BY attempts ASC, created_at ASC
ORDER BY (state = 'terminal') ASC, attempts ASC, created_at ASC, id ASC
) AS position,
SUM(LENGTH(CAST(message_json AS BLOB))) OVER (
ORDER BY attempts ASC, created_at ASC
ORDER BY (state = 'terminal') ASC, attempts ASC, created_at ASC, id ASC
) AS cumulative_bytes
FROM channel_outbox
WHERE state != 'delivered'
@@ -4136,7 +4141,7 @@ export class AssistantDatabase {
SELECT id, message_json, state, attempts, created_at
FROM pending
WHERE position = 1 OR cumulative_bytes <= ?
ORDER BY attempts ASC, created_at ASC
ORDER BY (state = 'terminal') ASC, attempts ASC, created_at ASC, id ASC
LIMIT ?`
)
.all(
@@ -10663,7 +10668,7 @@ export class AssistantDatabase {
ADD COLUMN channel TEXT
CHECK(
channel IS NULL OR
channel IN ('weixin', 'wecom', 'dingtalk')
channel IN ('weixin', 'wecom', 'dingtalk', 'telegram')
);
`)
}
@@ -10694,7 +10699,7 @@ export class AssistantDatabase {
ALTER TABLE conversations ADD COLUMN channel TEXT
CHECK(
channel IS NULL OR
channel IN ('weixin', 'wecom', 'dingtalk')
channel IN ('weixin', 'wecom', 'dingtalk', 'telegram')
);
`)
}
@@ -12299,6 +12304,36 @@ export class AssistantDatabase {
`)
} catch (error) { database.exec('ROLLBACK'); throw error }
}
if (version.user_version < 61) {
database.exec('BEGIN IMMEDIATE')
try {
// SQLite's metadata-only CHECK relaxation preserves table rows, rowids,
// indexes and references without rebuilding potentially large conversations.
const previous = "channel IN ('weixin', 'wecom', 'dingtalk')"
const expanded = "channel IN ('weixin', 'wecom', 'dingtalk', 'telegram')"
const schemaVersion = database.prepare('PRAGMA schema_version').get() as { schema_version: number }
database.enableDefensive(false)
for (const table of ['projects', 'conversations']) {
const definition = database.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table) as { sql: string }
if (definition.sql.includes(expanded)) continue
if (!definition.sql.includes(previous)) throw new Error(`Unrecognized channel constraint: ${table}`)
database.exec('PRAGMA writable_schema = ON')
database.prepare("UPDATE sqlite_schema SET sql = ? WHERE type = 'table' AND name = ?")
.run(definition.sql.replace(previous, expanded), table)
}
database.exec(`PRAGMA schema_version = ${schemaVersion.schema_version + 1}; PRAGMA writable_schema = RESET;`)
// Reparse with writable_schema disabled so invalid definitions roll back.
database.prepare('SELECT channel FROM projects LIMIT 0').all()
database.prepare('SELECT channel FROM conversations LIMIT 0').all()
database.exec('PRAGMA user_version = 61; COMMIT;')
} catch (error) {
database.exec('ROLLBACK')
throw error
} finally {
database.exec('PRAGMA writable_schema = RESET')
database.enableDefensive(true)
}
}
}
supervisionExperiences(): SupervisionExperienceStore {
@@ -110,7 +110,7 @@ describe('subagent progress storage', () => {
for (const [table, column] of [['projects', 'default_work_mode'], ['conversations', 'work_mode'], ['tasks', 'work_mode']]) {
expect(check.prepare(`PRAGMA table_info(${table})`).all().map(row => row.name)).not.toContain(column)
}
expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(60)
expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
const readJson = (table: string, column: string, id: string) =>
JSON.parse(check.prepare(`SELECT ${column} AS value FROM ${table} WHERE id = ?`).get(id)!.value as string)
expect(readJson('conversations', 'context_state_json', header.id))
@@ -164,7 +164,7 @@ describe('subagent progress storage', () => {
sql.prepare('UPDATE task_events SET payload_json = ? WHERE id = ?').run('{"workMode":"ask"}', damagedId)
upgradeAssistantStorage(path, () => undefined)
expect(sql.prepare('SELECT payload_json FROM task_events WHERE id = ?').get(damagedId)!.payload_json).toBe('{}')
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(60)
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
expect(sql.prepare('PRAGMA integrity_check').get()!.integrity_check).toBe('ok')
} finally { sql.close() }
})