mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 07:53:17 +08:00
feat: add stable proactive delivery
This commit is contained in:
parent
410cb822d7
commit
31792d7bc8
88 changed files with 6427 additions and 488 deletions
158
plugin-src/host/delivery-rpc.mjs
Normal file
158
plugin-src/host/delivery-rpc.mjs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
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) },
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue