-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 4850acd
Showing
13 changed files
with
3,184 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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* |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | | ||
|:--| |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.'); | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
|
||
} |
Oops, something went wrong.