Files
goodbuddy/src/main/channels/wecom-driver.ts
T
lofyer b8fc7bc86e
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
chore: prepare GoodBuddy 0.8.2
2026-08-06 22:47:13 +08:00

634 lines
17 KiB
TypeScript

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'
const WECOM_AUTHENTICATED_EVENT = 'authenticated'
const DEFAULT_AUTHENTICATION_TIMEOUT_MS = 15_000
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
on(event: 'authenticated', listener: () => void): unknown
off(event: 'message', listener: (frame: unknown) => void): unknown
off(event: 'error', listener: (error: Error) => void): unknown
off(event: 'authenticated', listener: () => 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
readonly authenticationTimeoutMs?: number
}
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 #authenticationTimeoutMs: number
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()}`)
this.#authenticationTimeoutMs =
options.authenticationTimeoutMs ?? DEFAULT_AUTHENTICATION_TIMEOUT_MS
if (
!Number.isSafeInteger(this.#authenticationTimeoutMs) ||
this.#authenticationTimeoutMs < 1
) {
throw new WeComDriverError(
'invalid_credentials',
'企业微信认证等待时间无效'
)
}
}
get started(): boolean {
return this.#transport !== undefined
}
async start(): Promise<void> {
if (this.#startPromise !== undefined) {
return this.#startPromise
}
if (this.#transport !== undefined) {
return
}
const version = ++this.#lifecycleVersion
const startPromise = this.#createAndConnect(version).catch(() => {
throw new WeComDriverError(
'transport_error',
'企业微信长连接启动失败'
)
})
this.#startPromise = startPromise
try {
await startPromise
} 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 this.#connectAndAuthenticate(transport)
} 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()
}
}
async #connectAndAuthenticate(
transport: WeComSdkTransport
): Promise<void> {
await new Promise<void>((resolve, reject) => {
let settled = false
const finish = (error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
transport.off(WECOM_AUTHENTICATED_EVENT, authenticated)
transport.off(WECOM_ERROR_EVENT, failed)
if (error) {
reject(error)
} else {
resolve()
}
}
const authenticated = (): void => finish()
const failed = (): void =>
finish(new Error('企业微信认证失败'))
const timeout = setTimeout(
() => finish(new Error('企业微信认证超时')),
this.#authenticationTimeoutMs
)
transport.on(WECOM_AUTHENTICATED_EVENT, authenticated)
transport.on(WECOM_ERROR_EVENT, failed)
try {
transport.connect()
} catch (error) {
finish(
error instanceof Error
? error
: new Error('企业微信长连接启动失败')
)
}
})
}
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} 字节`
)
}
}