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
+4 -2
View File
@@ -29,13 +29,15 @@ export const projectKindSchema = z.enum(['user', 'channel'])
export const projectChannels = [
'weixin',
'wecom',
'dingtalk'
'dingtalk',
'telegram'
] as const
export const projectChannelSchema = z.enum(projectChannels)
export const projectChannelLabels: Record<ProjectChannel, string> = {
weixin: '微信 ClawBot',
wecom: '企业微信',
dingtalk: '钉钉'
dingtalk: '钉钉',
telegram: 'Telegram'
}
export type ProjectKind = z.infer<typeof projectKindSchema>
@@ -2,12 +2,57 @@ import { describe, expect, it } from 'vitest'
import {
CHANNEL_SETTINGS_LIMITS,
channelConnectionTestResultSchema,
channelRuntimeStatusChangeSchema,
channelSecretUpdateSchema,
channelSettingsApplySchema,
channelSettingsSnapshotSchema
} from './channel-settings-contracts'
describe('channel settings contracts', () => {
it('validates incremental runtime status changes without settings or credentials', () => {
const change = { channel: 'telegram', status: { state: 'running' } }
expect(channelRuntimeStatusChangeSchema.parse(change)).toEqual(change)
for (const invalid of [
{ ...change, secret: 'unexpected' },
{ ...change, channel: 'unknown' },
{ ...change, status: { state: 'unknown' } },
{ ...change, status: { state: 'error', lastError: 'x'.repeat(501) } }
]) {
expect(channelRuntimeStatusChangeSchema.safeParse(invalid).success).toBe(false)
}
})
it('accepts Telegram help-only settings and positive numeric sender IDs', () => {
const telegram = {
enabled: true,
secret: { action: 'keep' },
allowedSenderIds: [],
allowGroupMessages: false
}
expect(channelSettingsApplySchema.parse({ telegram })).toEqual({ telegram })
expect(channelSettingsApplySchema.parse({
telegram: { ...telegram, allowedSenderIds: [' 123 ', '123', '456'] }
}).telegram?.allowedSenderIds).toEqual(['123', '456'])
for (const sender of ['0', '-1', '1.2', '@user', '01', '1e3', '']) {
expect(channelSettingsApplySchema.safeParse({
telegram: { ...telegram, allowedSenderIds: [sender] }
}).success).toBe(false)
}
for (const extra of [{ botId: '123' }, { allowGroupMessages: true }]) {
expect(channelSettingsApplySchema.safeParse({ telegram: { ...telegram, ...extra } }).success).toBe(false)
}
})
it('only exposes botUsername on successful Telegram tests', () => {
expect(channelConnectionTestResultSchema.parse({
channel: 'telegram', ok: true, botUsername: 'example_bot'
}).botUsername).toBe('example_bot')
for (const result of [
{ channel: 'telegram', ok: false, error: 'failed' },
{ channel: 'wecom', ok: true }
]) {
expect(channelConnectionTestResultSchema.safeParse({ ...result, botUsername: 'example_bot' }).success).toBe(false)
}
})
it('accepts bounded strict WeCom and DingTalk updates', () => {
expect(
channelSettingsApplySchema.parse({
@@ -75,6 +120,15 @@ describe('channel settings contracts', () => {
it('models public credential source and runtime status without secrets', () => {
const snapshot = channelSettingsSnapshotSchema.parse({
telegram: {
enabled: false,
secretConfigured: false,
source: 'none',
readOnly: false,
allowedSenderIds: [],
allowGroupMessages: false,
status: { state: 'disabled' }
},
weixin: {
enabled: true,
bindingConfigured: true,
+43 -3
View File
@@ -15,7 +15,7 @@ export const CHANNEL_SETTINGS_LIMITS = {
export const managedChannelSchema = projectChannelSchema
export type ManagedChannel = ProjectChannel
export const credentialChannelSchema = z.enum(['wecom', 'dingtalk'])
export const credentialChannelSchema = z.enum(['wecom', 'dingtalk', 'telegram'])
export type CredentialChannel = z.infer<
typeof credentialChannelSchema
>
@@ -32,6 +32,11 @@ export const allowedSenderIdsSchema = z
.max(CHANNEL_SETTINGS_LIMITS.maximumAllowedSenders)
.transform((values) => [...new Set(values)])
export const telegramAllowedSenderIdsSchema = z
.array(senderIdentifierSchema.regex(/^[1-9][0-9]*$/u))
.max(CHANNEL_SETTINGS_LIMITS.maximumAllowedSenders)
.transform((values) => [...new Set(values)])
export const channelSecretUpdateSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('keep') }).strict(),
z
@@ -77,6 +82,15 @@ export type DingTalkChannelSettingsInput = z.infer<
typeof dingTalkChannelSettingsInputSchema
>
export const telegramChannelSettingsInputSchema = z.object({
...editableChannelFields,
allowedSenderIds: telegramAllowedSenderIdsSchema,
allowGroupMessages: z.literal(false)
}).strict()
export type TelegramChannelSettingsInput = z.infer<
typeof telegramChannelSettingsInputSchema
>
export const weixinChannelSettingsInputSchema = z
.object({
enabled: z.boolean()
@@ -90,14 +104,16 @@ export const channelSettingsApplySchema = z
.object({
weixin: weixinChannelSettingsInputSchema.optional(),
wecom: weComChannelSettingsInputSchema.optional(),
dingtalk: dingTalkChannelSettingsInputSchema.optional()
dingtalk: dingTalkChannelSettingsInputSchema.optional(),
telegram: telegramChannelSettingsInputSchema.optional()
})
.strict()
.refine(
(input) =>
input.weixin !== undefined ||
input.wecom !== undefined ||
input.dingtalk !== undefined,
input.dingtalk !== undefined ||
input.telegram !== undefined,
'至少需要提供一个通道设置'
)
export type ChannelSettingsApply = z.infer<
@@ -140,6 +156,14 @@ export type ChannelRuntimeStatus = z.infer<
typeof channelRuntimeStatusSchema
>
export const channelRuntimeStatusChangeSchema = z.object({
channel: managedChannelSchema,
status: channelRuntimeStatusSchema
}).strict()
export type ChannelRuntimeStatusChange = z.infer<
typeof channelRuntimeStatusChangeSchema
>
const publicChannelFields = {
enabled: z.boolean(),
secretConfigured: z.boolean(),
@@ -170,6 +194,13 @@ export type DingTalkChannelSettings = z.infer<
typeof dingTalkChannelSettingsSchema
>
export const telegramChannelSettingsSchema = z.object({
...publicChannelFields,
allowedSenderIds: telegramAllowedSenderIdsSchema,
allowGroupMessages: z.literal(false)
}).strict()
export type TelegramChannelSettings = z.infer<typeof telegramChannelSettingsSchema>
export const weixinChannelSettingsSchema = z
.object({
enabled: z.boolean(),
@@ -188,6 +219,7 @@ export const channelSettingsSnapshotSchema = z
weixin: weixinChannelSettingsSchema,
wecom: weComChannelSettingsSchema,
dingtalk: dingTalkChannelSettingsSchema,
telegram: telegramChannelSettingsSchema,
warnings: settingsWarningsSchema.optional()
})
.strict()
@@ -199,6 +231,7 @@ export const channelConnectionTestResultSchema = z
.object({
channel: managedChannelSchema,
ok: z.boolean(),
botUsername: identifierSchema.min(1).optional(),
error: z
.string()
.trim()
@@ -208,6 +241,13 @@ export const channelConnectionTestResultSchema = z
})
.strict()
.superRefine((result, context) => {
if (result.botUsername !== undefined && (!result.ok || result.channel !== 'telegram')) {
context.addIssue({
code: 'custom',
path: ['botUsername'],
message: 'Bot username requires a successful Telegram connection test'
})
}
if (result.ok && result.error !== undefined) {
context.addIssue({
code: 'custom',
+6 -1
View File
@@ -95,10 +95,12 @@ import type {
} from './magic-notes-contracts'
import type {
ChannelConnectionTestResult,
ChannelRuntimeStatusChange,
ChannelSettingsApply,
ChannelSettingsSnapshot,
CredentialChannel,
DingTalkChannelSettingsInput,
TelegramChannelSettingsInput,
WeComChannelSettingsInput
} from './channel-settings-contracts'
import type {
@@ -1906,10 +1908,13 @@ export type DesktopApi = {
}
channels?: {
getSnapshot: () => Promise<ChannelSettingsSnapshot>
onStatusChanged: (
listener: (change: ChannelRuntimeStatusChange) => void
) => () => void
apply: (input: ChannelSettingsApply) => Promise<ChannelSettingsSnapshot>
testConnection: (
channel: CredentialChannel,
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
) => Promise<ChannelConnectionTestResult>
getWeixinBinding: () => Promise<WeixinBindingSnapshot>
startWeixinBinding: () => Promise<WeixinBindingSnapshot>
+1
View File
@@ -114,6 +114,7 @@ export const ipcChannels = {
channelSettingsGet: 'settings:channels:get',
channelSettingsApply: 'settings:channels:apply',
channelSettingsTest: 'settings:channels:test',
channelStatusChanged: 'channels:status-changed',
weixinBindingGet: 'settings:channels:weixin:binding:get',
weixinBindingStart: 'settings:channels:weixin:binding:start',
weixinBindingVerify: 'settings:channels:weixin:binding:verify',