Files
trade-message-center/packages/onetalk-contract/src/connection.ts
T

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 },
});