Skip to content

Commit

Permalink
feat: initial commit :)
Browse files Browse the repository at this point in the history
  • Loading branch information
emilylange committed Jan 8, 2022
0 parents commit 4850acd
Show file tree
Hide file tree
Showing 13 changed files with 3,184 additions and 0 deletions.
11 changes: 11 additions & 0 deletions .env-example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
MX_HOMESERVER="https://homeserver.example"
MX_ACCESSTOKEN="BOT ACCESSTOKEN (see README.md)"
MX_USERID="@localpart:homeserver.example"
MX_ROOMID="!roomId:homeserver.example"

## SIP_SERVER must begin with wss:// or ws://
## SIP server without WebSocket/WebRTC support won't work
SIP_SERVER="wss://sip.sipgate.de"
SIP_URI="sip:[email protected]"
SIP_USER="userAddress"
SIP_PASSWORD="your sip user password"
27 changes: 27 additions & 0 deletions .eslintrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
env:
es2021: true
node: true
extends:
- 'eslint:recommended'
- 'plugin:@typescript-eslint/recommended'
parser: '@typescript-eslint/parser'
parserOptions:
ecmaVersion: 13
sourceType: module
plugins:
- '@typescript-eslint'
rules:
quotes:
- 'error'
- 'single'
indent:
- 'error'
- 2
semi:
- 'error'
- 'always'
no-trailing-spaces:
- 'error'
comma-dangle:
- 'error'
- 'always-multiline'
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## node_modules
node_modules/

## dotenv (.env)
.env*
!.env-example

## typescript output dir
dist/

## Logs
*.log*
660 changes: 660 additions & 0 deletions LICENSE.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# matrix-sip-bridge
A bridge between [Matrix](https://matrix.org/) and VoIP via [SIP](https://en.wikipedia.org/wiki/Session_Initiation_Protocol) to answer (and in future make) phone calls from Matrix.

| :exclamation: Documentation will follow shortly |
|:--|
28 changes: 28 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"type": "module",
"license": "AGPL-3.0",
"scripts": {
"start": "node --experimental-specifier-resolution=node dist/index.js",
"eslint": "eslint src/",
"eslint:fix": "eslint --fix src/",
"build": "tsc",
"watch": "tsc --watch",
"clean": "rm -rf ./dist"
},
"dependencies": {
"dotenv": "^10.0.0",
"matrix-js-sdk": "^15.3.0",
"sip.js": "^0.20.0",
"wrtc": "^0.4.7",
"ws": "^8.4.0"
},
"devDependencies": {
"@types/node": "^17.0.5",
"@types/ws": "^8.2.2",
"@typescript-eslint/eslint-plugin": "^5.8.0",
"@typescript-eslint/parser": "^5.8.0",
"eslint": "^8.5.0",
"eslint-plugin-import": "^2.25.3",
"typescript": "^4.5.4"
}
}
77 changes: 77 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import 'dotenv/config';
import './webrtc/polyfill';

import webrtcMediaFactory from './webrtc/mediaStreams';
import SipClient from './sip/sip';
import MatrixBot from './matrix/bot';
import { CallErrorCode, CallEvent, CallState } from 'matrix-js-sdk/lib/webrtc/call';

const mediaStreams = new webrtcMediaFactory();

const matrixBot = new MatrixBot({
homeserver: process.env.MX_HOMESERVER,
accessToken: process.env.MX_ACCESSTOKEN,
userId: process.env.MX_USERID,
roomId: process.env.MX_ROOMID,
mediaStreams,
});

const sip = new SipClient({
sipServer: process.env.SIP_SERVER,
sipUri: process.env.SIP_URI,
sipUser: process.env.SIP_USER,
sipPassword: process.env.SIP_PASSWORD,
mediaStreams,
});

sip.client.delegate.onCallReceived = async () => {
await matrixBot.placeCall();

// hangle matrix call state changes
matrixBot.call.on(CallEvent.State, async (state) => {
// answer SIP call after matrix call is answered
if (state === CallState.Connecting) {
await sip.client.answer({});
}
});

// hangup SIP call when matrix call ends
matrixBot.call.on(CallEvent.Hangup, async () => {
// may fail when sip call already ended ("Session does not exist")
try {
await sip.client.hangup();
} catch (e) {
if (e.message !== 'Session does not exist') {
console.error(e);
}
}
});

// handle matrix->sip hold
matrixBot.call.on(CallEvent.RemoteHoldUnhold, async () => {
if (matrixBot.call.isRemoteOnHold()) {
await sip.client.hold();
} else {
await sip.client.unhold();
}
});
};

// handle sip->matrix hold
sip.client.delegate.onCallHold = async (held: boolean) => {
matrixBot.call.setRemoteOnHold(held);
};

// handle sip->matrix hangup
sip.client.delegate.onCallHangup = async () => {
await matrixBot.call.hangup(CallErrorCode.UserHangup, false);
};

const gracefulDisconnect = async () => {
await sip.client.disconnect();
await matrixBot.destroy();
};

// graceful stop
process.once('SIGINT', gracefulDisconnect);
process.once('SIGTERM', gracefulDisconnect);
113 changes: 113 additions & 0 deletions src/matrix/bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as matrixSdk from 'matrix-js-sdk';
import { MatrixClient, MatrixEvent, MsgType } from 'matrix-js-sdk';
import { CallErrorCode, CallEvent, MatrixCall } from 'matrix-js-sdk/lib/webrtc/call';
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/lib/webrtc/callEventTypes';
import { CallFeed } from 'matrix-js-sdk/lib/webrtc/callFeed';
import webrtcMediaFactory from '../webrtc/mediaStreams';

interface IMatrixBot {
homeserver: string;
accessToken: string;
userId: string,
roomId: string;
mediaStreams: webrtcMediaFactory;
}

export default class MatrixBot {
private bot: MatrixClient;
private roomId: string;
private mediaStreams: webrtcMediaFactory;
public call: MatrixCall;

constructor({
homeserver,
accessToken,
userId,
roomId,
mediaStreams,
}: IMatrixBot,
){
this.bot = matrixSdk.createClient({
baseUrl: homeserver,
accessToken,
userId,
useAuthorizationHeader: true, // Use Authorization header instead of query string
forceTURN: true, // Force TURN for WebRTC
});

this.roomId = roomId;
this.mediaStreams = mediaStreams;

this.initListener();

// TODO: Overwrite matrix logger
// TODO: Add event filter
// TODO: Add persistent store
// TODO: Add e2ee support
this.bot.startClient({
initialSyncLimit: 0,
});
}

public async placeCall() {
const call = matrixSdk.createNewMatrixCall(
this.bot,
this.roomId,
);

// get remote audio stream and attach it to the call sip call
call.on(CallEvent.FeedsChanged, (feeds: CallFeed[]) => {
const remoteFeed = feeds.find((feed) => !feed.isLocal());
if (remoteFeed !== undefined) {
this.mediaStreams.setAudioTrack(remoteFeed.stream, this.mediaStreams.invitingAudioSource);
}
});

call.placeCallWithCallFeeds([
new CallFeed({
client: this.bot,
roomId: call.roomId,
userId: this.bot.getUserId(),
stream: this.mediaStreams.answeringMediaStream,
purpose: SDPStreamMetadataPurpose.Usermedia,
audioMuted: false,
videoMuted: true,
}),
]);

this.call = call;
}

public async destroy() {
try {
this.call.hangup(CallErrorCode.UserHangup, true);
} catch { // ignore error since we are exiting }
this.bot.stopClient();
}
}

private initListener() {
this.bot.on('Room.timeline', async (event: MatrixEvent) => {
if (event.getType() === 'm.room.message') {
if (event.getContent().msgtype === MsgType.Text) {
console.log(event.getContent());
await this.bot.sendNotice(
event.getRoomId(),
`Hi! 👋
This is a little proof of concept to receive and answer real-world SIP calls from your matrix.org client.
Current limitations:
* one-to-one calls only (no group calls)
* you cannot place an SIP call from Matrix (SIP -> Matrix only)
* room must be unencrypted
`,
);
}
}});

this.bot.on('Call.incoming', (call: MatrixCall) => {
console.log(`Cannot handle outgoing calls yet (abort incomming matrix call in ${call.roomId})`);
call.reject();
this.bot.sendNotice(call.roomId, 'Outgoing calls from Matrix to SIP are not yet supported.');
});
}
}
61 changes: 61 additions & 0 deletions src/sip/sip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Web } from 'sip.js';
import webrtcMediaFactory from '../webrtc/mediaStreams';

interface ISipClient {
sipServer: string;
sipUri: string;
sipUser: string;
sipPassword: string;
mediaStreams: webrtcMediaFactory;
}

export default class SipClient {
public client: Web.SimpleUser;
private mediaStreams: webrtcMediaFactory;

constructor({
sipServer,
sipUri,
sipUser,
sipPassword,
mediaStreams,
}: ISipClient) {
this.mediaStreams = mediaStreams;

this.client = new Web.SimpleUser(sipServer, {
aor: sipUri,
media: {
constraints: {
audio: true,
video: false,
},
},
userAgentOptions: {
sessionDescriptionHandlerFactory: Web.defaultSessionDescriptionHandlerFactory(async () => {
return this.mediaStreams.invitingMediaStream;
}),
authorizationUsername: sipUser,
authorizationPassword: sipPassword,
},
});

this.init();
}

private async init() {
this.client.delegate = {

onCallAnswered: async () => {
try {
this.mediaStreams.setAudioTrack(this.client.remoteMediaStream, this.mediaStreams.answeringAudioSource);
} catch (e) {
console.error(e);
await this.client.disconnect();
}
},
};

await this.client.connect();
await this.client.register();
}
}
36 changes: 36 additions & 0 deletions src/webrtc/mediaStreams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// @ts-expect-error wrtc has no types
import wrtc, { MediaStream } from 'wrtc';

const { RTCAudioSource, RTCAudioSink, RTCAudioData } = wrtc.nonstandard;

export default class webrtcMediaFactory {
public invitingMediaStream: MediaStream;
public invitingAudioSource: typeof RTCAudioSource;
public answeringMediaStream: MediaStream;
public answeringAudioSource: typeof RTCAudioSource;

constructor() {
this.invitingMediaStream = new MediaStream();
this.answeringMediaStream = new MediaStream();

this.addTracks();
}

public setAudioTrack(stream: MediaStream, destinationAudioSource: typeof RTCAudioSource) {
const sink = new RTCAudioSink(stream.getAudioTracks()[0]);
sink.ondata = (data: typeof RTCAudioData) => {
destinationAudioSource.onData(data);
};
}

public addTracks() {
this.invitingAudioSource = new RTCAudioSource();
const inviteTrack: MediaStreamTrack = this.invitingAudioSource.createTrack();
this.invitingMediaStream.addTrack(inviteTrack);

this.answeringAudioSource = new RTCAudioSource();
const answerTrack: MediaStreamTrack = this.answeringAudioSource.createTrack();
this.answeringMediaStream.addTrack(answerTrack);
}

}
Loading

0 comments on commit 4850acd

Please sign in to comment.