mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +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
224
plugin-src/host/delivery-service.mjs
Normal file
224
plugin-src/host/delivery-service.mjs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { normalizeDeliveryTarget } from './delivery-adapter.mjs';
|
||||
|
||||
const BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const TARGET_ID_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/;
|
||||
const CHANNEL_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
const DRAFT_TARGET_ID = '__test__';
|
||||
|
||||
const ADAPTER_METHODS = Object.freeze([
|
||||
'ownsBot',
|
||||
'listTargets',
|
||||
'listSuggestions',
|
||||
'createTarget',
|
||||
'updateTarget',
|
||||
'deleteTarget',
|
||||
'sendText',
|
||||
]);
|
||||
|
||||
const DELIVERY_ERROR_CODES = new Set([
|
||||
'bad-request',
|
||||
'unknown-bot',
|
||||
'unknown-target',
|
||||
'target-conflict',
|
||||
'invalid-target',
|
||||
'bot-not-connected',
|
||||
'target-rejected',
|
||||
'delivery-failed',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
function deliveryError(code, message = code, options) {
|
||||
const error = new Error(message, options);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function botIdOf(value) {
|
||||
if (typeof value !== 'string' || !BOT_ID_PATTERN.test(value)) {
|
||||
throw deliveryError('bad-request', 'Invalid bot id');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function targetIdOf(value) {
|
||||
if (typeof value !== 'string' || !TARGET_ID_PATTERN.test(value)) {
|
||||
throw deliveryError('bad-request', 'Invalid target id');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function targetObject(value, { includesTargetId } = {}) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw deliveryError('bad-request', 'Invalid target');
|
||||
}
|
||||
if (includesTargetId) targetIdOf(value.targetId);
|
||||
else if (Object.hasOwn(value, 'targetId')) {
|
||||
throw deliveryError('bad-request', 'A target id cannot be changed');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function draftTargetObject(value) {
|
||||
targetObject(value);
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== 2 || !keys.includes('kind') || !keys.includes('route')) {
|
||||
throw deliveryError('bad-request', 'Invalid draft target');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cancellation(signal) {
|
||||
if (signal?.aborted) throw deliveryError('cancelled', 'Request cancelled');
|
||||
}
|
||||
|
||||
function publicOperationError(error, fallback = 'delivery-failed') {
|
||||
if (error?.code === 'workspace-bot-not-found') {
|
||||
return deliveryError('unknown-bot', 'Unknown bot', { cause: error });
|
||||
}
|
||||
if (DELIVERY_ERROR_CODES.has(error?.code)) return error;
|
||||
return deliveryError(fallback, fallback, { cause: error });
|
||||
}
|
||||
|
||||
function validateAdapter(adapter) {
|
||||
if (!adapter || typeof adapter !== 'object'
|
||||
|| typeof adapter.channel !== 'string' || !CHANNEL_PATTERN.test(adapter.channel)) {
|
||||
throw new TypeError('A delivery adapter with a valid channel is required');
|
||||
}
|
||||
for (const method of ADAPTER_METHODS) {
|
||||
if (typeof adapter[method] !== 'function') {
|
||||
throw new TypeError(`A complete delivery adapter is required (${method})`);
|
||||
}
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export class DeliveryService {
|
||||
#adapters = new Map();
|
||||
|
||||
registerAdapter(value) {
|
||||
const adapter = validateAdapter(value);
|
||||
const registration = Object.freeze({ adapter });
|
||||
this.#adapters.set(adapter.channel, registration);
|
||||
return () => {
|
||||
if (this.#adapters.get(adapter.channel) !== registration) return false;
|
||||
this.#adapters.delete(adapter.channel);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
async listTargets(botId) {
|
||||
const id = botIdOf(botId);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
const targets = await adapter.listTargets(id);
|
||||
if (!Array.isArray(targets)) throw new TypeError('Adapter returned invalid targets');
|
||||
return { botId: id, channel: adapter.channel, targets };
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listSuggestions(botId) {
|
||||
const id = botIdOf(botId);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
const suggestions = await adapter.listSuggestions(id);
|
||||
if (!Array.isArray(suggestions)) throw new TypeError('Adapter returned invalid suggestions');
|
||||
return {
|
||||
botId: id,
|
||||
channel: adapter.channel,
|
||||
suggestions: suggestions.map((suggestion) => normalizeDeliveryTarget(
|
||||
adapter.channel,
|
||||
suggestion,
|
||||
{ targetIdRequired: false },
|
||||
)),
|
||||
};
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createTarget(botId, target) {
|
||||
const id = botIdOf(botId);
|
||||
targetObject(target, { includesTargetId: true });
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
return await adapter.createTarget(id, target);
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateTarget(botId, targetId, replacement) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = targetIdOf(targetId);
|
||||
targetObject(replacement);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
return await adapter.updateTarget(id, targetKey, replacement);
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTarget(botId, targetId) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = targetIdOf(targetId);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
await adapter.deleteTarget(id, targetKey);
|
||||
return { deleted: true };
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async send(botId, targetIdOrDraft, text, { signal } = {}) {
|
||||
const id = botIdOf(botId);
|
||||
const targetKey = typeof targetIdOrDraft === 'string'
|
||||
? targetIdOf(targetIdOrDraft)
|
||||
: null;
|
||||
const draft = targetKey === null ? draftTargetObject(targetIdOrDraft) : null;
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw deliveryError('bad-request', 'Message text is required');
|
||||
}
|
||||
cancellation(signal);
|
||||
const adapter = await this.#adapterFor(id);
|
||||
try {
|
||||
let target;
|
||||
if (draft) {
|
||||
target = { targetId: DRAFT_TARGET_ID, ...draft };
|
||||
} else {
|
||||
const targets = await adapter.listTargets(id);
|
||||
if (!Array.isArray(targets)) throw new TypeError('Adapter returned invalid targets');
|
||||
target = targets.find((candidate) => candidate?.targetId === targetKey);
|
||||
if (!target) throw deliveryError('unknown-target', 'Unknown target');
|
||||
}
|
||||
cancellation(signal);
|
||||
await adapter.sendText(id, target, text, { signal });
|
||||
return { sent: true };
|
||||
} catch (error) {
|
||||
if (signal?.aborted || error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
|
||||
throw deliveryError('cancelled', 'Request cancelled', { cause: error });
|
||||
}
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #adapterFor(botId) {
|
||||
for (const { adapter } of this.#adapters.values()) {
|
||||
let ownsBot;
|
||||
try {
|
||||
ownsBot = await adapter.ownsBot(botId);
|
||||
} catch (error) {
|
||||
throw publicOperationError(error);
|
||||
}
|
||||
if (ownsBot) return adapter;
|
||||
}
|
||||
throw deliveryError('unknown-bot', 'Unknown bot');
|
||||
}
|
||||
}
|
||||
|
||||
export function createDeliveryService() {
|
||||
return new DeliveryService();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue