feat: prepare GoodBuddy 0.8.0
This commit is contained in:
@@ -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
|
||||
}>
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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('已停止')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-A ',
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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} 字节`
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user