mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 07:43:13 +08:00
158 lines
5.7 KiB
JavaScript
158 lines
5.7 KiB
JavaScript
import { resolveRpcAuthority } from './rpc-authority.mjs';
|
|
|
|
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
|
|
export const DELIVERY_TEST_MESSAGE = 'DSH-IM 主动投递测试成功。';
|
|
export const DELIVERY_ENDPOINTS = Object.freeze({
|
|
send: 'message.send',
|
|
listTargets: 'target.list',
|
|
listSuggestions: 'target.suggestion.list',
|
|
createTarget: 'target.create',
|
|
updateTarget: 'target.update',
|
|
deleteTarget: 'target.delete',
|
|
testTarget: 'target.test',
|
|
});
|
|
|
|
const ENDPOINTS = new Set(Object.values(DELIVERY_ENDPOINTS));
|
|
const PUBLIC_ERRORS = new Set([
|
|
'bad-request',
|
|
'unknown-bot',
|
|
'unknown-target',
|
|
'target-conflict',
|
|
'invalid-target',
|
|
'bot-not-connected',
|
|
'target-rejected',
|
|
'delivery-failed',
|
|
'cancelled',
|
|
]);
|
|
|
|
function isRecord(value) {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function exactKeys(value, keys) {
|
|
return isRecord(value)
|
|
&& Object.keys(value).length === keys.length
|
|
&& Object.keys(value).every((key) => keys.includes(key));
|
|
}
|
|
|
|
function validBotId(value) {
|
|
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
}
|
|
|
|
function validTargetId(value) {
|
|
return typeof value === 'string' && /^[A-Za-z0-9._:@-]{1,128}$/.test(value);
|
|
}
|
|
|
|
function validTarget(value, { targetId }) {
|
|
const keys = targetId ? ['targetId', 'name', 'kind', 'route'] : ['name', 'kind', 'route'];
|
|
if (!isRecord(value) || Object.keys(value).some((key) => !keys.includes(key))) return false;
|
|
if (targetId && !validTargetId(value.targetId)) return false;
|
|
if (value.name !== undefined
|
|
&& (typeof value.name !== 'string' || !value.name.trim() || value.name.trim().length > 80)) return false;
|
|
return typeof value.kind === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(value.kind)
|
|
&& isRecord(value.route);
|
|
}
|
|
|
|
function validDraftTarget(value) {
|
|
return exactKeys(value, ['kind', 'route'])
|
|
&& typeof value.kind === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(value.kind)
|
|
&& isRecord(value.route);
|
|
}
|
|
|
|
function validPayload(endpoint, payload) {
|
|
if (!ENDPOINTS.has(endpoint) || !isRecord(payload)) return false;
|
|
if (endpoint === DELIVERY_ENDPOINTS.send) {
|
|
return exactKeys(payload, ['botId', 'targetId', 'text'])
|
|
&& validBotId(payload.botId) && validTargetId(payload.targetId)
|
|
&& typeof payload.text === 'string' && Boolean(payload.text.trim());
|
|
}
|
|
if (endpoint === DELIVERY_ENDPOINTS.listTargets
|
|
|| endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
|
|
return exactKeys(payload, ['botId']) && validBotId(payload.botId);
|
|
}
|
|
if (endpoint === DELIVERY_ENDPOINTS.createTarget) {
|
|
return exactKeys(payload, ['botId', 'target'])
|
|
&& validBotId(payload.botId) && validTarget(payload.target, { targetId: true });
|
|
}
|
|
if (endpoint === DELIVERY_ENDPOINTS.updateTarget) {
|
|
return exactKeys(payload, ['botId', 'targetId', 'target'])
|
|
&& validBotId(payload.botId) && validTargetId(payload.targetId)
|
|
&& validTarget(payload.target, { targetId: false });
|
|
}
|
|
if (endpoint === DELIVERY_ENDPOINTS.testTarget) {
|
|
return (exactKeys(payload, ['botId', 'targetId'])
|
|
&& validBotId(payload.botId) && validTargetId(payload.targetId))
|
|
|| (exactKeys(payload, ['botId', 'target'])
|
|
&& validBotId(payload.botId) && validDraftTarget(payload.target));
|
|
}
|
|
return exactKeys(payload, ['botId', 'targetId'])
|
|
&& validBotId(payload.botId) && validTargetId(payload.targetId);
|
|
}
|
|
|
|
function publicError(error) {
|
|
const code = PUBLIC_ERRORS.has(error?.code) ? error.code : 'delivery-failed';
|
|
return { code, message: code, details: {} };
|
|
}
|
|
|
|
export function createDeliveryRpcHandler(service) {
|
|
for (const method of [
|
|
'send',
|
|
'listTargets',
|
|
'listSuggestions',
|
|
'createTarget',
|
|
'updateTarget',
|
|
'deleteTarget',
|
|
]) {
|
|
if (typeof service?.[method] !== 'function') {
|
|
throw new TypeError(`A complete delivery service is required (${method})`);
|
|
}
|
|
}
|
|
return async (endpoint, payload, signal) => {
|
|
if (!validPayload(endpoint, payload)) {
|
|
return {
|
|
ok: false,
|
|
error: { code: 'bad-request', message: 'Invalid delivery request.', details: {} },
|
|
};
|
|
}
|
|
if (signal?.aborted) {
|
|
return { ok: false, error: { code: 'cancelled', message: 'cancelled', details: {} } };
|
|
}
|
|
try {
|
|
let value;
|
|
if (endpoint === DELIVERY_ENDPOINTS.send) {
|
|
value = await service.send(payload.botId, payload.targetId, payload.text, { signal });
|
|
} else if (endpoint === DELIVERY_ENDPOINTS.listTargets) {
|
|
value = await service.listTargets(payload.botId);
|
|
} else if (endpoint === DELIVERY_ENDPOINTS.listSuggestions) {
|
|
value = await service.listSuggestions(payload.botId);
|
|
} else if (endpoint === DELIVERY_ENDPOINTS.createTarget) {
|
|
value = await service.createTarget(payload.botId, payload.target);
|
|
} else if (endpoint === DELIVERY_ENDPOINTS.updateTarget) {
|
|
value = await service.updateTarget(payload.botId, payload.targetId, payload.target);
|
|
} else if (endpoint === DELIVERY_ENDPOINTS.deleteTarget) {
|
|
value = await service.deleteTarget(payload.botId, payload.targetId);
|
|
} else {
|
|
value = await service.send(
|
|
payload.botId,
|
|
Object.hasOwn(payload, 'target') ? payload.target : payload.targetId,
|
|
DELIVERY_TEST_MESSAGE,
|
|
{ signal },
|
|
);
|
|
}
|
|
return { ok: true, value };
|
|
} catch (error) {
|
|
return { ok: false, error: publicError(error) };
|
|
}
|
|
};
|
|
}
|
|
|
|
export function installDeliveryRpc(ctx, service, { authority } = {}) {
|
|
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
|
throw new TypeError('DSH Host Connection RPC is required');
|
|
}
|
|
return ctx.connection.rpc.handle(
|
|
DELIVERY_RPC_CHANNEL,
|
|
createDeliveryRpcHandler(service),
|
|
{ authority: resolveRpcAuthority(authority) },
|
|
);
|
|
}
|