mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
225 lines
7.7 KiB
TypeScript
225 lines
7.7 KiB
TypeScript
// 定义 OneTalk 连接与授权会话协议
|
|
|
|
import { isPlainRecord } from "./guards.ts";
|
|
import { ONETALK_ERROR_CODES, ONETALK_PROTOCOL_VERSION } from "./wire.ts";
|
|
import type { OneTalkBaseFrame, OneTalkErrorCode, OneTalkFrameContext } from "./wire.ts";
|
|
|
|
export const ONETALK_CONNECTION_TYPES = ["plugin", "mind_page"] as const;
|
|
export type OneTalkConnectionType = (typeof ONETALK_CONNECTION_TYPES)[number];
|
|
|
|
export const ONETALK_PERMISSIONS = ["read", "send"] as const;
|
|
export type OneTalkPermission = (typeof ONETALK_PERMISSIONS)[number];
|
|
|
|
export const ONETALK_AUTH_OPERATIONS = ["connect", "heartbeat", "read", "sync", "send"] as const;
|
|
export type OneTalkAuthorizationOperation = (typeof ONETALK_AUTH_OPERATIONS)[number];
|
|
|
|
export const ONETALK_PLUGIN_STATUSES = ["online", "offline"] as const;
|
|
export type OneTalkPluginStatus = (typeof ONETALK_PLUGIN_STATUSES)[number];
|
|
|
|
export type OneTalkPluginScope = {
|
|
channelAccountId: string;
|
|
deviceId: string;
|
|
};
|
|
|
|
export type OneTalkMindScope = {
|
|
mindUserId: string;
|
|
workspaceId: string;
|
|
channelAccountId: string;
|
|
};
|
|
|
|
export type OneTalkScope = OneTalkPluginScope | OneTalkMindScope;
|
|
|
|
export type OneTalkScopeForConnection<TConnectionType extends OneTalkConnectionType> =
|
|
TConnectionType extends "plugin" ? OneTalkPluginScope : OneTalkMindScope;
|
|
|
|
const isNonEmptyString = (value: unknown): value is string => {
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
};
|
|
|
|
const isFiniteNumber = (value: unknown): value is number => {
|
|
return typeof value === "number" && Number.isFinite(value);
|
|
};
|
|
|
|
const isOneTalkPermission = (value: unknown): boolean => {
|
|
return typeof value === "string" && ONETALK_PERMISSIONS.includes(value as never);
|
|
};
|
|
|
|
const isStringArray = (value: unknown, itemGuard: (item: unknown) => boolean): boolean => {
|
|
return Array.isArray(value) && value.every(itemGuard);
|
|
};
|
|
|
|
const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]): boolean => {
|
|
const actual = Object.keys(value).sort();
|
|
const expected = [...keys].sort();
|
|
return (
|
|
actual.length === expected.length && actual.every((key, index) => key === expected[index])
|
|
);
|
|
};
|
|
|
|
/** 判断值是否为插件连接 scope。 */
|
|
export const isOneTalkPluginScope = (value: unknown): value is OneTalkPluginScope => {
|
|
if (!isPlainRecord(value)) return false;
|
|
const keys = Object.keys(value);
|
|
return (
|
|
keys.length === 2 &&
|
|
keys.every((key) => key === "channelAccountId" || key === "deviceId") &&
|
|
isNonEmptyString(value.channelAccountId) &&
|
|
isNonEmptyString(value.deviceId)
|
|
);
|
|
};
|
|
|
|
/** 判断值是否为 Mind 页面连接 scope。 */
|
|
export const isOneTalkMindScope = (value: unknown): value is OneTalkMindScope => {
|
|
if (!isPlainRecord(value)) return false;
|
|
const keys = Object.keys(value);
|
|
return (
|
|
keys.length === 3 &&
|
|
keys.every(
|
|
(key) => key === "mindUserId" || key === "workspaceId" || key === "channelAccountId",
|
|
) &&
|
|
isNonEmptyString(value.mindUserId) &&
|
|
isNonEmptyString(value.workspaceId) &&
|
|
isNonEmptyString(value.channelAccountId)
|
|
);
|
|
};
|
|
|
|
/** 严格验证连接与授权会话帧的业务 payload。 */
|
|
export const isValidOneTalkConnectionPayload = (
|
|
type: string,
|
|
connectionType: OneTalkConnectionType,
|
|
value: Record<string, unknown>,
|
|
): boolean => {
|
|
switch (type) {
|
|
case "ws.hello":
|
|
if (!isStringArray(value.requestedPermissions, isOneTalkPermission)) return false;
|
|
if (connectionType === "plugin") {
|
|
return (
|
|
hasExactKeys(value, ["binding", "requestedPermissions"]) &&
|
|
isNonEmptyString(value.binding)
|
|
);
|
|
}
|
|
return hasExactKeys(value, ["requestedPermissions"]);
|
|
case "ws.accepted":
|
|
return (
|
|
hasExactKeys(value, ["authorizationVersion", "permissions"]) &&
|
|
isNonEmptyString(value.authorizationVersion) &&
|
|
isStringArray(value.permissions, isOneTalkPermission)
|
|
);
|
|
case "ws.error":
|
|
return (
|
|
hasExactKeys(value, ["code"]) &&
|
|
typeof value.code === "string" &&
|
|
Object.values(ONETALK_ERROR_CODES).includes(value.code as never)
|
|
);
|
|
case "heartbeat":
|
|
case "heartbeat.ack":
|
|
return hasExactKeys(value, ["sentAtMs"]) && isFiniteNumber(value.sentAtMs);
|
|
case "plugin.status":
|
|
return (
|
|
hasExactKeys(value, ["status"]) &&
|
|
typeof value.status === "string" &&
|
|
ONETALK_PLUGIN_STATUSES.includes(value.status as never)
|
|
);
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export type OneTalkPluginHelloFrame = OneTalkBaseFrame<
|
|
"ws.hello",
|
|
{ binding: string; requestedPermissions: OneTalkPermission[] },
|
|
"plugin"
|
|
>;
|
|
|
|
export type OneTalkMindHelloFrame = OneTalkBaseFrame<
|
|
"ws.hello",
|
|
{ requestedPermissions: OneTalkPermission[] },
|
|
"mind_page"
|
|
>;
|
|
|
|
export type OneTalkHelloFrame = OneTalkPluginHelloFrame | OneTalkMindHelloFrame;
|
|
|
|
export type OneTalkAcceptedFrame = OneTalkBaseFrame<
|
|
"ws.accepted",
|
|
{ authorizationVersion: string; permissions: OneTalkPermission[] }
|
|
>;
|
|
|
|
export type OneTalkErrorFrame = OneTalkBaseFrame<"ws.error", { code: OneTalkErrorCode }>;
|
|
|
|
export type OneTalkHeartbeatFrame = OneTalkBaseFrame<"heartbeat", { sentAtMs: number }>;
|
|
|
|
export type OneTalkHeartbeatAckFrame = OneTalkBaseFrame<"heartbeat.ack", { sentAtMs: number }>;
|
|
|
|
export type OneTalkPluginStatusFrame = OneTalkBaseFrame<
|
|
"plugin.status",
|
|
{ status: OneTalkPluginStatus },
|
|
"mind_page"
|
|
>;
|
|
|
|
export const isSameOneTalkScope = (left: OneTalkScope, right: OneTalkScope): boolean => {
|
|
if (isOneTalkPluginScope(left) || isOneTalkPluginScope(right)) {
|
|
return (
|
|
isOneTalkPluginScope(left) &&
|
|
isOneTalkPluginScope(right) &&
|
|
left.channelAccountId === right.channelAccountId &&
|
|
left.deviceId === right.deviceId
|
|
);
|
|
}
|
|
return (
|
|
isOneTalkMindScope(left) &&
|
|
isOneTalkMindScope(right) &&
|
|
left.mindUserId === right.mindUserId &&
|
|
left.workspaceId === right.workspaceId &&
|
|
left.channelAccountId === right.channelAccountId
|
|
);
|
|
};
|
|
|
|
export const createOneTalkAcceptedFrame = (
|
|
frame: OneTalkFrameContext,
|
|
authorizationVersion: string,
|
|
permissions: OneTalkPermission[],
|
|
): OneTalkAcceptedFrame => ({
|
|
protocolVersion: ONETALK_PROTOCOL_VERSION,
|
|
connectionType: frame.connectionType,
|
|
type: "ws.accepted",
|
|
requestId: frame.requestId,
|
|
scope: frame.scope,
|
|
payload: { authorizationVersion, permissions },
|
|
});
|
|
|
|
export const createOneTalkErrorFrame = (
|
|
frame: OneTalkFrameContext,
|
|
code: OneTalkErrorCode,
|
|
): OneTalkErrorFrame => ({
|
|
protocolVersion: ONETALK_PROTOCOL_VERSION,
|
|
connectionType: frame.connectionType,
|
|
type: "ws.error",
|
|
requestId: frame.requestId,
|
|
...(frame.sendRequestId === undefined ? {} : { sendRequestId: frame.sendRequestId }),
|
|
scope: frame.scope,
|
|
payload: { code },
|
|
});
|
|
|
|
export const createOneTalkHeartbeatAckFrame = (
|
|
frame: OneTalkHeartbeatFrame,
|
|
): OneTalkHeartbeatAckFrame => ({
|
|
protocolVersion: ONETALK_PROTOCOL_VERSION,
|
|
connectionType: frame.connectionType,
|
|
type: "heartbeat.ack",
|
|
requestId: frame.requestId,
|
|
scope: frame.scope,
|
|
payload: frame.payload,
|
|
});
|
|
|
|
export const createOneTalkPluginStatusFrame = (
|
|
frame: OneTalkFrameContext & { connectionType: "mind_page"; scope: OneTalkMindScope },
|
|
status: OneTalkPluginStatus,
|
|
): OneTalkPluginStatusFrame => ({
|
|
protocolVersion: ONETALK_PROTOCOL_VERSION,
|
|
connectionType: "mind_page",
|
|
type: "plugin.status",
|
|
requestId: frame.requestId,
|
|
scope: frame.scope,
|
|
payload: { status },
|
|
});
|