Files
goodbuddy/src/main/channels/channel-service.ts
T
mesalogo cf598121b1 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 与模型的完整联调仍待完成。
2026-10-05 02:42:28 +08:00

594 lines
16 KiB
TypeScript

import {
CHANNEL_LIMITS,
channelExecutorResultSchema,
channelInboundTextSchema,
channelResultMessageSchema,
type ChannelInboundText,
type ChannelResultMessage
} from '../../shared/channel-contracts'
import {
MemoryDedupStore,
MemoryOutbox,
isPermanentChannelError,
type ChannelDriver,
type ChannelExecutor,
type DedupStore,
type Outbox
} from './channel-driver'
export type ChannelServiceOptions = {
allowedSenderIds?: readonly string[]
allowGroupMessages?: boolean
maximumConcurrency?: number
maximumInputLength?: number
dedupStore?: DedupStore
outbox?: Outbox
onDeliveryFailure?: (error: unknown) => void
onDeliverySuccess?: () => void
}
type ServiceState = 'idle' | 'running' | 'stopped'
function boundedInteger(
value: number | undefined,
fallback: number,
maximum: number,
name: string
): number {
const candidate = value ?? fallback
if (
!Number.isSafeInteger(candidate) ||
candidate < 1 ||
candidate > maximum
) {
throw new Error(`${name}无效`)
}
return candidate
}
export function redactChannelError(value: string): string {
return value
.replace(/\bBearer\s+[^\s,;]+/giu, 'Bearer [已隐藏]')
.replace(
/\b(api[_-]?key|authorization|password|secret|token)\b(\s*[:=]\s*)([^\s,;]+)/giu,
'$1$2[已隐藏]'
)
.replace(/\bsk-[a-z0-9_-]{8,}\b/giu, '[凭据已隐藏]')
.replace(
/\b(https?:\/\/)([^/\s:@]+):([^/\s@]+)@/giu,
'$1[凭据已隐藏]@'
)
.replace(
/(?:[a-z]:\\|\\\\)[^\r\n"'<>|]*/giu,
'[路径已隐藏]'
)
}
export class ChannelService {
private allowedSenderIds: ReadonlySet<string> = new Set()
private readonly allowGroupMessages: boolean
private readonly maximumConcurrency: number
private readonly maximumInputLength: number
private readonly dedupStore: DedupStore
private readonly outbox: Outbox
private readonly onDeliveryFailure?: (error: unknown) => void
private readonly onDeliverySuccess?: () => void
private readonly active = new Map<string, AbortController>()
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,
private readonly executor: ChannelExecutor,
options: ChannelServiceOptions = {}
) {
const channel = driver.channel.trim()
if (
channel.length < 1 ||
channel.length > CHANNEL_LIMITS.maximumChannelLength
) {
throw new Error('通道标识无效')
}
this.updateAllowedSenderIds(options.allowedSenderIds ?? [])
this.allowGroupMessages = options.allowGroupMessages ?? false
this.maximumConcurrency = boundedInteger(
options.maximumConcurrency,
2,
100,
'通道并发限制'
)
this.maximumInputLength = boundedInteger(
options.maximumInputLength,
8_000,
CHANNEL_LIMITS.maximumTextLength,
'通道输入长度限制'
)
this.dedupStore = options.dedupStore ?? new MemoryDedupStore()
this.outbox = options.outbox ?? new MemoryOutbox()
this.onDeliveryFailure = options.onDeliveryFailure
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
}
if (this.state === 'stopped') {
throw new Error('通道服务已停止')
}
this.state = 'running'
try {
await this.driver.start(async (rawMessage, acknowledge) => {
if (this.state !== 'running') {
await acknowledge()
return
}
try {
this.enqueue(rawMessage)
await acknowledge()
} catch (error) {
this.onDeliveryFailure?.(error)
}
})
this.scheduleRetry(0)
} catch (error) {
if (!this.lifetime.signal.aborted) {
this.state = 'idle'
}
throw error
}
}
cancel(eventId: string): boolean {
const suffix = `\u0000${eventId}`
const controller = [...this.active.entries()].find(
([key]) =>
key.startsWith(`${this.driver.channel}\u0000`) &&
key.endsWith(suffix)
)?.[1]
if (!controller) {
return false
}
controller.abort(new Error('通道请求已取消'))
return true
}
stop(): Promise<void> {
if (this.stopPromise) {
return this.stopPromise
}
if (this.state === 'stopped') {
return Promise.resolve()
}
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('通道服务已停止'))
}
this.stopPromise = this.finishStop()
return this.stopPromise
}
private async finishStop(): Promise<void> {
const driverStop = Promise.resolve().then(() => this.driver.stop())
const results = await Promise.allSettled([
driverStop,
this.retryPromise,
...this.conversationTails.values()
])
const driverResult = results[0]
if (driverResult?.status === 'rejected') {
throw driverResult.reason
}
}
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> {
// 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
}
if (entry.state === 'terminal' || entry.attempts >= 5) {
this.onDeliveryFailure?.(
new Error(
entry.attempts >= 5
? `通道结果已达到重试上限,发件箱记录 ${entry.id} 已终止`
: `通道结果无法投递,发件箱记录 ${entry.id} 已终止`
)
)
continue
}
if (this.sending.has(entry.id)) {
continue
}
try {
await this.driver.send(
entry.message,
this.lifetime.signal
)
await this.outbox.markDelivered(entry.id)
this.onDeliverySuccess?.()
consecutiveFailures = 0
} catch (error) {
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
}
const message = parsed.data
if (
message.channel !== this.driver.channel ||
!this.allowedSenderIds.has(message.senderId) ||
(message.conversationType === 'group' &&
(!this.allowGroupMessages || !message.mentioned))
) {
return
}
const claimed = await this.dedupStore.claim(
message.channel,
message.accountId,
message.eventId
)
if (!claimed) {
return
}
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} 个字符`
}),
this.lifetime.signal
)
return
}
if (this.active.size >= this.maximumConcurrency) {
durableResult = await this.tryDeliver(
this.result(message, {
status: 'busy',
error: '当前请求较多,请稍后重试'
}),
this.lifetime.signal
)
return
}
const key = this.activeKey(
message.channel,
message.accountId,
message.eventId
)
const controller = new AbortController()
this.active.set(key, controller)
try {
let rawResult: Awaited<ReturnType<ChannelExecutor>>
try {
rawResult = await this.execute(message, controller.signal)
} catch {
const cancelled = controller.signal.aborted
durableResult = await this.tryDeliver(
this.result(message, {
status: cancelled ? 'cancelled' : 'failed',
error: cancelled ? '请求已取消' : '请求处理失败'
}),
this.lifetime.signal
)
return
}
if (controller.signal.aborted) {
durableResult = await this.tryDeliver(
this.result(message, {
status: 'cancelled',
error: '请求已取消'
}),
this.lifetime.signal
)
return
}
const result = channelExecutorResultSchema.safeParse(rawResult)
if (!result.success) {
durableResult = await this.tryDeliver(
this.result(message, {
status: 'failed',
error: '请求返回了无效结果'
}),
controller.signal
)
return
}
durableResult = await this.tryDeliver(
this.result(message, result.data),
controller.signal
)
} finally {
this.active.delete(key)
}
} finally {
if (!durableResult) {
await this.dedupStore.release(
message.channel,
message.accountId,
message.eventId
)
}
}
}
private execute(
message: ChannelInboundText,
signal: AbortSignal
): Promise<Awaited<ReturnType<ChannelExecutor>>> {
if (signal.aborted) {
return Promise.reject(signal.reason)
}
return new Promise((resolve, reject) => {
let settled = false
const finish = (
callback: typeof resolve | typeof reject,
value: Awaited<ReturnType<ChannelExecutor>> | unknown
): void => {
if (settled) {
return
}
settled = true
signal.removeEventListener('abort', abort)
callback(value as Awaited<ReturnType<ChannelExecutor>>)
}
const abort = (): void => {
finish(reject, signal.reason)
}
signal.addEventListener('abort', abort, { once: true })
let progressCount = 0
const reportProgress = async (rawResult: {
status: string
output?: string
error?: string
}): Promise<void> => {
if (signal.aborted) {
throw signal.reason
}
if (progressCount >= 3) {
throw new Error('远程进度消息超过限制')
}
const result = channelExecutorResultSchema.parse(rawResult)
progressCount += 1
await this.deliver(
this.result(message, result),
signal
)
}
void Promise.resolve()
.then(() => {
signal.throwIfAborted()
return this.executor(message, signal, reportProgress)
})
.then(
(result) => finish(resolve, result),
(error: unknown) => finish(reject, error)
)
})
}
private result(
message: ChannelInboundText,
result: {
status: string
output?: string
error?: string
attachments?: ChannelResultMessage['attachments']
}
): ChannelResultMessage {
return channelResultMessageSchema.parse({
channel: message.channel,
eventId: message.eventId,
conversationId: message.conversationId,
recipientId: message.senderId,
status: result.status,
...(result.output === undefined
? {}
: {
output: result.output
}),
...(result.error === undefined
? {}
: {
error: redactChannelError(result.error).slice(
0,
CHANNEL_LIMITS.maximumErrorLength
)
}),
...(result.attachments?.length
? { attachments: result.attachments }
: {})
})
}
private async deliver(
message: ChannelResultMessage,
signal: AbortSignal
): Promise<boolean> {
const entry = await this.outbox.enqueue(message)
this.sending.add(entry.id)
try {
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) {
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
): Promise<boolean> {
try {
return await this.deliver(message, signal)
} catch (error) {
this.onDeliveryFailure?.(error)
return false
}
}
private activeKey(
channel: string,
accountId: string,
eventId: string
): string {
return `${channel}\u0000${accountId}\u0000${eventId}`
}
private enqueue(rawMessage: unknown): void {
const parsed = channelInboundTextSchema.safeParse(rawMessage)
if (!parsed.success) {
throw new Error('通道消息格式无效')
}
if (parsed.data.channel !== this.driver.channel) {
throw new Error('通道消息来源不匹配')
}
const key =
`${parsed.data.channel}\u0000${parsed.data.accountId}` +
`\u0000${parsed.data.conversationId}`
const previous = this.conversationTails.get(key) ?? Promise.resolve()
const task =
this.conversationTails.has(key)
? previous
.catch(() => undefined)
.then(() => this.process(parsed.data))
: this.process(parsed.data)
const tail = task.then(
() => undefined,
() => undefined
)
this.conversationTails.set(key, tail)
void tail.finally(() => {
if (this.conversationTails.get(key) === tail) {
this.conversationTails.delete(key)
}
})
void task.catch(() => {
// The event claim is released when no durable result could be recorded.
})
}
}