agent-twitter-summary
Version:
A twitter client for agents
494 lines (421 loc) • 13.6 kB
text/typescript
// src/core/Space.ts
import { EventEmitter } from 'events';
import { ChatClient } from './ChatClient';
import { JanusClient } from './JanusClient';
import {
getTurnServers,
createBroadcast,
publishBroadcast,
authorizeToken,
getRegion,
} from '../utils';
import type {
BroadcastCreated,
SpeakerRequest,
OccupancyUpdate,
GuestReaction,
Plugin,
AudioDataWithUser,
PluginRegistration,
SpeakerInfo,
} from '../types';
import { Scraper } from '../../scraper';
import { Logger } from '../logger';
export interface SpaceConfig {
mode: 'BROADCAST' | 'LISTEN' | 'INTERACTIVE';
title?: string;
description?: string;
languages?: string[];
debug?: boolean;
}
/**
* This class orchestrates:
* 1) Creation of the broadcast
* 2) Instantiation of Janus + Chat
* 3) Approve speakers, push audio, etc.
*/
export class Space extends EventEmitter {
private readonly debug: boolean;
private readonly logger: Logger;
private janusClient?: JanusClient;
private chatClient?: ChatClient;
private authToken?: string;
private broadcastInfo?: BroadcastCreated;
private isInitialized = false;
private plugins = new Set<PluginRegistration>();
private speakers = new Map<string, SpeakerInfo>();
constructor(
private readonly scraper: Scraper,
options?: { debug?: boolean },
) {
super();
this.debug = options?.debug ?? false;
this.logger = new Logger(this.debug);
}
public use(plugin: Plugin, config?: Record<string, any>) {
const registration: PluginRegistration = { plugin, config };
this.plugins.add(registration);
this.logger.debug('[Space] Plugin added =>', plugin.constructor.name);
plugin.onAttach?.(this);
if (this.isInitialized && plugin.init) {
plugin.init({
space: this,
pluginConfig: config,
});
}
return this;
}
/**
* Main entry point
*/
async initialize(config: SpaceConfig) {
this.logger.debug('[Space] Initializing...');
const cookie = await this.scraper.getPeriscopeCookie();
const region = await getRegion();
this.logger.debug('[Space] Got region =>', region);
this.logger.debug('[Space] Creating broadcast...');
const broadcast = await createBroadcast({
description: config.description,
languages: config.languages,
cookie,
region,
});
this.broadcastInfo = broadcast;
this.logger.debug('[Space] Authorizing token...');
this.authToken = await authorizeToken(cookie);
this.logger.debug('[Space] Getting turn servers...');
const turnServers = await getTurnServers(cookie);
this.janusClient = new JanusClient({
webrtcUrl: broadcast.webrtc_gw_url,
roomId: broadcast.room_id,
credential: broadcast.credential,
userId: broadcast.broadcast.user_id,
streamName: broadcast.stream_name,
turnServers,
logger: this.logger,
});
await this.janusClient.initialize();
this.janusClient.on('audioDataFromSpeaker', (data: AudioDataWithUser) => {
this.logger.debug('[Space] Received PCM from speaker =>', data.userId);
this.handleAudioData(data);
// You can store or forward to a plugin, run STT, etc.
});
this.janusClient.on('subscribedSpeaker', ({ userId, feedId }) => {
const speaker = this.speakers.get(userId);
if (!speaker) {
this.logger.debug(
'[Space] subscribedSpeaker => no speaker found',
userId,
);
return;
}
speaker.janusParticipantId = feedId;
this.logger.debug(
`[Space] updated speaker info => userId=${userId}, feedId=${feedId}`,
);
});
// 7) Publish the broadcast
this.logger.debug('[Space] Publishing broadcast...');
await publishBroadcast({
title: config.title || '',
broadcast,
cookie,
janusSessionId: this.janusClient.getSessionId(),
janusHandleId: this.janusClient.getHandleId(),
janusPublisherId: this.janusClient.getPublisherId(),
});
// 8) If interactive, open chat
if (config.mode === 'INTERACTIVE') {
this.logger.debug('[Space] Connecting chat...');
this.chatClient = new ChatClient({
spaceId: broadcast.room_id,
accessToken: broadcast.access_token,
endpoint: broadcast.endpoint,
logger: this.logger,
});
await this.chatClient.connect();
this.setupChatEvents();
}
this.logger.info('[Space] Initialized =>', broadcast.share_url);
this.isInitialized = true;
for (const { plugin, config: pluginConfig } of this.plugins) {
if (plugin.init) {
plugin.init({
space: this,
pluginConfig,
});
}
}
this.logger.debug('[Space] All plugins initialized');
return broadcast;
}
reactWithEmoji(emoji: string) {
if (!this.chatClient) return;
this.chatClient.reactWithEmoji(emoji);
}
private setupChatEvents() {
if (!this.chatClient) return;
this.chatClient.on('speakerRequest', (req: SpeakerRequest) => {
this.logger.info('[Space] Speaker request =>', req);
this.emit('speakerRequest', req);
});
this.chatClient.on('occupancyUpdate', (update: OccupancyUpdate) => {
this.logger.debug('[Space] occupancyUpdate =>', update);
this.emit('occupancyUpdate', update);
});
this.chatClient.on('muteStateChanged', (evt) => {
this.logger.debug('[Space] muteStateChanged =>', evt);
this.emit('muteStateChanged', evt);
});
this.chatClient.on('guestReaction', (reaction: GuestReaction) => {
this.logger.info('[Space] Guest reaction =>', reaction);
this.emit('guestReaction', reaction);
});
}
/**
* Approves a speaker on Periscope side, then subscribes on Janus side
*/
async approveSpeaker(userId: string, sessionUUID: string) {
if (!this.isInitialized || !this.broadcastInfo) {
throw new Error('[Space] Not initialized or no broadcastInfo');
}
if (!this.authToken) {
throw new Error('[Space] No auth token available');
}
this.speakers.set(userId, { userId, sessionUUID });
// 1) Call the "request/approve" endpoint
await this.callApproveEndpoint(
this.broadcastInfo,
this.authToken,
userId,
sessionUUID,
);
// 2) Subscribe in Janus => receive speaker's audio
await this.janusClient?.subscribeSpeaker(userId);
}
private async callApproveEndpoint(
broadcast: BroadcastCreated,
authorizationToken: string,
userId: string,
sessionUUID: string,
): Promise<void> {
const endpoint = 'https://guest.pscp.tv/api/v1/audiospace/request/approve';
const headers = {
'Content-Type': 'application/json',
Referer: 'https://x.com/',
Authorization: authorizationToken,
};
const body = {
ntpForBroadcasterFrame: '2208988800024000300',
ntpForLiveFrame: '2208988800024000300',
chat_token: broadcast.access_token,
session_uuid: sessionUUID,
};
this.logger.debug('[Space] Approving speaker =>', endpoint, body);
const resp = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!resp.ok) {
const error = await resp.text();
throw new Error(
`[Space] Failed to approve speaker => ${resp.status}: ${error}`,
);
}
this.logger.info('[Space] Speaker approved =>', userId);
}
/**
* Removes a speaker (userId) on the Twitter side (audiospace/stream/eject)
* then unsubscribes in Janus if needed.
*/
public async removeSpeaker(userId: string) {
if (!this.isInitialized || !this.broadcastInfo) {
throw new Error('[Space] Not initialized or no broadcastInfo');
}
if (!this.authToken) {
throw new Error('[Space] No auth token available');
}
if (!this.janusClient) {
throw new Error('[Space] No Janus client initialized');
}
const speaker = this.speakers.get(userId);
if (!speaker) {
throw new Error(
`[Space] removeSpeaker => no speaker found for userId=${userId}`,
);
}
const { sessionUUID, janusParticipantId } = speaker;
this.logger.debug(
'[Space] removeSpeaker =>',
sessionUUID,
janusParticipantId,
speaker,
);
if (!sessionUUID || janusParticipantId === undefined) {
throw new Error(
`[Space] removeSpeaker => missing sessionUUID or feedId for userId=${userId}`,
);
}
const janusHandleId = this.janusClient.getHandleId();
const janusSessionId = this.janusClient.getSessionId();
if (!janusHandleId || !janusSessionId) {
throw new Error(
`[Space] removeSpeaker => missing Janus handle/session for userId=${userId}`,
);
}
// 1) Call the eject endpoint
await this.callRemoveEndpoint(
this.broadcastInfo,
this.authToken,
sessionUUID,
janusParticipantId,
this.broadcastInfo.room_id,
janusHandleId,
janusSessionId,
);
// 2) Remove from local speakers map
this.speakers.delete(userId);
this.logger.info(`[Space] removeSpeaker => removed userId=${userId}`);
}
/**
* Calls the audiospace/stream/eject endpoint to remove a speaker on Twitter
*/
private async callRemoveEndpoint(
broadcast: BroadcastCreated,
authorizationToken: string,
sessionUUID: string,
janusParticipantId: number,
janusRoomId: string,
webrtcHandleId: number,
webrtcSessionId: number,
): Promise<void> {
const endpoint = 'https://guest.pscp.tv/api/v1/audiospace/stream/eject';
const headers = {
'Content-Type': 'application/json',
Referer: 'https://x.com/',
Authorization: authorizationToken,
};
const body = {
ntpForBroadcasterFrame: '2208988800024000300',
ntpForLiveFrame: '2208988800024000300',
session_uuid: sessionUUID,
chat_token: broadcast.access_token,
janus_room_id: janusRoomId,
janus_participant_id: janusParticipantId,
webrtc_handle_id: webrtcHandleId,
webrtc_session_id: webrtcSessionId,
};
this.logger.debug('[Space] Removing speaker =>', endpoint, body);
const resp = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!resp.ok) {
const error = await resp.text();
throw new Error(
`[Space] Failed to remove speaker => ${resp.status}: ${error}`,
);
}
this.logger.debug('[Space] Speaker removed => sessionUUID=', sessionUUID);
}
pushAudio(samples: Int16Array, sampleRate: number) {
this.janusClient?.pushLocalAudio(samples, sampleRate);
}
/**
* This method is called by JanusClient on 'audioDataFromSpeaker'
* or we do it from the 'initialize(...)' once Janus is set up.
*/
private handleAudioData(data: AudioDataWithUser) {
// Forward to plugins
for (const { plugin } of this.plugins) {
plugin.onAudioData?.(data);
}
}
/**
* Gracefully end the Space (stop broadcast, destroy Janus room, etc.)
*/
public async finalizeSpace(): Promise<void> {
this.logger.info('[Space] finalizeSpace => stopping broadcast gracefully');
const tasks: Array<Promise<any>> = [];
if (this.janusClient) {
tasks.push(
this.janusClient.destroyRoom().catch((err) => {
this.logger.error('[Space] destroyRoom error =>', err);
}),
);
}
if (this.broadcastInfo) {
tasks.push(
this.endAudiospace({
broadcastId: this.broadcastInfo.room_id,
chatToken: this.broadcastInfo.access_token,
}).catch((err) => {
this.logger.error('[Space] endAudiospace error =>', err);
}),
);
}
if (this.janusClient) {
tasks.push(
this.janusClient.leaveRoom().catch((err) => {
this.logger.error('[Space] leaveRoom error =>', err);
}),
);
}
await Promise.all(tasks);
this.logger.info('[Space] finalizeSpace => done.');
}
/**
* Calls the endAudiospace endpoint from Twitter
*/
private async endAudiospace(params: {
broadcastId: string;
chatToken: string;
}): Promise<void> {
const url = 'https://guest.pscp.tv/api/v1/audiospace/admin/endAudiospace';
const headers = {
'Content-Type': 'application/json',
Referer: 'https://x.com/',
Authorization: this.authToken || '',
};
const body = {
broadcast_id: params.broadcastId,
chat_token: params.chatToken,
};
this.logger.debug('[Space] endAudiospace =>', body);
const resp = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!resp.ok) {
const errText = await resp.text();
throw new Error(`[Space] endAudiospace => ${resp.status} ${errText}`);
}
const json = await resp.json();
this.logger.debug('[Space] endAudiospace => success =>', json);
}
public getSpeakers(): SpeakerInfo[] {
return Array.from(this.speakers.values());
}
public async stop() {
this.logger.info('[Space] Stopping...');
await this.finalizeSpace().catch((err) => {
this.logger.error('[Space] finalizeBroadcast error =>', err);
});
if (this.chatClient) {
await this.chatClient.disconnect();
this.chatClient = undefined;
}
if (this.janusClient) {
await this.janusClient.stop();
this.janusClient = undefined;
}
for (const { plugin } of this.plugins) {
plugin.cleanup?.();
}
this.plugins.clear();
this.isInitialized = false;
}
}