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
+154
View File
@@ -0,0 +1,154 @@
import type {
ChannelInboundText,
ChannelResultMessage
} from '../../shared/channel-contracts'
export type ChannelAcknowledge = () => void | Promise<void>
export type ChannelInboundHandler = (
message: unknown,
acknowledge: ChannelAcknowledge
) => void | Promise<void>
export interface ChannelDriver {
readonly channel: string
start(handler: ChannelInboundHandler): void | Promise<void>
send(message: ChannelResultMessage, signal: AbortSignal): Promise<void>
stop(): void | Promise<void>
}
export interface DedupStore {
claim(channel: string, eventId: string): boolean | Promise<boolean>
release(channel: string, eventId: string): void | Promise<void>
}
export class MemoryDedupStore implements DedupStore {
private readonly claimed = new Map<string, number>()
constructor(private readonly maximumEntries = 10_000) {
if (!Number.isSafeInteger(maximumEntries) || maximumEntries < 1) {
throw new Error('通道去重容量无效')
}
}
claim(channel: string, eventId: string): boolean {
const key = this.key(channel, eventId)
if (this.claimed.has(key)) {
return false
}
this.claimed.set(key, Date.now())
while (this.claimed.size > this.maximumEntries) {
const oldest = this.claimed.keys().next().value
if (oldest === undefined) {
break
}
this.claimed.delete(oldest)
}
return true
}
release(channel: string, eventId: string): void {
this.claimed.delete(this.key(channel, eventId))
}
clear(): void {
this.claimed.clear()
}
private key(channel: string, eventId: string): string {
return `${channel}\u0000${eventId}`
}
}
export type OutboxEntry = {
id: string
message: ChannelResultMessage
state: 'pending' | 'delivered' | 'failed'
attempts: number
createdAt: number
}
export interface Outbox {
enqueue(message: ChannelResultMessage): OutboxEntry | Promise<OutboxEntry>
markDelivered(id: string): void | Promise<void>
markFailed(id: string): void | Promise<void>
listUndelivered(): readonly OutboxEntry[] | Promise<readonly OutboxEntry[]>
}
export class MemoryOutbox implements Outbox {
private readonly entries = new Map<string, OutboxEntry>()
constructor(private readonly maximumEntries = 10_000) {
if (!Number.isSafeInteger(maximumEntries) || maximumEntries < 1) {
throw new Error('通道发件箱容量无效')
}
}
enqueue(message: ChannelResultMessage): OutboxEntry {
const entry: OutboxEntry = {
id: crypto.randomUUID(),
message: structuredClone(message),
state: 'pending',
attempts: 0,
createdAt: Date.now()
}
this.entries.set(entry.id, entry)
this.enforceLimit()
return this.clone(entry)
}
markDelivered(id: string): void {
const entry = this.entries.get(id)
if (!entry) {
return
}
entry.state = 'delivered'
entry.attempts += 1
}
markFailed(id: string): void {
const entry = this.entries.get(id)
if (!entry) {
return
}
entry.state = 'failed'
entry.attempts += 1
}
listUndelivered(): readonly OutboxEntry[] {
return [...this.entries.values()]
.filter((entry) => entry.state !== 'delivered')
.map((entry) => this.clone(entry))
}
private enforceLimit(): void {
while (this.entries.size > this.maximumEntries) {
const delivered = [...this.entries.values()].find(
(entry) => entry.state === 'delivered'
)
const oldest = delivered ?? this.entries.values().next().value
if (!oldest) {
return
}
this.entries.delete(oldest.id)
}
}
private clone(entry: OutboxEntry): OutboxEntry {
return {
...entry,
message: structuredClone(entry.message)
}
}
}
export type ChannelExecutor = (
message: ChannelInboundText,
signal: AbortSignal
) => Promise<{
status: string
output?: string
error?: string
}>
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest'
import {
parseChannelEnvironment,
startEnvironmentChannels
} from './channel-env'
describe('channel environment bootstrap', () => {
it('starts only complete credentials with a non-empty explicit allowlist', () => {
expect(
parseChannelEnvironment({
GOODBUDDY_DINGTALK_CLIENT_ID: ' client-id ',
GOODBUDDY_DINGTALK_CLIENT_SECRET: ' secret ',
GOODBUDDY_DINGTALK_ALLOWED_SENDERS: ' USER-1,user-2 ',
GOODBUDDY_DINGTALK_ALLOW_GROUPS: 'true',
GOODBUDDY_WECOM_BOT_ID: 'bot-id',
GOODBUDDY_WECOM_SECRET: 'wecom-secret'
})
).toEqual([
{
channel: 'dingtalk',
clientId: 'client-id',
clientSecret: 'secret',
allowedSenderIds: ['user-1', 'user-2'],
allowGroupMessages: true
}
])
})
it('strictly parses booleans and comma-separated identities', () => {
expect(() =>
parseChannelEnvironment({
GOODBUDDY_WECOM_ALLOW_GROUPS: 'TRUE'
})
).toThrow('必须是 true 或 false')
expect(() =>
parseChannelEnvironment({
GOODBUDDY_WECOM_ALLOWED_SENDERS: 'user-1,,user-2'
})
).toThrow('包含空白身份')
})
it('defaults groups off and contains asynchronous startup failures', async () => {
const start = vi.fn(async () => {
throw new Error('secret=must-not-escape')
})
const stop = vi.fn(async () => undefined)
const onStartError = vi.fn()
const createService = vi.fn(() => ({ start, stop }))
const services = startEnvironmentChannels({
env: {
GOODBUDDY_WECOM_BOT_ID: 'bot-id',
GOODBUDDY_WECOM_SECRET: 'secret',
GOODBUDDY_WECOM_ALLOWED_SENDERS: 'user-1'
},
executor: vi.fn(async () => ({ status: 'completed' })),
createWeComDriver: vi.fn(() => ({ channel: 'wecom' }) as never),
createService,
onStartError
})
expect(services).toHaveLength(1)
expect(createService).toHaveBeenCalledWith(
expect.objectContaining({ channel: 'wecom' }),
expect.any(Function),
{
allowedSenderIds: ['user-1'],
allowGroupMessages: false
}
)
await vi.waitFor(() => {
expect(onStartError).toHaveBeenCalledWith(
'wecom',
'wecom 通道启动失败'
)
})
expect(JSON.stringify(onStartError.mock.calls)).not.toContain(
'must-not-escape'
)
})
})
+230
View File
@@ -0,0 +1,230 @@
import type { ChannelInboundText } from '../../shared/channel-contracts'
import type { ChannelExecutor } from './channel-driver'
import { ChannelService } from './channel-service'
import {
DingTalkChannelDriver,
type DingTalkChannelDriverOptions
} from './dingtalk-channel-driver'
import {
normalizeDingTalkStaffId,
type DingTalkTransportFactory
} from './dingtalk-driver'
import {
WeComChannelDriver,
type WeComChannelDriverOptions
} from './wecom-channel-driver'
import type { WeComTransportFactory } from './wecom-driver'
type ChannelEnvironmentConfig =
| {
channel: 'dingtalk'
clientId: string
clientSecret: string
allowedSenderIds: readonly string[]
allowGroupMessages: boolean
}
| {
channel: 'wecom'
botId: string
secret: string
allowedSenderIds: readonly string[]
allowGroupMessages: boolean
}
export type EnvironmentChannelService = Pick<
ChannelService,
'start' | 'stop'
>
export type EnvironmentChannelBootstrapOptions = {
executor: ChannelExecutor
env?: NodeJS.ProcessEnv
dingtalkTransportFactory?: DingTalkTransportFactory
wecomTransportFactory?: WeComTransportFactory
createDingTalkDriver?: (
options: DingTalkChannelDriverOptions
) => DingTalkChannelDriver
createWeComDriver?: (
options: WeComChannelDriverOptions
) => WeComChannelDriver
createService?: (
driver: DingTalkChannelDriver | WeComChannelDriver,
executor: ChannelExecutor,
options: {
allowedSenderIds: readonly string[]
allowGroupMessages: boolean
}
) => EnvironmentChannelService
onStartError?: (channel: string, error: string) => void
}
function optionalCredential(
env: NodeJS.ProcessEnv,
name: string
): string | undefined {
const value = env[name]
if (value === undefined || value.trim() === '') {
return undefined
}
return value.trim()
}
function parseBoolean(
env: NodeJS.ProcessEnv,
name: string
): boolean {
const raw = env[name]
if (raw === undefined || raw === '') {
return false
}
if (raw === 'true') {
return true
}
if (raw === 'false') {
return false
}
throw new Error(`${name} 必须是 true 或 false`)
}
function parseList(
env: NodeJS.ProcessEnv,
name: string
): readonly string[] {
const raw = env[name]
if (raw === undefined || raw === '') {
return []
}
const values = raw.split(',').map((value) => value.trim())
if (values.some((value) => value === '')) {
throw new Error(`${name} 包含空白身份`)
}
return [...new Set(values)]
}
export function parseChannelEnvironment(
env: NodeJS.ProcessEnv
): readonly ChannelEnvironmentConfig[] {
const configs: ChannelEnvironmentConfig[] = []
const dingTalkClientId = optionalCredential(
env,
'GOODBUDDY_DINGTALK_CLIENT_ID'
)
const dingTalkClientSecret = optionalCredential(
env,
'GOODBUDDY_DINGTALK_CLIENT_SECRET'
)
const dingTalkAllowedSenderIds = parseList(
env,
'GOODBUDDY_DINGTALK_ALLOWED_SENDERS'
).map(normalizeDingTalkStaffId)
const dingTalkAllowGroupMessages = parseBoolean(
env,
'GOODBUDDY_DINGTALK_ALLOW_GROUPS'
)
if (
dingTalkClientId &&
dingTalkClientSecret &&
dingTalkAllowedSenderIds.length > 0
) {
configs.push({
channel: 'dingtalk',
clientId: dingTalkClientId,
clientSecret: dingTalkClientSecret,
allowedSenderIds: dingTalkAllowedSenderIds,
allowGroupMessages: dingTalkAllowGroupMessages
})
}
const weComBotId = optionalCredential(
env,
'GOODBUDDY_WECOM_BOT_ID'
)
const weComSecret = optionalCredential(
env,
'GOODBUDDY_WECOM_SECRET'
)
const weComAllowedSenderIds = parseList(
env,
'GOODBUDDY_WECOM_ALLOWED_SENDERS'
)
const weComAllowGroupMessages = parseBoolean(
env,
'GOODBUDDY_WECOM_ALLOW_GROUPS'
)
if (
weComBotId &&
weComSecret &&
weComAllowedSenderIds.length > 0
) {
configs.push({
channel: 'wecom',
botId: weComBotId,
secret: weComSecret,
allowedSenderIds: weComAllowedSenderIds,
allowGroupMessages: weComAllowGroupMessages
})
}
return configs
}
export function startEnvironmentChannels(
options: EnvironmentChannelBootstrapOptions
): readonly EnvironmentChannelService[] {
let configs: readonly ChannelEnvironmentConfig[]
try {
configs = parseChannelEnvironment(options.env ?? process.env)
} catch {
options.onStartError?.('environment', '通道环境变量配置无效')
return []
}
const services = configs.map((config) => {
const driver =
config.channel === 'dingtalk'
? (options.createDingTalkDriver ??
((driverOptions) =>
new DingTalkChannelDriver(driverOptions)))({
clientId: config.clientId,
clientSecret: config.clientSecret,
allowedSenderIds: config.allowedSenderIds,
...(options.dingtalkTransportFactory
? {
transportFactory:
options.dingtalkTransportFactory
}
: {})
})
: (options.createWeComDriver ??
((driverOptions) =>
new WeComChannelDriver(driverOptions)))({
botId: config.botId,
secret: config.secret,
...(options.wecomTransportFactory
? { transportFactory: options.wecomTransportFactory }
: {})
})
const service = (
options.createService ??
((channelDriver, executor, serviceOptions) =>
new ChannelService(channelDriver, executor, serviceOptions))
)(driver, options.executor, {
allowedSenderIds: config.allowedSenderIds,
allowGroupMessages: config.allowGroupMessages
})
void Promise.resolve()
.then(() => service.start())
.catch(() => {
options.onStartError?.(
config.channel,
`${config.channel} 通道启动失败`
)
})
return service
})
return services
}
export function isReadOnlyChannelMessage(
message: ChannelInboundText
): boolean {
return message.workMode === 'ask' || message.workMode === 'plan'
}
+345
View File
@@ -0,0 +1,345 @@
import { describe, expect, it, vi } from 'vitest'
import {
channelInboundTextSchema,
type ChannelInboundText,
type ChannelResultMessage
} from '../../shared/channel-contracts'
import {
MemoryDedupStore,
MemoryOutbox,
type ChannelDriver,
type ChannelInboundHandler
} from './channel-driver'
import { ChannelService } from './channel-service'
class FakeChannelDriver implements ChannelDriver {
readonly channel = 'fake'
readonly sent: ChannelResultMessage[] = []
acknowledgements = 0
stopped = false
private handler?: ChannelInboundHandler
start(handler: ChannelInboundHandler): void {
this.handler = handler
}
async send(
message: ChannelResultMessage,
signal: AbortSignal
): Promise<void> {
signal.throwIfAborted()
this.sent.push(structuredClone(message))
}
stop(): void {
this.stopped = true
}
async emit(message: unknown): Promise<void> {
if (!this.handler) {
throw new Error('Fake driver was not started')
}
await this.handler(message, () => {
this.acknowledgements += 1
})
}
}
function inbound(
overrides: Partial<ChannelInboundText> = {}
): ChannelInboundText {
return {
channel: 'fake',
eventId: 'event-1',
senderId: 'allowed-user',
conversationId: 'conversation-1',
conversationType: 'direct',
text: '你好',
mentioned: false,
workMode: 'ask',
...overrides
}
}
async function waitForSent(
driver: FakeChannelDriver,
count: number
): Promise<void> {
await vi.waitFor(() => {
expect(driver.sent).toHaveLength(count)
})
}
describe('channel contracts', () => {
it('normalizes text, defaults to ask, and strictly refuses execute mode', () => {
expect(
channelInboundTextSchema.parse({
channel: ' fake ',
eventId: ' event-1 ',
senderId: ' user-1 ',
conversationId: ' direct-1 ',
conversationType: 'direct',
text: ' 你好 '
})
).toEqual({
channel: 'fake',
eventId: 'event-1',
senderId: 'user-1',
conversationId: 'direct-1',
conversationType: 'direct',
text: '你好',
mentioned: false,
workMode: 'ask'
})
expect(
channelInboundTextSchema.safeParse({
...inbound(),
workMode: 'execute'
}).success
).toBe(false)
expect(
channelInboundTextSchema.safeParse({
...inbound(),
platformPayload: { token: 'must not pass through' }
}).success
).toBe(false)
})
})
describe('ChannelService', () => {
it('acknowledges first and denies all senders when no allowlist is configured', async () => {
const driver = new FakeChannelDriver()
const executor = vi.fn()
const service = new ChannelService(driver, executor)
await service.start()
await driver.emit(inbound())
expect(driver.acknowledgements).toBe(1)
expect(executor).not.toHaveBeenCalled()
expect(driver.sent).toEqual([])
await service.stop()
})
it('executes an allowed request asynchronously with the normalized ask mode', async () => {
const driver = new FakeChannelDriver()
let finish: ((value: { status: string; output: string }) => void) | undefined
const executor = vi.fn(
() =>
new Promise<{ status: string; output: string }>((resolve) => {
finish = resolve
})
)
const service = new ChannelService(driver, executor, {
allowedSenderIds: ['allowed-user']
})
await service.start()
await driver.emit({
channel: 'fake',
eventId: 'event-1',
senderId: 'allowed-user',
conversationId: 'conversation-1',
conversationType: 'direct',
text: ' 帮我分析 '
})
expect(driver.acknowledgements).toBe(1)
expect(executor).toHaveBeenCalledWith(
expect.objectContaining({
text: '帮我分析',
workMode: 'ask'
}),
expect.any(AbortSignal)
)
expect(driver.sent).toEqual([])
finish?.({ status: 'completed', output: '完成' })
await waitForSent(driver, 1)
expect(driver.sent[0]).toMatchObject({
eventId: 'event-1',
recipientId: 'allowed-user',
status: 'completed',
output: '完成'
})
await service.stop()
})
it('requires both explicit group enablement and an @ mention', async () => {
const blockedDriver = new FakeChannelDriver()
const blockedExecutor = vi.fn(async () => ({ status: 'completed' }))
const blockedService = new ChannelService(
blockedDriver,
blockedExecutor,
{
allowedSenderIds: ['allowed-user']
}
)
await blockedService.start()
await blockedDriver.emit(
inbound({
conversationType: 'group',
mentioned: true
})
)
expect(blockedExecutor).not.toHaveBeenCalled()
await blockedService.stop()
const driver = new FakeChannelDriver()
const executor = vi.fn(async () => ({ status: 'completed' }))
const service = new ChannelService(driver, executor, {
allowedSenderIds: ['allowed-user'],
allowGroupMessages: true
})
await service.start()
await driver.emit(
inbound({
eventId: 'without-mention',
conversationType: 'group',
mentioned: false
})
)
await driver.emit(
inbound({
eventId: 'with-mention',
conversationType: 'group',
mentioned: true
})
)
await waitForSent(driver, 1)
expect(executor).toHaveBeenCalledOnce()
expect(driver.sent[0]?.eventId).toBe('with-mention')
await service.stop()
})
it('deduplicates by channel and event id', async () => {
const store = new MemoryDedupStore()
expect(store.claim('first', 'same-id')).toBe(true)
expect(store.claim('first', 'same-id')).toBe(false)
expect(store.claim('second', 'same-id')).toBe(true)
const driver = new FakeChannelDriver()
const executor = vi.fn(async () => ({
status: 'completed',
output: 'only once'
}))
const service = new ChannelService(driver, executor, {
allowedSenderIds: ['allowed-user'],
dedupStore: store
})
await service.start()
await driver.emit(inbound())
await driver.emit(inbound())
await waitForSent(driver, 1)
expect(executor).toHaveBeenCalledOnce()
expect(driver.acknowledgements).toBe(2)
await service.stop()
})
it('enforces concurrency and input length limits', async () => {
const driver = new FakeChannelDriver()
let finish: (() => void) | undefined
const executor = vi.fn(
() =>
new Promise<{ status: string }>((resolve) => {
finish = () => resolve({ status: 'completed' })
})
)
const service = new ChannelService(driver, executor, {
allowedSenderIds: ['allowed-user'],
maximumConcurrency: 1,
maximumInputLength: 5
})
await service.start()
await driver.emit(inbound({ eventId: 'active', text: '12345' }))
await driver.emit(inbound({ eventId: 'busy', text: '12345' }))
await driver.emit(inbound({ eventId: 'too-long', text: '123456' }))
await waitForSent(driver, 2)
expect(driver.sent).toEqual(
expect.arrayContaining([
expect.objectContaining({
eventId: 'busy',
status: 'busy'
}),
expect.objectContaining({
eventId: 'too-long',
status: 'rejected'
})
])
)
finish?.()
await waitForSent(driver, 3)
expect(executor).toHaveBeenCalledOnce()
await service.stop()
})
it('bounds output and redacts executor-provided error details', async () => {
const driver = new FakeChannelDriver()
const outbox = new MemoryOutbox()
const executor = vi
.fn()
.mockResolvedValueOnce({
status: 'completed',
output: 'x'.repeat(100)
})
.mockResolvedValueOnce({
status: 'failed',
error:
'Authorization: Bearer top-secret token=abc123 path=C:\\Users\\private\\file.txt'
})
const service = new ChannelService(driver, executor, {
allowedSenderIds: ['allowed-user'],
maximumResultLength: 32,
outbox
})
await service.start()
await driver.emit(inbound({ eventId: 'long-output' }))
await driver.emit(inbound({ eventId: 'secret-error' }))
await waitForSent(driver, 2)
expect(driver.sent[0]?.output).toHaveLength(32)
const serialized = JSON.stringify(driver.sent[1])
expect(serialized).not.toContain('top-secret')
expect(serialized).not.toContain('abc123')
expect(serialized).not.toContain('Users')
expect(serialized).toContain('已隐藏')
expect(await outbox.listUndelivered()).toEqual([])
await service.stop()
})
it('cancels an active executor and stops the driver', async () => {
const driver = new FakeChannelDriver()
let receivedSignal: AbortSignal | undefined
const executor = vi.fn(
(_message: ChannelInboundText, signal: AbortSignal) =>
new Promise<never>(() => {
receivedSignal = signal
})
)
const service = new ChannelService(driver, executor, {
allowedSenderIds: ['allowed-user']
})
await service.start()
await driver.emit(inbound({ eventId: 'cancel-me' }))
expect(service.cancel('cancel-me')).toBe(true)
await waitForSent(driver, 1)
expect(receivedSignal?.aborted).toBe(true)
expect(driver.sent[0]).toMatchObject({
eventId: 'cancel-me',
status: 'cancelled',
error: '请求已取消'
})
await service.stop()
expect(driver.stopped).toBe(true)
expect(service.cancel('cancel-me')).toBe(false)
await expect(service.start()).rejects.toThrow('已停止')
})
})
+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}`
}
}
@@ -0,0 +1,180 @@
import { describe, expect, it, vi } from 'vitest'
import {
DingTalkChannelDriver,
createOfficialDingTalkTransportFactory
} from './dingtalk-channel-driver'
import type {
DingTalkStreamEnvelope,
DingTalkStreamTransport,
DingTalkTransportFactory
} from './dingtalk-driver'
const SESSION_WEBHOOK =
'https://oapi.dingtalk.com/robot/sendBySession?session=opaque'
class FakeTransport implements DingTalkStreamTransport {
listener?: (envelope: DingTalkStreamEnvelope) => Promise<void>
readonly stop = vi.fn(async () => undefined)
readonly replyText = vi.fn(async () => undefined)
async start(
listener: (envelope: DingTalkStreamEnvelope) => Promise<void>
): Promise<void> {
this.listener = listener
}
}
function envelope(
messageId = 'event-1',
conversationType = '2'
): DingTalkStreamEnvelope {
return {
headers: { messageId },
data: JSON.stringify({
conversationId: 'conversation-1',
conversationType,
createAt: 1_800_000_000_000,
isInAtList: conversationType === '2',
msgId: 'provider-1',
msgtype: 'text',
senderStaffId: 'USER-1',
sessionWebhook: SESSION_WEBHOOK,
sessionWebhookExpiredTime: 4_000_000_000_000,
text: { content: '请总结进展' }
})
}
}
describe('DingTalkChannelDriver', () => {
it('adapts group text and consumes only the issued reply context', async () => {
const transport = new FakeTransport()
const factory: DingTalkTransportFactory = {
create: async () => transport
}
const driver = new DingTalkChannelDriver({
clientId: 'client-id',
clientSecret: 'client-secret',
allowedSenderIds: ['user-1'],
transportFactory: factory
})
const messages: unknown[] = []
await driver.start((message) => {
messages.push(message)
})
await transport.listener?.(envelope())
expect(messages).toEqual([
{
channel: 'dingtalk',
eventId: 'event-1',
senderId: 'user-1',
conversationId: 'conversation-1',
conversationType: 'group',
text: '请总结进展',
mentioned: true,
workMode: 'ask',
receivedAt: 1_800_000_000_000
}
])
await driver.send(
{
channel: 'dingtalk',
eventId: 'event-1',
conversationId: 'conversation-1',
recipientId: 'user-1',
status: 'completed',
output: '已完成'
},
new AbortController().signal
)
expect(transport.replyText).toHaveBeenCalledWith(
SESSION_WEBHOOK,
'已完成'
)
await expect(
driver.send(
{
channel: 'dingtalk',
eventId: 'event-1',
conversationId: 'conversation-1',
recipientId: 'user-1',
status: 'completed',
output: '重复回复'
},
new AbortController().signal
)
).rejects.toThrow('上下文无效')
})
it('acks official Stream callbacks before asynchronous processing', async () => {
const order: string[] = []
let listener:
| ((message: {
headers: { messageId: string }
data: string
}) => void)
| undefined
const client = {
registerCallbackListener: vi.fn(
(
_topic: string,
value: (message: {
headers: { messageId: string }
data: string
}) => void
) => {
listener = value
}
),
socketCallBackResponse: vi.fn(() => {
order.push('ack')
}),
connect: vi.fn(async () => undefined),
disconnect: vi.fn()
}
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }))
const factory = createOfficialDingTalkTransportFactory({
clientFactory: async (credentials) => {
expect(credentials).toEqual({
clientId: 'client-id',
clientSecret: 'client-secret'
})
return client
},
fetchImpl
})
const transport = await factory.create({
clientId: 'client-id',
clientSecret: 'client-secret'
})
await transport.start(async () => {
order.push('processed')
})
listener?.({
headers: { messageId: 'stream-1' },
data: '{}'
})
expect(order).toEqual(['ack'])
await vi.waitFor(() => {
expect(order).toEqual(['ack', 'processed'])
})
await transport.replyText(SESSION_WEBHOOK, '安全回复')
expect(fetchImpl).toHaveBeenCalledWith(
SESSION_WEBHOOK,
expect.objectContaining({
method: 'POST',
redirect: 'error'
})
)
expect(client.registerCallbackListener).toHaveBeenCalledWith(
'/v1.0/im/bot/messages/get',
expect.any(Function)
)
expect(client.socketCallBackResponse).toHaveBeenCalledWith(
'stream-1',
{ status: 'SUCCESS' }
)
})
})
@@ -0,0 +1,300 @@
import type {
ChannelInboundText,
ChannelResultMessage
} from '../../shared/channel-contracts'
import type { ChannelDriver, ChannelInboundHandler } from './channel-driver'
import {
DingTalkDriver,
type DingTalkStreamEnvelope,
type DingTalkStreamTransport,
type DingTalkInboundTextMessage,
type DingTalkReplyContext,
type DingTalkTransportCredentials,
type DingTalkTransportFactory
} from './dingtalk-driver'
const DEFAULT_MAXIMUM_REPLY_CONTEXTS = 1_000
const MAXIMUM_REPLY_BYTES = 32 * 1024
const MAXIMUM_RESPONSE_BYTES = 64 * 1024
const REPLY_TIMEOUT_MS = 10_000
const DINGTALK_ROBOT_TOPIC = '/v1.0/im/bot/messages/get'
type ReplyRecord = {
context: DingTalkReplyContext
conversationId: string
senderId: string
}
export type DingTalkChannelDriverOptions = {
clientId: string
clientSecret: string
allowedSenderIds: readonly string[]
transportFactory?: DingTalkTransportFactory
maximumReplyContexts?: number
}
type DingTalkSdkClient = {
registerCallbackListener(
topic: string,
listener: (message: {
headers: { messageId: string }
data: string
}) => void
): unknown
socketCallBackResponse(messageId: string, result: unknown): void
connect(): Promise<void>
disconnect(): void
}
type DingTalkClientFactory = (
credentials: DingTalkTransportCredentials
) => Promise<DingTalkSdkClient>
type DingTalkFetch = (
input: string,
init: RequestInit
) => Promise<Response>
export type OfficialDingTalkTransportOptions = {
clientFactory?: DingTalkClientFactory
fetchImpl?: DingTalkFetch
}
async function defaultClientFactory(
credentials: DingTalkTransportCredentials
): Promise<DingTalkSdkClient> {
const { DWClient } = await import('dingtalk-stream')
return new DWClient({
clientId: credentials.clientId,
clientSecret: credentials.clientSecret,
debug: false
})
}
class OfficialDingTalkTransport implements DingTalkStreamTransport {
private client?: DingTalkSdkClient
constructor(
private readonly credentials: DingTalkTransportCredentials,
private readonly clientFactory: DingTalkClientFactory,
private readonly fetchImpl: DingTalkFetch
) {}
async start(
onEnvelope: (envelope: DingTalkStreamEnvelope) => Promise<void>
): Promise<void> {
const client = await this.clientFactory(this.credentials)
client.registerCallbackListener(
DINGTALK_ROBOT_TOPIC,
(message) => {
const messageId = message.headers.messageId
client.socketCallBackResponse(messageId, {
status: 'SUCCESS'
})
void Promise.resolve()
.then(() =>
onEnvelope({
headers: { messageId },
data: message.data
})
)
.catch(() => undefined)
}
)
this.client = client
try {
await client.connect()
} catch {
this.client = undefined
client.disconnect()
throw new Error('钉钉 Stream 连接失败')
}
}
async stop(): Promise<void> {
const client = this.client
this.client = undefined
client?.disconnect()
}
async replyText(sessionWebhook: string, text: string): Promise<void> {
const body = JSON.stringify({
msgtype: 'text',
text: { content: text }
})
if (
Buffer.byteLength(text, 'utf8') > MAXIMUM_REPLY_BYTES ||
Buffer.byteLength(body, 'utf8') > MAXIMUM_REPLY_BYTES
) {
throw new Error('钉钉回复内容过大')
}
const controller = new AbortController()
const timeout = setTimeout(() => {
controller.abort(new Error('钉钉回复超时'))
}, REPLY_TIMEOUT_MS)
try {
const response = await this.fetchImpl(sessionWebhook, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
body,
redirect: 'error',
signal: controller.signal
})
const responseLength = Number(
response.headers.get('content-length') ?? '0'
)
if (
!response.ok ||
!Number.isFinite(responseLength) ||
responseLength > MAXIMUM_RESPONSE_BYTES
) {
throw new Error('钉钉回复请求失败')
}
await response.body?.cancel()
} catch {
throw new Error('钉钉回复请求失败')
} finally {
clearTimeout(timeout)
}
}
}
export function createOfficialDingTalkTransportFactory(
options: OfficialDingTalkTransportOptions = {}
): DingTalkTransportFactory {
const clientFactory = options.clientFactory ?? defaultClientFactory
const fetchImpl =
options.fetchImpl ??
((input, init) => fetch(input, init))
return {
create: (credentials) =>
new OfficialDingTalkTransport(
credentials,
clientFactory,
fetchImpl
)
}
}
function maximumReplyContexts(value: number | undefined): number {
const candidate = value ?? DEFAULT_MAXIMUM_REPLY_CONTEXTS
if (!Number.isSafeInteger(candidate) || candidate < 1) {
throw new Error('钉钉回复上下文容量无效')
}
return candidate
}
function resultText(message: ChannelResultMessage): string {
return message.output?.trim() || message.error?.trim() || '请求已完成'
}
export class DingTalkChannelDriver implements ChannelDriver {
readonly channel = 'dingtalk'
private readonly driver: DingTalkDriver
private readonly maximumContexts: number
private readonly replyContexts = new Map<string, ReplyRecord>()
private handler?: ChannelInboundHandler
constructor(options: DingTalkChannelDriverOptions) {
this.maximumContexts = maximumReplyContexts(
options.maximumReplyContexts
)
this.driver = new DingTalkDriver(
{
clientId: options.clientId,
clientSecret: options.clientSecret,
allowedSenderStaffIds: options.allowedSenderIds,
onMessage: (message) => this.handleMessage(message)
},
options.transportFactory ??
createOfficialDingTalkTransportFactory()
)
}
async start(handler: ChannelInboundHandler): Promise<void> {
this.handler = handler
try {
await this.driver.start()
} catch {
this.handler = undefined
throw new Error('钉钉通道启动失败')
}
}
async send(
message: ChannelResultMessage,
signal: AbortSignal
): Promise<void> {
const record = this.replyContexts.get(message.eventId)
if (
!record ||
message.channel !== this.channel ||
message.conversationId !== record.conversationId ||
message.recipientId !== record.senderId
) {
throw new Error('钉钉回复上下文无效或已过期')
}
try {
signal.throwIfAborted()
await this.driver.reply(record.context, resultText(message))
} catch {
throw new Error('钉钉消息回复失败')
} finally {
this.replyContexts.delete(message.eventId)
}
}
async stop(): Promise<void> {
this.handler = undefined
this.replyContexts.clear()
try {
await this.driver.stop()
} catch {
throw new Error('钉钉通道停止失败')
}
}
private async handleMessage(
message: DingTalkInboundTextMessage
): Promise<void> {
const handler = this.handler
if (!handler) {
return
}
this.replyContexts.set(message.dedupeKey, {
context: message.replyContext,
conversationId: message.conversationId,
senderId: message.senderId
})
this.enforceContextLimit()
const inbound: ChannelInboundText = {
channel: this.channel,
eventId: message.dedupeKey,
senderId: message.senderId,
conversationId: message.conversationId,
conversationType: message.conversationType,
text: message.text,
mentioned: message.conversationType === 'group',
workMode: 'ask',
receivedAt: message.createdAt
}
await handler(inbound, () => undefined)
}
private enforceContextLimit(): void {
while (this.replyContexts.size > this.maximumContexts) {
const oldest = this.replyContexts.keys().next().value
if (typeof oldest !== 'string') {
return
}
this.replyContexts.delete(oldest)
}
}
}
+325
View File
@@ -0,0 +1,325 @@
import { describe, expect, it, vi } from 'vitest'
import {
DingTalkDriver,
type DingTalkInboundTextMessage,
type DingTalkStreamEnvelope,
type DingTalkStreamTransport,
type DingTalkTransportFactory,
normalizeDingTalkStaffId,
parseDingTalkStreamMessage
} from './dingtalk-driver'
const NOW = 1_800_000_000_000
const SESSION_WEBHOOK =
'https://oapi.dingtalk.com/robot/sendBySession?session=opaque'
function envelope(
overrides: Record<string, unknown> = {},
messageId = 'stream-message-1'
): DingTalkStreamEnvelope {
return {
headers: { messageId },
data: JSON.stringify({
conversationId: 'conversation-1',
conversationType: '1',
createAt: NOW - 1_000,
isInAtList: false,
msgId: 'provider-message-1',
msgtype: 'text',
senderNick: '测试用户',
senderStaffId: ' Staff- ',
sessionWebhook: SESSION_WEBHOOK,
sessionWebhookExpiredTime: NOW + 60_000,
text: { content: ' 你好,GoodBuddy ' },
...overrides
})
}
}
class FakeTransport implements DingTalkStreamTransport {
readonly start = vi.fn(
async (
onEnvelope: (
value: DingTalkStreamEnvelope
) => Promise<void>
) => {
this.onEnvelope = onEnvelope
}
)
readonly stop = vi.fn(async () => undefined)
readonly replyText = vi.fn(async () => undefined)
private onEnvelope?: (
value: DingTalkStreamEnvelope
) => Promise<void>
async emit(value: DingTalkStreamEnvelope): Promise<void> {
if (!this.onEnvelope) {
throw new Error('transport not started')
}
await this.onEnvelope(value)
}
}
function createDriver(options?: {
allowedSenderStaffIds?: readonly string[]
onMessage?: (message: DingTalkInboundTextMessage) => Promise<void>
maxProcessedMessageIds?: number
transports?: FakeTransport[]
}) {
const transports = options?.transports ?? [new FakeTransport()]
let factoryIndex = 0
const factory: DingTalkTransportFactory = {
create: vi.fn(async (credentials) => {
expect(credentials).toEqual({
clientId: 'client-id',
clientSecret: 'client-secret'
})
const transport = transports[factoryIndex]
factoryIndex += 1
if (!transport) {
throw new Error('missing fake transport')
}
return transport
})
}
const handler =
options?.onMessage ?? vi.fn(async () => undefined)
const driver = new DingTalkDriver(
{
clientId: 'client-id',
clientSecret: 'client-secret',
allowedSenderStaffIds:
options?.allowedSenderStaffIds ?? ['staff-a'],
onMessage: handler,
maxProcessedMessageIds: options?.maxProcessedMessageIds,
now: () => NOW
},
factory
)
return { driver, factory, handler, transports }
}
describe('parseDingTalkStreamMessage', () => {
it('strictly parses text and carries a bounded reply context', () => {
expect(parseDingTalkStreamMessage(envelope())).toEqual({
channel: 'dingtalk',
kind: 'text',
messageId: 'stream-message-1',
providerMessageId: 'provider-message-1',
dedupeKey: 'stream-message-1',
conversationId: 'conversation-1',
conversationType: 'direct',
senderId: 'staff-a',
senderName: '测试用户',
text: '你好,GoodBuddy',
createdAt: NOW - 1_000,
replyContext: {
channel: 'dingtalk',
sessionWebhook: SESSION_WEBHOOK,
expiresAt: NOW + 60_000
}
})
expect(normalizeDingTalkStaffId(' STAFF-A ')).toBe(
'staff-a'
)
})
it('ignores attachment messages without reading attachment fields', () => {
expect(
parseDingTalkStreamMessage(
envelope({
msgtype: 'picture',
text: undefined,
content: {
downloadCode: 'must-not-be-used'
}
})
)
).toBeNull()
})
it('requires an explicit bot mention in group conversations', () => {
expect(
parseDingTalkStreamMessage(
envelope({
conversationType: '2',
isInAtList: false
})
)
).toBeNull()
expect(
parseDingTalkStreamMessage(
envelope({
conversationType: '2',
isInAtList: true
})
)?.conversationType
).toBe('group')
})
it.each([
[
'non-JSON data',
{ headers: { messageId: 'id' }, data: '{' }
],
[
'blank stream message ID',
envelope({}, ' ')
],
[
'missing senderStaffId',
envelope({ senderStaffId: undefined })
],
[
'blank text',
envelope({ text: { content: ' ' } })
],
[
'unknown conversation type',
envelope({ conversationType: '3' })
],
[
'non-DingTalk reply host',
envelope({
sessionWebhook:
'https://example.com/steal-session-token'
})
],
[
'insecure reply URL',
envelope({
sessionWebhook:
'http://oapi.dingtalk.com/robot/sendBySession'
})
]
])('rejects malformed payload: %s', (_name, value) => {
expect(() =>
parseDingTalkStreamMessage(value as DingTalkStreamEnvelope)
).toThrow()
})
})
describe('DingTalkDriver', () => {
it('normalizes the sender allowlist and deduplicates message IDs', async () => {
const { driver, handler, transports } = createDriver({
allowedSenderStaffIds: [' STAFF-A ']
})
await driver.start()
await transports[0]?.emit(envelope())
await transports[0]?.emit(
envelope({ msgId: 'redelivered-provider-id' })
)
await transports[0]?.emit(
envelope(
{
senderStaffId: 'not-allowed',
msgId: 'provider-message-2'
},
'stream-message-2'
)
)
expect(handler).toHaveBeenCalledTimes(1)
})
it('does not mark a failed delivery as processed', async () => {
const handler = vi
.fn<(message: DingTalkInboundTextMessage) => Promise<void>>()
.mockRejectedValueOnce(new Error('temporary failure'))
.mockResolvedValue()
const { driver, transports } = createDriver({ onMessage: handler })
await driver.start()
await expect(transports[0]?.emit(envelope())).rejects.toThrow(
'temporary failure'
)
await transports[0]?.emit(envelope())
expect(handler).toHaveBeenCalledTimes(2)
})
it('bounds the in-memory deduplication window', async () => {
const { driver, handler, transports } = createDriver({
maxProcessedMessageIds: 2
})
await driver.start()
await transports[0]?.emit(envelope({}, 'stream-message-1'))
await transports[0]?.emit(envelope({}, 'stream-message-2'))
await transports[0]?.emit(envelope({}, 'stream-message-3'))
await transports[0]?.emit(envelope({}, 'stream-message-1'))
expect(handler).toHaveBeenCalledTimes(4)
})
it('replies only through the current unexpired session webhook', async () => {
const { driver, transports } = createDriver()
await driver.start()
const parsed = parseDingTalkStreamMessage(envelope())
expect(parsed).not.toBeNull()
await driver.reply(parsed!.replyContext, '回复内容')
expect(transports[0]?.replyText).toHaveBeenCalledWith(
SESSION_WEBHOOK,
'回复内容'
)
await expect(
driver.reply(
{
...parsed!.replyContext,
expiresAt: NOW
},
'too late'
)
).rejects.toThrow('已过期')
await expect(
driver.reply(
{
...parsed!.replyContext,
sessionWebhook: 'https://example.com/not-trusted'
},
'unsafe'
)
).rejects.toThrow('不是受信任')
})
it('serializes idempotent start and stop calls and can restart', async () => {
const firstTransport = new FakeTransport()
const secondTransport = new FakeTransport()
const { driver, factory } = createDriver({
transports: [firstTransport, secondTransport]
})
await Promise.all([driver.start(), driver.start()])
expect(factory.create).toHaveBeenCalledTimes(1)
expect(firstTransport.start).toHaveBeenCalledTimes(1)
await Promise.all([driver.stop(), driver.stop()])
expect(firstTransport.stop).toHaveBeenCalledTimes(1)
await driver.start()
expect(factory.create).toHaveBeenCalledTimes(2)
expect(secondTransport.start).toHaveBeenCalledTimes(1)
})
it('cleans up a failed transport start and allows retry', async () => {
const failedTransport = new FakeTransport()
failedTransport.start.mockRejectedValueOnce(
new Error('connect failed')
)
const retryTransport = new FakeTransport()
const { driver } = createDriver({
transports: [failedTransport, retryTransport]
})
await expect(driver.start()).rejects.toThrow('connect failed')
expect(failedTransport.stop).toHaveBeenCalledTimes(1)
await driver.start()
expect(retryTransport.start).toHaveBeenCalledTimes(1)
})
})
+400
View File
@@ -0,0 +1,400 @@
const DINGTALK_CHANNEL = 'dingtalk' as const
const DIRECT_CONVERSATION = '1'
const GROUP_CONVERSATION = '2'
const MAX_STREAM_DATA_BYTES = 64 * 1024
const DEFAULT_MAX_PROCESSED_MESSAGE_IDS = 1_000
const DINGTALK_SESSION_WEBHOOK_HOST = 'oapi.dingtalk.com'
export interface DingTalkStreamEnvelope {
headers: {
messageId: string
}
data: string
}
export interface DingTalkReplyContext {
channel: typeof DINGTALK_CHANNEL
sessionWebhook: string
expiresAt: number
}
export interface DingTalkInboundTextMessage {
channel: typeof DINGTALK_CHANNEL
kind: 'text'
messageId: string
providerMessageId: string
dedupeKey: string
conversationId: string
conversationType: 'direct' | 'group'
senderId: string
senderName?: string
text: string
createdAt: number
replyContext: DingTalkReplyContext
}
export type DingTalkMessageHandler = (
message: DingTalkInboundTextMessage
) => Promise<void> | void
/**
* The SDK-specific boundary. An implementation may wrap DWClient and an HTTP
* session-webhook replier; unit tests can provide an entirely local transport.
*/
export interface DingTalkStreamTransport {
start(
onEnvelope: (envelope: DingTalkStreamEnvelope) => Promise<void>
): Promise<void>
stop(): Promise<void>
replyText(sessionWebhook: string, text: string): Promise<void>
}
export interface DingTalkTransportCredentials {
clientId: string
clientSecret: string
}
export interface DingTalkTransportFactory {
create(
credentials: DingTalkTransportCredentials
): DingTalkStreamTransport | Promise<DingTalkStreamTransport>
}
export interface DingTalkDriverOptions {
clientId: string
clientSecret: string
allowedSenderStaffIds: readonly string[]
onMessage?: DingTalkMessageHandler
maxProcessedMessageIds?: number
now?: () => number
}
export function normalizeDingTalkStaffId(staffId: string): string {
return staffId.normalize('NFKC').trim().toLocaleLowerCase('en-US')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value)
)
}
function requiredString(
value: unknown,
field: string,
options: { trim?: boolean } = {}
): string {
if (typeof value !== 'string') {
throw new Error(`钉钉消息字段 ${field} 必须是字符串`)
}
const result = options.trim === false ? value : value.trim()
if (value.trim().length === 0) {
throw new Error(`钉钉消息字段 ${field} 不能为空`)
}
return result
}
function requiredTimestamp(value: unknown, field: string): number {
if (
typeof value !== 'number' ||
!Number.isSafeInteger(value) ||
value <= 0
) {
throw new Error(`钉钉消息字段 ${field} 必须是正整数时间戳`)
}
return value
}
function parseSessionWebhook(value: unknown): string {
const sessionWebhook = requiredString(value, 'sessionWebhook')
let parsed: URL
try {
parsed = new URL(sessionWebhook)
} catch {
throw new Error('钉钉消息字段 sessionWebhook 无效')
}
if (
parsed.protocol !== 'https:' ||
parsed.hostname.toLowerCase() !== DINGTALK_SESSION_WEBHOOK_HOST ||
parsed.pathname !== '/robot/sendBySession' ||
parsed.username ||
parsed.password
) {
throw new Error('钉钉消息字段 sessionWebhook 不是受信任的钉钉地址')
}
return parsed.toString()
}
function parsePayloadData(data: string): Record<string, unknown> {
if (Buffer.byteLength(data, 'utf8') > MAX_STREAM_DATA_BYTES) {
throw new Error('钉钉消息内容过大')
}
let payload: unknown
try {
payload = JSON.parse(data)
} catch {
throw new Error('钉钉消息不是有效的 JSON')
}
if (!isRecord(payload)) {
throw new Error('钉钉消息 payload 必须是对象')
}
return payload
}
/**
* Parses one official robot callback frame. Non-text callbacks and group
* messages that did not mention the bot are intentionally ignored.
*/
export function parseDingTalkStreamMessage(
envelope: DingTalkStreamEnvelope
): DingTalkInboundTextMessage | null {
if (!isRecord(envelope) || !isRecord(envelope.headers)) {
throw new Error('钉钉 Stream 消息格式无效')
}
const messageId = requiredString(
envelope.headers.messageId,
'headers.messageId'
)
if (typeof envelope.data !== 'string') {
throw new Error('钉钉消息字段 data 必须是 JSON 字符串')
}
const payload = parsePayloadData(envelope.data)
const messageType = requiredString(payload.msgtype, 'msgtype')
if (messageType !== 'text') {
return null
}
const conversationType = requiredString(
payload.conversationType,
'conversationType'
)
if (
conversationType !== DIRECT_CONVERSATION &&
conversationType !== GROUP_CONVERSATION
) {
throw new Error('钉钉消息字段 conversationType 无效')
}
if (
conversationType === GROUP_CONVERSATION &&
payload.isInAtList !== true
) {
return null
}
if (!isRecord(payload.text)) {
throw new Error('钉钉文本消息字段 text 必须是对象')
}
const text = requiredString(payload.text.content, 'text.content')
const rawSenderId = requiredString(
payload.senderStaffId,
'senderStaffId'
)
const senderId = normalizeDingTalkStaffId(rawSenderId)
if (!senderId) {
throw new Error('钉钉消息字段 senderStaffId 不能为空')
}
const senderName =
typeof payload.senderNick === 'string' &&
payload.senderNick.trim().length > 0
? payload.senderNick.trim()
: undefined
const replyContext: DingTalkReplyContext = {
channel: DINGTALK_CHANNEL,
sessionWebhook: parseSessionWebhook(payload.sessionWebhook),
expiresAt: requiredTimestamp(
payload.sessionWebhookExpiredTime,
'sessionWebhookExpiredTime'
)
}
return {
channel: DINGTALK_CHANNEL,
kind: 'text',
messageId,
providerMessageId: requiredString(payload.msgId, 'msgId'),
dedupeKey: messageId,
conversationId: requiredString(
payload.conversationId,
'conversationId'
),
conversationType:
conversationType === GROUP_CONVERSATION ? 'group' : 'direct',
senderId,
...(senderName ? { senderName } : {}),
text,
createdAt: requiredTimestamp(payload.createAt, 'createAt'),
replyContext
}
}
export class DingTalkDriver {
readonly channel = DINGTALK_CHANNEL
private readonly credentials: DingTalkTransportCredentials
private readonly allowedSenderIds: ReadonlySet<string>
private readonly maxProcessedMessageIds: number
private readonly now: () => number
private handler?: DingTalkMessageHandler
private transport?: DingTalkStreamTransport
private lifecycle: Promise<void> = Promise.resolve()
private readonly inFlightMessageIds = new Set<string>()
private readonly processedMessageIds = new Set<string>()
constructor(
options: DingTalkDriverOptions,
private readonly transportFactory: DingTalkTransportFactory
) {
this.credentials = {
clientId: requiredString(options.clientId, 'clientId'),
clientSecret: requiredString(options.clientSecret, 'clientSecret')
}
this.allowedSenderIds = new Set(
options.allowedSenderStaffIds
.map((staffId) =>
normalizeDingTalkStaffId(
requiredString(staffId, 'allowedSenderStaffIds')
)
)
.filter((staffId) => staffId.length > 0)
)
this.handler = options.onMessage
this.now = options.now ?? Date.now
const maximum =
options.maxProcessedMessageIds ??
DEFAULT_MAX_PROCESSED_MESSAGE_IDS
if (!Number.isSafeInteger(maximum) || maximum <= 0) {
throw new Error('maxProcessedMessageIds 必须是正整数')
}
this.maxProcessedMessageIds = maximum
}
start(handler?: DingTalkMessageHandler): Promise<void> {
return this.enqueueLifecycle(async () => {
if (handler) {
this.handler = handler
}
if (this.transport) {
return
}
if (!this.handler) {
throw new Error('启动钉钉通道前必须设置消息处理器')
}
const transport = await this.transportFactory.create(
this.credentials
)
this.transport = transport
try {
await transport.start((envelope) =>
this.handleEnvelope(envelope)
)
} catch (error) {
this.transport = undefined
try {
await transport.stop()
} catch {
// Keep the original startup failure; the transport owns cleanup.
}
throw error
}
})
}
stop(): Promise<void> {
return this.enqueueLifecycle(async () => {
const transport = this.transport
if (!transport) {
return
}
await transport.stop()
this.transport = undefined
})
}
async reply(
context: DingTalkReplyContext,
text: string
): Promise<void> {
const transport = this.transport
if (!transport) {
throw new Error('钉钉通道尚未启动')
}
if (context.channel !== DINGTALK_CHANNEL) {
throw new Error('回复上下文不属于钉钉通道')
}
const sessionWebhook = parseSessionWebhook(
context.sessionWebhook
)
if (
!Number.isSafeInteger(context.expiresAt) ||
context.expiresAt <= this.now()
) {
throw new Error('钉钉会话回复地址已过期')
}
await transport.replyText(
sessionWebhook,
requiredString(text, 'reply.text', { trim: false })
)
}
private enqueueLifecycle(operation: () => Promise<void>): Promise<void> {
const result = this.lifecycle.then(operation, operation)
this.lifecycle = result.then(
() => undefined,
() => undefined
)
return result
}
private async handleEnvelope(
envelope: DingTalkStreamEnvelope
): Promise<void> {
const message = parseDingTalkStreamMessage(envelope)
if (
!message ||
!this.allowedSenderIds.has(message.senderId) ||
this.processedMessageIds.has(message.dedupeKey) ||
this.inFlightMessageIds.has(message.dedupeKey)
) {
return
}
const handler = this.handler
if (!handler) {
throw new Error('钉钉通道没有消息处理器')
}
this.inFlightMessageIds.add(message.dedupeKey)
try {
await handler(message)
this.rememberProcessedMessageId(message.dedupeKey)
} finally {
this.inFlightMessageIds.delete(message.dedupeKey)
}
}
private rememberProcessedMessageId(messageId: string): void {
this.processedMessageIds.add(messageId)
while (
this.processedMessageIds.size >
this.maxProcessedMessageIds
) {
const oldestMessageId =
this.processedMessageIds.values().next().value
if (typeof oldestMessageId !== 'string') {
break
}
this.processedMessageIds.delete(oldestMessageId)
}
}
}
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest'
import {
WECHAT_SIDECAR_MAX_QR_PAYLOAD_LENGTH,
WECHAT_SIDECAR_MAX_TEXT_LENGTH,
WechatQrStateMachine,
wechatSidecarMessageSchema
} from './wechat-sidecar-protocol'
const NOW = Date.parse('2026-08-06T10:00:00.000Z')
function qr(expiresAt = NOW + 60_000): {
type: 'qr'
qrId: string
payload: string
expiresAt: string
} {
return {
type: 'qr',
qrId: 'qr-1',
payload: 'bounded-local-qr-payload',
expiresAt: new Date(expiresAt).toISOString()
}
}
describe('wechatSidecarMessageSchema', () => {
it('accepts the bounded message variants and reply correlation', () => {
expect(
wechatSidecarMessageSchema.parse({
type: 'status',
status: 'connected'
})
).toEqual({ type: 'status', status: 'connected' })
expect(
wechatSidecarMessageSchema.parse({
type: 'inbound_text',
eventId: 'event-1',
senderId: 'sender-1',
conversationId: 'conversation-1',
text: '你好'
})
).toMatchObject({ eventId: 'event-1', text: '你好' })
expect(
wechatSidecarMessageSchema.parse({
type: 'reply',
replyId: 'reply-1',
inReplyToEventId: 'event-1',
conversationId: 'conversation-1',
text: '收到'
})
).toMatchObject({
replyId: 'reply-1',
inReplyToEventId: 'event-1'
})
})
it.each(['session', 'cookie', 'token'])(
'rejects the sensitive %s field',
(field) => {
expect(() =>
wechatSidecarMessageSchema.parse({
type: 'status',
status: 'connected',
[field]: 'must-not-cross-boundary'
})
).toThrow()
}
)
it('rejects unknown, malicious, and oversized payloads', () => {
expect(() =>
wechatSidecarMessageSchema.parse({
type: 'inbound_text',
eventId: 'event-1',
senderId: 'sender-1',
conversationId: 'conversation-1',
text: 'hello',
command: 'exec'
})
).toThrow()
expect(() =>
wechatSidecarMessageSchema.parse({
type: 'inbound_text',
eventId: 'event-1\nforged',
senderId: 'sender-1',
conversationId: 'conversation-1',
text: 'hello'
})
).toThrow()
expect(() =>
wechatSidecarMessageSchema.parse({
type: 'inbound_text',
eventId: 'event-1',
senderId: 'sender-1',
conversationId: 'conversation-1',
text: 'x'.repeat(WECHAT_SIDECAR_MAX_TEXT_LENGTH + 1)
})
).toThrow()
expect(() =>
wechatSidecarMessageSchema.parse({
...qr(),
payload: 'x'.repeat(
WECHAT_SIDECAR_MAX_QR_PAYLOAD_LENGTH + 1
)
})
).toThrow()
})
})
describe('WechatQrStateMachine', () => {
it('allows the expected scan flow and rejects skipped states', () => {
const machine = new WechatQrStateMachine()
expect(() => machine.transition('connected', NOW)).toThrow(
'非法的微信扫码状态转换'
)
expect(machine.transition('starting', NOW).status).toBe('starting')
expect(machine.transition('pending', NOW).status).toBe('pending')
expect(machine.setQr(qr(), NOW).qr?.qrId).toBe('qr-1')
expect(machine.transition('scanned', NOW).status).toBe('scanned')
const connected = machine.transition('connected', NOW)
expect(connected).toEqual({ status: 'connected' })
})
it('expires a short-lived QR and prevents scanning it', () => {
const machine = new WechatQrStateMachine()
machine.transition('starting', NOW)
machine.transition('pending', NOW)
machine.setQr(qr(NOW + 1_000), NOW)
expect(machine.expire(NOW + 1_000)).toBe(true)
expect(machine.snapshot()).toEqual({ status: 'expired' })
expect(() => machine.transition('scanned', NOW + 1_000)).toThrow(
'非法的微信扫码状态转换'
)
})
it('rejects expired and excessively long-lived QR payloads', () => {
const machine = new WechatQrStateMachine()
machine.transition('starting', NOW)
machine.transition('pending', NOW)
expect(() => machine.setQr(qr(NOW), NOW)).toThrow(
'二维码有效期无效'
)
expect(() =>
machine.setQr(qr(NOW + 5 * 60_000 + 1), NOW)
).toThrow('二维码有效期无效')
})
})
@@ -0,0 +1,220 @@
import { z } from 'zod'
export const WECHAT_SIDECAR_MAX_TEXT_LENGTH = 8_000
export const WECHAT_SIDECAR_MAX_QR_PAYLOAD_LENGTH = 4_096
export const WECHAT_SIDECAR_MAX_QR_TTL_MS = 5 * 60 * 1_000
function containsControlCharacter(value: string): boolean {
for (const character of value) {
const code = character.codePointAt(0)
if (code !== undefined && (code <= 31 || code === 127)) {
return true
}
}
return false
}
function containsWhitespaceOrControlCharacter(value: string): boolean {
for (const character of value) {
if (
character.trim() === '' ||
containsControlCharacter(character)
) {
return true
}
}
return false
}
const identifierSchema = z
.string()
.min(1)
.max(256)
.refine((value) => !containsWhitespaceOrControlCharacter(value))
const textSchema = z
.string()
.min(1)
.max(WECHAT_SIDECAR_MAX_TEXT_LENGTH)
export const wechatSidecarStatusSchema = z.enum([
'stopped',
'starting',
'pending',
'scanned',
'connected',
'expired',
'failed'
])
export type WechatSidecarStatus = z.infer<
typeof wechatSidecarStatusSchema
>
export const wechatSidecarStatusMessageSchema = z
.object({
type: z.literal('status'),
status: wechatSidecarStatusSchema,
detail: z.string().min(1).max(512).optional()
})
.strict()
export const wechatSidecarQrMessageSchema = z
.object({
type: z.literal('qr'),
qrId: identifierSchema,
payload: z
.string()
.min(1)
.max(WECHAT_SIDECAR_MAX_QR_PAYLOAD_LENGTH)
.refine((value) => !containsControlCharacter(value)),
expiresAt: z.string().datetime({ offset: true })
})
.strict()
export const wechatSidecarInboundTextMessageSchema = z
.object({
type: z.literal('inbound_text'),
eventId: identifierSchema,
senderId: identifierSchema,
conversationId: identifierSchema,
text: textSchema
})
.strict()
export const wechatSidecarReplyMessageSchema = z
.object({
type: z.literal('reply'),
replyId: identifierSchema,
inReplyToEventId: identifierSchema,
conversationId: identifierSchema,
text: textSchema
})
.strict()
export const wechatSidecarMessageSchema = z.discriminatedUnion('type', [
wechatSidecarStatusMessageSchema,
wechatSidecarQrMessageSchema,
wechatSidecarInboundTextMessageSchema,
wechatSidecarReplyMessageSchema
])
export type WechatSidecarMessage = z.infer<
typeof wechatSidecarMessageSchema
>
export type WechatSidecarQrMessage = z.infer<
typeof wechatSidecarQrMessageSchema
>
const allowedTransitions: Readonly<
Record<WechatSidecarStatus, ReadonlySet<WechatSidecarStatus>>
> = {
stopped: new Set(['stopped', 'starting']),
starting: new Set(['starting', 'pending', 'failed', 'stopped']),
pending: new Set([
'pending',
'scanned',
'expired',
'failed',
'stopped'
]),
scanned: new Set([
'scanned',
'connected',
'expired',
'failed',
'stopped'
]),
connected: new Set(['connected', 'failed', 'stopped']),
expired: new Set(['expired', 'starting', 'stopped']),
failed: new Set(['failed', 'starting', 'stopped'])
}
export type WechatQrStateSnapshot = {
status: WechatSidecarStatus
qr?: WechatSidecarQrMessage
}
export class WechatQrStateMachine {
private status: WechatSidecarStatus = 'stopped'
private qr?: WechatSidecarQrMessage
snapshot(): WechatQrStateSnapshot {
return {
status: this.status,
...(this.qr ? { qr: { ...this.qr } } : {})
}
}
transition(
next: WechatSidecarStatus,
now = Date.now()
): WechatQrStateSnapshot {
this.assertTimestamp(now)
this.expire(now)
if (!allowedTransitions[this.status].has(next)) {
throw new Error(
`非法的微信扫码状态转换:${this.status} -> ${next}`
)
}
if (
next === 'scanned' &&
(!this.qr || Date.parse(this.qr.expiresAt) <= now)
) {
throw new Error('无法扫描已过期或不存在的二维码')
}
this.status = next
if (
next === 'stopped' ||
next === 'starting' ||
next === 'connected' ||
next === 'expired' ||
next === 'failed'
) {
this.qr = undefined
}
return this.snapshot()
}
setQr(input: unknown, now = Date.now()): WechatQrStateSnapshot {
this.assertTimestamp(now)
this.expire(now)
if (this.status !== 'pending') {
throw new Error('仅等待扫码状态可以接收二维码')
}
const qr = wechatSidecarQrMessageSchema.parse(input)
const expiresAt = Date.parse(qr.expiresAt)
if (
!Number.isFinite(expiresAt) ||
expiresAt <= now ||
expiresAt - now > WECHAT_SIDECAR_MAX_QR_TTL_MS
) {
throw new Error('二维码有效期无效')
}
this.qr = qr
return this.snapshot()
}
expire(now = Date.now()): boolean {
this.assertTimestamp(now)
if (
(this.status === 'pending' || this.status === 'scanned') &&
this.qr &&
Date.parse(this.qr.expiresAt) <= now
) {
this.status = 'expired'
this.qr = undefined
return true
}
return false
}
private assertTimestamp(now: number): void {
if (!Number.isFinite(now) || now < 0) {
throw new Error('状态机时间无效')
}
}
}
@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from 'vitest'
import { WeComChannelDriver } from './wecom-channel-driver'
import type { WeComSdkTransport } from './wecom-driver'
type MessageListener = (frame: unknown) => void
type ErrorListener = (error: Error) => void
class FakeTransport implements WeComSdkTransport {
readonly connect = vi.fn()
readonly disconnect = vi.fn()
readonly replyStream = vi.fn<WeComSdkTransport['replyStream']>(
async () => ({})
)
private messageListener?: MessageListener
on(event: 'message', listener: MessageListener): unknown
on(event: 'error', listener: ErrorListener): unknown
on(
event: 'message' | 'error',
listener: MessageListener | ErrorListener
): unknown {
if (event === 'message') {
this.messageListener = listener as MessageListener
}
return this
}
off(event: 'message', listener: MessageListener): unknown
off(event: 'error', listener: ErrorListener): unknown
off(event: 'message' | 'error'): unknown {
if (event === 'message') {
this.messageListener = undefined
}
return this
}
emit(frame: unknown): void {
this.messageListener?.(frame)
}
}
function groupFrame(
eventId: string,
requestId: string
): Record<string, unknown> {
return {
cmd: 'aibot_msg_callback',
headers: { req_id: requestId },
body: {
msgid: eventId,
aibotid: 'bot-1',
chatid: 'group-1',
chattype: 'group',
from: { userid: 'user-1' },
create_time: 1_700_000_000,
msgtype: 'text',
text: { content: '@GoodBuddy 请规划下一步' }
}
}
}
describe('WeComChannelDriver', () => {
it('adapts mentioned group messages and bounds reply contexts', async () => {
const transport = new FakeTransport()
const driver = new WeComChannelDriver({
botId: 'bot-1',
secret: 'secret',
transportFactory: () => transport,
maximumReplyContexts: 1
})
const messages: unknown[] = []
await driver.start((message) => {
messages.push(message)
})
transport.emit(groupFrame('event-1', 'request-1'))
transport.emit(groupFrame('event-2', 'request-2'))
expect(messages[0]).toEqual({
channel: 'wecom',
eventId: 'event-1',
senderId: 'user-1',
conversationId: 'group-1',
conversationType: 'group',
text: '@GoodBuddy 请规划下一步',
mentioned: true,
workMode: 'ask',
receivedAt: 1_700_000_000
})
await expect(
driver.send(
{
channel: 'wecom',
eventId: 'event-1',
conversationId: 'group-1',
recipientId: 'user-1',
status: 'completed',
output: '旧回复'
},
new AbortController().signal
)
).rejects.toThrow('上下文无效')
await driver.send(
{
channel: 'wecom',
eventId: 'event-2',
conversationId: 'group-1',
recipientId: 'user-1',
status: 'completed',
output: '新回复'
},
new AbortController().signal
)
expect(transport.replyStream).toHaveBeenCalledWith(
{ headers: { req_id: 'request-2' } },
expect.stringMatching(/^goodbuddy_/u),
'新回复',
true
)
await driver.stop()
expect(transport.disconnect).toHaveBeenCalledOnce()
})
})
+146
View File
@@ -0,0 +1,146 @@
import type {
ChannelInboundText,
ChannelResultMessage
} from '../../shared/channel-contracts'
import type { ChannelDriver, ChannelInboundHandler } from './channel-driver'
import {
WeComDriver,
type WeComInboundMessage,
type WeComReplyContext,
type WeComTransportFactory
} from './wecom-driver'
const DEFAULT_MAXIMUM_REPLY_CONTEXTS = 1_000
type ReplyRecord = {
context: WeComReplyContext
conversationId: string
senderId: string
}
export type WeComChannelDriverOptions = {
botId: string
secret: string
transportFactory?: WeComTransportFactory
maximumReplyContexts?: number
}
function maximumReplyContexts(value: number | undefined): number {
const candidate = value ?? DEFAULT_MAXIMUM_REPLY_CONTEXTS
if (!Number.isSafeInteger(candidate) || candidate < 1) {
throw new Error('企业微信回复上下文容量无效')
}
return candidate
}
function resultText(message: ChannelResultMessage): string {
return message.output?.trim() || message.error?.trim() || '请求已完成'
}
export class WeComChannelDriver implements ChannelDriver {
readonly channel = 'wecom'
private readonly driver: WeComDriver
private readonly maximumContexts: number
private readonly replyContexts = new Map<string, ReplyRecord>()
private handler?: ChannelInboundHandler
constructor(options: WeComChannelDriverOptions) {
this.maximumContexts = maximumReplyContexts(
options.maximumReplyContexts
)
this.driver = new WeComDriver({
botId: options.botId,
secret: options.secret,
onMessage: (message) => this.handleMessage(message),
...(options.transportFactory
? { transportFactory: options.transportFactory }
: {})
})
}
async start(handler: ChannelInboundHandler): Promise<void> {
this.handler = handler
try {
await this.driver.start()
} catch {
this.handler = undefined
throw new Error('企业微信通道启动失败')
}
}
async send(
message: ChannelResultMessage,
signal: AbortSignal
): Promise<void> {
const record = this.replyContexts.get(message.eventId)
if (
!record ||
message.channel !== this.channel ||
message.conversationId !== record.conversationId ||
message.recipientId !== record.senderId
) {
throw new Error('企业微信回复上下文无效或已过期')
}
try {
signal.throwIfAborted()
await this.driver.reply(record.context, {
text: resultText(message)
})
} catch {
throw new Error('企业微信消息回复失败')
} finally {
this.replyContexts.delete(message.eventId)
}
}
async stop(): Promise<void> {
this.handler = undefined
this.replyContexts.clear()
try {
await this.driver.stop()
} catch {
throw new Error('企业微信通道停止失败')
}
}
private async handleMessage(message: WeComInboundMessage): Promise<void> {
const handler = this.handler
if (!handler) {
return
}
this.replyContexts.set(message.eventId, {
context: message.replyContext,
conversationId: message.conversationId,
senderId: message.userId
})
this.enforceContextLimit()
const inbound: ChannelInboundText = {
channel: this.channel,
eventId: message.eventId,
senderId: message.userId,
conversationId: message.conversationId,
conversationType:
message.chatType === 'group' ? 'group' : 'direct',
text: message.text,
mentioned: message.mentionedBot,
workMode: 'ask',
...(message.createdAt === undefined
? {}
: { receivedAt: message.createdAt })
}
await handler(inbound, () => undefined)
}
private enforceContextLimit(): void {
while (this.replyContexts.size > this.maximumContexts) {
const oldest = this.replyContexts.keys().next().value
if (typeof oldest !== 'string') {
return
}
this.replyContexts.delete(oldest)
}
}
}
+420
View File
@@ -0,0 +1,420 @@
import { describe, expect, it, vi } from 'vitest'
import {
WECOM_TEXT_MAX_BYTES,
WeComDriver,
WeComDriverError,
type WeComInboundMessage,
type WeComSdkTransport,
type WeComTransportCredentials
} from './wecom-driver'
type MessageListener = (frame: unknown) => void
type ErrorListener = (error: Error) => void
class FakeTransport implements WeComSdkTransport {
readonly connect = vi.fn(() => undefined)
readonly disconnect = vi.fn(() => undefined)
readonly replyStream = vi.fn<WeComSdkTransport['replyStream']>(
async () => ({})
)
readonly #messageListeners = new Set<MessageListener>()
readonly #errorListeners = new Set<ErrorListener>()
on(event: 'message', listener: MessageListener): unknown
on(event: 'error', listener: ErrorListener): unknown
on(
event: 'message' | 'error',
listener: MessageListener | ErrorListener
): unknown {
if (event === 'message') {
this.#messageListeners.add(listener as MessageListener)
} else {
this.#errorListeners.add(listener as ErrorListener)
}
return this
}
off(event: 'message', listener: MessageListener): unknown
off(event: 'error', listener: ErrorListener): unknown
off(
event: 'message' | 'error',
listener: MessageListener | ErrorListener
): unknown {
if (event === 'message') {
this.#messageListeners.delete(listener as MessageListener)
} else {
this.#errorListeners.delete(listener as ErrorListener)
}
return this
}
emitMessage(frame: unknown): void {
for (const listener of this.#messageListeners) {
listener(frame)
}
}
emitError(error: Error): void {
for (const listener of this.#errorListeners) {
listener(error)
}
}
get listenerCounts(): { message: number; error: number } {
return {
message: this.#messageListeners.size,
error: this.#errorListeners.size
}
}
}
function textFrame(
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
return {
cmd: 'aibot_msg_callback',
headers: { req_id: 'request-1' },
body: {
msgid: 'message-1',
aibotid: 'bot-main',
chatid: 'group-1',
chattype: 'group',
from: { userid: 'user-1' },
create_time: 1_700_000_000,
msgtype: 'text',
text: { content: '@GoodBuddy 请总结今天的进展' },
quote: {
msgtype: 'text',
text: { content: '昨天完成了基础设计' }
},
...overrides
}
}
}
function createHarness(): {
driver: WeComDriver
transport: FakeTransport
messages: WeComInboundMessage[]
rejected: Array<{ reason: string; eventId?: string; messageType?: string }>
errors: WeComDriverError[]
credentials: WeComTransportCredentials[]
} {
const transport = new FakeTransport()
const messages: WeComInboundMessage[] = []
const rejected: Array<{
reason: string
eventId?: string
messageType?: string
}> = []
const errors: WeComDriverError[] = []
const credentials: WeComTransportCredentials[] = []
const driver = new WeComDriver({
botId: 'bot-main',
secret: 'main-process-secret',
transportFactory: (value) => {
credentials.push(value)
return transport
},
streamIdFactory: () => 'stream-fixed',
onMessage: (message) => {
messages.push(message)
},
onRejected: (rejection) => {
rejected.push(rejection)
},
onError: (error) => {
errors.push(error)
}
})
return {
driver,
transport,
messages,
rejected,
errors,
credentials
}
}
describe('WeComDriver', () => {
it('normalizes a group text callback with stable identities and reply context', async () => {
const { driver, transport, messages, credentials } = createHarness()
await driver.start()
transport.emitMessage(textFrame())
expect(credentials).toEqual([
{ botId: 'bot-main', secret: 'main-process-secret' }
])
expect(Object.isFrozen(credentials[0])).toBe(true)
expect(messages).toEqual([
{
channel: 'wecom',
eventId: 'message-1',
userId: 'user-1',
conversationId: 'group-1',
chatType: 'group',
mentionedBot: true,
text: '@GoodBuddy 请总结今天的进展',
quotedText: '昨天完成了基础设计',
createdAt: 1_700_000_000,
replyContext: {
channel: 'wecom',
eventId: 'message-1',
requestId: 'request-1'
}
}
])
expect(Object.isFrozen(messages[0])).toBe(true)
expect(Object.isFrozen(messages[0]?.replyContext)).toBe(true)
expect(JSON.stringify(messages[0])).not.toContain('main-process-secret')
expect(JSON.stringify(messages[0])).not.toContain('bot-main')
})
it('uses the user id as a single-chat conversation id without mention semantics', async () => {
const { driver, transport, messages } = createHarness()
await driver.start()
transport.emitMessage(
textFrame({
chatid: undefined,
chattype: 'single',
from: { userid: 'direct-user' },
text: { content: '你好' },
quote: undefined,
create_time: undefined
})
)
expect(messages[0]).toMatchObject({
userId: 'direct-user',
conversationId: 'direct-user',
chatType: 'single',
mentionedBot: false,
text: '你好'
})
expect(messages[0]).not.toHaveProperty('createdAt')
expect(messages[0]).not.toHaveProperty('quotedText')
})
it('rejects malformed and wrong-bot callbacks at the boundary', async () => {
const { driver, transport, messages, rejected } = createHarness()
await driver.start()
transport.emitMessage(null)
transport.emitMessage(textFrame({ aibotid: 'another-bot' }))
transport.emitMessage(textFrame({ from: {} }))
transport.emitMessage(textFrame({ chattype: 'group', chatid: '' }))
transport.emitMessage(textFrame({ text: { content: ' ' } }))
transport.emitMessage(textFrame({ create_time: -1 }))
expect(messages).toHaveLength(0)
expect(rejected.map(({ reason }) => reason)).toEqual([
'invalid_message',
'bot_mismatch',
'invalid_message',
'invalid_message',
'invalid_message',
'invalid_message'
])
expect(rejected[1]).toEqual({
reason: 'bot_mismatch',
eventId: 'message-1',
messageType: 'text',
channel: 'wecom'
})
})
it.each(['file', 'image', 'mixed', 'video', 'voice'])(
'rejects inbound %s attachments without fetching them',
async (messageType) => {
const { driver, transport, messages, rejected } = createHarness()
await driver.start()
transport.emitMessage(
textFrame({
msgtype: messageType,
text: undefined,
[messageType]: {
url: 'https://example.invalid/private',
aeskey: 'do-not-use'
}
})
)
expect(messages).toHaveLength(0)
expect(rejected).toEqual([
{
channel: 'wecom',
reason: 'attachment_not_supported',
eventId: 'message-1',
messageType
}
])
}
)
it('rejects an attachment quote instead of silently dropping it', async () => {
const { driver, transport, messages, rejected } = createHarness()
await driver.start()
transport.emitMessage(
textFrame({
quote: {
msgtype: 'file',
file: {
url: 'https://example.invalid/document',
aeskey: 'do-not-use'
}
}
})
)
expect(messages).toHaveLength(0)
expect(rejected[0]?.reason).toBe('attachment_not_supported')
})
it('enforces the official 20480-byte UTF-8 text limit inbound and outbound', async () => {
const { driver, transport, messages, rejected } = createHarness()
await driver.start()
transport.emitMessage(
textFrame({ text: { content: 'x'.repeat(WECOM_TEXT_MAX_BYTES) } })
)
transport.emitMessage(
textFrame({
msgid: 'message-too-large',
text: { content: '你'.repeat(6_827) }
})
)
expect(messages).toHaveLength(1)
expect(rejected).toContainEqual({
channel: 'wecom',
reason: 'text_too_large',
eventId: 'message-too-large',
messageType: 'text'
})
const context = messages[0]?.replyContext
if (context === undefined) {
throw new Error('Expected a reply context')
}
await driver.reply(context, {
text: 'y'.repeat(WECOM_TEXT_MAX_BYTES)
})
await expect(
driver.reply(context, { text: '你'.repeat(6_827) })
).rejects.toMatchObject({ code: 'invalid_text' })
expect(transport.replyStream).toHaveBeenCalledOnce()
})
it('uses only an issued reply context and the callback request id', async () => {
const { driver, transport, messages } = createHarness()
await driver.start()
transport.emitMessage(textFrame())
const context = messages[0]?.replyContext
if (context === undefined) {
throw new Error('Expected a reply context')
}
await driver.reply(context, { text: '已完成总结' })
expect(transport.replyStream).toHaveBeenCalledWith(
{ headers: { req_id: 'request-1' } },
'stream-fixed',
'已完成总结',
true
)
await expect(
driver.reply({ ...context }, { text: '伪造上下文' })
).rejects.toMatchObject({ code: 'context_expired' })
await expect(
driver.reply(context, {
text: '附件',
attachments: [{}]
})
).rejects.toMatchObject({ code: 'unsupported_attachment' })
})
it('makes concurrent start and repeated stop idempotent and detaches listeners', async () => {
const { driver, transport, messages } = createHarness()
await Promise.all([driver.start(), driver.start(), driver.start()])
expect(transport.connect).toHaveBeenCalledOnce()
expect(transport.listenerCounts).toEqual({ message: 1, error: 1 })
expect(driver.started).toBe(true)
await driver.stop()
await driver.stop()
expect(transport.disconnect).toHaveBeenCalledOnce()
expect(transport.listenerCounts).toEqual({ message: 0, error: 0 })
expect(driver.started).toBe(false)
transport.emitMessage(textFrame())
expect(messages).toHaveLength(0)
})
it('invalidates reply contexts when restarted with another transport', async () => {
const first = new FakeTransport()
const second = new FakeTransport()
const messages: WeComInboundMessage[] = []
const factory = vi
.fn<(credentials: WeComTransportCredentials) => WeComSdkTransport>()
.mockReturnValueOnce(first)
.mockReturnValueOnce(second)
const driver = new WeComDriver({
botId: 'bot-main',
secret: 'main-process-secret',
transportFactory: factory,
onMessage: (message) => {
messages.push(message)
}
})
await driver.start()
first.emitMessage(textFrame())
const oldContext = messages[0]?.replyContext
if (oldContext === undefined) {
throw new Error('Expected a reply context')
}
await driver.stop()
await driver.start()
await expect(
driver.reply(oldContext, { text: '迟到的回复' })
).rejects.toMatchObject({ code: 'context_expired' })
expect(second.replyStream).not.toHaveBeenCalled()
})
it('reports sanitized transport and handler errors', async () => {
const transport = new FakeTransport()
const errors: WeComDriverError[] = []
const driver = new WeComDriver({
botId: 'bot-main',
secret: 'main-process-secret',
transportFactory: () => transport,
onMessage: async () => {
throw new Error('main-process-secret')
},
onRejected: async () => {
throw new Error('main-process-secret')
},
onError: (error) => {
errors.push(error)
}
})
await driver.start()
transport.emitMessage(textFrame())
transport.emitMessage(textFrame({ aibotid: 'wrong-bot' }))
transport.emitError(new Error('main-process-secret'))
await Promise.resolve()
expect(errors).toHaveLength(3)
expect(errors.every(({ code }) => code === 'transport_error')).toBe(true)
expect(JSON.stringify(errors)).not.toContain('main-process-secret')
})
})
+576
View File
@@ -0,0 +1,576 @@
import { randomUUID } from 'node:crypto'
export const WECOM_TEXT_MAX_BYTES = 20_480
const IDENTIFIER_MAX_BYTES = 1_024
const WECOM_MESSAGE_EVENT = 'message'
const WECOM_ERROR_EVENT = 'error'
export type WeComChatType = 'single' | 'group'
export interface WeComReplyContext {
readonly channel: 'wecom'
readonly eventId: string
readonly requestId: string
}
export interface WeComInboundMessage {
readonly channel: 'wecom'
readonly eventId: string
readonly userId: string
readonly conversationId: string
readonly chatType: WeComChatType
/**
* WeCom only delivers group messages to an AI bot when the bot is
* mentioned. The display-name mention remains in `text`, because the
* protocol does not provide a reliable display-name boundary to remove.
*/
readonly mentionedBot: boolean
readonly text: string
readonly createdAt?: number
readonly quotedText?: string
readonly replyContext: WeComReplyContext
}
export type WeComRejectionReason =
| 'attachment_not_supported'
| 'bot_mismatch'
| 'invalid_message'
| 'text_too_large'
export interface WeComRejectedMessage {
readonly channel: 'wecom'
readonly reason: WeComRejectionReason
readonly eventId?: string
readonly messageType?: string
}
export interface WeComOutboundMessage {
readonly text: string
readonly attachments?: readonly unknown[]
}
export type WeComDriverErrorCode =
| 'context_expired'
| 'invalid_credentials'
| 'invalid_text'
| 'not_started'
| 'transport_error'
| 'unsupported_attachment'
export class WeComDriverError extends Error {
readonly code: WeComDriverErrorCode
constructor(code: WeComDriverErrorCode, message: string) {
super(message)
this.name = 'WeComDriverError'
this.code = code
}
}
interface WeComFrameHeaders {
readonly headers: {
readonly req_id: string
}
}
export interface WeComSdkTransport {
on(event: 'message', listener: (frame: unknown) => void): unknown
on(event: 'error', listener: (error: Error) => void): unknown
off(event: 'message', listener: (frame: unknown) => void): unknown
off(event: 'error', listener: (error: Error) => void): unknown
connect(): unknown
disconnect(): unknown
replyStream(
frame: WeComFrameHeaders,
streamId: string,
content: string,
finish: boolean
): Promise<unknown>
}
export interface WeComTransportCredentials {
readonly botId: string
readonly secret: string
}
export type WeComTransportFactory = (
credentials: WeComTransportCredentials
) => WeComSdkTransport | Promise<WeComSdkTransport>
export interface WeComDriverOptions extends WeComTransportCredentials {
readonly onMessage: (
message: WeComInboundMessage
) => void | Promise<void>
readonly onRejected?: (
rejection: WeComRejectedMessage
) => void | Promise<void>
readonly onError?: (error: WeComDriverError) => void
readonly transportFactory?: WeComTransportFactory
readonly streamIdFactory?: () => string
}
interface NormalizedWeComPayload {
readonly eventId: string
readonly requestId: string
readonly userId: string
readonly conversationId: string
readonly chatType: WeComChatType
readonly mentionedBot: boolean
readonly text: string
readonly createdAt?: number
readonly quotedText?: string
readonly frame: WeComFrameHeaders
}
type NormalizationResult =
| { readonly ok: true; readonly value: NormalizedWeComPayload }
| { readonly ok: false; readonly rejection: WeComRejectedMessage }
interface ReplyRecord {
readonly frame: WeComFrameHeaders
readonly transport: WeComSdkTransport
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function utf8Length(value: string): number {
return Buffer.byteLength(value, 'utf8')
}
function isBoundedIdentifier(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
utf8Length(value) <= IDENTIFIER_MAX_BYTES
)
}
function optionalEventId(frame: unknown): string | undefined {
if (!isRecord(frame) || !isRecord(frame.body)) {
return undefined
}
return isBoundedIdentifier(frame.body.msgid) ? frame.body.msgid : undefined
}
function optionalMessageType(frame: unknown): string | undefined {
if (!isRecord(frame) || !isRecord(frame.body)) {
return undefined
}
return typeof frame.body.msgtype === 'string'
? frame.body.msgtype
: undefined
}
function reject(
frame: unknown,
reason: WeComRejectionReason
): NormalizationResult {
const eventId = optionalEventId(frame)
const messageType = optionalMessageType(frame)
return {
ok: false,
rejection: {
channel: 'wecom',
reason,
...(eventId === undefined ? {} : { eventId }),
...(messageType === undefined ? {} : { messageType })
}
}
}
function normalizeQuotedText(quote: unknown): string | undefined | null {
if (quote === undefined) {
return undefined
}
if (!isRecord(quote) || quote.msgtype !== 'text' || !isRecord(quote.text)) {
return null
}
const content = quote.text.content
if (
typeof content !== 'string' ||
content.trim().length === 0 ||
utf8Length(content) > WECOM_TEXT_MAX_BYTES
) {
return null
}
return content
}
function normalizeWeComFrame(
frame: unknown,
expectedBotId: string
): NormalizationResult {
if (
!isRecord(frame) ||
frame.cmd !== 'aibot_msg_callback' ||
!isRecord(frame.headers) ||
!isRecord(frame.body)
) {
return reject(frame, 'invalid_message')
}
const requestId = frame.headers.req_id
const body = frame.body
const eventId = body.msgid
const userId = isRecord(body.from) ? body.from.userid : undefined
if (
!isBoundedIdentifier(requestId) ||
!isBoundedIdentifier(eventId) ||
!isBoundedIdentifier(body.aibotid) ||
!isBoundedIdentifier(userId) ||
(body.chattype !== 'single' && body.chattype !== 'group') ||
typeof body.msgtype !== 'string'
) {
return reject(frame, 'invalid_message')
}
if (body.aibotid !== expectedBotId) {
return reject(frame, 'bot_mismatch')
}
if (body.msgtype !== 'text') {
const attachmentTypes = new Set([
'file',
'image',
'mixed',
'video',
'voice'
])
return reject(
frame,
attachmentTypes.has(body.msgtype)
? 'attachment_not_supported'
: 'invalid_message'
)
}
if (!isRecord(body.text) || typeof body.text.content !== 'string') {
return reject(frame, 'invalid_message')
}
const text = body.text.content
if (text.trim().length === 0) {
return reject(frame, 'invalid_message')
}
if (utf8Length(text) > WECOM_TEXT_MAX_BYTES) {
return reject(frame, 'text_too_large')
}
const chatType = body.chattype
const conversationId =
chatType === 'group'
? body.chatid
: userId
if (!isBoundedIdentifier(conversationId)) {
return reject(frame, 'invalid_message')
}
const createdAt = body.create_time
if (
createdAt !== undefined &&
(typeof createdAt !== 'number' ||
!Number.isSafeInteger(createdAt) ||
createdAt < 0)
) {
return reject(frame, 'invalid_message')
}
const quotedText = normalizeQuotedText(body.quote)
if (quotedText === null) {
return reject(
frame,
isRecord(body.quote) && body.quote.msgtype !== 'text'
? 'attachment_not_supported'
: 'invalid_message'
)
}
const normalized: NormalizedWeComPayload = {
eventId,
requestId,
userId,
conversationId,
chatType,
mentionedBot: chatType === 'group',
text,
frame: {
headers: {
req_id: requestId
}
},
...(createdAt === undefined ? {} : { createdAt }),
...(quotedText === undefined ? {} : { quotedText })
}
return { ok: true, value: normalized }
}
/**
* Default factory for the verified @wecom/aibot-node-sdk v1 transport surface.
* The dynamic import keeps tests isolated from the SDK and creates the client
* only in Electron's main process when the driver is started.
*/
export const createOfficialWeComTransport: WeComTransportFactory = async (
credentials
) => {
const { WSClient } = await import('@wecom/aibot-node-sdk')
return new WSClient({
botId: credentials.botId,
secret: credentials.secret
})
}
export class WeComDriver {
readonly #botId: string
readonly #secret: string
readonly #onMessage: WeComDriverOptions['onMessage']
readonly #onRejected: WeComDriverOptions['onRejected']
readonly #onError: WeComDriverOptions['onError']
readonly #transportFactory: WeComTransportFactory
readonly #streamIdFactory: () => string
readonly #replyRecords = new WeakMap<WeComReplyContext, ReplyRecord>()
#transport: WeComSdkTransport | undefined
#startPromise: Promise<void> | undefined
#lifecycleVersion = 0
constructor(options: WeComDriverOptions) {
if (
!isBoundedIdentifier(options.botId) ||
!isBoundedIdentifier(options.secret)
) {
throw new WeComDriverError(
'invalid_credentials',
'企业微信机器人凭据无效'
)
}
this.#botId = options.botId
this.#secret = options.secret
this.#onMessage = options.onMessage
this.#onRejected = options.onRejected
this.#onError = options.onError
this.#transportFactory =
options.transportFactory ?? createOfficialWeComTransport
this.#streamIdFactory =
options.streamIdFactory ?? (() => `goodbuddy_${randomUUID()}`)
}
get started(): boolean {
return this.#transport !== undefined
}
async start(): Promise<void> {
if (this.#transport !== undefined) {
return
}
if (this.#startPromise !== undefined) {
return this.#startPromise
}
const version = ++this.#lifecycleVersion
const startPromise = this.#createAndConnect(version)
this.#startPromise = startPromise
try {
await startPromise
} catch {
throw new WeComDriverError(
'transport_error',
'企业微信长连接启动失败'
)
} finally {
if (this.#startPromise === startPromise) {
this.#startPromise = undefined
}
}
}
async stop(): Promise<void> {
++this.#lifecycleVersion
const pendingStart = this.#startPromise
if (pendingStart !== undefined) {
await pendingStart.catch(() => undefined)
}
const transport = this.#transport
if (transport === undefined) {
return
}
this.#transport = undefined
this.#detachTransport(transport)
try {
await transport.disconnect()
} catch {
throw new WeComDriverError(
'transport_error',
'企业微信长连接停止失败'
)
}
}
async reply(
context: WeComReplyContext,
message: WeComOutboundMessage
): Promise<void> {
if (message.attachments !== undefined && message.attachments.length > 0) {
throw new WeComDriverError(
'unsupported_attachment',
'企业微信适配器暂不支持发送附件'
)
}
validateOutboundText(message.text)
const transport = this.#transport
if (transport === undefined) {
throw new WeComDriverError(
'not_started',
'企业微信适配器尚未启动'
)
}
const replyRecord = this.#replyRecords.get(context)
if (replyRecord === undefined || replyRecord.transport !== transport) {
throw new WeComDriverError(
'context_expired',
'企业微信回复上下文无效或已过期'
)
}
const streamId = this.#streamIdFactory()
if (!isBoundedIdentifier(streamId)) {
throw new WeComDriverError(
'invalid_text',
'企业微信流式消息标识无效'
)
}
try {
await transport.replyStream(
replyRecord.frame,
streamId,
message.text,
true
)
} catch {
throw new WeComDriverError(
'transport_error',
'企业微信消息回复失败'
)
}
}
async #createAndConnect(version: number): Promise<void> {
const credentials = Object.freeze({
botId: this.#botId,
secret: this.#secret
})
const transport = await this.#transportFactory(credentials)
if (version !== this.#lifecycleVersion) {
await transport.disconnect()
return
}
this.#transport = transport
this.#attachTransport(transport)
try {
await transport.connect()
} catch (error) {
if (this.#transport === transport) {
this.#transport = undefined
}
this.#detachTransport(transport)
await Promise.resolve(transport.disconnect()).catch(() => undefined)
throw error
}
if (version !== this.#lifecycleVersion) {
if (this.#transport === transport) {
this.#transport = undefined
}
this.#detachTransport(transport)
await transport.disconnect()
}
}
readonly #handleMessage = (frame: unknown): void => {
const transport = this.#transport
if (transport === undefined) {
return
}
const result = normalizeWeComFrame(frame, this.#botId)
if (!result.ok) {
if (this.#onRejected !== undefined) {
void Promise.resolve(this.#onRejected(result.rejection)).catch(() => {
this.#emitTransportError()
})
}
return
}
const replyContext = Object.freeze<WeComReplyContext>({
channel: 'wecom',
eventId: result.value.eventId,
requestId: result.value.requestId
})
this.#replyRecords.set(replyContext, {
frame: result.value.frame,
transport
})
const message: WeComInboundMessage = Object.freeze({
channel: 'wecom',
eventId: result.value.eventId,
userId: result.value.userId,
conversationId: result.value.conversationId,
chatType: result.value.chatType,
mentionedBot: result.value.mentionedBot,
text: result.value.text,
replyContext,
...(result.value.createdAt === undefined
? {}
: { createdAt: result.value.createdAt }),
...(result.value.quotedText === undefined
? {}
: { quotedText: result.value.quotedText })
})
void Promise.resolve(this.#onMessage(message)).catch(() => {
this.#emitTransportError()
})
}
readonly #handleTransportError = (): void => {
this.#emitTransportError()
}
#emitTransportError(): void {
this.#onError?.(
new WeComDriverError(
'transport_error',
'企业微信长连接处理失败'
)
)
}
#attachTransport(transport: WeComSdkTransport): void {
transport.on(WECOM_MESSAGE_EVENT, this.#handleMessage)
transport.on(WECOM_ERROR_EVENT, this.#handleTransportError)
}
#detachTransport(transport: WeComSdkTransport): void {
transport.off(WECOM_MESSAGE_EVENT, this.#handleMessage)
transport.off(WECOM_ERROR_EVENT, this.#handleTransportError)
}
}
function validateOutboundText(text: unknown): asserts text is string {
if (typeof text !== 'string' || text.trim().length === 0) {
throw new WeComDriverError(
'invalid_text',
'企业微信回复文本不能为空'
)
}
if (utf8Length(text) > WECOM_TEXT_MAX_BYTES) {
throw new WeComDriverError(
'invalid_text',
`企业微信回复文本不能超过 ${WECOM_TEXT_MAX_BYTES} 字节`
)
}
}