feat: prepare GoodBuddy 0.8.0

This commit is contained in:
lofyer
2026-08-05 19:40:40 +08:00
parent 38ac2206f2
commit 39a457ded8
96 changed files with 14221 additions and 1027 deletions
+375
View File
@@ -0,0 +1,375 @@
import {
CHANNEL_LIMITS,
channelExecutorResultSchema,
channelInboundTextSchema,
channelResultMessageSchema,
type ChannelInboundText,
type ChannelResultMessage
} from '../../shared/channel-contracts'
import {
MemoryDedupStore,
MemoryOutbox,
type ChannelDriver,
type ChannelExecutor,
type DedupStore,
type Outbox
} from './channel-driver'
const TRUNCATION_MARKER = '\n…(结果已截断)'
export type ChannelServiceOptions = {
allowedSenderIds?: readonly string[]
allowGroupMessages?: boolean
maximumConcurrency?: number
maximumInputLength?: number
maximumResultLength?: number
dedupStore?: DedupStore
outbox?: Outbox
}
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
}
function truncate(value: string, maximumLength: number): string {
if (value.length <= maximumLength) {
return value
}
if (maximumLength <= TRUNCATION_MARKER.length) {
return value.slice(0, maximumLength)
}
return (
value.slice(0, maximumLength - TRUNCATION_MARKER.length) +
TRUNCATION_MARKER
)
}
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 readonly allowedSenderIds: ReadonlySet<string>
private readonly allowGroupMessages: boolean
private readonly maximumConcurrency: number
private readonly maximumInputLength: number
private readonly maximumResultLength: number
private readonly dedupStore: DedupStore
private readonly outbox: Outbox
private readonly tasks = new Set<Promise<void>>()
private readonly active = new Map<string, AbortController>()
private state: ServiceState = 'idle'
private stopPromise?: Promise<void>
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.allowedSenderIds = new Set(
(options.allowedSenderIds ?? []).map((senderId) => senderId.trim())
)
if (this.allowedSenderIds.has('')) {
throw new Error('通道白名单包含无效身份')
}
this.allowGroupMessages = options.allowGroupMessages ?? false
this.maximumConcurrency = boundedInteger(
options.maximumConcurrency,
2,
100,
'通道并发限制'
)
this.maximumInputLength = boundedInteger(
options.maximumInputLength,
8_000,
CHANNEL_LIMITS.maximumTextLength,
'通道输入长度限制'
)
this.maximumResultLength = boundedInteger(
options.maximumResultLength,
4_000,
CHANNEL_LIMITS.maximumResultLength,
'通道结果长度限制'
)
this.dedupStore = options.dedupStore ?? new MemoryDedupStore()
this.outbox = options.outbox ?? new MemoryOutbox()
}
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) => {
await acknowledge()
if (this.state !== 'running') {
return
}
const task = this.process(rawMessage).catch(() => {
// Processing failures are converted to bounded channel results.
})
this.tasks.add(task)
void task.finally(() => {
this.tasks.delete(task)
})
})
} catch (error) {
this.state = 'idle'
throw error
}
}
cancel(eventId: string): boolean {
const controller = this.active.get(
this.activeKey(this.driver.channel, eventId)
)
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'
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.tasks
])
const driverResult = results[0]
if (driverResult?.status === 'rejected') {
throw driverResult.reason
}
}
private async process(rawMessage: unknown): Promise<void> {
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.eventId
)
if (!claimed) {
return
}
if (message.text.length > this.maximumInputLength) {
await this.deliver(
this.result(message, {
status: 'rejected',
error: `消息过长,最多允许 ${this.maximumInputLength} 个字符`
}),
new AbortController().signal
)
return
}
if (this.active.size >= this.maximumConcurrency) {
await this.deliver(
this.result(message, {
status: 'busy',
error: '当前请求较多,请稍后重试'
}),
new AbortController().signal
)
return
}
const key = this.activeKey(message.channel, message.eventId)
const controller = new AbortController()
this.active.set(key, controller)
try {
const rawResult = await this.execute(message, controller.signal)
if (controller.signal.aborted) {
await this.deliver(
this.result(message, {
status: 'cancelled',
error: '请求已取消'
}),
new AbortController().signal
)
return
}
const result = channelExecutorResultSchema.safeParse(rawResult)
if (!result.success) {
await this.deliver(
this.result(message, {
status: 'failed',
error: '请求返回了无效结果'
}),
controller.signal
)
return
}
await this.deliver(this.result(message, result.data), controller.signal)
} catch {
const cancelled = controller.signal.aborted
await this.deliver(
this.result(message, {
status: cancelled ? 'cancelled' : 'failed',
error: cancelled ? '请求已取消' : '请求处理失败'
}),
new AbortController().signal
)
} finally {
this.active.delete(key)
}
}
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 })
void Promise.resolve()
.then(() => this.executor(message, signal))
.then(
(result) => finish(resolve, result),
(error: unknown) => finish(reject, error)
)
})
}
private result(
message: ChannelInboundText,
result: {
status: string
output?: string
error?: string
}
): ChannelResultMessage {
return channelResultMessageSchema.parse({
channel: message.channel,
eventId: message.eventId,
conversationId: message.conversationId,
recipientId: message.senderId,
status: result.status,
...(result.output === undefined
? {}
: {
output: truncate(result.output, this.maximumResultLength)
}),
...(result.error === undefined
? {}
: {
error: truncate(
redactChannelError(result.error),
CHANNEL_LIMITS.maximumErrorLength
)
})
})
}
private async deliver(
message: ChannelResultMessage,
signal: AbortSignal
): Promise<void> {
const entry = await this.outbox.enqueue(message)
try {
await this.driver.send(message, signal)
await this.outbox.markDelivered(entry.id)
} catch (error) {
await this.outbox.markFailed(entry.id)
throw error
}
}
private activeKey(channel: string, eventId: string): string {
return `${channel}\u0000${eventId}`
}
}