raiden-ts
Version:
Raiden Light Client Typescript/Javascript SDK
276 lines • 16.5 kB
JavaScript
;
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