mirror of
https://github.com/koush/scrypted.git
synced 2026-05-04 21:30:30 +01:00
Refactor and re-use sip-call and sip-session in btcino plugin (#474)
* Refactor and re-use sip-call and sip-session: * added sipDebug functionality * added register functionality * support for internal domains * support srtp * Refactor and re-use sip-call and sip-session: * added sipDebug functionality * added register functionality * support for internal domains * support srtp * * implemented SIP message handling * fix contactId
This commit is contained in:
4
plugins/bticino/package-lock.json
generated
4
plugins/bticino/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/bticino",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/bticino",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"dependencies": {
|
||||
"@homebridge/camera-utils": "^2.0.4",
|
||||
"rxjs": "^7.5.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/bticino",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
"prescrypted-setup-project": "scrypted-package-json",
|
||||
|
||||
@@ -1,37 +1,35 @@
|
||||
import { listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
|
||||
import { SipOptions } from './sip-call';
|
||||
import { SipMessageHandler, SipCall, SipOptions, SipRequest } from '../../sip/src/sip-call';
|
||||
import { RtspServer } from '@scrypted/common/src/rtsp-server';
|
||||
import { addTrackControls, parseSdp, replacePorts } from '@scrypted/common/src/sdp-utils';
|
||||
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
||||
import sdk, { BinarySensor, Camera, DeviceCreator, DeviceCreatorSettings, DeviceProvider, FFmpegInput, Intercom, MediaObject, MediaStreamUrl, PictureOptions, ResponseMediaStreamOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera } from '@scrypted/sdk';
|
||||
import dgram from 'dgram';
|
||||
import { SipSession } from './sip-session';
|
||||
import { SipSession } from '../../sip/src/sip-session';
|
||||
import { isStunMessage, getPayloadType, getSequenceNumber, isRtpMessagePayloadType } from '../../sip/src/rtp-utils';
|
||||
import { ChildProcess } from 'child_process';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const STREAM_TIMEOUT = 50000;
|
||||
const SIP_EXPIRATION_DEFAULT = 3600;
|
||||
const { deviceManager, mediaManager } = sdk;
|
||||
|
||||
class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCamera, Settings, BinarySensor {
|
||||
export class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCamera, Settings, BinarySensor {
|
||||
session: SipSession;
|
||||
audioOutForwarder: dgram.Socket;
|
||||
audioOutProcess: ChildProcess;
|
||||
doorbellAudioActive: boolean;
|
||||
audioInProcess: ChildProcess;
|
||||
currentMedia: FFmpegInput | MediaStreamUrl;
|
||||
currentMediaMimeType: string;
|
||||
audioSilenceProcess: ChildProcess;
|
||||
refreshTimeout: NodeJS.Timeout;
|
||||
pendingPicture: Promise<MediaObject>;
|
||||
messageHandler: SipMessageHandler;
|
||||
|
||||
constructor(nativeId: string, public provider: SipCamProvider) {
|
||||
super(nativeId);
|
||||
this.binaryState = false;
|
||||
this.doorbellAudioActive = false;
|
||||
this.audioSilenceProcess = null;
|
||||
}
|
||||
let logger = this.log;
|
||||
this.messageHandler = new class extends SipMessageHandler {
|
||||
handle( request: SipRequest ) {
|
||||
// TODO: implement netatmo.onPresence handling?
|
||||
// {"jsonrpc":"2.0","method":"netatmo.onPresence","params":[{"persons":[]}]}
|
||||
logger.d("remote message: " + request.content );
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
async takePicture(option?: PictureOptions): Promise<MediaObject> {
|
||||
throw new Error("The SIP doorbell camera does not provide snapshots. Install the Snapshot Plugin if snapshots are available via an URL.");
|
||||
@@ -100,8 +98,6 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
}
|
||||
|
||||
stopSession() {
|
||||
this.doorbellAudioActive = false;
|
||||
this.audioInProcess?.kill('SIGKILL');
|
||||
if (this.session) {
|
||||
this.log.d('ending sip session');
|
||||
this.session.stop();
|
||||
@@ -172,23 +168,39 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
expire: Number.parseInt( expiration ),
|
||||
localIp,
|
||||
localPort,
|
||||
tcp: false,
|
||||
udp: true,
|
||||
debug: sipdebug
|
||||
shouldRegister: true,
|
||||
debugSip: sipdebug,
|
||||
messageHandler: this.messageHandler
|
||||
};
|
||||
sip = await SipSession.createSipSession(console, "Bticino", sipOptions);
|
||||
|
||||
sip.onCallEnded.subscribe(cleanup);
|
||||
|
||||
// Call the C300X
|
||||
let remoteRtpDescription = await sip.start();
|
||||
if( sipOptions.debug )
|
||||
let remoteRtpDescription = await sip.call(
|
||||
( audio ) => {
|
||||
return [
|
||||
'a=DEVADDR:20', // Needed for bt_answering_machine (bticino specific)
|
||||
`m=audio ${audio.port} RTP/SAVP 97`,
|
||||
`a=rtpmap:97 speex/8000`,
|
||||
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X`,
|
||||
]
|
||||
}, ( video ) => {
|
||||
return [
|
||||
`m=video ${video.port} RTP/SAVP 97`,
|
||||
`a=rtpmap:97 H264/90000`,
|
||||
`a=fmtp:97 profile-level-id=42801F`,
|
||||
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X`,
|
||||
'a=recvonly'
|
||||
]
|
||||
} );
|
||||
if( sipOptions.debugSip )
|
||||
this.log.d('SIP: Received remote SDP:\n' + remoteRtpDescription.sdp)
|
||||
|
||||
let sdp: string = replacePorts( remoteRtpDescription.sdp, 0, 0 );
|
||||
sdp = addTrackControls(sdp);
|
||||
sdp = sdp.split('\n').filter(line => !line.includes('a=rtcp-mux')).join('\n');
|
||||
if( sipOptions.debug )
|
||||
if( sipOptions.debugSip )
|
||||
this.log.d('SIP: Updated SDP:\n' + sdp);
|
||||
|
||||
let vseq = 0;
|
||||
@@ -202,7 +214,7 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
const parsedSdp = parseSdp(rtsp.sdp);
|
||||
const videoTrack = parsedSdp.msections.find(msection => msection.type === 'video').control;
|
||||
const audioTrack = parsedSdp.msections.find(msection => msection.type === 'audio').control;
|
||||
if( sipOptions.debug ) {
|
||||
if( sipOptions.debugSip ) {
|
||||
rtsp.console = this.console;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
import { noop, Subject } from 'rxjs'
|
||||
import { randomInteger, randomString } from '../../sip/src/util'
|
||||
import { RtpDescription, RtpOptions, RtpStreamDescription } from '../../sip/src/rtp-utils'
|
||||
import { stringify } from 'sip/sip'
|
||||
import { decodeSrtpOptions } from '../../ring/src/srtp-utils'
|
||||
|
||||
const contactId = randomInteger();
|
||||
|
||||
const sip = require('sip'),
|
||||
sdp = require('sdp')
|
||||
|
||||
export interface SipOptions {
|
||||
to: string
|
||||
from: string
|
||||
domain: string
|
||||
expire: number
|
||||
localIp: string
|
||||
localPort: number
|
||||
udp: boolean
|
||||
tcp: boolean
|
||||
debug: boolean
|
||||
}
|
||||
|
||||
interface UriOptions {
|
||||
name?: string
|
||||
uri: string
|
||||
params?: {
|
||||
tag?: string
|
||||
expires?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface SipHeaders {
|
||||
[name: string]: string | any
|
||||
cseq: { seq: number; method: string }
|
||||
to: UriOptions
|
||||
from: UriOptions
|
||||
contact?: UriOptions[]
|
||||
via?: UriOptions[]
|
||||
}
|
||||
|
||||
export interface SipRequest {
|
||||
uri: UriOptions | string
|
||||
method: string
|
||||
headers: SipHeaders
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SipResponse {
|
||||
status: number
|
||||
reason: string
|
||||
headers: SipHeaders
|
||||
content: string
|
||||
}
|
||||
|
||||
interface SipStack {
|
||||
send: (
|
||||
request: SipRequest | SipResponse,
|
||||
handler?: (response: SipResponse) => void
|
||||
) => void
|
||||
destroy: () => void
|
||||
makeResponse: (
|
||||
response: SipRequest,
|
||||
status: number,
|
||||
method: string
|
||||
) => SipResponse
|
||||
}
|
||||
|
||||
function getRandomId() {
|
||||
return Math.floor(Math.random() * 1e6).toString()
|
||||
}
|
||||
|
||||
function getRtpDescription(
|
||||
console: any,
|
||||
sections: string[],
|
||||
mediaType: 'audio' | 'video'
|
||||
): RtpStreamDescription {
|
||||
try {
|
||||
const section = sections.find((s) => s.startsWith('m=' + mediaType));
|
||||
if( section === undefined ) {
|
||||
return {
|
||||
port: 0,
|
||||
rtcpPort: 0
|
||||
};
|
||||
}
|
||||
|
||||
const { port } = sdp.parseMLine(section),
|
||||
lines: string[] = sdp.splitLines(section),
|
||||
rtcpLine = lines.find((l: string) => l.startsWith('a=rtcp:')),
|
||||
cryptoLine = lines.find((l: string) => l.startsWith('a=crypto'))!,
|
||||
rtcpMuxLine = lines.find((l: string) => l.startsWith('a=rtcp-mux')),
|
||||
ssrcLine = lines.find((l: string) => l.startsWith('a=ssrc')),
|
||||
iceUFragLine = lines.find((l: string) => l.startsWith('a=ice-ufrag')),
|
||||
icePwdLine = lines.find((l: string) => l.startsWith('a=ice-pwd')),
|
||||
encodedCrypto = cryptoLine.match(/inline:(\S*)/)![1]
|
||||
|
||||
let rtcpPort: number;
|
||||
if (rtcpMuxLine) {
|
||||
rtcpPort = port; // rtcp-mux would cause rtcpLine to not be present
|
||||
}
|
||||
else {
|
||||
rtcpPort = (rtcpLine && Number(rtcpLine.match(/rtcp:(\S*)/)?.[1])) || port + 1; // if there is no explicit RTCP port, then use RTP port + 1
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
rtcpPort,
|
||||
ssrc: (ssrcLine && Number(ssrcLine.match(/ssrc:(\S*)/)?.[1])) || undefined,
|
||||
iceUFrag: (iceUFragLine && iceUFragLine.match(/ice-ufrag:(\S*)/)?.[1]) || undefined,
|
||||
icePwd: (icePwdLine && icePwdLine.match(/ice-pwd:(\S*)/)?.[1]) || undefined,
|
||||
...decodeSrtpOptions(encodedCrypto),
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SDP from remote end')
|
||||
console.error(sections.join('\r\n'))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function parseRtpDescription(console: any, inviteResponse: {
|
||||
content: string
|
||||
}): RtpDescription {
|
||||
const sections: string[] = sdp.splitSections(inviteResponse.content),
|
||||
lines: string[] = sdp.splitLines(sections[0]),
|
||||
cLine = lines.find((line: string) => line.startsWith('c='))!
|
||||
|
||||
return {
|
||||
sdp: inviteResponse.content,
|
||||
address: cLine.match(/c=IN IP4 (\S*)/)![1],
|
||||
audio: getRtpDescription(console, sections, 'audio'),
|
||||
video: getRtpDescription(console, sections, 'video')
|
||||
}
|
||||
}
|
||||
|
||||
export class SipCall {
|
||||
private seq = 20
|
||||
private fromParams = { tag: getRandomId() }
|
||||
private toParams: { tag?: string } = {}
|
||||
private callId = getRandomId()
|
||||
private sipStack: SipStack
|
||||
public readonly onEndedByRemote = new Subject()
|
||||
private destroyed = false
|
||||
private readonly console: any
|
||||
|
||||
public readonly sdp: string
|
||||
public readonly audioUfrag = randomString(16)
|
||||
public readonly videoUfrag = randomString(16)
|
||||
|
||||
constructor(
|
||||
console: any,
|
||||
private sipOptions: SipOptions,
|
||||
rtpOptions: RtpOptions,
|
||||
//tlsPort: number
|
||||
) {
|
||||
this.console = console;
|
||||
|
||||
const { audio, video } = rtpOptions,
|
||||
{ from } = this.sipOptions,
|
||||
host = this.sipOptions.localIp,
|
||||
port = this.sipOptions.localPort,
|
||||
ssrc = randomInteger();
|
||||
|
||||
this.sipStack = {
|
||||
makeResponse: sip.makeResponse,
|
||||
...sip.create({
|
||||
host,
|
||||
hostname: host,
|
||||
port: port,
|
||||
udp: this.sipOptions.udp,
|
||||
tcp: this.sipOptions.tcp,
|
||||
tls: false,
|
||||
logger: {
|
||||
recv: function(m, remote) {
|
||||
if( sipOptions.debug ) {
|
||||
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
|
||||
console.log(stringify( m ));
|
||||
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
|
||||
}
|
||||
},
|
||||
send: function(m, remote) {
|
||||
let toWithDomain: string = (sipOptions.to.split('@')[0] + '@' + sipOptions.domain).trim()
|
||||
let fromWithDomain: string = (sipOptions.from.split('@')[0] + '@' + sipOptions.domain).trim()
|
||||
if( m.method == 'REGISTER' || m.method == 'INVITE' ) {
|
||||
if( m.method == 'REGISTER' ) {
|
||||
m.uri = "sip:" + sipOptions.domain
|
||||
} else if( m.method == 'INVITE' ) {
|
||||
m.uri = toWithDomain
|
||||
} else {
|
||||
throw new Error("Error: Method construct for uri not implemented: " + m.method)
|
||||
}
|
||||
|
||||
m.headers.to.uri = toWithDomain
|
||||
m.headers.from.uri = fromWithDomain
|
||||
if( m.headers.contact[0].uri.split('@')[0].indexOf('-') < 0 ) {
|
||||
m.headers.contact[0].uri = m.headers.contact[0].uri.replace("@", "-" + contactId + "@");
|
||||
}
|
||||
}
|
||||
if( sipOptions.debug ) {
|
||||
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
|
||||
console.log(stringify( m ));
|
||||
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
|
||||
}
|
||||
},
|
||||
},
|
||||
// tls_port: tlsPort,
|
||||
// tls: {
|
||||
// rejectUnauthorized: false,
|
||||
// },
|
||||
ws: false
|
||||
},
|
||||
(request: SipRequest) => {
|
||||
if (request.method === 'BYE') {
|
||||
this.console.info('received BYE from remote end')
|
||||
this.sipStack.send(this.sipStack.makeResponse(request, 200, 'Ok'))
|
||||
|
||||
if (this.destroyed) {
|
||||
this.onEndedByRemote.next(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
this.sdp = ([
|
||||
'v=0',
|
||||
//`o=- 3747 461 IN IP4 ${host}`,
|
||||
`o=${from.split(':')[1].split('@')[0]} 3747 461 IN IP4 ${host}`,
|
||||
's=ScryptedSipPlugin',
|
||||
`c=IN IP4 ${host}`,
|
||||
't=0 0',
|
||||
'a=DEVADDR:20',
|
||||
`m=audio ${audio.port} RTP/SAVP 97`,
|
||||
`a=rtpmap:97 speex/8000`,
|
||||
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X`,
|
||||
`m=video ${video.port} RTP/SAVP 97`,
|
||||
`a=rtpmap:97 H264/90000`,
|
||||
`a=fmtp:97 profile-level-id=42801F`,
|
||||
`a=ssrc:${ssrc}`,
|
||||
`a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:/qE7OPGKp9hVGALG2KcvKWyFEZfSSvm7bYVDjT8X`,
|
||||
'a=recvonly'
|
||||
]
|
||||
.filter((l) => l)
|
||||
.join('\r\n')) + '\r\n';
|
||||
}
|
||||
|
||||
request({
|
||||
method,
|
||||
headers,
|
||||
content,
|
||||
seq,
|
||||
}: {
|
||||
method: string
|
||||
headers?: Partial<SipHeaders>
|
||||
content?: string
|
||||
seq?: number
|
||||
}) {
|
||||
if (this.destroyed) {
|
||||
return Promise.reject(
|
||||
new Error('SIP request made after call was destroyed')
|
||||
)
|
||||
}
|
||||
|
||||
return new Promise<SipResponse>((resolve, reject) => {
|
||||
seq = seq || this.seq++
|
||||
this.sipStack.send(
|
||||
{
|
||||
method,
|
||||
uri: this.sipOptions.to,
|
||||
headers: {
|
||||
to: {
|
||||
//name: '"Scrypted SIP Plugin Client"',
|
||||
uri: this.sipOptions.to,
|
||||
params: (method == 'REGISTER' || method == 'INVITE' ? null : this.toParams),
|
||||
},
|
||||
from: {
|
||||
uri: this.sipOptions.from,
|
||||
params: this.fromParams,
|
||||
},
|
||||
'max-forwards': 70,
|
||||
'call-id': this.callId,
|
||||
cseq: { seq, method },
|
||||
...headers,
|
||||
},
|
||||
content: content || '',
|
||||
},
|
||||
(response: SipResponse) => {
|
||||
if (response.headers.to.params && response.headers.to.params.tag) {
|
||||
this.toParams.tag = response.headers.to.params.tag
|
||||
}
|
||||
|
||||
if (response.status >= 300) {
|
||||
if (response.status !== 408 || method !== 'BYE') {
|
||||
this.console.error(
|
||||
`sip ${method} request failed with status ` + response.status
|
||||
)
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`sip ${method} request failed with status ` + response.status
|
||||
)
|
||||
)
|
||||
} else if (response.status < 200) {
|
||||
// call made progress, do nothing and wait for another response
|
||||
// console.log('call progress status ' + response.status)
|
||||
} else {
|
||||
if (method === 'INVITE') {
|
||||
// The ACK must be sent with every OK to keep the connection alive.
|
||||
this.acknowledge(seq!).catch((e) => {
|
||||
this.console.error('Failed to send SDP ACK')
|
||||
this.console.error(e)
|
||||
})
|
||||
}
|
||||
resolve(response)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async acknowledge(seq: number) {
|
||||
// Don't wait for ack, it won't ever come back.
|
||||
this.request({
|
||||
method: 'ACK',
|
||||
seq, // The ACK must have the original sequence number.
|
||||
}).catch(noop)
|
||||
}
|
||||
|
||||
sendDtmf(key: string) {
|
||||
return this.request({
|
||||
method: 'INFO',
|
||||
headers: {
|
||||
'Content-Type': 'application/dtmf-relay',
|
||||
},
|
||||
content: `Signal=${key}\r\nDuration=250`,
|
||||
})
|
||||
}
|
||||
|
||||
async invite() {
|
||||
const { from } = this.sipOptions,
|
||||
inviteResponse = await this.request({
|
||||
method: 'INVITE',
|
||||
headers: {
|
||||
supported: 'replaces, outbound',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'application/sdp',
|
||||
contact: [{ uri: from }],
|
||||
},
|
||||
content: this.sdp,
|
||||
})
|
||||
return parseRtpDescription(this.console, inviteResponse)
|
||||
}
|
||||
|
||||
async register() {
|
||||
const { from } = this.sipOptions,
|
||||
inviteResponse = await this.request({
|
||||
method: 'REGISTER',
|
||||
headers: {
|
||||
//supported: 'replaces, outbound',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'application/sdp',
|
||||
contact: [{ uri: from, params: { expires: this.sipOptions.expire } }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async sendBye() {
|
||||
this.console.log('Sending BYE...')
|
||||
return this.request({ method: 'BYE' }).catch(() => {
|
||||
// Don't care if we get an exception here.
|
||||
})
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.console.debug("detroying sip-call")
|
||||
this.destroyed = true
|
||||
this.sipStack.destroy()
|
||||
this.console.debug("detroying sip-call: done")
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { reservePorts } from '@homebridge/camera-utils';
|
||||
import { createBindUdp, createBindZero } from '@scrypted/common/src/listen-cluster';
|
||||
import dgram from 'dgram';
|
||||
import { ReplaySubject, timer } from 'rxjs';
|
||||
import { createStunResponder, RtpDescription, RtpOptions, sendStunBindingRequest } from '../../sip/src/rtp-utils';
|
||||
import { SipCall, SipOptions } from './sip-call';
|
||||
import { Subscribed } from '../../sip/src/subscribed';
|
||||
|
||||
export class SipSession extends Subscribed {
|
||||
private hasStarted = false
|
||||
private hasCallEnded = false
|
||||
private onCallEndedSubject = new ReplaySubject(1)
|
||||
private sipCall: SipCall
|
||||
onCallEnded = this.onCallEndedSubject.asObservable()
|
||||
|
||||
constructor(
|
||||
public readonly console: any,
|
||||
public readonly sipOptions: SipOptions,
|
||||
public readonly rtpOptions: RtpOptions,
|
||||
public readonly audioSplitter: dgram.Socket,
|
||||
public audioRtcpSplitter: dgram.Socket,
|
||||
public readonly videoSplitter: dgram.Socket,
|
||||
public videoRtcpSplitter: dgram.Socket,
|
||||
public readonly cameraName: string
|
||||
) {
|
||||
super()
|
||||
|
||||
this.sipCall = this.createSipCall(this.sipOptions)
|
||||
}
|
||||
|
||||
static async createSipSession(console: any, cameraName: string, sipOptions: SipOptions) {
|
||||
const audioSplitter = await createBindZero(),
|
||||
audioRtcpSplitter = await createBindUdp(audioSplitter.port + 1),
|
||||
videoSplitter = await createBindZero(),
|
||||
videoRtcpSplitter = await createBindUdp(videoSplitter.port + 1),
|
||||
rtpOptions = {
|
||||
audio: {
|
||||
port: audioSplitter.port,
|
||||
rtcpPort: audioRtcpSplitter.port
|
||||
},
|
||||
video: {
|
||||
port: videoSplitter.port,
|
||||
rtcpPort: videoRtcpSplitter.port
|
||||
}
|
||||
}
|
||||
|
||||
return new SipSession(
|
||||
console,
|
||||
sipOptions,
|
||||
rtpOptions,
|
||||
audioSplitter.server,
|
||||
audioRtcpSplitter.server,
|
||||
videoSplitter.server,
|
||||
videoRtcpSplitter.server,
|
||||
cameraName
|
||||
)
|
||||
}
|
||||
|
||||
createSipCall(sipOptions: SipOptions) {
|
||||
if (this.sipCall) {
|
||||
this.sipCall.destroy()
|
||||
}
|
||||
|
||||
const call = (this.sipCall = new SipCall(
|
||||
this.console,
|
||||
sipOptions,
|
||||
this.rtpOptions
|
||||
))
|
||||
|
||||
this.addSubscriptions(
|
||||
call.onEndedByRemote.subscribe(() => this.callEnded(false))
|
||||
)
|
||||
|
||||
return this.sipCall
|
||||
}
|
||||
|
||||
async start(): Promise<RtpDescription> {
|
||||
if (this.hasStarted) {
|
||||
throw new Error('SIP Session has already been started')
|
||||
}
|
||||
this.hasStarted = true
|
||||
|
||||
if (this.hasCallEnded) {
|
||||
throw new Error('SIP Session has already ended')
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sipCall.register();
|
||||
const rtpDescription = await this.sipCall.invite(),
|
||||
sendStunRequests = () => {
|
||||
sendStunBindingRequest({
|
||||
rtpSplitter: this.audioSplitter,
|
||||
rtcpSplitter: this.audioRtcpSplitter,
|
||||
rtpDescription,
|
||||
localUfrag: this.sipCall.audioUfrag,
|
||||
type: 'audio',
|
||||
})
|
||||
sendStunBindingRequest({
|
||||
rtpSplitter: this.videoSplitter,
|
||||
rtcpSplitter: this.videoRtcpSplitter,
|
||||
rtpDescription,
|
||||
localUfrag: this.sipCall.videoUfrag,
|
||||
type: 'video',
|
||||
})
|
||||
}
|
||||
|
||||
// if rtcp-mux is supported, rtp splitter will be used for both rtp and rtcp
|
||||
if (rtpDescription.audio.port === rtpDescription.audio.rtcpPort) {
|
||||
this.audioRtcpSplitter.close()
|
||||
this.audioRtcpSplitter = this.audioSplitter
|
||||
}
|
||||
if (rtpDescription.video.port === rtpDescription.video.rtcpPort) {
|
||||
this.videoRtcpSplitter.close()
|
||||
this.videoRtcpSplitter = this.videoSplitter
|
||||
}
|
||||
|
||||
if (rtpDescription.video.iceUFrag) {
|
||||
// ICE is supported
|
||||
this.console.debug(`Connecting to ${this.cameraName} using ICE`)
|
||||
createStunResponder(this.audioSplitter)
|
||||
createStunResponder(this.videoSplitter)
|
||||
|
||||
sendStunRequests()
|
||||
} else {
|
||||
// ICE is not supported, use stun as keep alive
|
||||
this.console.debug(`Connecting to ${this.cameraName} using STUN`)
|
||||
this.addSubscriptions(
|
||||
// hole punch every .5 seconds to keep stream alive and port open (matches behavior from Ring app)
|
||||
timer(0, 500).subscribe(sendStunRequests)
|
||||
)
|
||||
}
|
||||
|
||||
this.audioSplitter.once('message', () => {
|
||||
this.console.debug(`Audio stream latched for ${this.cameraName} on port: ${this.rtpOptions.audio.port}`)
|
||||
})
|
||||
this.videoSplitter.once('message', () => {
|
||||
this.console.debug(`Video stream latched for ${this.cameraName} on port: ${this.rtpOptions.video.port}`)
|
||||
})
|
||||
|
||||
return rtpDescription
|
||||
} catch (e) {
|
||||
|
||||
this.callEnded(true)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
static async reserveRtpRtcpPorts() {
|
||||
const ports = await reservePorts({ count: 4, type: 'udp' })
|
||||
return ports
|
||||
}
|
||||
|
||||
private callEnded(sendBye: boolean) {
|
||||
if (this.hasCallEnded) {
|
||||
return
|
||||
}
|
||||
this.hasCallEnded = true
|
||||
|
||||
if (sendBye) {
|
||||
this.sipCall.sendBye()
|
||||
.then(() => {
|
||||
// clean up
|
||||
this.console.log("sip-session callEnded")
|
||||
this.onCallEndedSubject.next(null)
|
||||
this.sipCall.destroy()
|
||||
this.videoSplitter.close()
|
||||
this.audioSplitter.close()
|
||||
this.audioRtcpSplitter.close()
|
||||
this.videoRtcpSplitter.close()
|
||||
this.unsubscribe()
|
||||
this.console.log("sip-session callEnded: done")
|
||||
})
|
||||
.catch()
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.callEnded(true)
|
||||
}
|
||||
}
|
||||
4
plugins/sip/package-lock.json
generated
4
plugins/sip/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/sip",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/sip",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"dependencies": {
|
||||
"@homebridge/camera-utils": "^2.0.4",
|
||||
"rxjs": "^7.5.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/sip",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
"prescrypted-setup-project": "scrypted-package-json",
|
||||
|
||||
@@ -195,7 +195,15 @@ class SipCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCam
|
||||
|
||||
sip = await SipSession.createSipSession(this.console, this.name, sipOptions);
|
||||
sip.onCallEnded.subscribe(cleanup);
|
||||
this.remoteRtpDescription = await sip.start();
|
||||
this.remoteRtpDescription = await sip.call(
|
||||
( audio ) => {
|
||||
return [
|
||||
`m=audio ${audio.port} RTP/AVP 0`,
|
||||
'a=rtpmap:0 PCMU/8000',
|
||||
'a=sendrecv'
|
||||
]
|
||||
}
|
||||
);
|
||||
this.console.log('SIP: Received remote SDP:\n', this.remoteRtpDescription.sdp)
|
||||
|
||||
let [rtpPort, rtcpPort] = await SipSession.reserveRtpRtcpPorts()
|
||||
@@ -429,6 +437,9 @@ export class SipCamProvider extends ScryptedDeviceBase implements DeviceProvider
|
||||
}
|
||||
}
|
||||
|
||||
async releaseDevice(id: string, nativeId: string, device: any): Promise<void> {
|
||||
}
|
||||
|
||||
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
|
||||
const nativeId = randomBytes(4).toString('hex');
|
||||
const name = settings.newCamera.toString();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { noop, Subject } from 'rxjs'
|
||||
import { randomInteger, randomString } from './util'
|
||||
import { RtpDescription, RtpOptions, RtpStreamDescription } from './rtp-utils'
|
||||
import { decodeSrtpOptions } from '@homebridge/camera-utils'
|
||||
import { stringify } from 'sip/sip'
|
||||
|
||||
const sip = require('sip'),
|
||||
sdp = require('sdp')
|
||||
@@ -8,16 +10,29 @@ const sip = require('sip'),
|
||||
export interface SipOptions {
|
||||
to: string
|
||||
from: string
|
||||
domain?: string
|
||||
expire?: number
|
||||
localIp: string
|
||||
localPort: number
|
||||
udp: boolean
|
||||
tcp: boolean
|
||||
debugSip?: boolean
|
||||
messageHandler?: SipMessageHandler
|
||||
shouldRegister?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows handling of SIP messages
|
||||
*/
|
||||
export abstract class SipMessageHandler {
|
||||
abstract handle( request: SipRequest )
|
||||
}
|
||||
|
||||
interface UriOptions {
|
||||
name?: string
|
||||
uri: string
|
||||
params?: { tag?: string }
|
||||
params?: {
|
||||
tag?: string
|
||||
expires?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface SipHeaders {
|
||||
@@ -77,10 +92,12 @@ function getRtpDescription(
|
||||
const { port } = sdp.parseMLine(section),
|
||||
lines: string[] = sdp.splitLines(section),
|
||||
rtcpLine = lines.find((l: string) => l.startsWith('a=rtcp:')),
|
||||
cryptoLine = lines.find((l: string) => l.startsWith('a=crypto'))!,
|
||||
rtcpMuxLine = lines.find((l: string) => l.startsWith('a=rtcp-mux')),
|
||||
ssrcLine = lines.find((l: string) => l.startsWith('a=ssrc')),
|
||||
iceUFragLine = lines.find((l: string) => l.startsWith('a=ice-ufrag')),
|
||||
icePwdLine = lines.find((l: string) => l.startsWith('a=ice-pwd'))
|
||||
icePwdLine = lines.find((l: string) => l.startsWith('a=ice-pwd')),
|
||||
encodedCrypto = cryptoLine?.match(/inline:(\S*)/)![1] || undefined
|
||||
|
||||
let rtcpPort: number;
|
||||
if (rtcpMuxLine) {
|
||||
@@ -96,6 +113,7 @@ function getRtpDescription(
|
||||
ssrc: (ssrcLine && Number(ssrcLine.match(/ssrc:(\S*)/)?.[1])) || undefined,
|
||||
iceUFrag: (iceUFragLine && iceUFragLine.match(/ice-ufrag:(\S*)/)?.[1]) || undefined,
|
||||
icePwd: (icePwdLine && icePwdLine.match(/ice-pwd:(\S*)/)?.[1]) || undefined,
|
||||
...(encodedCrypto? decodeSrtpOptions(encodedCrypto) : {})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SDP from remote end')
|
||||
@@ -127,25 +145,22 @@ export class SipCall {
|
||||
private sipStack: SipStack
|
||||
public readonly onEndedByRemote = new Subject()
|
||||
private destroyed = false
|
||||
private readonly console: any
|
||||
private readonly console: Console
|
||||
|
||||
public readonly sdp: string
|
||||
public readonly audioUfrag = randomString(16)
|
||||
public readonly videoUfrag = randomString(16)
|
||||
|
||||
constructor(
|
||||
console: any,
|
||||
console: Console,
|
||||
private sipOptions: SipOptions,
|
||||
rtpOptions: RtpOptions,
|
||||
private rtpOptions: RtpOptions,
|
||||
//tlsPort: number
|
||||
) {
|
||||
this.console = console;
|
||||
|
||||
const { audio, video } = rtpOptions,
|
||||
{ from } = this.sipOptions,
|
||||
host = this.sipOptions.localIp,
|
||||
port = this.sipOptions.localPort,
|
||||
ssrc = randomInteger();
|
||||
const host = this.sipOptions.localIp,
|
||||
port = this.sipOptions.localPort,
|
||||
contactId = randomInteger()
|
||||
|
||||
this.sipStack = {
|
||||
makeResponse: sip.makeResponse,
|
||||
@@ -153,14 +168,57 @@ export class SipCall {
|
||||
host,
|
||||
hostname: host,
|
||||
port: port,
|
||||
udp: this.sipOptions.udp,
|
||||
tcp: this.sipOptions.tcp,
|
||||
udp: true,
|
||||
tcp: false,
|
||||
tls: false,
|
||||
// tls_port: tlsPort,
|
||||
// tls: {
|
||||
// rejectUnauthorized: false,
|
||||
// },
|
||||
ws: false
|
||||
ws: false,
|
||||
logger: {
|
||||
recv: function(m, remote) {
|
||||
if( sipOptions.debugSip ) {
|
||||
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
|
||||
console.log(stringify( m ));
|
||||
console.log("<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<")
|
||||
}
|
||||
},
|
||||
send: function(m, remote) {
|
||||
/*
|
||||
Some door bells run an embedded SIP server with an unresolvable public domain
|
||||
Due to bugs in the DNS resolution in sip/sip we abuse the 'send' logger to modify some headers
|
||||
just before they get sent to the SIP server.
|
||||
*/
|
||||
if( sipOptions.domain && sipOptions.domain.length > 0 ) {
|
||||
// Bticino CX300 specific: runs on an internet 2048362.bs.iotleg.com domain
|
||||
// While underlying UDP socket is bound to the IP, the header is rewritten to match the domain
|
||||
let toWithDomain: string = (sipOptions.to.split('@')[0] + '@' + sipOptions.domain).trim()
|
||||
let fromWithDomain: string = (sipOptions.from.split('@')[0] + '@' + sipOptions.domain).trim()
|
||||
if( m.method == 'REGISTER' || m.method == 'INVITE' ) {
|
||||
if( m.method == 'REGISTER' ) {
|
||||
m.uri = "sip:" + sipOptions.domain
|
||||
} else if( m.method == 'INVITE' ) {
|
||||
m.uri = toWithDomain
|
||||
} else {
|
||||
throw new Error("Error: Method construct for uri not implemented: " + m.method)
|
||||
}
|
||||
|
||||
m.headers.to.uri = toWithDomain
|
||||
m.headers.from.uri = fromWithDomain
|
||||
if( m.headers.contact[0].uri.split('@')[0].indexOf('-') < 0 ) {
|
||||
m.headers.contact[0].uri = m.headers.contact[0].uri.replace("@", "-" + contactId + "@");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
if( sipOptions.debugSip ) {
|
||||
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
|
||||
console.log(stringify( m ));
|
||||
console.log(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
(request: SipRequest) => {
|
||||
if (request.method === 'BYE') {
|
||||
@@ -170,44 +228,17 @@ export class SipCall {
|
||||
if (this.destroyed) {
|
||||
this.onEndedByRemote.next(null)
|
||||
}
|
||||
} else if( request.method === 'MESSAGE' && sipOptions.messageHandler ) {
|
||||
sipOptions.messageHandler.handle( request )
|
||||
this.sipStack.send(this.sipStack.makeResponse(request, 200, 'Ok'))
|
||||
} else {
|
||||
if( sipOptions.debugSip ) {
|
||||
this.console.warn("unimplemented method received from remote: " + request.method)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
this.sdp = ([
|
||||
'v=0',
|
||||
`o=${from.split(':')[1].split('@')[0]} 3747 461 IN IP4 ${host}`,
|
||||
's=ScryptedSipPlugin',
|
||||
`c=IN IP4 ${host}`,
|
||||
't=0 0',
|
||||
`m=audio ${audio.port} RTP/AVP 0`,
|
||||
'a=rtpmap:0 PCMU/8000',
|
||||
`a=rtcp:${audio.rtcpPort}`,
|
||||
`a=ssrc:${ssrc}`,
|
||||
'a=sendrecv'
|
||||
]
|
||||
.filter((l) => l)
|
||||
.join('\r\n')) + '\r\n';
|
||||
|
||||
/* Example SDP for audio and video
|
||||
this.sdp = ([
|
||||
'v=0',
|
||||
`o=${from.split(':')[1].split('@')[0]} 3747 461 IN IP4 ${host}`,
|
||||
's=ScryptedSipPlugin',
|
||||
`c=IN IP4 ${host}`,
|
||||
't=0 0',
|
||||
`m=audio ${audio.port} RTP/AVP 97`,
|
||||
`a=rtpmap:97 speex/8000`,
|
||||
`m=video ${video.port} RTP/AVP 97`,
|
||||
`a=rtpmap:97 H264/90000`,
|
||||
`a=fmtp:97 profile-level-id=42801F`,
|
||||
`a=ssrc:${ssrc}`,
|
||||
'a=recvonly'
|
||||
]
|
||||
.filter((l) => l)
|
||||
.join('\r\n')) + '\r\n';
|
||||
*/
|
||||
}
|
||||
|
||||
request({
|
||||
@@ -235,9 +266,9 @@ export class SipCall {
|
||||
uri: this.sipOptions.to,
|
||||
headers: {
|
||||
to: {
|
||||
name: '"Scrypted SIP Plugin Client"',
|
||||
//name: '"Scrypted SIP Plugin Client"',
|
||||
uri: this.sipOptions.to,
|
||||
params: this.toParams,
|
||||
params: (method == 'REGISTER' || method == 'INVITE' ? null : this.toParams),
|
||||
},
|
||||
from: {
|
||||
uri: this.sipOptions.from,
|
||||
@@ -302,9 +333,14 @@ export class SipCall {
|
||||
})
|
||||
}
|
||||
|
||||
async invite() {
|
||||
|
||||
const { from } = this.sipOptions,
|
||||
/**
|
||||
* Initiate a call by sending a SIP INVITE request
|
||||
*/
|
||||
async invite( audioSection, videoSection? ) {
|
||||
let ssrc = randomInteger()
|
||||
let audio = audioSection ? audioSection( this.rtpOptions.audio, ssrc ).concat( ...[`a=ssrc:${ssrc}`, `a=rtcp:${this.rtpOptions.audio.rtcpPort}`] ) : []
|
||||
let video = videoSection ? videoSection( this.rtpOptions.video, ssrc ).concat( ...[`a=ssrc:${ssrc}`, `a=rtcp:${this.rtpOptions.video.rtcpPort}`] ) : []
|
||||
const { from, localIp } = this.sipOptions,
|
||||
inviteResponse = await this.request({
|
||||
method: 'INVITE',
|
||||
headers: {
|
||||
@@ -314,12 +350,57 @@ export class SipCall {
|
||||
'content-type': 'application/sdp',
|
||||
contact: [{ uri: from }],
|
||||
},
|
||||
content: this.sdp,
|
||||
content: ([
|
||||
'v=0',
|
||||
`o=${from.split(':')[1].split('@')[0]} 3747 461 IN IP4 ${localIp}`,
|
||||
's=ScryptedSipPlugin',
|
||||
`c=IN IP4 ${this.sipOptions.localIp}`,
|
||||
't=0 0',
|
||||
...audio,
|
||||
...video
|
||||
]
|
||||
.filter((l) => l)
|
||||
.join('\r\n')) + '\r\n'
|
||||
})
|
||||
|
||||
return parseRtpDescription(this.console, inviteResponse)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the user agent with a Registrar
|
||||
*/
|
||||
async register() {
|
||||
const { from } = this.sipOptions,
|
||||
inviteResponse = await this.request({
|
||||
method: 'REGISTER',
|
||||
headers: {
|
||||
//supported: 'replaces, outbound',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'application/sdp',
|
||||
contact: [{ uri: from, params: { expires: this.sipOptions.expire } }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the current call contact
|
||||
*/
|
||||
async message( content: string ) {
|
||||
const { from } = this.sipOptions,
|
||||
inviteResponse = await this.request({
|
||||
method: 'MESSAGE',
|
||||
headers: {
|
||||
//supported: 'replaces, outbound',
|
||||
allow:
|
||||
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
|
||||
'content-type': 'application/sdp',
|
||||
contact: [{ uri: from, params: { expires: this.sipOptions.expire } }],
|
||||
},
|
||||
content: content
|
||||
});
|
||||
}
|
||||
|
||||
async sendBye() {
|
||||
this.console.log('Sending BYE...')
|
||||
return this.request({ method: 'BYE' }).catch(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export class SipSession extends Subscribed {
|
||||
onCallEnded = this.onCallEndedSubject.asObservable()
|
||||
|
||||
constructor(
|
||||
public readonly console: any,
|
||||
public readonly console: Console,
|
||||
public readonly sipOptions: SipOptions,
|
||||
public readonly rtpOptions: RtpOptions,
|
||||
public readonly audioSplitter: dgram.Socket,
|
||||
@@ -33,7 +33,7 @@ export class SipSession extends Subscribed {
|
||||
audioRtcpSplitter = await createBindUdp(audioSplitter.port + 1),
|
||||
videoSplitter = await createBindZero(),
|
||||
videoRtcpSplitter = await createBindUdp(videoSplitter.port + 1),
|
||||
rtpOptions = {
|
||||
rtpOptions : RtpOptions = {
|
||||
audio: {
|
||||
port: audioSplitter.port,
|
||||
rtcpPort: audioRtcpSplitter.port
|
||||
@@ -74,7 +74,7 @@ export class SipSession extends Subscribed {
|
||||
return this.sipCall
|
||||
}
|
||||
|
||||
async start(): Promise<RtpDescription> {
|
||||
async call( audioSection, videoSection? ): Promise<RtpDescription> {
|
||||
this.console.log(`SipSession::start()`);
|
||||
|
||||
if (this.hasStarted) {
|
||||
@@ -86,8 +86,11 @@ export class SipSession extends Subscribed {
|
||||
throw new Error('SIP Session has already ended')
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const rtpDescription = await this.sipCall.invite(),
|
||||
if( this.sipOptions.shouldRegister )
|
||||
await this.sipCall.register()
|
||||
const rtpDescription = await this.sipCall.invite( audioSection, videoSection ),
|
||||
sendStunRequests = () => {
|
||||
sendStunBindingRequest({
|
||||
rtpSplitter: this.audioSplitter,
|
||||
|
||||
Reference in New Issue
Block a user