UNPKG

raiden-ts

Version:

Raiden Light Client Typescript/Javascript SDK

276 lines 16.5 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.deliveredEpic = exports.matrixMessageReceivedEpic = exports.matrixMessageServiceSendEpic = exports.matrixMessageSendEpic = void 0; const constant_1 = __importDefault(require("lodash/constant")); const uniq_1 = __importDefault(require("lodash/uniq")); const rxjs_1 = require("rxjs"); const operators_1 = require("rxjs/operators"); const config_1 = require("../../config"); const actions_1 = require("../../messages/actions"); const types_1 = require("../../messages/types"); const utils_1 = require("../../messages/utils"); const types_2 = require("../../services/types"); const utils_2 = require("../../services/utils"); const actions_2 = require("../../utils/actions"); const error_1 = require("../../utils/error"); const lru_1 = require("../../utils/lru"); const matrix_1 = require("../../utils/matrix"); const rx_1 = require("../../utils/rx"); const types_3 = require("../../utils/types"); const actions_3 = require("../actions"); const utils_3 = require("../utils"); function getMessageBody(message) { return typeof message === 'string' ? message : (0, utils_1.encodeJsonMessage)(message); } const textMsgType = 'm.text'; function getMsgType(request) { return request.payload.msgtype ?? textMsgType; } // toDevice API can send multiple messages to multiple userIds+deviceIds, but for each, only a // single `msgtype` is allowed; This function walks through the queue and on each call pops at most // masBatchSize messages from as many recipients as needed as long as all messages popped for a // peer are of the same msgtype; left messages will be picked on next iteration function popMessagesOfSameType(queue, maxBatchSize) { let selectedCount = 0; // per userId message queues with at least one message (which defines chosen msgtype) const selected = new Map(); for (let i = 0; i < queue.length && selectedCount < maxBatchSize; i++) { const request = queue[i]; // guaranteed to be defined from request$ const peerId = request.payload.userId; const peerQueue = selected.get(peerId); const msgTypeDiffersPeerQueue = peerQueue && getMsgType(request) !== getMsgType(peerQueue[0]); if (msgTypeDiffersPeerQueue) continue; queue.splice(i--, 1); if (!peerQueue) selected.set(peerId, [request]); else peerQueue.push(request); selectedCount++; } return selected; } function flushQueueToDevice(queue, deps) { if (!queue.length) return rxjs_1.EMPTY; const start = Date.now(); // limit each batch size to prevent API errors if request's payload is too big const batch = popMessagesOfSameType(queue, 20); const payload = {}; for (const [peerId, peerQueue] of batch) { // several messages can be batched in a single body, as long as they share same msgtype const body = peerQueue.map((action) => getMessageBody(action.payload.message)).join('\n'); const content = { msgtype: getMsgType(peerQueue[0]), body }; payload[peerId] = { ['*']: content }; } let retries = -1; return deps.matrix$.pipe((0, operators_1.mergeMap)(async (matrix) => { retries++; return matrix.sendToDevice('m.room.message', payload); }), (0, rx_1.retryWhile)((0, config_1.intervalFromConfig)(deps.config$), { maxRetries: 3, onErrors: error_1.networkErrors, log: deps.log.warn, }), // a higher-order observable is used to be able to have requests serialized/batched, // but return observables for success/failures which will resolve independently when peer comes // online, and get merged in parallel at the end to avoid holding concatMap (0, operators_1.mergeMap)(function* () { const tookMs = Date.now() - start; for (const [peerId, peerQueue] of batch) { for (const action of peerQueue) { // FIXME: if possible, restore waiting for some condition which indicates the // peer has received the message; maybe an incoming message, rtc channel or presence yield (0, rxjs_1.of)(actions_1.messageSend.success({ via: peerId, tookMs, retries }, action.meta)); } } }), (0, operators_1.catchError)(function* (err) { for (const [, peerQueue] of batch) { for (const action of peerQueue) { // if failure, fail all queued responses at once, no need to wait yield (0, rxjs_1.of)(actions_1.messageSend.failure(err, action.meta)); } } })); } function sendBatchedToDevice(action$, deps) { const queue = []; return action$.pipe((0, operators_1.tap)(([action, userId]) => queue.push({ ...action, payload: { ...action.payload, userId } })), // like concatBuffer, but takes queue control in order to do custom batch selection (0, operators_1.concatMap)(() => (0, rxjs_1.defer)(() => flushQueueToDevice(queue, deps))), // notice the emitted elements are observables, which can be merged in the output *after* // the concatMap was released in order to "wait" for some condition before success|failure (0, operators_1.mergeMap)((obs$) => obs$.pipe((0, rx_1.completeWith)(action$, 10)))); } function sendNowToRtc(toRtc$) { return toRtc$.pipe((0, operators_1.map)(([action, rtcChannel]) => { try { const start = Date.now(); rtcChannel.send(getMessageBody(action.payload.message)); const payload = { via: rtcChannel.label, tookMs: Date.now() - start, retries: 0 }; return actions_1.messageSend.success(payload, action.meta); } catch (err) { return actions_1.messageSend.failure(err, action.meta); } })); } /** * Handles a [[messageSend.request]] action and send its message to peer through rtcChannel or * toDevice * * @param action$ - Observable of messageSend.request actions * @param state$ - Observable of RaidenStates * @param deps - RaidenEpicDeps members * @returns Observable of messageSend.success | messageSend.failure actions */ function matrixMessageSendEpic(action$, {}, deps) { return action$.pipe((0, rx_1.dispatchRequestAndGetResponse)(actions_3.matrixPresence, (dispatch) => { const request$ = action$.pipe((0, operators_1.filter)(actions_1.messageSend.request.is), // "holds" message until it can be handled: there's either a rtcChannel or presence for peer (0, rx_1.withMergeFrom)((action) => { const address = action.meta.address; const rtc$ = getMsgType(action) !== textMsgType ? rxjs_1.EMPTY // don't send through rtc if msgtype is text : deps.latest$.pipe((0, operators_1.pluck)('rtc', address), (0, operators_1.filter)(types_3.isntNil), (0, operators_1.filter)(({ readyState }) => readyState === 'open')); const userId$ = action.payload.userId ? (0, rxjs_1.of)(action.payload.userId) : dispatch(actions_3.matrixPresence.request(undefined, { address })).pipe((0, operators_1.pluck)('payload', 'userId'), (0, operators_1.catchError)((err) => (0, rxjs_1.of)(err))); return (0, rxjs_1.merge)(rtc$, userId$).pipe((0, operators_1.take)(1), (0, rx_1.completeWith)(action$)); }), (0, operators_1.share)()); // split messages which can be sent right away through rtc and the ones which need to be queued // and sent through toDevice messages const [toRtc$, toDeviceOrError$] = (0, rxjs_1.partition)(request$, // eslint-disable-next-line @typescript-eslint/no-explicit-any ([, rtcOrPresence]) => !!rtcOrPresence && rtcOrPresence?.readyState); // partition isn't smart enough to detect the split condition, so we need to cast const [toDevice$, presenceError$] = (0, rxjs_1.partition)(toDeviceOrError$, ([, userIdOrError]) => typeof userIdOrError === 'string'); const sendToRtc$ = sendNowToRtc(toRtc$); const sendToDevice$ = sendBatchedToDevice(toDevice$, deps); const presenceRequestErrored$ = presenceError$.pipe((0, operators_1.map)(([request, error]) => actions_1.messageSend.failure(error, request.meta))); return (0, rxjs_1.merge)(presenceRequestErrored$, sendToRtc$, sendToDevice$); }, undefined, ({ meta }) => meta.address)); } exports.matrixMessageSendEpic = matrixMessageSendEpic; function sendServiceMessage(request, deps) { const { matrix$, config$, latest$ } = deps; return matrix$.pipe((0, operators_1.withLatestFrom)(latest$, config$), (0, rx_1.withMergeFrom)(([, , { additionalServices }]) => (0, rxjs_1.from)(additionalServices).pipe((0, operators_1.mergeMap)((serviceAddrOrUrl) => types_3.Address.is(serviceAddrOrUrl) ? (0, rxjs_1.of)(serviceAddrOrUrl) : (0, rxjs_1.defer)(async () => (0, utils_2.pfsInfoAddress)(serviceAddrOrUrl, deps)).pipe((0, operators_1.catchError)((0, constant_1.default)(rxjs_1.EMPTY))), 5), (0, operators_1.toArray)())), (0, operators_1.mergeMap)(([[matrix, { state }], additionalServicesAddrs]) => { const servicesAddrs = (0, uniq_1.default)(additionalServicesAddrs.concat(Object.keys(state.services))); (0, error_1.assert)(servicesAddrs.length, 'no services to messageServiceSend to'); const serverName = (0, matrix_1.getServerName)(matrix.getHomeserverUrl()); const userIds = servicesAddrs.map((service) => `@${service.toLowerCase()}:${serverName}`); // batch action messages in a single text body const content = { msgtype: 'm.text', body: getMessageBody(request.payload.message), }; const payload = Object.fromEntries(userIds.map((uid) => [uid, { [types_2.ServiceDeviceId[request.meta.service]]: content }])); const start = Date.now(); let retries = -1; return (0, rxjs_1.defer)(async () => { retries++; await matrix.sendToDevice('m.room.message', payload); // send message! return actions_1.messageServiceSend.success({ via: userIds, tookMs: Date.now() - start, retries }, request.meta); }).pipe((0, rx_1.retryWhile)((0, config_1.intervalFromConfig)(config$), { maxRetries: 3, onErrors: error_1.networkErrors })); }), (0, operators_1.catchError)((err) => (0, rxjs_1.of)(actions_1.messageServiceSend.failure(err, request.meta)))); } /** * Handles a [[messageServiceSend.request]] action and send one-shot message to a service * * @param action$ - Observable of messageServiceSend actions * @param state$ - Observable of RaidenStates * @param deps - Epics dependencies * @returns Observable of messageServiceSend.success|failure actions */ function matrixMessageServiceSendEpic(action$, {}, deps) { return action$.pipe((0, operators_1.filter)((0, actions_2.isActionOf)(actions_1.messageServiceSend.request)), // wait until init$ is completed before handling requests, so state.services is populated (0, operators_1.delayWhen)(() => deps.init$.pipe((0, operators_1.ignoreElements)(), (0, operators_1.endWith)(true))), (0, operators_1.mergeMap)((request) => sendServiceMessage(request, deps), 5)); } exports.matrixMessageServiceSendEpic = matrixMessageServiceSendEpic; /** * Subscribe to matrix messages and emits MessageReceivedAction upon receiving a valid message from * an user of interest (one valid signature from an address we monitor) in a room we have for them * * @param action$ - Observable of RaidenActions * @param state$ - Observable of RaidenStates * @param deps - RaidenEpicDeps members * @param deps.log - Logger instance * @param deps.matrix$ - MatrixClient async subject * @param deps.config$ - Config observable * @param deps.latest$ - Latest values * @returns Observable of messageReceived|matrixPresence.request actions */ function matrixMessageReceivedEpic(action$, {}, { log, matrix$, config$, latest$ }) { return action$.pipe((0, rx_1.dispatchRequestAndGetResponse)(actions_3.matrixPresence, (requestPresence) => matrix$.pipe( // when matrix finishes initialization, register to matrix timeline events (0, rx_1.withMergeFrom)((matrix) => (0, rxjs_1.fromEvent)(matrix, 'toDeviceEvent')), (0, operators_1.filter)(([matrix, event]) => event.getType() === 'm.room.message' && event.getSender() !== matrix.getUserId()), (0, operators_1.pluck)(1), (0, operators_1.withLatestFrom)(config$, action$.pipe((0, utils_3.getPresencesByUserId)())), (0, rx_1.withMergeFrom)(([event, { httpTimeout }, seenPresences]) => { const senderId = event.getSender(); const presence = seenPresences[senderId]; if (presence) return (0, rxjs_1.of)(presence); return latest$.pipe((0, rx_1.pluckDistinct)('whitelisted'), (0, operators_1.map)((whitelisted) => { const address = (0, utils_3.getAddressFromUserId)(senderId); if (!!address && whitelisted.includes(address)) return address; }), (0, operators_1.filter)(types_3.isntNil), (0, operators_1.take)(1), // take up to an arbitrary timeout for sender to be whitelisted (0, operators_1.takeUntil)((0, rxjs_1.timer)(httpTimeout)), // only request presence if node was whitelisted, to avoid whitelisting unknown senders (0, operators_1.mergeMap)((address) => requestPresence(actions_3.matrixPresence.request(undefined, { address }))), (0, operators_1.catchError)((0, constant_1.default)(rxjs_1.EMPTY))); }), (0, rx_1.completeWith)(action$), (0, operators_1.mergeMap)(([[event], presence]) => { const lines = (event.getContent()['body'] ?? '').split('\n'); return (0, rxjs_1.scheduled)(lines, rxjs_1.asapScheduler).pipe((0, operators_1.map)((line) => { let message; if (event.getContent().msgtype === textMsgType) message = (0, utils_1.parseMessage)(line, presence, { log }); return (0, actions_1.messageReceived)({ text: line, ...(message ? { message } : {}), ts: Date.now(), userId: event.getSender(), ...(event.getContent().msgtype ? { msgtype: event.getContent().msgtype } : {}), }, presence.meta); })); })))); } exports.matrixMessageReceivedEpic = matrixMessageReceivedEpic; /** * Sends Delivered for specific messages * * @param action$ - Observable of messageReceived actions * @param state$ - Observable of RaidenStates * @param deps - RaidenEpicDeps members * @param deps.log - Logger instance * @param deps.signer - Signer instance * @returns Observable of messageSend.request actions */ function deliveredEpic(action$, {}, { log, signer }) { const cache = new lru_1.LruCache(32); return action$.pipe((0, operators_1.filter)((0, utils_1.isMessageReceivedOfType)([(0, types_3.Signed)(types_1.Processed), (0, types_3.Signed)(types_1.SecretRequest), (0, types_3.Signed)(types_1.SecretReveal)])), (0, operators_1.withLatestFrom)(action$.pipe((0, utils_3.getPresencesByAddress)())), // light-clients don't need Delivered messages (0, operators_1.filter)(([action, presences]) => !(0, utils_3.peerIsOnlineLC)(presences[action.meta.address])), (0, operators_1.concatMap)(([action]) => { const message = action.payload.message; // defer causes the cache check to be performed at subscription time return (0, rxjs_1.defer)(() => { const msgId = message.message_identifier; const key = msgId.toString(); const cached = cache.get(key); if (cached) return (0, rxjs_1.of)(actions_1.messageSend.request({ message: cached }, { address: action.meta.address, msgId: key })); const delivered = { type: types_1.MessageType.DELIVERED, delivered_message_identifier: msgId, }; log.info(`Signing "${delivered.type}" for "${message.type}" with id=${msgId.toString()}`); return (0, rxjs_1.from)((0, utils_1.signMessage)(signer, delivered, { log })).pipe((0, operators_1.tap)((signed) => cache.set(key, signed)), (0, operators_1.map)((signed) => actions_1.messageSend.request({ message: signed }, { address: action.meta.address, msgId: key }))); }); })); } exports.deliveredEpic = deliveredEpic; //# sourceMappingURL=messages.js.map