UNPKG

agent-twitter-summary

Version:

A twitter client for agents

494 lines (421 loc) 13.6 kB
// 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; } }