diff --git a/common/src/generator/dart/json_serializable.ts b/common/src/generator/dart/json_serializable.ts index 809a778..6da9cec 100644 --- a/common/src/generator/dart/json_serializable.ts +++ b/common/src/generator/dart/json_serializable.ts @@ -1,4 +1,4 @@ -import { IRField, IRNestedUnionStruct, IRPlainUnionStruct } from '@common/ir/types'; +import { IR, IRField, IRNestedUnionStruct, IRPlainUnionStruct } from '@common/ir/types'; import { milkyPackageVersion, milkyVersion } from '@saltify/milky-types'; import { generateIR } from '@common/ir'; @@ -30,6 +30,121 @@ function resolveDefaultValue(value: unknown): unknown { return value; } +function collectArrayUnionRefs(ir: IR): Set { + const unionStructNames = new Set( + ir.commonStructs.filter((struct) => struct.structType === 'union').map((struct) => struct.name) + ); + const arrayUnionRefs = new Set(); + + const handleFields = (fields: IRField[]) => { + fields.forEach((field) => { + if (field.fieldType === 'ref' && field.isArray && unionStructNames.has(field.refStructName)) { + arrayUnionRefs.add(field.refStructName); + } + }); + }; + + ir.commonStructs.forEach((struct) => { + if (struct.structType === 'simple') { + handleFields(struct.fields); + return; + } + if (struct.unionType === 'withData') { + handleFields(struct.baseFields); + struct.derivedTypes.forEach((derivedType) => { + if (derivedType.derivingType === 'struct') { + handleFields(derivedType.fields); + } + }); + return; + } + struct.derivedStructs.forEach((derivedStruct) => { + handleFields(derivedStruct.fields); + }); + }); + + ir.apiCategories.forEach((category) => { + category.apis.forEach((api) => { + if (api.requestFields) { + handleFields(api.requestFields); + } + if (api.responseFields) { + handleFields(api.responseFields); + } + }); + }); + + return arrayUnionRefs; +} + +function renderDropBadElementListHelper(typeName: string): string { + const className = toUpperCamelCase(typeName); + return [ + `List<${className}> _dropBad${className}ListFromJson(Object? json) {`, + ` if (json == null) {`, + ` return const <${className}>[];`, + ` }`, + ` if (json is! List) {`, + ` throw FormatException('Expected a list for ${className}');`, + ` }`, + ` final List<${className}> output = [];`, + ` for (final element in json) {`, + ` try {`, + ` if (element is Map) {`, + ` output.add(${className}.fromJson(element));`, + ` } else if (element is Map) {`, + ` output.add(${className}.fromJson(Map.from(element)));`, + ` }`, + ` } catch (_) {`, + ` // Skip invalid or unknown variants.`, + ` }`, + ` }`, + ` return output;`, + `}`, + ].join('\n'); +} + +function renderIncomingSegmentListHelper(): string { + return [ + 'IncomingSegment _unknownIncomingSegment(Object? element) {', + " var typeValue = 'unknown';", + ' if (element is Map) {', + " final rawType = element['type'];", + ' if (rawType != null) {', + ' typeValue = rawType.toString();', + ' }', + ' }', + ' return IncomingSegment.text(', + ' data: IncomingSegmentTextData(', + " text: '[${typeValue}]',", + ' ),', + ' );', + '}', + '', + 'List _incomingSegmentListFromJson(Object? json) {', + ' if (json == null) {', + ' return const [];', + ' }', + ' if (json is! List) {', + " throw FormatException('Expected a list for IncomingSegment');", + ' }', + ' final List output = [];', + ' for (final element in json) {', + ' try {', + ' if (element is Map) {', + ' output.add(IncomingSegment.fromJson(element));', + ' } else if (element is Map) {', + ' output.add(IncomingSegment.fromJson(Map.from(element)));', + ' }', + ' } catch (_) {', + ' output.add(_unknownIncomingSegment(element));', + ' }', + ' }', + ' return output;', + '}', + ].join('\n'); +} + function getDartTypeSpec(field: IRField): string { let baseType: string; if (field.fieldType === 'scalar') { @@ -49,7 +164,7 @@ function getDartTypeSpec(field: IRField): string { return field.isArray ? `List<${baseType}>` : baseType; } -function renderFieldLines(key: string, field: IRField, typeOverride?: string): string[] { +function renderFieldLines(key: string, field: IRField, unionStructNames: Set, typeOverride?: string): string[] { const lines: string[] = []; const fieldName = toLowerCamelCase(key); const description = field.description; @@ -58,6 +173,15 @@ function renderFieldLines(key: string, field: IRField, typeOverride?: string): s const isNullable = field.isOptional && !hasDefault; const dartType = isNullable ? `${baseType}?` : baseType; const isRequired = !isNullable && !hasDefault; + const jsonKeyParts = [`name: "${key}"`]; + + if (field.fieldType === 'ref' && field.isArray) { + if (field.refStructName === 'IncomingSegment') { + jsonKeyParts.push('fromJson: _incomingSegmentListFromJson'); + } else if (unionStructNames.has(field.refStructName)) { + jsonKeyParts.push(`fromJson: _dropBad${toUpperCamelCase(field.refStructName)}ListFromJson`); + } + } if (description) { formatDocComment(description).forEach((line) => lines.push(line)); @@ -65,13 +189,18 @@ function renderFieldLines(key: string, field: IRField, typeOverride?: string): s if (hasDefault) { lines.push(`@Default(${formatDartLiteral(resolveDefaultValue(field.defaultValue))})`); } - lines.push(`@JsonKey(name: "${key}")`); + lines.push(`@JsonKey(${jsonKeyParts.join(', ')})`); lines.push(`${isRequired ? 'required ' : ''}${dartType} ${fieldName},`); return lines; } -function renderIRSimpleStruct(name: string, fields: IRField[], description: string): string { +function renderIRSimpleStruct( + name: string, + fields: IRField[], + description: string, + unionStructNames: Set +): string { const className = toUpperCamelCase(name); const entries = fields; const lines: string[] = []; @@ -86,7 +215,7 @@ function renderIRSimpleStruct(name: string, fields: IRField[], description: stri } else { lines.push(` const factory ${className}({`); entries.forEach((field) => { - renderFieldLines(field.name, field).forEach((line) => { + renderFieldLines(field.name, field, unionStructNames).forEach((line) => { lines.push(` ${line}`); }); }); @@ -99,7 +228,10 @@ function renderIRSimpleStruct(name: string, fields: IRField[], description: stri return lines.join('\n'); } -function renderIRUnionStruct(struct: IRPlainUnionStruct | IRNestedUnionStruct): { union: string; extraDefs: string[] } { +function renderIRUnionStruct( + struct: IRPlainUnionStruct | IRNestedUnionStruct, + unionStructNames: Set +): { union: string; extraDefs: string[] } { const name = struct.name; const className = toUpperCamelCase(name); const lines: string[] = []; @@ -123,13 +255,13 @@ function renderIRUnionStruct(struct: IRPlainUnionStruct | IRNestedUnionStruct): dataTypeName = toUpperCamelCase(derivedType.refStructName); } else { dataTypeName = `${className}${toUpperCamelCase(variantValue)}Data`; - extraDefs.push(renderIRSimpleStruct(dataTypeName, derivedType.fields, '')); + extraDefs.push(renderIRSimpleStruct(dataTypeName, derivedType.fields, '', unionStructNames)); } lines.push(` @FreezedUnionValue("${variantValue}")`); lines.push(` const factory ${className}.${variantConstructor}({`); struct.baseFields.forEach((field) => { - renderFieldLines(field.name, field).forEach((line) => { + renderFieldLines(field.name, field, unionStructNames).forEach((line) => { lines.push(` ${line}`); }); }); @@ -141,7 +273,7 @@ function renderIRUnionStruct(struct: IRPlainUnionStruct | IRNestedUnionStruct): isOptional: false, refStructName: dataTypeName, }; - renderFieldLines('data', dataField, dataTypeName).forEach((line) => { + renderFieldLines('data', dataField, unionStructNames, dataTypeName).forEach((line) => { lines.push(` ${line}`); }); lines.push(` }) = ${variantClassName};`); @@ -158,7 +290,7 @@ function renderIRUnionStruct(struct: IRPlainUnionStruct | IRNestedUnionStruct): lines.push(` @FreezedUnionValue("${variantValue}")`); lines.push(` const factory ${className}.${variantConstructor}({`); derivedStruct.fields.forEach((field) => { - renderFieldLines(field.name, field).forEach((line) => { + renderFieldLines(field.name, field, unionStructNames).forEach((line) => { lines.push(` ${line}`); }); }); @@ -179,6 +311,10 @@ function renderIRUnionStruct(struct: IRPlainUnionStruct | IRNestedUnionStruct): export function generateDartJsonSerializableSpec(): string { const lines: string[] = []; const ir = generateIR(); + const unionStructNames = new Set( + ir.commonStructs.filter((struct) => struct.structType === 'union').map((struct) => struct.name) + ); + const arrayUnionRefs = collectArrayUnionRefs(ir); function l(line: string = '') { lines.push(line); } @@ -194,6 +330,16 @@ export function generateDartJsonSerializableSpec(): string { l(`const milkyVersion = "${milkyVersion}";`); l(`const milkyPackageVersion = "${milkyPackageVersion}";`); l(); + if (arrayUnionRefs.has('IncomingSegment')) { + l(renderIncomingSegmentListHelper()); + l(); + } + Array.from(arrayUnionRefs) + .filter((name) => name !== 'IncomingSegment') + .forEach((name) => { + l(renderDropBadElementListHelper(name)); + l(); + }); l('@freezed'); l('abstract class ApiGeneralResponse with _$ApiGeneralResponse {'); l(' const factory ApiGeneralResponse({'); @@ -219,9 +365,9 @@ export function generateDartJsonSerializableSpec(): string { l(); ir.commonStructs.forEach((struct) => { if (struct.structType === 'simple') { - l(renderIRSimpleStruct(struct.name, struct.fields, struct.description)); + l(renderIRSimpleStruct(struct.name, struct.fields, struct.description, unionStructNames)); } else { - const rendered = renderIRUnionStruct(struct); + const rendered = renderIRUnionStruct(struct, unionStructNames); l(rendered.union); if (rendered.extraDefs.length > 0) { l(); @@ -245,14 +391,14 @@ export function generateDartJsonSerializableSpec(): string { category.apis.forEach((api) => { const inputName = `${toUpperCamelCase(api.endpoint)}Input`; if (api.requestFields && api.requestFields.length > 0) { - l(renderIRSimpleStruct(inputName, api.requestFields, '')); + l(renderIRSimpleStruct(inputName, api.requestFields, '', unionStructNames)); } else { l(`typedef ${inputName} = ApiEmptyStruct;`); } l(); const outputName = `${toUpperCamelCase(api.endpoint)}Output`; if (api.responseFields && api.responseFields.length > 0) { - l(renderIRSimpleStruct(outputName, api.responseFields, '')); + l(renderIRSimpleStruct(outputName, api.responseFields, '', unionStructNames)); } else if (api.responseFields) { l(`typedef ${outputName} = ApiEmptyStruct;`); } else { diff --git a/common/src/generator/kotlin/kotlinx-serialization.ts b/common/src/generator/kotlin/kotlinx-serialization.ts index 1ec0663..c3b8e43 100644 --- a/common/src/generator/kotlin/kotlinx-serialization.ts +++ b/common/src/generator/kotlin/kotlinx-serialization.ts @@ -1,4 +1,4 @@ -import { IRField, IRStruct } from '@common/ir/types'; +import { IR, IRField, IRNestedUnionStruct, IRPlainUnionStruct } from '@common/ir/types'; import { milkyPackageVersion, milkyVersion } from '@saltify/milky-types'; import { generateIR } from '@common/ir'; @@ -51,6 +51,7 @@ function getKotlinTypeSpec(field: IRField): string { } function renderIRObject( + ir: IR, name: string, fields: IRField[], description: string, @@ -70,6 +71,13 @@ function renderIRObject( fields.forEach((field) => { const defaultValue = field.defaultValue !== undefined ? JSON.stringify(field.defaultValue) : null; l(` /** ${field.description ?? ''} */`); + if (field.fieldType === 'ref' && field.isArray) { + if (field.refStructName === 'IncomingSegment') { + l(' @Serializable(with = TransformUnknownSegmentListSerializer::class)'); + } else if (ir.commonStructs.find((s) => s.name === field.refStructName)?.structType === 'union') { + l(' @Serializable(with = DropBadElementListSerializer::class)'); + } + } l( ` @SerialName("${field.name}")${ defaultValue ? ` @LiteralDefault("${escapeString(defaultValue)}")` : '' @@ -80,11 +88,7 @@ function renderIRObject( return lines.join('\n'); } -function renderIRUnionStruct(struct: IRStruct): string { - if (struct.structType !== 'union') { - throw new Error('Expected union struct'); - } - +function renderIRUnionStruct(ir: IR, struct: IRPlainUnionStruct | IRNestedUnionStruct) { const name = struct.name; const lines: string[] = []; function l(line: string = '') { @@ -120,7 +124,7 @@ function renderIRUnionStruct(struct: IRStruct): string { l(` ) : ${toUpperCamelCase(name)}()`); if (derivedType.derivingType === 'struct') { a(' {'); - l(indentLines(renderIRObject('Data', derivedType.fields, '', false), ' ')); + l(indentLines(renderIRObject(ir, 'Data', derivedType.fields, '', false), ' ')); l(' }'); } if (index !== struct.derivedTypes.length - 1) { @@ -134,7 +138,7 @@ function renderIRUnionStruct(struct: IRStruct): string { } l( indentLines( - renderIRObject(derivedStruct.tagValue, derivedStruct.fields, '', false, [ + renderIRObject(ir, derivedStruct.tagValue, derivedStruct.fields, '', false, [ `@SerialName("${derivedStruct.tagValue}")`, ]), ' ' @@ -162,8 +166,10 @@ export function generateKotlinxSerializationSpec(): string { l(); l('package org.ntqqrev.milky'); l(); - l('import kotlinx.serialization.Serializable'); l('import kotlinx.serialization.*'); + l('import kotlinx.serialization.builtins.*'); + l('import kotlinx.serialization.descriptors.*'); + l('import kotlinx.serialization.encoding.*'); l('import kotlinx.serialization.json.*'); l(); l(`const val milkyVersion = "${milkyVersion}"`); @@ -177,15 +183,89 @@ export function generateKotlinxSerializationSpec(): string { l(' explicitNulls = false'); l('}'); l(); + l( + ` +internal class DropBadElementListSerializer(private val elementSerializer: KSerializer) : KSerializer> { + val listSerializer = ListSerializer(elementSerializer) + + override val descriptor: SerialDescriptor = + listSerializer.descriptor + + override fun serialize(encoder: Encoder, value: List) { + encoder.encodeSerializableValue(listSerializer, value) + } + + override fun deserialize(decoder: Decoder): List { + if (decoder !is JsonDecoder) { + throw SerializationException("This serializer can be used only with Json format") + } + + val element = decoder.decodeJsonElement() as? JsonArray + ?: throw SerializationException("Expected JsonArray for List deserialization") + + val out = ArrayList(element.size) + for (e in element) { + try { + out += decoder.json.decodeFromJsonElement(elementSerializer, e) + } catch (_: SerializationException) { + // discard bad element quietly + } + } + return out + } +} + `.trim() + ); + l(); + l( + ` +internal class TransformUnknownSegmentListSerializer(private val elementSerializer: KSerializer) : + KSerializer> { + + val listSerializer = ListSerializer(elementSerializer) + + override val descriptor: SerialDescriptor = + listSerializer.descriptor + + override fun serialize(encoder: Encoder, value: List) { + encoder.encodeSerializableValue(listSerializer, value) + } + + override fun deserialize(decoder: Decoder): List { + if (decoder !is JsonDecoder) { + throw SerializationException("This serializer can be used only with Json format") + } + + val element = decoder.decodeJsonElement() as? JsonArray + ?: throw SerializationException("Expected JsonArray for List deserialization") + + val out = ArrayList(element.size) + for (e in element) { + out += try { + decoder.json.decodeFromJsonElement(elementSerializer, e) + } catch (_: SerializationException) { + IncomingSegment.Text( + data = IncomingSegment.Text.Data( + text = "[\${e.jsonObject["type"]!!.jsonPrimitive.content}]" + ) + ) + } + } + return out + } +} + `.trim() + ); + l(); l('// ####################################'); l('// Common Structs'); l('// ####################################'); l(); ir.commonStructs.forEach((struct) => { if (struct.structType === 'simple') { - l(renderIRObject(struct.name, struct.fields, struct.description)); + l(renderIRObject(ir, struct.name, struct.fields, struct.description)); } else { - l(renderIRUnionStruct(struct)); + l(renderIRUnionStruct(ir, struct)); } l(); }); @@ -209,13 +289,13 @@ export function generateKotlinxSerializationSpec(): string { l(); category.apis.forEach((api) => { if (api.requestFields && api.requestFields.length > 0) { - l(renderIRObject(`${toUpperCamelCase(api.endpoint)}Input`, api.requestFields, '', false)); + l(renderIRObject(ir, `${toUpperCamelCase(api.endpoint)}Input`, api.requestFields, '', false)); } else { l(`typealias ${toUpperCamelCase(api.endpoint)}Input = ApiEmptyStruct`); } l(); if (api.responseFields) { - l(renderIRObject(`${toUpperCamelCase(api.endpoint)}Output`, api.responseFields, '', false)); + l(renderIRObject(ir, `${toUpperCamelCase(api.endpoint)}Output`, api.responseFields, '', false)); } else { l(`typealias ${toUpperCamelCase(api.endpoint)}Output = ApiEmptyStruct`); } diff --git a/common/src/generator/markdown/roadmap.ts b/common/src/generator/markdown/roadmap.ts index ce6bd87..f97c925 100644 --- a/common/src/generator/markdown/roadmap.ts +++ b/common/src/generator/markdown/roadmap.ts @@ -30,8 +30,9 @@ export function generateMarkdownRoadmap(): string { l(); l('### 接收消息段 (IncomingSegment)'); l(); - IncomingSegment.options.forEach((option) => { - l(`- [ ] \`${option.shape[IncomingSegment.def.discriminator].value}\` ${option.description}`); + const incomingSegment = IncomingSegment.unwrap(); + incomingSegment.options.forEach((option) => { + l(`- [ ] \`${option.shape[incomingSegment.def.discriminator].value}\` ${option.description}`); }); l(); l('### 发送消息段 (OutgoingSegment)'); diff --git a/common/src/ir/index.ts b/common/src/ir/index.ts index 8ef809c..3564d48 100644 --- a/common/src/ir/index.ts +++ b/common/src/ir/index.ts @@ -31,6 +31,16 @@ function irFieldForNoDesc(name: string, type: $ZodType): IRField { isArray: true, }; } + if (commonStructNameMap.has(type as z.ZodType)) { + return { + fieldType: 'ref', + name, + description: '', + isArray: false, + isOptional: false, + refStructName: commonStructNameMap.get(type as z.ZodType)!, + }; + } if (type instanceof z.ZodNumber) { const scalarType = type.meta()?.scalarType === 'int64' ? 'int64' : 'int32'; return { @@ -78,6 +88,9 @@ function irFieldForNoDesc(name: string, type: $ZodType): IRField { if (type instanceof z.ZodPipe) { return irFieldForNoDesc(name, type.def.in); } + if (type instanceof z.ZodCatch) { + return irFieldForNoDesc(name, type.unwrap()); + } if (type instanceof z.ZodOptional) { const innerField = irFieldForNoDesc(name, type.unwrap()); return { @@ -102,21 +115,15 @@ function irFieldForNoDesc(name: string, type: $ZodType): IRField { if (type instanceof z.ZodLazy) { return irFieldForNoDesc(name, type.unwrap()); } - if (commonStructNameMap.has(type as z.ZodType)) { - return { - fieldType: 'ref', - name, - description: '', - isArray: false, - isOptional: false, - refStructName: commonStructNameMap.get(type as z.ZodType)!, - }; - } throw new Error(`Unsupported schema type: ${type.constructor.name} for field ${name}`); } -function irUnionStructFor(name: string, struct: z.ZodDiscriminatedUnion): IRPlainUnionStruct | IRNestedUnionStruct { +function irUnionStructFor( + name: string, + description: string, + struct: z.ZodDiscriminatedUnion +): IRPlainUnionStruct | IRNestedUnionStruct { function isArrayEqual(arr1: string[], arr2: string[]): boolean { if (arr1.length !== arr2.length) { return false; @@ -157,7 +164,7 @@ function irUnionStructFor(name: string, struct: z.ZodDiscriminatedUnion): IRPlai structType: 'union', unionType: 'withData', name, - description: struct.description ?? '', + description, tagFieldName: struct.def.discriminator, baseFields: commonKeys.map((key) => irFieldFor(key, options[0].shape[key])), derivedTypes: options.map((option) => { @@ -206,16 +213,21 @@ function irUnionStructFor(name: string, struct: z.ZodDiscriminatedUnion): IRPlai export function generateIR(): IR { const irStructs: IRStruct[] = Array.from(commonStructMap).map(([name, schema]) => { - if (schema instanceof z.ZodObject) { + let s: z.ZodType = schema; + const description: string = s.description ?? ''; + if (s instanceof z.ZodCatch) { + s = s.unwrap() as z.ZodType; + } + if (s instanceof z.ZodObject) { return { structType: 'simple', name, - description: schema.description ?? '', - fields: Object.entries(schema.shape).map(([fieldName, fieldType]) => irFieldFor(fieldName, fieldType)), + description, + fields: Object.entries(s.shape).map(([fieldName, fieldType]) => irFieldFor(fieldName, fieldType)), }; } - if (schema instanceof z.ZodDiscriminatedUnion) { - return irUnionStructFor(name, schema); + if (s instanceof z.ZodDiscriminatedUnion) { + return irUnionStructFor(name, description, s); } throw new Error('Unsupported schema type'); }); diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx index eaf0b98..2f886bb 100644 --- a/docs/app/layout.tsx +++ b/docs/app/layout.tsx @@ -7,7 +7,10 @@ import { Banner, Head, Search } from 'nextra/components'; import { milkyPackageVersion, milkyVersion } from '@saltify/milky-types'; import { Metadata } from 'next'; import { apiSpecCategories } from '@saltify/milky-types/namings'; -import { commonStructMap } from '@saltify/milky-common/src/common'; +import { generateIR } from '@common/ir'; + +const ir = generateIR(); +const commonStructMap = new Map(ir.commonStructs.map((struct) => [struct.name, struct])); export const metadata: Metadata = { title: '🥛 Milky', diff --git a/docs/content/guide/_meta.ts b/docs/content/guide/_meta.ts index f75b156..dd2a9a8 100644 --- a/docs/content/guide/_meta.ts +++ b/docs/content/guide/_meta.ts @@ -2,6 +2,7 @@ import type { MetaRecord } from 'nextra'; export default { communication: '通信', + compatibility: '兼容性', faq: 'Q&A', background: '背景', } satisfies MetaRecord; diff --git a/docs/content/guide/compatibility.md b/docs/content/guide/compatibility.md new file mode 100644 index 0000000..d91c367 --- /dev/null +++ b/docs/content/guide/compatibility.md @@ -0,0 +1,61 @@ +# 兼容性 + +## 更新计划 + +语义化版本(Semantic Versioning,简称 SemVer)是一种版本控制策略,旨在通过版本号传达软件的变化信息。SemVer 版本号由三部分组成:主版本号(Major)、次版本号(Minor)和修订号(Patch),格式为 `MAJOR.MINOR.PATCH`。例如,Milky 的第二个正式版的版本号是 `1.1.0`。 + +Milky 计划每季度更新一个 Minor 版本(Milky 称之为累积更新)。称从一个累积更新发布开始到下一个累积更新发布的时间段为一个更新周期,每个更新周期维持约 3 个月。Milky 在更新周期的前 2 个月期间接收新的 Feature Request(abbr. FR)并且评议是否纳入下一个累积更新;最后一个月期间,仅评议已有的 FR,而此后的 FR 将至少推迟到下一个更新周期。 + +Milky 在一般情况下不会发布 Patch 版本,除非当前版本存在影响使用的严重 Bug。目前尚无发布下一个 Major 版本的计划。 + +## 字段兼容性 + +Milky 十分注重不同版本之间的兼容性。然而,由于更新同时涉及协议端(实现侧)和应用端(适配器/SDK 侧),所以字段的增减必然引发不兼容现象。Milky 在不同 Minor 版本之间避免对现有字段的重命名、语义变更和删除,以降低对用户的影响。Milky 的兼容策略是保证**协议端向后兼容**、**应用端向前兼容**,即确保**旧版本的应用端**能够继续与**新版本的协议端**通信,而反过来的情况则不作保证。 + +### 概念介绍 + +Milky 在兼容性设计中给结构体划分了两种类型: + +- **请求结构体**:应用端传递给协议端的数据结构,包含: + - 各 API 的请求参数 + - [OutgoingSegment](/struct/OutgoingSegment) + - [OutgoingForwardedMessage](/struct/OutgoingForwardedMessage) +- **响应结构体**:协议端传递给应用端的数据结构,包含**其余所有的数据结构**,例如: + - 各 API 的响应结果 + - [Event](/struct/Event) + - [IncomingSegment](/struct/IncomingSegment) + +请求结构体中的字段分三类:必需(Required)字段、可选(Optional)字段、具有默认值(Default)的字段;响应结构体中的字段分两类:可空(Nullable)字段和不可空(Non-Nullable)字段。 + +此外,结构体的类型也有两种情况: + +- **简单类型**:不存在子类型的结构体,例如 [Friend](/struct/Friend) 结构体。 +- **联合类型**(Union Type):同一个结构体根据 `type` 等区分字段(Discriminator)的不同,可能包含不同的字段集合。例如 [Event](/struct/Event) 结构体,根据 `event_type` 字段的不同,可能包含与消息事件相关的字段,或与通知事件相关的字段。 + +### 简单类型的兼容性策略 + +简单类型结构体的兼容性策略相对简单: + +- 对于请求结构体,只增加可选的或具有默认值的字段,或删除字段,或给现有字段增添默认值,而不修改现有字段或增加必需字段。 +- 对于响应结构体,则只增加字段,不删除或修改现有字段。 + +### 联合类型的兼容性策略 + +联合类型的兼容策略是只增加子类型、不删除子类型。 + +这个策略看似简单,而且从增添特性的角度出发可以理解。然而这种策略对应用端并不友好。譬如,应用端的反序列化逻辑中仅考虑了 A 和 B 两种子类型,而更新加入了 C 子类型,这时会导致应用端无法处理新的 C 子类型。因此,有必要对**应用端的反序列化逻辑**进行一定的标准化。 + +应用端对联合类型进行反序列化的场景主要包括: + +- 处理事件推送,例如消息事件、群通知事件; +- 解析部分 API 的响应结果,例如: + - `get_message` API,其中 IncomingSegment 是联合类型; + - `get_group_notifications` API,其中 [GroupNotification](/struct/GroupNotification) 是联合类型。 + +Milky 作以下规定: + +- 在处理除消息事件以外的事件推送时,遇到未知的事件子类型,不应抛出错误,而应忽略或打印警告信息; +- 在处理消息事件推送以及处理有关消息获取的 API 响应结果时,遇到未知的消息段子类型,不应抛出错误,而应将其转换为 `text` 消息段,消息段的内容由应用端自行决定,但应当反映该消息段在当前版本的 Milky 中不受支持; +- 在处理其他联合类型时: + - 如果该联合类型包含在数组中,遇到未知的子类型,不应抛出错误,而应当在反序列化时跳过这一元素; + - 如果该联合类型是单个元素,遇到未知的子类型,可以抛出错误。 diff --git a/types/package.json b/types/package.json index af765ba..600e5a3 100644 --- a/types/package.json +++ b/types/package.json @@ -1,7 +1,7 @@ { "name": "@saltify/milky-types", "type": "module", - "version": "1.1.0", + "version": "1.2.0-draft.1", "description": "Type definitions for Milky protocol", "main": "src/index.ts", "exports": { diff --git a/types/src/api/group.ts b/types/src/api/group.ts index aee8fe6..d2ea36d 100644 --- a/types/src/api/group.ts +++ b/types/src/api/group.ts @@ -11,6 +11,7 @@ import { } from '../scalar'; import { GroupAnnouncementEntity, GroupNotification } from '../common'; import { GroupEssenceMessage } from '../message'; +import { ZRobustArray } from '../robust'; export const SetGroupNameInput = z.object({ group_id: ZUin.describe('群号'), @@ -100,7 +101,8 @@ export const QuitGroupInput = z.object({ export const SendGroupMessageReactionInput = z.object({ group_id: ZUin.describe('群号'), message_seq: ZInt64.describe('要回应的消息序列号'), - reaction: ZString.describe('表情 ID'), + reaction: ZString.describe('发送的回应的表情 ID'), + reaction_type: z.enum(['face', 'emoji']).default('face').describe('发送的回应类型'), is_add: ZBooleanWithDefault(true).describe('是否添加表情,`false` 表示取消'), }); @@ -116,7 +118,7 @@ export const GetGroupNotificationsInput = z.object({ }); export const GetGroupNotificationsOutput = z.object({ - notifications: z.array(z.lazy(() => GroupNotification)).describe('获取到的群通知(notification_seq 降序排列),序列号不一定连续'), + notifications: ZRobustArray(GroupNotification).describe('获取到的群通知(notification_seq 降序排列),序列号不一定连续'), next_notification_seq: ZInt64.nullish().describe('下一页起始通知序列号'), }); diff --git a/types/src/api/system.ts b/types/src/api/system.ts index 6d52a5c..7d31fe0 100644 --- a/types/src/api/system.ts +++ b/types/src/api/system.ts @@ -92,6 +92,17 @@ export const GetGroupMemberInfoOutput = z.object({ member: z.lazy(() => GroupMemberEntity).describe('群成员信息'), }); +export const GetPeerPinsOutput = z.object({ + friends: z.array(z.lazy(() => FriendEntity)).describe('置顶的好友列表'), + groups: z.array(z.lazy(() => GroupEntity)).describe('置顶的群列表'), +}); + +export const SetPeerPinInput = z.object({ + message_scene: z.enum(['friend', 'group', 'temp']).describe('要设置的会话的消息场景'), + peer_id: ZUin.describe('要设置的好友 QQ 号或群号'), + is_pinned: ZBooleanWithDefault(true).describe('是否置顶, `false` 表示取消置顶'), +}); + export const SetAvatarInput = z.object({ uri: ZString.describe('头像文件 URI,支持 `file://` `http(s)://` `base64://` 三种格式'), }); @@ -136,6 +147,8 @@ export type GetGroupMemberListInput = z.infer; export type GetGroupMemberListOutput = z.infer; export type GetGroupMemberInfoInput = z.infer; export type GetGroupMemberInfoOutput = z.infer; +export type GetPeerPinsOutput = z.infer; +export type SetPeerPinInput = z.infer; export type SetAvatarInput = z.infer; export type SetNicknameInput = z.infer; export type SetBioInput = z.infer; diff --git a/types/src/event.ts b/types/src/event.ts index 8bda92b..92e361a 100644 --- a/types/src/event.ts +++ b/types/src/event.ts @@ -15,6 +15,12 @@ export const MessageRecallEvent = z.object({ display_suffix: ZString.describe('撤回提示的后缀文本'), }); +export const PeerPinChangeEvent = z.object({ + message_scene: z.enum(['friend', 'group', 'temp']).describe('发生改变的会话的消息场景'), + peer_id: ZUin.describe('发生改变的好友 QQ 号或群号'), + is_pinned: ZBoolean.describe('是否被置顶, `false` 表示取消置顶'), +}); + export const FriendRequestEvent = z.object({ initiator_id: ZUin.describe('申请好友的用户 QQ 号'), initiator_uid: ZString.describe('用户 UID'), @@ -154,6 +160,13 @@ export const Event = z.discriminatedUnion('event_type', [ data: MessageRecallEvent, }).describe('消息撤回事件'), + z.object({ + event_type: z.literal('peer_pin_change'), + time: ZInt64.describe('事件 Unix 时间戳(秒)'), + self_id: ZUin.describe('机器人 QQ 号'), + data: PeerPinChangeEvent, + }).describe('会话置顶变更事件'), + z.object({ event_type: z.literal('friend_request'), time: ZInt64.describe('事件 Unix 时间戳(秒)'), @@ -269,6 +282,7 @@ export const Event = z.discriminatedUnion('event_type', [ export type BotOfflineEvent = z.infer; export type MessageRecallEvent = z.infer; +export type PeerPinChangeEvent = z.infer; export type FriendRequestEvent = z.infer; export type GroupJoinRequestEvent = z.infer; export type GroupInvitedJoinRequestEvent = z.infer; diff --git a/types/src/message.ts b/types/src/message.ts index dc189a9..a16fe6b 100644 --- a/types/src/message.ts +++ b/types/src/message.ts @@ -23,6 +23,7 @@ export const IncomingSegment = z.discriminatedUnion('type', [ type: z.literal('mention'), data: z.object({ user_id: ZUin.describe('提及的 QQ 号'), + name: ZString.describe('去掉 `@` 前缀的提及的名称'), }), }).describe('提及消息段'), @@ -43,6 +44,12 @@ export const IncomingSegment = z.discriminatedUnion('type', [ type: z.literal('reply'), data: z.object({ message_seq: ZInt64.describe('被引用的消息序列号'), + sender_id: ZUin.describe('被引用的消息发送者 QQ 号'), + sender_name: ZString.nullish().describe('被引用的消息发送者名称,仅在合并转发中能够获取'), + time: ZInt64.describe('被引用的消息的 Unix 时间戳(秒)'), + get segments() { + return z.array(z.lazy(() => IncomingSegment)).describe('被引用的消息内容'); + } }), }).describe('回复消息段'), @@ -118,9 +125,15 @@ export const IncomingSegment = z.discriminatedUnion('type', [ xml_payload: ZString.describe('XML 数据'), }), }).describe('XML 消息段'), -]).describe('接收消息段'); +]).catch({ + type: 'text', + data: { + text: '[unknown]' + } +}).describe('接收消息段'); export const IncomingForwardedMessage = z.object({ + message_seq: ZInt64.describe('消息序列号'), sender_name: ZString.describe('发送者名称'), avatar_url: ZString.describe('发送者头像 URL'), time: ZInt64.describe('消息 Unix 时间戳(秒)'), @@ -186,10 +199,20 @@ export const OutgoingSegment = z.discriminatedUnion('type', [ type: z.literal('forward'), data: z.object({ get messages() { - return z.array(z.lazy(() => OutgoingForwardedMessage)).describe('合并转发消息段'); - } + return z.array(z.lazy(() => OutgoingForwardedMessage)).describe('合并转发消息内容'); + }, + title: ZString.nullish().describe('合并转发标题'), + preview: z.array(ZString).min(1).max(4).nullish().describe('合并转发预览文本,若提供,至少 1 条,至多 4 条'), + summary: ZString.nullish().describe('合并转发摘要'), }), }).describe('合并转发消息段'), + + z.object({ + type: z.literal('light_app'), + data: z.object({ + json_payload: ZString.describe('小程序 JSON 数据'), + }), + }).describe('小程序消息段'), ]).describe('发送消息段'); export const OutgoingForwardedMessage = z.object({ diff --git a/types/src/namings.ts b/types/src/namings.ts index 3530aed..f530d91 100644 --- a/types/src/namings.ts +++ b/types/src/namings.ts @@ -93,6 +93,18 @@ export const apiSpecCategories: ApiSpecCategory[] = [ inputStructName: 'GetGroupMemberInfoInput', outputStructName: 'GetGroupMemberInfoOutput', }, + { + endpoint: 'get_peer_pins', + description: '获取置顶的好友和群列表', + inputStructName: null, + outputStructName: 'GetPeerPinsOutput', + }, + { + endpoint: 'set_peer_pin', + description: '设置好友或群的置顶状态', + inputStructName: 'SetPeerPinInput', + outputStructName: null, + }, { endpoint: 'set_avatar', description: '设置 QQ 账号头像', diff --git a/types/src/robust.ts b/types/src/robust.ts new file mode 100644 index 0000000..d6a07dd --- /dev/null +++ b/types/src/robust.ts @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import z from 'zod'; + +export const ZRobustArray: ( + element: T +) => z.ZodPipe>>> = (element) => + // @ts-ignore + z.array(element.catch(null as any)).transform((val) => val.filter((item) => item !== null));