Files
trade-message-center/apps/onetalk-contract/src/content.ts
T

370 lines
12 KiB
TypeScript

// 定义 OneTalk 归一化消息内容和 Mind 读取模型
import { isPlainRecord } from "./guards.ts";
import { ONETALK_DIRECTIONS, type OneTalkDirection } from "./messages.ts";
export const ONETALK_CONTENT_VERSION = 1 as const;
export const ONETALK_MAX_MEDIA_SIZE_BYTES = 10 * 1024 ** 3;
export const ONETALK_CONTENT_KINDS = ["text", "image", "file"] as const;
export type OneTalkMessageContentKind = (typeof ONETALK_CONTENT_KINDS)[number];
export type OneTalkTextContent = {
version: typeof ONETALK_CONTENT_VERSION;
kind: "text";
text: string;
};
export type OneTalkImageContent = {
version: typeof ONETALK_CONTENT_VERSION;
kind: "image";
fileId: string;
extension: string;
sizeBytes: number;
isOriginal: boolean;
md5: string | null;
previewUrl: string | null;
urlScope: "onetalk_session";
};
export type OneTalkFileContent = {
version: typeof ONETALK_CONTENT_VERSION;
kind: "file";
fileId: string;
parentId: string;
fileName: string;
extension: string;
sizeBytes: number;
md5: string | null;
previewUrl: string | null;
thumbnailUrl: string | null;
downloadUrl: string | null;
downloadState: "available" | "not_provided";
urlScope: "onetalk_session";
};
export type OneTalkMessageContent = OneTalkTextContent | OneTalkImageContent | OneTalkFileContent;
/** Mind HTTP 会话与 conversation.updated 共用的公开会话读取模型。 */
export type CenterConversation = {
conversationId: string;
conversationType: "direct";
name: string | null;
avatarUrl: string | null;
lastContactTimeLong: number | null;
messagePreview: string | null;
};
/** Mind HTTP history 和 message.created 共用的公开消息读取模型。 */
export type OneTalkCenterMessage = {
messageId: string;
conversationId: string;
senderId: string;
participantIds: string[];
direction: OneTalkDirection;
sentAtMs: number;
content: OneTalkMessageContent;
};
export type OneTalkMessageContentDecodeResult =
| { ok: true; content: OneTalkMessageContent }
| { ok: false };
const MAX_IDENTIFIER_LENGTH = 512;
const MAX_TEXT_LENGTH = 64 * 1024;
const MAX_FILE_NAME_LENGTH = 255;
const MAX_EXTENSION_LENGTH = 64;
const MAX_MD5_LENGTH = 128;
const MAX_MEDIA_URL_LENGTH = 8 * 1024;
const CONTENT_KEYS = {
text: ["version", "kind", "text"],
image: [
"version",
"kind",
"fileId",
"extension",
"sizeBytes",
"isOriginal",
"md5",
"previewUrl",
"urlScope",
],
file: [
"version",
"kind",
"fileId",
"parentId",
"fileName",
"extension",
"sizeBytes",
"md5",
"previewUrl",
"thumbnailUrl",
"downloadUrl",
"downloadState",
"urlScope",
],
} as const;
const CENTER_MESSAGE_KEYS = [
"messageId",
"conversationId",
"senderId",
"participantIds",
"direction",
"sentAtMs",
"content",
] as const;
const REDIRECT_URL_PATH = "/file/redirectFileUrl.htm";
const THUMBNAIL_URL_PATH = "/file/videoThumb.htm";
const ALLOWED_MEDIA_QUERY_KEYS = [
"appkey",
"fileAction",
"id",
"parentId",
"scene",
"secOperateAliId",
] as const;
const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]): boolean => {
const actualKeys = Object.keys(value).sort();
const expectedKeys = [...keys].sort();
return (
actualKeys.length === expectedKeys.length &&
actualKeys.every((key, index) => key === expectedKeys[index])
);
};
const isNonBlankString = (value: unknown, maximumLength: number): value is string => {
return (
typeof value === "string" &&
value.trim().length > 0 &&
value.length <= maximumLength &&
!/[\u0000-\u001f\u007f]/u.test(value)
);
};
const isNonBlankText = (value: unknown, maximumLength: number): value is string => {
return (
typeof value === "string" &&
value.trim().length > 0 &&
value.length <= maximumLength &&
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
);
};
/** 判断直接会话的两个参与者是否唯一,且包含消息发送者。 */
export const isOneTalkDirectParticipantSet = (
participantIds: unknown,
senderId: unknown,
): participantIds is [string, string] => {
if (
!Array.isArray(participantIds) ||
participantIds.length !== 2 ||
!isNonBlankString(senderId, MAX_IDENTIFIER_LENGTH) ||
!participantIds.every((participantId) =>
isNonBlankString(participantId, MAX_IDENTIFIER_LENGTH),
)
) {
return false;
}
return new Set(participantIds).size === 2 && participantIds.includes(senderId);
};
const isNonNegativeSafeInteger = (value: unknown): value is number => {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
};
const isBoundedNonNegativeSafeInteger = (value: unknown, maximum: number): value is number => {
return isNonNegativeSafeInteger(value) && value <= maximum;
};
const isExtension = (value: unknown): value is string => {
return (
isNonBlankString(value, MAX_EXTENSION_LENGTH) &&
value === value.toLowerCase() &&
/^[a-z0-9][a-z0-9._-]*$/u.test(value)
);
};
const isMd5 = (value: unknown): value is string | null => {
return value === null || isNonBlankString(value, MAX_MD5_LENGTH);
};
const hasAllowedMediaQuery = (url: URL): boolean => {
const seenKeys = new Set<string>();
for (const [key, value] of url.searchParams) {
if (
!ALLOWED_MEDIA_QUERY_KEYS.includes(key as (typeof ALLOWED_MEDIA_QUERY_KEYS)[number]) ||
seenKeys.has(key) ||
value.length === 0
) {
return false;
}
seenKeys.add(key);
}
return true;
};
const isMediaUrl = (
value: unknown,
expectedPath: string,
allowedFileActions: readonly string[] | null,
): value is string => {
if (
typeof value !== "string" ||
value.length === 0 ||
value.length > MAX_MEDIA_URL_LENGTH ||
value.trim() !== value ||
/\s/u.test(value)
) {
return false;
}
try {
const url = new URL(value);
if (
url.protocol !== "https:" ||
url.hostname !== "clouddisk.alibaba.com" ||
url.port.length > 0 ||
url.username.length > 0 ||
url.password.length > 0 ||
url.hash.length > 0 ||
url.pathname !== expectedPath ||
!hasAllowedMediaQuery(url)
) {
return false;
}
const fileAction = url.searchParams.get("fileAction");
if (allowedFileActions === null) {
return (
fileAction === null ||
["imagePreview", "download", "officePreview"].includes(fileAction)
);
}
return fileAction !== null && allowedFileActions.includes(fileAction);
} catch {
return false;
}
};
const isOptionalMediaUrl = (
value: unknown,
expectedPath: string,
allowedFileActions: readonly string[] | null,
): value is string | null => {
return value === null || isMediaUrl(value, expectedPath, allowedFileActions);
};
const isOneTalkTextContent = (value: Record<string, unknown>): value is OneTalkTextContent => {
return (
hasExactKeys(value, CONTENT_KEYS.text) &&
value.version === ONETALK_CONTENT_VERSION &&
value.kind === "text" &&
isNonBlankText(value.text, MAX_TEXT_LENGTH)
);
};
const isOneTalkImageContent = (value: Record<string, unknown>): value is OneTalkImageContent => {
return (
hasExactKeys(value, CONTENT_KEYS.image) &&
value.version === ONETALK_CONTENT_VERSION &&
value.kind === "image" &&
isNonBlankString(value.fileId, MAX_IDENTIFIER_LENGTH) &&
isExtension(value.extension) &&
isBoundedNonNegativeSafeInteger(value.sizeBytes, ONETALK_MAX_MEDIA_SIZE_BYTES) &&
typeof value.isOriginal === "boolean" &&
isMd5(value.md5) &&
isOptionalMediaUrl(value.previewUrl, REDIRECT_URL_PATH, ["imagePreview"]) &&
value.urlScope === "onetalk_session"
);
};
const isOneTalkFileContent = (value: Record<string, unknown>): value is OneTalkFileContent => {
return (
hasExactKeys(value, CONTENT_KEYS.file) &&
value.version === ONETALK_CONTENT_VERSION &&
value.kind === "file" &&
isNonBlankString(value.fileId, MAX_IDENTIFIER_LENGTH) &&
isNonBlankString(value.parentId, MAX_IDENTIFIER_LENGTH) &&
isNonBlankString(value.fileName, MAX_FILE_NAME_LENGTH) &&
isExtension(value.extension) &&
value.fileName.toLowerCase().endsWith(`.${value.extension}`) &&
value.fileName.length > value.extension.length + 1 &&
isBoundedNonNegativeSafeInteger(value.sizeBytes, ONETALK_MAX_MEDIA_SIZE_BYTES) &&
isMd5(value.md5) &&
isOptionalMediaUrl(value.previewUrl, REDIRECT_URL_PATH, ["officePreview"]) &&
isOptionalMediaUrl(value.thumbnailUrl, THUMBNAIL_URL_PATH, null) &&
isOptionalMediaUrl(value.downloadUrl, REDIRECT_URL_PATH, ["download"]) &&
((value.downloadState === "available" && value.downloadUrl !== null) ||
(value.downloadState === "not_provided" && value.downloadUrl === null)) &&
value.urlScope === "onetalk_session"
);
};
/** 解码 exact-shape 的已归一化 OneTalk 消息内容。 */
export const decodeOneTalkMessageContent = (value: unknown): OneTalkMessageContentDecodeResult => {
if (!isPlainRecord(value) || value.version !== ONETALK_CONTENT_VERSION) return { ok: false };
if (value.kind === "text" && isOneTalkTextContent(value)) {
return { ok: true, content: value };
}
if (value.kind === "image" && isOneTalkImageContent(value)) {
return { ok: true, content: value };
}
if (value.kind === "file" && isOneTalkFileContent(value)) {
return { ok: true, content: value };
}
return { ok: false };
};
/** 判断值是否为 exact-shape 的已归一化 OneTalk 消息内容。 */
export const isOneTalkMessageContent = (value: unknown): value is OneTalkMessageContent => {
return decodeOneTalkMessageContent(value).ok;
};
/** 判断值是否为 exact-shape 的 Mind 公开会话读取模型。 */
export const isCenterConversation = (value: unknown): value is CenterConversation => {
if (
!isPlainRecord(value) ||
!hasExactKeys(value, [
"conversationId",
"conversationType",
"name",
"avatarUrl",
"lastContactTimeLong",
"messagePreview",
])
) {
return false;
}
return (
isNonBlankString(value.conversationId, MAX_IDENTIFIER_LENGTH) &&
value.conversationType === "direct" &&
(value.name === null || isNonBlankText(value.name, MAX_TEXT_LENGTH)) &&
(value.avatarUrl === null || isNonBlankString(value.avatarUrl, MAX_MEDIA_URL_LENGTH)) &&
(value.lastContactTimeLong === null ||
isNonNegativeSafeInteger(value.lastContactTimeLong)) &&
(value.messagePreview === null || isNonBlankText(value.messagePreview, MAX_TEXT_LENGTH))
);
};
/** 判断值是否为 exact-shape 的 Mind 公开消息读取模型。 */
export const isOneTalkCenterMessage = (value: unknown): value is OneTalkCenterMessage => {
if (!isPlainRecord(value) || !hasExactKeys(value, CENTER_MESSAGE_KEYS)) return false;
return (
isNonBlankString(value.messageId, MAX_IDENTIFIER_LENGTH) &&
isNonBlankString(value.conversationId, MAX_IDENTIFIER_LENGTH) &&
isNonBlankString(value.senderId, MAX_IDENTIFIER_LENGTH) &&
isOneTalkDirectParticipantSet(value.participantIds, value.senderId) &&
typeof value.direction === "string" &&
ONETALK_DIRECTIONS.includes(value.direction as OneTalkDirection) &&
isNonNegativeSafeInteger(value.sentAtMs) &&
isOneTalkMessageContent(value.content)
);
};