mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
370 lines
12 KiB
TypeScript
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)
|
|
);
|
|
};
|