591 lines
16 KiB
TypeScript
591 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 = options.maximumConcurrency === undefined
|
|
? undefined
|
|
: boundedInteger(options.maximumConcurrency, 1, Number.MAX_SAFE_INTEGER, '通道并发限制')
|
|
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.maximumConcurrency !== undefined && 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.
|
|
})
|
|
}
|
|
}
|