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
+123 -25
View File
@@ -9,6 +9,7 @@ import {
import {
MemoryDedupStore,
MemoryOutbox,
isPermanentChannelError,
type ChannelDriver,
type ChannelExecutor,
type DedupStore,
@@ -64,7 +65,7 @@ export function redactChannelError(value: string): string {
}
export class ChannelService {
private readonly allowedSenderIds: ReadonlySet<string>
private allowedSenderIds: ReadonlySet<string> = new Set()
private readonly allowGroupMessages: boolean
private readonly maximumConcurrency: number
private readonly maximumInputLength: number
@@ -76,6 +77,12 @@ export class ChannelService {
private readonly conversationTails = new Map<string, Promise<void>>()
private state: ServiceState = 'idle'
private stopPromise?: Promise<void>
private readonly lifetime = new AbortController()
private readonly sending = new Set<string>()
private retryTimer?: ReturnType<typeof setTimeout>
private retryPromise?: Promise<void>
private retryRequested = false
private retryDelay = 1_000
constructor(
private readonly driver: ChannelDriver,
@@ -90,12 +97,7 @@ export class ChannelService {
throw new Error('通道标识无效')
}
this.allowedSenderIds = new Set(
(options.allowedSenderIds ?? []).map((senderId) => senderId.trim())
)
if (this.allowedSenderIds.has('')) {
throw new Error('通道白名单包含无效身份')
}
this.updateAllowedSenderIds(options.allowedSenderIds ?? [])
this.allowGroupMessages = options.allowGroupMessages ?? false
this.maximumConcurrency = boundedInteger(
options.maximumConcurrency,
@@ -115,6 +117,14 @@ export class ChannelService {
this.onDeliverySuccess = options.onDeliverySuccess
}
updateAllowedSenderIds(senderIds: readonly string[]): void {
const allowed = new Set(senderIds.map((senderId) => senderId.trim()))
if (allowed.has('')) {
throw new Error('通道白名单包含无效身份')
}
this.allowedSenderIds = allowed
}
async start(): Promise<void> {
if (this.state === 'running') {
return
@@ -138,9 +148,11 @@ export class ChannelService {
this.onDeliveryFailure?.(error)
}
})
await this.retryUndelivered()
this.scheduleRetry(0)
} catch (error) {
this.state = 'idle'
if (!this.lifetime.signal.aborted) {
this.state = 'idle'
}
throw error
}
}
@@ -168,6 +180,9 @@ export class ChannelService {
}
this.state = 'stopped'
this.lifetime.abort(new Error('通道服务已停止'))
clearTimeout(this.retryTimer)
this.retryTimer = undefined
for (const controller of this.active.values()) {
controller.abort(new Error('通道服务已停止'))
}
@@ -180,6 +195,7 @@ export class ChannelService {
const driverStop = Promise.resolve().then(() => this.driver.stop())
const results = await Promise.allSettled([
driverStop,
this.retryPromise,
...this.conversationTails.values()
])
const driverResult = results[0]
@@ -188,12 +204,52 @@ export class ChannelService {
}
}
private scheduleRetry(delay = this.retryDelay): void {
if (this.state !== 'running') {
return
}
if (this.retryPromise) {
this.retryRequested = true
return
}
if (this.retryTimer) {
return
}
const run = (): void => {
this.retryTimer = undefined
this.retryRequested = false
this.retryPromise = this.retryUndelivered()
.catch((error: unknown) => {
if (!this.lifetime.signal.aborted) {
this.onDeliveryFailure?.(error)
}
})
.finally(() => {
this.retryPromise = undefined
if (this.retryRequested) {
this.scheduleRetry()
} else {
this.retryDelay = 1_000
}
})
}
if (delay === 0) {
run()
} else {
this.retryTimer = setTimeout(run, delay)
this.retryTimer.unref?.()
}
}
private async retryUndelivered(): Promise<void> {
const entries = await this.outbox.listUndelivered(
// Live sends may finish while this snapshot is read or earlier retries await I/O.
const liveDeliveries = new Set(this.sending)
const entries = (await this.outbox.listUndelivered(
this.driver.channel,
100
)
)).filter((entry) => !liveDeliveries.has(entry.id) && !this.sending.has(entry.id))
let consecutiveFailures = 0
let failed = false
for (const entry of entries) {
if (this.state !== 'running') {
return
@@ -201,31 +257,49 @@ export class ChannelService {
if (entry.state === 'terminal' || entry.attempts >= 5) {
this.onDeliveryFailure?.(
new Error(
`通道结果已达到重试上限,发件箱记录 ${entry.id} 已终止`
entry.attempts >= 5
? `通道结果已达到重试上限,发件箱记录 ${entry.id} 已终止`
: `通道结果无法投递,发件箱记录 ${entry.id} 已终止`
)
)
continue
}
if (this.sending.has(entry.id)) {
continue
}
try {
await this.driver.send(
entry.message,
new AbortController().signal
this.lifetime.signal
)
await this.outbox.markDelivered(entry.id)
this.onDeliverySuccess?.()
consecutiveFailures = 0
} catch (error) {
await this.outbox.markFailed(entry.id)
this.onDeliveryFailure?.(error)
consecutiveFailures += 1
if (consecutiveFailures >= 3) {
if (this.lifetime.signal.aborted) {
return
}
await this.recordDeliveryFailure(entry.id, error)
failed = true
consecutiveFailures += 1
if (consecutiveFailures >= 3) {
break
}
}
}
if (this.state === 'running') {
const remaining = await this.outbox.listUndelivered(this.driver.channel, 100)
this.retryRequested ||= remaining.some((entry) =>
entry.state !== 'terminal' && entry.attempts < 5 && !this.sending.has(entry.id)
)
this.retryDelay = failed ? Math.min(this.retryDelay * 2, 30_000) : 1_000
}
}
private async process(rawMessage: unknown): Promise<void> {
if (this.state !== 'running') {
return
}
const parsed = channelInboundTextSchema.safeParse(rawMessage)
if (!parsed.success) {
return
@@ -252,13 +326,16 @@ export class ChannelService {
let durableResult = false
try {
if (this.lifetime.signal.aborted || !this.allowedSenderIds.has(message.senderId)) {
return
}
if (message.text.length > this.maximumInputLength) {
durableResult = await this.tryDeliver(
this.result(message, {
status: 'rejected',
error: `消息过长,最多允许 ${this.maximumInputLength} 个字符`
}),
new AbortController().signal
this.lifetime.signal
)
return
}
@@ -269,7 +346,7 @@ export class ChannelService {
status: 'busy',
error: '当前请求较多,请稍后重试'
}),
new AbortController().signal
this.lifetime.signal
)
return
}
@@ -292,7 +369,7 @@ export class ChannelService {
status: cancelled ? 'cancelled' : 'failed',
error: cancelled ? '请求已取消' : '请求处理失败'
}),
new AbortController().signal
this.lifetime.signal
)
return
}
@@ -302,7 +379,7 @@ export class ChannelService {
status: 'cancelled',
error: '请求已取消'
}),
new AbortController().signal
this.lifetime.signal
)
return
}
@@ -382,7 +459,10 @@ export class ChannelService {
)
}
void Promise.resolve()
.then(() => this.executor(message, signal, reportProgress))
.then(() => {
signal.throwIfAborted()
return this.executor(message, signal, reportProgress)
})
.then(
(result) => finish(resolve, result),
(error: unknown) => finish(reject, error)
@@ -429,17 +509,35 @@ export class ChannelService {
signal: AbortSignal
): Promise<boolean> {
const entry = await this.outbox.enqueue(message)
this.sending.add(entry.id)
try {
await this.driver.send(message, signal)
const deliverySignal = AbortSignal.any([signal, this.lifetime.signal])
deliverySignal.throwIfAborted()
await this.driver.send(message, deliverySignal)
await this.outbox.markDelivered(entry.id)
this.onDeliverySuccess?.()
} catch (error) {
await this.outbox.markFailed(entry.id)
this.onDeliveryFailure?.(error)
if (!this.lifetime.signal.aborted) {
await this.recordDeliveryFailure(entry.id, error)
if (!isPermanentChannelError(error)) {
this.scheduleRetry()
}
}
} finally {
this.sending.delete(entry.id)
}
return true
}
private async recordDeliveryFailure(id: string, error: unknown): Promise<void> {
if (isPermanentChannelError(error) && this.outbox.markTerminal) {
await this.outbox.markTerminal(id)
} else {
await this.outbox.markFailed(id)
}
this.onDeliveryFailure?.(error)
}
private async tryDeliver(
message: ChannelResultMessage,
signal: AbortSignal