agent-twitter-summary
Version:
A twitter client for agents
626 lines (581 loc) • 18.1 kB
text/typescript
// src/core/JanusClient.ts
import { EventEmitter } from 'events';
import wrtc from '@roamhq/wrtc';
const { RTCPeerConnection, MediaStream } = wrtc;
import { JanusAudioSink, JanusAudioSource } from './JanusAudio';
import type { AudioDataWithUser, TurnServersInfo } from '../types';
import { Logger } from '../logger';
interface JanusConfig {
webrtcUrl: string;
roomId: string;
credential: string;
userId: string;
streamName: string;
turnServers: TurnServersInfo;
logger: Logger;
}
/**
* This class is in charge of the Janus session, handle,
* joining the videoroom, and polling events.
*/
export class JanusClient extends EventEmitter {
private logger: Logger;
private sessionId?: number;
private handleId?: number;
private publisherId?: number;
private pc?: RTCPeerConnection;
private localAudioSource?: JanusAudioSource;
private pollActive = false;
private eventWaiters: Array<{
predicate: (evt: any) => boolean;
resolve: (value: any) => void;
reject: (error: Error) => void;
}> = [];
private subscribers = new Map<
string,
{
handleId: number;
pc: RTCPeerConnection;
}
>();
constructor(private readonly config: JanusConfig) {
super();
this.logger = config.logger;
}
async initialize() {
this.logger.debug('[JanusClient] initialize() called');
this.sessionId = await this.createSession();
this.handleId = await this.attachPlugin();
this.pollActive = true;
this.startPolling();
await this.createRoom();
this.publisherId = await this.joinRoom();
this.pc = new RTCPeerConnection({
iceServers: [
{
urls: this.config.turnServers.uris,
username: this.config.turnServers.username,
credential: this.config.turnServers.password,
},
],
});
this.setupPeerEvents();
this.enableLocalAudio();
await this.configurePublisher();
this.logger.info('[JanusClient] Initialization complete');
}
public async subscribeSpeaker(userId: string): Promise<void> {
this.logger.debug('[JanusClient] subscribeSpeaker => userId=', userId);
const subscriberHandleId = await this.attachPlugin();
this.logger.debug('[JanusClient] subscriber handle =>', subscriberHandleId);
const publishersEvt = await this.waitForJanusEvent(
(e) =>
e.janus === 'event' &&
e.plugindata?.plugin === 'janus.plugin.videoroom' &&
e.plugindata?.data?.videoroom === 'event' &&
Array.isArray(e.plugindata?.data?.publishers) &&
e.plugindata?.data?.publishers.length > 0,
8000,
'discover feed_id from "publishers"',
);
const list = publishersEvt.plugindata.data.publishers as any[];
const pub = list.find(
(p) => p.display === userId || p.periscope_user_id === userId,
);
if (!pub) {
throw new Error(
`[JanusClient] subscribeSpeaker => No publisher found for userId=${userId}`,
);
}
const feedId = pub.id;
this.logger.debug('[JanusClient] found feedId =>', feedId);
this.emit('subscribedSpeaker', { userId, feedId });
const joinBody = {
request: 'join',
room: this.config.roomId,
periscope_user_id: this.config.userId,
ptype: 'subscriber',
streams: [
{
feed: feedId,
mid: '0',
send: true,
},
],
};
await this.sendJanusMessage(subscriberHandleId, joinBody);
const attachedEvt = await this.waitForJanusEvent(
(e) =>
e.janus === 'event' &&
e.sender === subscriberHandleId &&
e.plugindata?.plugin === 'janus.plugin.videoroom' &&
e.plugindata?.data?.videoroom === 'attached' &&
e.jsep?.type === 'offer',
8000,
'subscriber attached + offer',
);
this.logger.debug('[JanusClient] subscriber => "attached" with offer');
const offer = attachedEvt.jsep;
const subPc = new RTCPeerConnection({
iceServers: [
{
urls: this.config.turnServers.uris,
username: this.config.turnServers.username,
credential: this.config.turnServers.password,
},
],
});
subPc.ontrack = (evt) => {
this.logger.debug(
'[JanusClient] subscriber track => kind=%s, readyState=%s, muted=%s',
evt.track.kind,
evt.track.readyState,
evt.track.muted,
);
const sink = new JanusAudioSink(evt.track, { logger: this.logger });
sink.on('audioData', (frame) => {
if (this.logger.isDebugEnabled()) {
let maxVal = 0;
for (let i = 0; i < frame.samples.length; i++) {
const val = Math.abs(frame.samples[i]);
if (val > maxVal) maxVal = val;
}
this.logger.debug(
`[AudioSink] userId=${userId}, maxAmplitude=${maxVal}`,
);
}
this.emit('audioDataFromSpeaker', {
userId,
bitsPerSample: frame.bitsPerSample,
sampleRate: frame.sampleRate,
numberOfFrames: frame.numberOfFrames,
channelCount: frame.channelCount,
samples: frame.samples,
} as AudioDataWithUser);
});
};
await subPc.setRemoteDescription(offer);
const answer = await subPc.createAnswer();
await subPc.setLocalDescription(answer);
await this.sendJanusMessage(
subscriberHandleId,
{
request: 'start',
room: this.config.roomId,
periscope_user_id: this.config.userId,
},
answer,
);
this.logger.debug('[JanusClient] subscriber => done (user=', userId, ')');
this.subscribers.set(userId, { handleId: subscriberHandleId, pc: subPc });
}
pushLocalAudio(samples: Int16Array, sampleRate: number, channels = 1) {
if (!this.localAudioSource) {
this.logger.warn('[JanusClient] No localAudioSource; enabling now...');
this.enableLocalAudio();
}
this.localAudioSource?.pushPcmData(samples, sampleRate, channels);
}
enableLocalAudio() {
if (!this.pc) {
this.logger.warn(
'[JanusClient] enableLocalAudio => No RTCPeerConnection',
);
return;
}
if (this.localAudioSource) {
this.logger.debug('[JanusClient] localAudioSource already active');
return;
}
this.localAudioSource = new JanusAudioSource({ logger: this.logger });
const track = this.localAudioSource.getTrack();
const localStream = new MediaStream();
localStream.addTrack(track);
this.pc.addTrack(track, localStream);
}
async stop() {
this.logger.info('[JanusClient] Stopping...');
this.pollActive = false;
if (this.pc) {
this.pc.close();
this.pc = undefined;
}
}
getSessionId() {
return this.sessionId;
}
getHandleId() {
return this.handleId;
}
getPublisherId() {
return this.publisherId;
}
private async createSession(): Promise<number> {
const transaction = this.randomTid();
const resp = await fetch(this.config.webrtcUrl, {
method: 'POST',
headers: {
Authorization: this.config.credential,
'Content-Type': 'application/json',
Referer: 'https://x.com',
},
body: JSON.stringify({
janus: 'create',
transaction,
}),
});
if (!resp.ok) {
throw new Error('[JanusClient] createSession failed');
}
const json = await resp.json();
if (json.janus !== 'success') {
throw new Error('[JanusClient] createSession invalid response');
}
return json.data.id;
}
private async attachPlugin(): Promise<number> {
if (!this.sessionId) {
throw new Error('[JanusClient] attachPlugin => no sessionId');
}
const transaction = this.randomTid();
const resp = await fetch(`${this.config.webrtcUrl}/${this.sessionId}`, {
method: 'POST',
headers: {
Authorization: this.config.credential,
'Content-Type': 'application/json',
},
body: JSON.stringify({
janus: 'attach',
plugin: 'janus.plugin.videoroom',
transaction,
}),
});
if (!resp.ok) {
throw new Error('[JanusClient] attachPlugin failed');
}
const json = await resp.json();
if (json.janus !== 'success') {
throw new Error('[JanusClient] attachPlugin invalid response');
}
return json.data.id;
}
private async createRoom() {
if (!this.sessionId || !this.handleId) {
throw new Error('[JanusClient] createRoom => No session/handle');
}
const transaction = this.randomTid();
const body = {
request: 'create',
room: this.config.roomId,
periscope_user_id: this.config.userId,
audiocodec: 'opus',
videocodec: 'h264',
transport_wide_cc_ext: true,
app_component: 'audio-room',
h264_profile: '42e01f',
dummy_publisher: false,
};
const resp = await fetch(
`${this.config.webrtcUrl}/${this.sessionId}/${this.handleId}`,
{
method: 'POST',
headers: {
Authorization: this.config.credential,
'Content-Type': 'application/json',
Referer: 'https://x.com',
},
body: JSON.stringify({
janus: 'message',
transaction,
body,
}),
},
);
if (!resp.ok) {
throw new Error(`[JanusClient] createRoom failed => ${resp.status}`);
}
const json = await resp.json();
this.logger.debug('[JanusClient] createRoom =>', JSON.stringify(json));
if (json.janus === 'error') {
throw new Error(
`[JanusClient] createRoom error => ${
json.error?.reason || 'Unknown error'
}`,
);
}
if (json.plugindata?.data?.videoroom !== 'created') {
throw new Error(
`[JanusClient] unexpected createRoom response => ${JSON.stringify(
json,
)}`,
);
}
this.logger.debug(
`[JanusClient] Room '${this.config.roomId}' created successfully`,
);
}
private async joinRoom(): Promise<number> {
if (!this.sessionId || !this.handleId) {
throw new Error('[JanusClient] no session/handle');
}
this.logger.debug('[JanusClient] joinRoom => start');
const evtPromise = this.waitForJanusEvent(
(e) =>
e.janus === 'event' &&
e.plugindata?.plugin === 'janus.plugin.videoroom' &&
e.plugindata?.data?.videoroom === 'joined',
12000,
'Host Joined Event',
);
const body = {
request: 'join',
room: this.config.roomId,
ptype: 'publisher',
display: this.config.userId,
periscope_user_id: this.config.userId,
};
await this.sendJanusMessage(this.handleId, body);
const evt = await evtPromise;
const publisherId = evt.plugindata.data.id;
this.logger.debug('[JanusClient] joined room => publisherId=', publisherId);
return publisherId;
}
private async configurePublisher() {
if (!this.pc || !this.sessionId || !this.handleId) {
return;
}
this.logger.debug('[JanusClient] createOffer...');
const offer = await this.pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: false,
});
await this.pc.setLocalDescription(offer);
this.logger.debug('[JanusClient] sending configure with JSEP...');
await this.sendJanusMessage(
this.handleId,
{
request: 'configure',
room: this.config.roomId,
periscope_user_id: this.config.userId,
session_uuid: '',
stream_name: this.config.streamName,
vidman_token: this.config.credential,
},
offer,
);
this.logger.debug('[JanusClient] waiting for answer...');
}
private async sendJanusMessage(
handleId: number,
body: any,
jsep?: any,
): Promise<void> {
if (!this.sessionId) {
throw new Error('[JanusClient] No session');
}
const transaction = this.randomTid();
const resp = await fetch(
`${this.config.webrtcUrl}/${this.sessionId}/${handleId}`,
{
method: 'POST',
headers: {
Authorization: this.config.credential,
'Content-Type': 'application/json',
},
body: JSON.stringify({
janus: 'message',
transaction,
body,
jsep,
}),
},
);
if (!resp.ok) {
throw new Error(
'[JanusClient] sendJanusMessage failed => ' + resp.status,
);
}
}
private startPolling() {
this.logger.debug('[JanusClient] Starting polling...');
const doPoll = async () => {
if (!this.pollActive || !this.sessionId) {
this.logger.debug('[JanusClient] Polling stopped');
return;
}
try {
const url = `${this.config.webrtcUrl}/${
this.sessionId
}?maxev=1&_=${Date.now()}`;
const resp = await fetch(url, {
headers: { Authorization: this.config.credential },
});
if (resp.ok) {
const event = await resp.json();
this.handleJanusEvent(event);
} else {
this.logger.warn('[JanusClient] poll error =>', resp.status);
}
} catch (err) {
this.logger.error('[JanusClient] poll exception =>', err);
}
setTimeout(doPoll, 500);
};
doPoll();
}
private handleJanusEvent(evt: any) {
if (!evt.janus) {
return;
}
if (evt.janus === 'keepalive') {
this.logger.debug('[JanusClient] keepalive received');
return;
}
if (evt.janus === 'webrtcup') {
this.logger.debug('[JanusClient] webrtcup =>', evt.sender);
}
if (evt.jsep && evt.jsep.type === 'answer') {
this.onReceivedAnswer(evt.jsep);
}
if (evt.plugindata?.data?.id) {
this.publisherId = evt.plugindata.data.id;
}
if (evt.error) {
this.logger.error('[JanusClient] Janus error =>', evt.error.reason);
this.emit('error', new Error(evt.error.reason));
}
for (let i = 0; i < this.eventWaiters.length; i++) {
const waiter = this.eventWaiters[i];
if (waiter.predicate(evt)) {
this.eventWaiters.splice(i, 1);
waiter.resolve(evt);
break;
}
}
}
private async onReceivedAnswer(answer: any) {
if (!this.pc) {
return;
}
this.logger.debug('[JanusClient] got answer => setRemoteDescription');
await this.pc.setRemoteDescription(answer);
}
private setupPeerEvents() {
if (!this.pc) {
return;
}
this.pc.addEventListener('iceconnectionstatechange', () => {
this.logger.debug(
'[JanusClient] ICE state =>',
this.pc?.iceConnectionState,
);
if (this.pc?.iceConnectionState === 'failed') {
this.emit('error', new Error('ICE connection failed'));
}
});
this.pc.addEventListener('track', (evt) => {
this.logger.debug('[JanusClient] track =>', evt.track.kind);
});
}
private randomTid() {
return Math.random().toString(36).slice(2, 10);
}
/**
* Allows code to wait for a specific Janus event that matches a predicate
*/
private async waitForJanusEvent(
predicate: (evt: any) => boolean,
timeoutMs = 5000,
description = 'some event',
): Promise<any> {
return new Promise((resolve, reject) => {
const waiter = { predicate, resolve, reject };
this.eventWaiters.push(waiter);
setTimeout(() => {
const idx = this.eventWaiters.indexOf(waiter);
if (idx !== -1) {
this.eventWaiters.splice(idx, 1);
this.logger.warn(
`[JanusClient] waitForJanusEvent => timed out waiting for: ${description}`,
);
reject(
new Error(
`[JanusClient] waitForJanusEvent (expecting "${description}") timed out after ${timeoutMs}ms`,
),
);
}
}, timeoutMs);
});
}
public async destroyRoom(): Promise<void> {
if (!this.sessionId || !this.handleId) {
this.logger.warn('[JanusClient] destroyRoom => no session/handle');
return;
}
if (!this.config.roomId || !this.config.userId) {
this.logger.warn('[JanusClient] destroyRoom => no roomId/userId');
return;
}
const transaction = this.randomTid();
const body = {
request: 'destroy',
room: this.config.roomId,
periscope_user_id: this.config.userId,
};
this.logger.info('[JanusClient] destroying room =>', body);
const resp = await fetch(
`${this.config.webrtcUrl}/${this.sessionId}/${this.handleId}`,
{
method: 'POST',
headers: {
Authorization: this.config.credential,
'Content-Type': 'application/json',
Referer: 'https://x.com',
},
body: JSON.stringify({
janus: 'message',
transaction,
body,
}),
},
);
if (!resp.ok) {
throw new Error(`[JanusClient] destroyRoom failed => ${resp.status}`);
}
const json = await resp.json();
this.logger.debug('[JanusClient] destroyRoom =>', JSON.stringify(json));
}
public async leaveRoom(): Promise<void> {
if (!this.sessionId || !this.handleId) {
this.logger.warn('[JanusClient] leaveRoom => no session/handle');
return;
}
const transaction = this.randomTid();
const body = {
request: 'leave',
room: this.config.roomId,
periscope_user_id: this.config.userId,
};
this.logger.info('[JanusClient] leaving room =>', body);
const resp = await fetch(
`${this.config.webrtcUrl}/${this.sessionId}/${this.handleId}`,
{
method: 'POST',
headers: {
Authorization: this.config.credential,
'Content-Type': 'application/json',
},
body: JSON.stringify({
janus: 'message',
transaction,
body,
}),
},
);
if (!resp.ok) {
throw new Error(`[JanusClient] leaveRoom => error code ${resp.status}`);
}
const json = await resp.json();
this.logger.debug('[JanusClient] leaveRoom =>', JSON.stringify(json));
}
}