355 lines
8.8 KiB
TypeScript
355 lines
8.8 KiB
TypeScript
import { z } from 'zod'
|
|
import {
|
|
weixinBindingStatusSchema,
|
|
weixinVerificationInputSchema
|
|
} from '../../shared/weixin-channel-contracts'
|
|
import {
|
|
channelAttachmentsSchema
|
|
} from '../../shared/channel-contracts'
|
|
|
|
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
|
|
export const WECHAT_SIDECAR_PROTOCOL_VERSION = 2
|
|
|
|
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 = weixinBindingStatusSchema
|
|
|
|
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 wechatSidecarInboundMessageSchema = z
|
|
.object({
|
|
type: z.literal('inbound_message'),
|
|
eventId: identifierSchema,
|
|
senderId: identifierSchema,
|
|
conversationId: identifierSchema,
|
|
text: z.string().max(WECHAT_SIDECAR_MAX_TEXT_LENGTH),
|
|
attachments: channelAttachmentsSchema.optional(),
|
|
attachmentError: z.string().trim().min(1).max(512).optional()
|
|
})
|
|
.strict()
|
|
.superRefine((message, context) => {
|
|
if (
|
|
message.text.trim().length === 0 &&
|
|
!message.attachments?.length &&
|
|
!message.attachmentError
|
|
) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
path: ['text'],
|
|
message: '消息内容不能为空'
|
|
})
|
|
}
|
|
})
|
|
|
|
export const wechatSidecarVerificationRequiredMessageSchema = z
|
|
.object({
|
|
type: z.literal('verification_required'),
|
|
prompt: z.string().trim().min(1).max(256)
|
|
})
|
|
.strict()
|
|
|
|
export const wechatSidecarConnectedMessageSchema = z
|
|
.object({
|
|
type: z.literal('connected'),
|
|
accountId: identifierSchema,
|
|
userId: identifierSchema
|
|
})
|
|
.strict()
|
|
|
|
export const wechatSidecarReplyResultMessageSchema = z
|
|
.object({
|
|
type: z.literal('reply_result'),
|
|
replyId: identifierSchema,
|
|
ok: z.boolean(),
|
|
error: z.string().trim().min(1).max(512).optional()
|
|
})
|
|
.strict()
|
|
.superRefine((result, context) => {
|
|
if (result.ok === (result.error !== undefined)) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
path: ['error'],
|
|
message: result.ok
|
|
? '成功回复不能包含错误'
|
|
: '失败回复必须包含错误'
|
|
})
|
|
}
|
|
})
|
|
|
|
export const wechatSidecarReplyCommandSchema = z
|
|
.object({
|
|
type: z.literal('reply'),
|
|
replyId: identifierSchema,
|
|
inReplyToEventId: identifierSchema,
|
|
conversationId: identifierSchema,
|
|
text: textSchema,
|
|
attachments: channelAttachmentsSchema.optional()
|
|
})
|
|
.strict()
|
|
|
|
export const wechatSidecarCancelReplyCommandSchema = z
|
|
.object({
|
|
type: z.literal('cancel_reply'),
|
|
replyId: identifierSchema
|
|
})
|
|
.strict()
|
|
|
|
export const wechatSidecarMessageSchema = z.discriminatedUnion('type', [
|
|
wechatSidecarStatusMessageSchema,
|
|
wechatSidecarQrMessageSchema,
|
|
wechatSidecarInboundMessageSchema,
|
|
wechatSidecarVerificationRequiredMessageSchema,
|
|
wechatSidecarConnectedMessageSchema,
|
|
wechatSidecarReplyResultMessageSchema
|
|
])
|
|
|
|
export type WechatSidecarMessage = z.infer<
|
|
typeof wechatSidecarMessageSchema
|
|
>
|
|
export type WechatSidecarQrMessage = z.infer<
|
|
typeof wechatSidecarQrMessageSchema
|
|
>
|
|
|
|
export const wechatSidecarStartLoginCommandSchema = z
|
|
.object({
|
|
type: z.literal('start_login')
|
|
})
|
|
.strict()
|
|
|
|
export const wechatSidecarSubmitVerificationCommandSchema = z
|
|
.object({
|
|
type: z.literal('submit_verification'),
|
|
code: weixinVerificationInputSchema.shape.code
|
|
})
|
|
.strict()
|
|
|
|
export const wechatSidecarDisconnectCommandSchema = z
|
|
.object({
|
|
type: z.literal('disconnect')
|
|
})
|
|
.strict()
|
|
|
|
export const wechatSidecarShutdownCommandSchema = z
|
|
.object({
|
|
type: z.literal('shutdown')
|
|
})
|
|
.strict()
|
|
|
|
export const wechatSidecarCommandSchema = z.discriminatedUnion('type', [
|
|
wechatSidecarStartLoginCommandSchema,
|
|
wechatSidecarSubmitVerificationCommandSchema,
|
|
wechatSidecarReplyCommandSchema,
|
|
wechatSidecarCancelReplyCommandSchema,
|
|
wechatSidecarDisconnectCommandSchema,
|
|
wechatSidecarShutdownCommandSchema
|
|
])
|
|
export type WechatSidecarCommand = z.infer<
|
|
typeof wechatSidecarCommandSchema
|
|
>
|
|
|
|
export const wechatSidecarStartAccountCommandSchema = z
|
|
.object({
|
|
type: z.literal('start_account'),
|
|
accountId: identifierSchema,
|
|
userId: identifierSchema,
|
|
baseUrl: z.string().url().max(2_048),
|
|
token: z.string().trim().min(1).max(4_096)
|
|
})
|
|
.strict()
|
|
|
|
export const wechatSidecarCredentialMessageSchema = z
|
|
.object({
|
|
type: z.literal('credential'),
|
|
accountId: identifierSchema,
|
|
userId: identifierSchema,
|
|
baseUrl: z.string().url().max(2_048),
|
|
token: z.string().trim().min(1).max(4_096)
|
|
})
|
|
.strict()
|
|
|
|
export type WechatSidecarStartAccountCommand = z.infer<
|
|
typeof wechatSidecarStartAccountCommandSchema
|
|
>
|
|
export type WechatSidecarCredentialMessage = z.infer<
|
|
typeof wechatSidecarCredentialMessageSchema
|
|
>
|
|
|
|
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',
|
|
'verification_required',
|
|
'connected',
|
|
'expired',
|
|
'failed',
|
|
'stopped'
|
|
]),
|
|
verification_required: new Set([
|
|
'verification_required',
|
|
'scanned',
|
|
'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.status === 'verification_required') &&
|
|
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('状态机时间无效')
|
|
}
|
|
}
|
|
}
|