@jsxc/jsxc
Version:
Real-time XMPP chat application with video calls, file transfer and encrypted communication
249 lines (195 loc) • 7.85 kB
text/typescript
import { AbstractPlugin, IMetaData } from '../plugin/AbstractPlugin';
import PluginAPI from '../plugin/PluginAPI';
import Translation from '@util/Translation';
import * as Namespace from '@connection/xmpp/namespace';
import { IContact } from '@src/Contact.interface';
import UUID from '@util/UUID';
import JID from '@src/JID';
import { Call } from '@src/Call';
import { CallState, CallType } from '@src/CallManager';
import { IJID } from '@src/JID.interface';
/**
* XEP-0353: Jingle Message Initiation
*
* @version: 0.3.1
* @see: https://xmpp.org/extensions/xep-0353.html
*
*/
const JMI = 'urn:xmpp:jingle-message:0';
type Actions = 'propose' | 'retract' | 'accept' | 'reject' | 'proceed';
const MIN_VERSION = '4.0.0';
const MAX_VERSION = '99.0.0';
Namespace.register('JINGLE_MESSAGE_INITIATION', JMI);
export default class JingleMessageInitiationPlugin extends AbstractPlugin {
public static getId(): string {
return 'jmi';
}
public static getName(): string {
return 'Jingle Message Initiation';
}
public static getMetaData(): IMetaData {
return {
description: Translation.t('setting-jmi-enable'),
xeps: [
{
id: 'XEP-0353',
name: 'Jingle Message Initiation',
version: '0.3.1',
},
],
};
}
private calls: { [id: string]: Call } = {};
private pendingOutgoingSessionIds: string[] = [];
constructor(pluginAPI: PluginAPI) {
super(MIN_VERSION, MAX_VERSION, pluginAPI);
this.pluginAPI.addFeature(JMI);
this.pluginAPI.getConnection().registerHandler(this.onJingleMessageInitiation, null, 'message', null);
this.pluginAPI.addCallProcessor(this.callProcessor);
this.pluginAPI.addTerminateCallProcessor(this.terminateCallProcessor);
this.pluginAPI
.getSessionStorage()
.registerHook('*', (value: { action: Actions; jid: string; type: CallType }, _, sessionId) => {
if (!value || !value.action || !value.jid || !value.type) {
return;
}
const jid = new JID(value.jid);
const action = value.action;
const type = value.type;
const peer = this.pluginAPI.getContact(jid);
if (!peer) {
return;
}
const ownJID = this.pluginAPI.getConnection().getJID();
if (action === 'propose') {
this.calls[sessionId] = this.pluginAPI.getCallManager().onIncomingCall(type, sessionId, peer);
this.calls[sessionId].getState().then(state => {
if (state === CallState.Accepted) {
this.sendMessage(ownJID.toBareJID(), sessionId, 'accept');
this.sendMessage(jid, sessionId, 'proceed');
} else if (state === CallState.Declined) {
this.sendMessage(ownJID.toBareJID(), sessionId, 'reject');
this.sendMessage(jid, sessionId, 'reject');
}
});
} else if (action === 'retract' || (action === 'accept' && jid.full !== ownJID.full)) {
if (this.calls[sessionId]) {
this.calls[sessionId].abort();
}
}
});
}
private onJingleMessageInitiation = (stanza: string): boolean => {
let stanzaElement = $(stanza);
let element = stanzaElement.find(`[xmlns="${JMI}"]`);
if (element.length !== 1) {
return true;
}
const action = element.prop('tagName')?.toString().toLowerCase();
if (!['propose', 'retract', 'accept', 'reject', 'proceed'].includes(action)) {
return true;
}
const fromAttribute = stanzaElement.attr('from');
const fromJid = new JID(fromAttribute);
const sessionId = element.attr('id');
const contact = this.pluginAPI.getContact(fromJid);
if (!contact || !sessionId) {
return true;
}
const requestedMedia = element
.find('> description[xmlns="urn:xmpp:jingle:apps:rtp:1"]')
.map((_, descElement) => $(descElement).attr('media'))
.get();
const type: CallType =
requestedMedia.includes('video') && requestedMedia.includes('audio')
? 'video'
: requestedMedia.includes('audio')
? 'audio'
: 'stream';
this.pluginAPI.getSessionStorage().setItem(sessionId, {
action,
jid: fromJid.full,
type,
});
return true;
};
private callProcessor = async (
contact: IContact,
type: 'video' | 'audio' | 'screen',
resources: string[],
sessionId: string
): Promise<[IContact, 'video' | 'audio' | 'screen', string[], string]> => {
let capableResources = await contact.getCapableResources(JMI);
if (capableResources.length === 0 || sessionId) {
return [contact, type, resources, sessionId];
}
sessionId = UUID.v4();
const descriptions = [];
if (type === 'video' || type === 'audio') {
descriptions.push('audio');
}
if (type === 'video') {
descriptions.push('video');
}
this.pendingOutgoingSessionIds.push(sessionId);
this.sendMessage(contact.getJid().toBareJID(), sessionId, 'propose', descriptions);
return new Promise(resolve => {
const storage = this.pluginAPI.getSessionStorage();
const hook = (data: { action: Actions; jid: string }) => {
if (data.action === 'retract') {
this.sendMessage(contact.getJid().toBareJID(), sessionId, 'retract');
} else if (data?.action === 'proceed') {
resolve([contact, type, [new JID(data.jid).resource], sessionId]);
} else if (data?.action === 'reject') {
resolve([contact, type, [], sessionId]);
}
if (data?.action !== 'propose') {
storage.removeHook(sessionId, hook);
}
this.pendingOutgoingSessionIds = this.pendingOutgoingSessionIds.filter(id => id !== sessionId);
};
storage.registerHook(sessionId, hook);
setTimeout(() => {
resolve([contact, type, [], undefined]);
}, 30 * 1000);
});
};
private terminateCallProcessor = async (sessionId?: string): Promise<[string]> => {
const ids = sessionId ? [sessionId] : [...this.pendingOutgoingSessionIds];
const sessionStorage = this.pluginAPI.getSessionStorage();
this.pendingOutgoingSessionIds = sessionId ? this.pendingOutgoingSessionIds.filter(id => id !== sessionId) : [];
ids.forEach(id => {
sessionStorage.setItem(id, { action: 'retract' });
});
return [sessionId];
};
private sendMessage(jid: IJID, sessionId: string, action: 'propose', descriptions?: ('audio' | 'video')[]): void;
private sendMessage(jid: IJID, sessionId: string, action: 'retract' | 'accept' | 'proceed' | 'reject'): void;
private sendMessage(jid: IJID, sessionId: string, action, descriptions = []) {
let xmlMsg = $msg({
to: jid.full,
}).c(action, {
xmlns: JMI,
id: sessionId,
});
if (action === 'propose') {
if (descriptions.includes('video') || descriptions.includes('audio')) {
xmlMsg
.c('description', {
xmlns: 'urn:xmpp:jingle:apps:rtp:1',
media: 'audio',
})
.up();
}
if (descriptions.includes('video')) {
xmlMsg
.c('description', {
xmlns: 'urn:xmpp:jingle:apps:rtp:1',
media: 'video',
})
.up();
}
}
this.pluginAPI.send(xmlMsg);
}
}