mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20:46 +08:00
224 lines
6.6 KiB
JavaScript
224 lines
6.6 KiB
JavaScript
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();
|
|
}
|