This commit is contained in:
cj@futureporn.net 2023-12-04 09:44:15 -08:00
parent df05eec5aa
commit 3500bd68ba
22 changed files with 1786 additions and 2181 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
venv
scrap*
# Created by https://www.toptal.com/developers/gitignore/api/node

View File

@ -1,7 +1,9 @@
{
"extensions": ["ts"],
"node-option": [
"experimental-specifier-resolution=node",
"loader=ts-node/esm"
]
}
"extensions": [
"ts"
],
"node-option": [
"experimental-specifier-resolution=node",
"loader=ts-node/esm"
]
}

View File

@ -195,3 +195,18 @@ I think it needs to go like this.
```
####
```
debug: handling room error. Bad Request {"service":"futureporn/scout","timestamp":"2023-11-20T19:44:44.535Z"}
debug: handling room error. Bad Request {"service":"futureporn/scout","timestamp":"2023-11-20T19:44:44.536Z"}
debug: Ably authCallback. Getting a fresh new Ably TokenRequest. {"service":"futureporn/scout","timestamp":"2023-11-20T19:44:44.537Z"}
debug: getting pushServiceAuth. {"service":"futureporn/scout","timestamp":"2023-11-20T19:44:44.537Z"}
debug: using cookie string csrftoken=gQUnyHUTg7lZBTNn374s96a7DYAVxqZ5xrPi2RzWfrzJT61EfrLHvr6wFRMwhcUz; sbr=sec:sbref421fda-4ec5-4a7f-93ea-9a74a8c85484:1qdk9F:movx7uli5YEZ4UNB_GPURHopOLI {"service":"futureporn/scout","timestamp":"2023-11-20T19:44:44.537Z"}
debug: {"token":null,"channels":{"RoomMessageTopic#RoomMessageTopic:G0TWFS5":"room:grouped:G0TWFS5:16","RoomStatusTopic#RoomStatusTopic:G0TWFS5":"room:grouped:G0TWFS5:16","RoomTipAlertTopic#RoomTipAlertTopic:G0TWFS5":"room:grouped:G0TWFS5:16","RoomPasswordProtectedTopic#RoomPasswordProtectedTopic:G0TWFS5":"room:grouped:G0TWFS5:16"},"failures":{"RoomMessageTopic#RoomMessageTopic:G0TWFS5":"Bad Request","RoomStatusTopic#RoomStatusTopic:G0TWFS5":"Bad Request","RoomTipAlertTopic#RoomTipAlertTopic:G0TWFS5":"Bad Request","RoomPasswordProtectedTopic#RoomPasswordProtectedTopic:G0TWFS5":"Bad Request"},"token_request":{},"client_id":"-anonef421fda-4ec5-4a7f-93ea-9a74a8c85484","settings":{"backend":"a","flags":{"pm_enabled":true,"wowza_disabled":true,"userlist_enabled":true,"verify_enabled":false,"fallback_eligible":true,"is_live":true},"rest_host":"realtime.pa.highwebmedia.com","realtime_host":"realtime.pa.highwebmedia.com","fallback_hosts":["a-fallback.pa.highwebmedia.com","b-fallback.pa.highwebmedia.com","c-fallback.pa.highwebmedia.com","d-fallback.pa.highwebmedia.com","e-fallback.pa.highwebmedia.com"]}} {"service":"futureporn/scout","timestamp":"2023-11-20T19:44:46.747Z"}
restHost:realtime.pa.highwebmedia.com >> realtimeHost:realtime.pa.highwebmedia.com >> fallbackHosts:a-fallback.pa.highwebmedia.com,b-fallback.pa.highwebmedia.com,c-fallback.pa.highwebmedia.com,d-fallback.pa.highwebmedia.com,e-fallback.pa.highwebmedia.com
debug: Got a new TokenRequest {"service":"futureporn/scout","timestamp":"2023-11-20T19:44:46.748Z"}
debug: {} {"service":"futureporn/scout","timestamp":"2023-11-20T19:44:46.748Z"}
11:44:46.748 Ably: Auth.requestToken(): Expected token request callback to call back with a token string, token request object, or token details object
```

View File

@ -8,6 +8,8 @@ import * as faye from './src/faye.js'
import * as sound from './src/sound.js'
import * as cb from './src/cb.js'
import { appEnv, getAppContext } from './src/appContext.js'
import { getPushServiceAuth } from './src/headless.js'
// import { getSuperRealtimeClient } from './src/realtime.js'
import {
onCbMessage,
onCbTitle,
@ -73,76 +75,19 @@ async function init () {
/**
*
* listen to room status messages 24/7
*
* @todo [ ] when room goes online, spawn a woerker
* which listens to more of it's events
*
* @todo [ ] when room goes offline
* and remains offline for >5 minutes,
* retire the worker
*
* @todo [ ] 1 TUI row per user
* @todo [ ] TUI columns for subscriptins status
* when events are received on the ably realtime client, we log the appropriate message to db.
*/
async function registerRoomStatusListeners(appContext, rooms, cb, ably) {
// for each room, render a TUI row
// for (const room of rooms) {
// }
const sampleRoom = rooms.at(0)
console.log(sampleRoom)
// get cookie from cb. We only need one for `chaturbate.com/` (not one per room.)
await cb.fetchCookiesIfNeeded(sampleRoom.url)
const csrfToken = await cb.getCsrfToken(sampleRoom.url) // doesn't really matter which roomUrl for this token, as long as it is on chaturbate.com domain
const cookieString = await cb.cookieJar.getCookieString(sampleRoom.url)
console.log('here is the cookiestring')
console.log(cookieString)
await Promise.allSettled((rooms.map((r) => r.id)))
await Promise.allSettled((rooms.map((r) => r.dossier)))
/**
get permission form for all the rooms
we are interested in.
we be greedy with choosing all permissions up-front
for channels we may possibly want in the future.
Basically we request every permission possible for
website visitors. Just because we request perms,
doesn't mean we have to subscribe to the relevant channel.
we only use what we need, but we don't want the permission
form to hold us back and require an extra request.
*/
const channelsRequests = await Promise.all(
rooms.map(async (r) => await r.getChannelsRequest())
);
// const oldPermissionForm = Room.getPermissionsForm(rooms.map((r) => r.id), ['status'], csrfToken)
const mergedChannelsRequests = Room.mergeChannelsRequests(channelsRequests)
console.log('here is the result of merging channels requests')
console.log(mergedChannelsRequests)
const permissionForm = Room.formalizeChannelsRequest(mergedChannelsRequests, csrfToken)
// console.log('OLD, (known good) FORMDATA AS FOLLOWS')
// for (let pair of oldPermissionForm.entries()) {
// console.log(pair[0] + ': ' + pair[1]);
// }
console.log(`FORMDATA AS FOLLOWS >>>>>>>> vvvvvvvvv`)
for (let pair of permissionForm.entries()) {
console.log(pair[0] + ': ' + pair[1]);
}
// get signed ably tokenrequest from cb
console.log({ level: 'debug', message: `Ok let's get a signed ably tokenrequest. we are going to use cookieString:${cookieString}` })
const psa = await cb.getPushServiceAuth(sampleRoom.url, permissionForm, cookieString)
const psa = await getPushServiceAuth(sampleRoom.url, permissionForm, cookieString)
const ablyWrapper = new AblyWrapper(appContext, {
chaturbateAuth: cb,
@ -238,24 +183,19 @@ async function daemon () {
}))
appContext.faye = faye.fayeFactory(appContext)
// registerRoomTimers(appContext, rooms)
const chaturbateAuth = new ChaturbateAuth(appContext)
const ably = new AblyWrapper(appContext, {
chaturbateAuth
})
// registerRoomListeners(appContext, rooms, cb, ably)
// @todo [ ] Listen to interesting rooms 24/7
// @todo [ ] When interesting room comes online, listen to more of it's events
// @todo [ ] When interesting room goes offline, stop listening to more of it's events after 5 minutes
// for each room we are scouting, get an Ably realtime client
// once we have an Ably realtime client,
// we attach our custom callback functions which log events to db
for (const room of rooms) {
}
// await registerRoomStatusListeners(appContext, rooms, chaturbateAuth, ably)
// const concurrency = 12
// const queue = fastq(worker, 12)
await registerRoomStatusListeners(appContext, rooms, chaturbateAuth, ably)
// console.log(`lets kick the webserver`)
}

View File

@ -9,21 +9,22 @@
"build": "rimraf dist && tsc -p tsconfig.json",
"start:daemon": "pm2 start ecosystem.config.cjs; pm2 logs",
"start": "node --loader ts-node/esm index.ts daemon",
"schema": "pnpm ts-to-zod src/headless.ts src/headlessSchema.ts",
"test": "mocha ./test/unit",
"dev": "FUTUREPORN_WORKDIR=/home/chris/Downloads pnpm nodemon --exec \"node --loader ts-node/esm \" -e js,ts,mjs,json index.ts daemon"
},
"type": "module",
"dependencies": {
"@neutralinojs/neu": "^9.5.1",
"ably": "1.2.13",
"better-sqlite3": "^8.3.0",
"cheerio": "^1.0.0-rc.12",
"date-fns": "^2.29.3",
"@playwright/test": "^1.40.1",
"ably": "1.2.37",
"better-sqlite3": "^8.7.0",
"cheerio": "1.0.0-rc.12",
"date-fns": "^2.30.0",
"debounce": "^1.2.1",
"dexie-export-import": "^4.0.7",
"dotenv": "^16.0.3",
"execa": "^7.1.1",
"fastify": "^4.20.0",
"dotenv": "^16.3.1",
"execa": "^7.2.0",
"fastify": "^4.24.3",
"fastq": "^1.15.0",
"faye": "^1.4.0",
"fetch-blob": "^3.2.0",
@ -32,32 +33,35 @@
"got-plugin-debounce": "^1.0.3",
"http": "0.0.1-security",
"ky": "^0.33.3",
"node-fetch": "^3.3.0",
"node-fetch": "^3.3.2",
"nunjucks": "^3.2.4",
"p-retry": "^5.1.2",
"pm2": "^5.3.0",
"puppeteer": "^20.9.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"qs": "^6.11.2",
"seedrandom": "^3.0.5",
"terminal-kit": "^3.0.0",
"tough-cookie": "^4.1.2",
"terminal-kit": "^3.0.1",
"tough-cookie": "^4.1.3",
"tough-cookie-file-store": "^2.0.3",
"ts-node": "^10.9.1",
"tty-table": "^4.2.1",
"tty-table": "^4.2.3",
"twitter-api-scraper": "^0.0.5",
"twitter-openapi-typescript": "^0.0.11",
"twitter-v2": "^1.1.0",
"typescript": "^5.1.6",
"winston": "^3.8.2",
"yargs": "^17.7.2"
"typescript": "^5.3.2",
"winston": "^3.11.0",
"yargs": "^17.7.2",
"zod": "^3.22.4"
},
"devDependencies": {
"chai": "^4.3.7",
"@types/mocha": "^10.0.6",
"chai": "^4.3.10",
"chai-events": "^0.0.3",
"mocha": "^10.2.0",
"nodemon": "^2.0.20",
"rimraf": "^5.0.1",
"sinon": "^15.1.0"
"nodemon": "^2.0.22",
"rimraf": "^5.0.5",
"ts-to-zod": "^3.4.1"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -115,6 +115,15 @@ export default class AblyWrapper {
await this.saveRealtimeUrls(restHost, realtimeHost, fallbackHosts)
this.tokenRequest = pushServiceAuth.token_request;
this.logger.log({
level: 'debug',
message: `the following is pushServiceAuth`
});
this.logger.log({
level: 'debug',
message: JSON.stringify(pushServiceAuth, null, 2)
});
this.logger.log({
level: 'debug',
message: `Got a new TokenRequest`
@ -123,6 +132,7 @@ export default class AblyWrapper {
level: 'debug',
message: JSON.stringify(this.tokenRequest)
});
if (!this?.tokenRequest) throw new Error('tokenRequest was empty')
callback(null, this.tokenRequest);
})

View File

@ -160,6 +160,7 @@ export default class ChaturbateAuth {
return form;
}
static getTokenCookie(cookies) {
return cookies.find((c) => c.key == 'csrftoken');
}

View File

@ -1,20 +1,19 @@
import cheerio from 'cheerio'
import { execa } from 'execa'
import pRetry from 'p-retry'
import gotClient from './gotClient';
export async function getRandomRoom (appContext) {
const res = await appContext.gotClient.get('https://chaturbate.com/')
const body = await res.text()
const $ = cheerio.load(body)
let roomsRaw = $('a[data-room]')
let rooms = []
export function getRandomRoomFromCbHtml (body) {
const $ = cheerio.load(body);
let roomsRaw = $('a[data-room]');
let rooms = [];
$(roomsRaw).each((_, e) => {
rooms.push($(e).attr('href'))
rooms.push($(e).attr('href'));
})
// greets https://stackoverflow.com/a/4435017/1004931
var randomIndex = Math.floor(Math.random() * rooms.length);
return rooms[randomIndex].replaceAll('/', '')
return rooms[randomIndex].replaceAll('/', '');
}

146
src/headless.ts Normal file
View File

@ -0,0 +1,146 @@
import puppeteer from 'puppeteer';
import { getRandomRoomFromCbHtml } from './cb.js';
import { z } from 'zod';
import { iPushServiceAuthSchema } from './headlessSchema.js';
import qs from 'qs';
export interface IPushServiceAuth {
token: string;
channels: Record<string, string>;
failures: Record<string, string>;
token_request: {
keyName: string;
clientId: string;
ttl: number;
nonce: string;
capability: string;
timestamp: number;
mac: string;
}
client_id: string;
settings: {
backend: string;
flags: {
pm_enabled: boolean;
wowza_disabled: boolean;
userlist_enabled: boolean;
verify_enabled: boolean;
fallback_eligible: boolean;
is_live: boolean;
}
rest_host: string;
realtime_host: string;
fallback_hosts: string[];
}
}
const roomSchema = z.string().min(3);
export function getSemverNumber(str: string): string {
// Regular expression for matching SemVer numbers
const semverRegex = /(?:\D|^)(\d+\.\d+\.\d+)(?=\D|$)/;
// Use the regex to extract the SemVer number
const match = str.match(semverRegex);
// Check if there's a match and extract the SemVer number
const semverNumber = match ? match[1] : null;
return semverNumber;
}
export async function getRandomRoom(): Promise<string> {
const browser = await puppeteer.launch({
headless: 'new'
});
const page = await browser.newPage();
await page.goto('https://chaturbate.com')
const html = await page.content();
await browser.close();
const room = await getRandomRoomFromCbHtml(html);
roomSchema.parse(room);
return room;
}
export async function getAblyAgent(): Promise<string> {
const browser = await puppeteer.launch({
headless: 'new'
});
const page = await browser.newPage();
await page.goto('https://chaturbate.com')
const html = await page.content();
const room = await getRandomRoomFromCbHtml(html);
roomSchema.parse(room);
await page.setRequestInterception(true);
const url: string = await new Promise((resolve) => {
page.on('request', interceptRequest => {
const url = interceptRequest.url();
if (url.startsWith('https://realtime')) {
console.log(`request! ${interceptRequest.url()} ${JSON.stringify(interceptRequest.headers(), null, 2)}`);
resolve(interceptRequest.url());
}
interceptRequest.continue();
});
page.goto(`https://chaturbate.com/${room}`);
})
const query = qs.parse(url);
console.log(query);
await browser.close();
return decodeURIComponent(query?.agent);
}
export async function getAblyAgentVersionSemver(): Promise<string> {
const agentString = await getAblyAgent();
return getSemverNumber(agentString);
}
export async function getPushServiceAuth(roomUrl: string): Promise<IPushServiceAuth> {
if (!roomUrl) throw new Error('roomUrl is a required param, but it was undefined.');
const roomUrlUrl = new URL(roomUrl);
const browser = await puppeteer.launch({
headless: 'new'
});
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('request', interceptRequest => {
// const url = interceptRequest.url();
// if (url === 'https://chaturbate.com/push_service/auth/') {
// console.log(`request! ${interceptRequest.url()} ${JSON.stringify(interceptRequest.headers(), null, 2)}`);
// }
interceptRequest.continue();
})
const json = await new Promise(async (resolve) => {
page.on('response', async (interceptResponse) => {
const url = interceptResponse.url();
if (url === 'https://chaturbate.com/push_service/auth/') {
// console.log(`response! ${url}`);
const json = await interceptResponse.json();
resolve(json);
}
})
await page.goto(roomUrlUrl.toString());
})
let psa;
try {
psa = iPushServiceAuthSchema.parse(json);
// console.log(psa);
} catch (e) {
if (e instanceof Error) {
console.error(e)
}
} finally {
await browser.close()
}
return psa;
}

32
src/headlessSchema.ts Normal file
View File

@ -0,0 +1,32 @@
// Generated by ts-to-zod
import { z } from "zod";
export const iPushServiceAuthSchema = z.object({
token: z.string(),
channels: z.record(z.string()),
failures: z.record(z.string()),
token_request: z.object({
keyName: z.string(),
clientId: z.string(),
ttl: z.number(),
nonce: z.string(),
capability: z.string(),
timestamp: z.number(),
mac: z.string(),
}),
client_id: z.string(),
settings: z.object({
backend: z.string(),
flags: z.object({
pm_enabled: z.boolean(),
wowza_disabled: z.boolean(),
userlist_enabled: z.boolean(),
verify_enabled: z.boolean(),
fallback_eligible: z.boolean(),
is_live: z.boolean(),
}),
rest_host: z.string(),
realtime_host: z.string(),
fallback_hosts: z.array(z.string()),
}),
});

42
src/realtime.ts Normal file
View File

@ -0,0 +1,42 @@
import * as Ably from 'ably/promises';
import { getPushServiceAuth } from './headless';
import { Realtime } from 'ably';
export async function getSuperRealtimeClient(room: string): Promise<Ably.Realtime> {
const authCallback: Ably.Types.AuthOptions['authCallback'] = (async (_, callback) => {
console.log(`Ably authCallback. Getting a fresh new Ably TokenRequest.`);
const roomUrl = new URL(`https://chaturbate.comm/${room}`);
let pushServiceAuth = await getPushServiceAuth(roomUrl.toString());
console.log(pushServiceAuth);
const tokenRequest = pushServiceAuth.token_request;
console.log(`Got a new TokenRequest`);
console.log(JSON.stringify(tokenRequest, null, 2));
if (!tokenRequest) throw new Error('tokenRequest was empty');
callback(null, tokenRequest);
})
const options: Ably.Types.ClientOptions = {
autoConnect: false,
closeOnUnload: true,
transportParams: {
remainPresentFor: '0'
},
restHost: 'realtime.pa.highwebmedia.com',
realtimeHost: 'realtime.pa.highwebmedia.com',
fallbackHosts: [
'a-fallback.pa.highwebmedia.com',
'b-fallback.pa.highwebmedia.com',
'c-fallback.pa.highwebmedia.com',
'd-fallback.pa.highwebmedia.com',
'e-fallback.pa.highwebmedia.com'
],
authCallback: authCallback
};
const realtime = new Ably.Realtime(options);
return realtime;
}

View File

@ -0,0 +1,33 @@
import { expect } from 'chai';
import path from 'node:path';
import ChaturbateAuth from '../../src/ChaturbateAuth.js';
import gotClient from '../../src/gotClient.js'
import fs from 'node:fs';
import { loggerFactory } from '../../src/logger.js';
describe('ChaturbateAuth Integration Tests', () => {
const randomDirectoryName = `test-${Math.random().toString(36).substring(2)}`;
const tmpDirectory = path.join('/tmp', randomDirectoryName);
// Create the random directory in /tmp
fs.mkdirSync(tmpDirectory);
let appContext = {
logger: loggerFactory(),
dataDir: tmpDirectory,
gotClient
};
let chaturbateAuth;
beforeEach(() => {
chaturbateAuth = new ChaturbateAuth(appContext);
});
// Add more test cases for other methods as needed
// You can also add tests for error cases, edge cases, etc.
});

View File

@ -1,145 +0,0 @@
import 'dotenv/config'
import chai from "chai";
import sinon from 'sinon';
import { onCbStart } from '../../src/cbCallbacks.js'
import { getAppContext } from '../../src/appContext.js'
import * as pubsub from '../../src/pubsub.js'
describe('onCbStart', () => {
let appContext;
let roomName;
let message;
let dbMock;
let clearTimeoutMock;
beforeEach(() => {
// Create a mock object for appContext
appContext = {
logger: {
log: sinon.stub(),
},
db: {
prepare: sinon.stub(),
},
roomTimers: {
[roomName]: {
offline: 'mockTimeout',
},
},
sound: {
alert: sinon.stub()
},
cb: {
getPlaylistUrl: sinon.stub()
},
pubsub: pubsub
};
// Set up test data
roomName = 'testRoom';
message = {
name: 'testName',
id: 'testId',
encoding: 'testEncoding',
data: {
tid: 'testTid',
ts: 'testTs',
status: 'testStatus',
message: 'testMessage',
hash: 'testHash',
method: 'testMethod',
pub_ts: 'testPubTs',
},
};
// Create mock functions
dbMock = {
run: sinon.stub(),
};
appContext.db.prepare.returns(dbMock);
appContext.cb.getPlaylistUrl.resolves('testPlaylistUrl');
console.log('here are the mock')
console.log(appContext.cb.getPlaylistUrl)
// clearTimeoutMock = sinon.stub();
// // Stub global functions
// sinon.stub(global, 'clearTimeout').callsFake(clearTimeoutMock);
// // Stub async function using sinon
// sinon.stub(global, 'setTimeout').callsFake((callback) => {
// callback();
// return 'mockTimeout';
// });
});
afterEach(() => {
sinon.restore();
});
it('should insert into lifecycles and call start', async () => {
console.log(appContext)
console.log(`>>>>>>>>>>>>>>`)
console.log(appContext.pubsub.start)
console.log(message)
// Invoke the function
await onCbStart(appContext, roomName, message);
// Assert the logger was called with the correct arguments
sinon.assert.calledWith(
appContext.logger.log,
{ level: 'debug', message: `🟢🟢🟢 [STREAM START] ${JSON.stringify(message)}` }
);
// Assert the database prepared statement was called with the correct SQL query
sinon.assert.calledWith(
appContext.db.prepare,
'INSERT INTO lifecycles VALUES ($_room, $name, $id, $encoding, $data_tid, $data_ts, $data_status, $data_message, $data_hash, $data_method, $data_pub_ts)'
);
// Assert the database statement was run with the correct parameters
sinon.assert.calledWith(
dbMock.run,
sinon.match({
_room: roomName,
name: message.name || null,
id: message.id || null,
encoding: message.encoding || null,
data_tid: message.data?.tid || null,
data_ts: message.data?.status || null,
data_status: message.data?.status || null,
data_message: message.data?.message || null,
data_hash: message.data?.hash || null,
data_method: message.data?.method || null,
data_pub_ts: message.data?.pub_ts || null,
})
);
// Assert the soundAlert function was called
sinon.assert.calledOnce(appContext.sound.alert);
// // Assert the getPlaylistUrl function was called with the correct arguments
sinon.assert.calledWith(appContext.cb.getPlaylistUrl, appContext, roomName);
// Assert the start function was called with the correct arguments
sinon.assert.calledWith(
appContext.pubsub.start,
appContext,
roomName,
'testPlaylistUrl'
);
// // Assert clearTimeout was called with the correct argument
// sinon.assert.calledWith(clearTimeoutMock, 'mockTimeout');
});
});

View File

@ -1,24 +0,0 @@
import got from 'got';
import plugin from 'got-plugin-debounce';
const client = got
.extend(plugin) // load plugin
.extend({ debounce: 2000 }) // random value from 350ms to 450ms between 2 requests
const startedAt = Date.now();
const report = () => new Promise((resolve) => {console.log(`elapsed:${Date.now()-startedAt}`); resolve()})
await Promise.all([
client('https://www.google.com'),
report(),
client('https://www.google.com'),
report(),
client('https://www.google.com'),
report(),
client('https://www.google.com'),
report(),
]);
const elapsed = Date.now() - startedAt;
console.log(elapsed > 1000); // true

View File

@ -0,0 +1,89 @@
import puppeteer from 'puppeteer';
import { expect } from 'chai';
import gotClient from '../../src/gotClient.js';
import { getRandomRoom, getAblyAgentVersionSemver, getSemverNumber, getPushServiceAuth } from '../../src/headless.js';
import { readFile } from 'fs/promises';
import path from 'path';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const getLocalAblyVersion = async function () {
const content = await readFile(path.join(__dirname, '../../package.json'), { encoding: 'utf-8' });
const json = JSON.parse(content, null, 2);
if (!json?.dependencies?.ably) throw new Error('failed to get ably version');
return json.dependencies.ably;
}
// Once request interception is enabled,
// every request will stall unless it's continued, responded or aborted.
describe('headless browser', function () {
// xdescribe('sanity checks', async function (done) {
// const browser = await puppeteer.launch({ headless: false });
// const page = await browser.newPage();
// await page.setRequestInterception(true);
// page.on('request', interceptedRequest => {
// if (interceptedRequest.isInterceptResolutionHandled()) return;
// if (
// interceptedRequest.url().endsWith('.png') ||
// interceptedRequest.url().endsWith('.jpg') ||
// interceptedRequest.url().endsWith('.webp') ||
// interceptedRequest.url().endsWith('.avif') ||
// interceptedRequest.url().endsWith('.svg')
// )
// interceptedRequest.abort();
// else interceptedRequest.continue();
// });
// await page.goto('https://google.com');
// await browser.close();
// done();
// })
describe('helpers', function () {
describe('getLocalAblyVersion', function () {
it('should return a version string', async function () {
const ver = await getLocalAblyVersion();
expect(ver).to.be.a('string');
expect(ver).to.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/);
return ver;
})
})
describe('getSemverNumber', function () {
it('should accept an agent string and return a semver number', function () {
const input = 'ably-js/1.2.37 browser'
const output = '1.2.37'
expect(getSemverNumber(input)).to.equal(output)
})
})
})
describe('chaturbate', function () {
this.timeout(1000*30);
it('should get a token_request', async function () {
const room = await getRandomRoom();
const roomUrl = `https://chaturbate.com/${room}`;
const psa = await getPushServiceAuth(roomUrl);
expect(psa).to.be.a('object');
expect(psa).to.have.a.property('token');
expect(psa).to.have.a.property('channels');
expect(psa).to.have.a.property('failures');
expect(psa).to.have.a.property('token_request');
expect(psa).to.have.a.property('client_id');
expect(psa).to.have.a.property('settings');
expect(psa.token).to.be.a('string').that.is.lengthOf.above(2);
expect(psa.channels).to.be.a('object');
expect(psa.failures).to.be.a('object').that.is.empty;
expect(psa.settings).to.have.a.property('rest_host');
expect(psa.settings).to.have.a.property('realtime_host');
});
it('cb ably version should match local ably agent version', async function () {
const cbAblyVersion = await getAblyAgentVersionSemver();
const localAblyVersion = await getLocalAblyVersion();
expect(cbAblyVersion).to.equal(localAblyVersion);
});
})
})

View File

@ -1,83 +0,0 @@
import Room from '../../src/Room.ts';
import ChaturbateAuth from '../../src/ChaturbateAuth.js';
import Realtime from '../../src/Realtime.js';
import chai, { expect } from "chai";
import chaiEvents from 'chai-events';
import gotClient from '../../src/gotClient.js';
import path from 'node:path';
import os from 'node:os';
chai.use(chaiEvents);
describe('realtime', function () {
describe('Can I use my own permission form?', function () {
this.timeout(60*1000)
it('should subscribe to Ably realtime, error free', async function () {
const fakeAppContext = {
logger: {
log: (msg) => console.log(JSON.stringify(msg)),
},
dataDir: path.join(os.homedir(), '.local/share/futureporn-scout'),
gotClient
}
expect(fakeAppContext.logger.log).to.be.a('function')
const cb = new ChaturbateAuth(fakeAppContext)
const ably = new Realtime(fakeAppContext, { chaturbate: cb })
const rooms = [
'projektmelody',
'el_xox',
'skyeanette',
// 'athena_airis'
].map((r) => new Room(fakeAppContext, { name: r, cb, ably }))
const elRoom = rooms[1]
expect(elRoom.url).to.equal('https://chaturbate.com/el_xox/')
expect(elRoom.id).to.be.a('promise')
// get cookie from cb. We only need one for `chaturbate.com/` (not one per room.)
await cb.fetchCookiesIfNeeded(elRoom.url)
const csrfToken = await cb.getCsrfToken(elRoom.url) // doesn't really matter which roomUrl for this token, as long as it is on chaturbate.com domain
const cookieString = await cb.cookieJar.getCookieString(elRoom.url)
console.log('here is the cookiestring')
console.log(cookieString)
await Promise.allSettled((rooms.map((r) => r.id)))
expect(elRoom.id).to.equal('JQ2YJS5')
// @todo in index.js, flag any rooms which don't have an ID. This would suggest a passworded room.
const openRooms = rooms.filter((r) => (!!r.id))
// get permission form for all the rooms we are interested in
const permissionForm = Room.getPermissionsForm(openRooms.map((r) => r.id), ['status', 'silence'], csrfToken)
for (let pair of permissionForm.entries()) {
console.log(pair[0] + ': ' + pair[1]);
}
// get signed ably tokenrequest from cb
const token = await cb.getTokenRequest(elRoom.url, permissionForm, cookieString)
expect(token).to.have.property('keyName')
console.log(token)
// get realtime connection with merged permission form(?)
})
})
})

View File

@ -0,0 +1,140 @@
import Room from '../../src/Room.js';
import ChaturbateAuth from '../../src/ChaturbateAuth.js';
import * as Ably from 'ably/promises';
import chai, { expect } from "chai";
import chaiEvents from 'chai-events';
import gotClient from '../../src/gotClient.js';
import path from 'node:path';
import os from 'node:os';
import { getPushServiceAuth, getRandomRoom } from '../../src/headless.js';
chai.use(chaiEvents);
describe('realtime', function () {
describe(`Ably`, function () {
this.timeout(60 * 1000);
it(`should subscribe to a room's events`, async function () {
const room = await getRandomRoom();
const authCallback: Ably.Types.AuthOptions['authCallback'] = (async (_, callback) => {
console.log(`Ably authCallback. Getting a fresh new Ably TokenRequest.`);
// This is where we get a signed Ably TokenRequest from CB!
const roomUrl = `https://chaturbate.com/${room}`;
let pushServiceAuth = await getPushServiceAuth(roomUrl);
console.log(pushServiceAuth);
const tokenRequest = pushServiceAuth.token_request;
console.log(`Got a new TokenRequest`);
console.log(JSON.stringify(tokenRequest, null, 2));
if (!tokenRequest) throw new Error('tokenRequest was empty');
callback(null, tokenRequest);
})
const options: Ably.Types.ClientOptions = {
autoConnect: false,
closeOnUnload: true,
transportParams: {
remainPresentFor: '0'
},
restHost: 'realtime.pa.highwebmedia.com',
realtimeHost: 'realtime.pa.highwebmedia.com',
fallbackHosts: [
'a-fallback.pa.highwebmedia.com',
'b-fallback.pa.highwebmedia.com',
'c-fallback.pa.highwebmedia.com',
'd-fallback.pa.highwebmedia.com',
'e-fallback.pa.highwebmedia.com'
],
authCallback: authCallback
};
const realtime = new Ably.Realtime(options);
const goodEnd = new Promise<void>((resolve) => {
realtime.connection.once('connected', (idk) => {
console.log(` [*] connected! ${JSON.stringify(idk, null, 2)}`);
realtime.close();
resolve()
});
console.log('>>> connecting to realtime');
realtime.connect();
});
const badEnd = new Promise((_, reject) => {
setTimeout(() => {
reject('timeout waiting for realtime connection');
}, 30 * 1000);
});
return Promise.race([goodEnd, badEnd]);
})
})
// xdescribe('Can I use my own permission form?', function () {
// this.timeout(60*1000)
// it('should subscribe to Ably realtime, error free', async function () {
// const fakeAppContext = {
// logger: {
// log: (msg) => console.log(JSON.stringify(msg)),
// },
// dataDir: path.join(os.homedir(), '.local/share/futureporn-scout'),
// gotClient
// }
// expect(fakeAppContext.logger.log).to.be.a('function')
// const cb = new ChaturbateAuth(fakeAppContext)
// const ably = new Realtime(fakeAppContext, { chaturbate: cb })
// const rooms = [
// 'projektmelody',
// 'el_xox',
// 'skyeanette',
// // 'athena_airis'
// ].map((r) => new Room(fakeAppContext, { name: r, cb, ably }))
// const elRoom = rooms[1]
// expect(elRoom.url).to.equal('https://chaturbate.com/el_xox/')
// expect(elRoom.id).to.be.a('promise')
// // get cookie from cb. We only need one for `chaturbate.com/` (not one per room.)
// await cb.fetchCookiesIfNeeded(elRoom.url)
// const csrfToken = await cb.getCsrfToken(elRoom.url) // doesn't really matter which roomUrl for this token, as long as it is on chaturbate.com domain
// const cookieString = await cb.cookieJar.getCookieString(elRoom.url)
// console.log('here is the cookiestring')
// console.log(cookieString)
// await Promise.allSettled((rooms.map((r) => r.id)))
// expect(elRoom.id).to.equal('JQ2YJS5')
// // @todo in index.js, flag any rooms which don't have an ID. This would suggest a passworded room.
// const openRooms = rooms.filter((r) => (!!r.id))
// // get permission form for all the rooms we are interested in
// const permissionForm = Room.getPermissionsForm(openRooms.map((r) => r.id), ['status', 'silence'], csrfToken)
// for (let pair of permissionForm.entries()) {
// console.log(pair[0] + ': ' + pair[1]);
// }
// // get signed ably tokenrequest from cb
// const token = await cb.getTokenRequest(elRoom.url, permissionForm, cookieString)
// expect(token).to.have.property('keyName')
// console.log(token)
// // get realtime connection with merged permission form(?)
// })
// })
})

View File

@ -1,8 +1,6 @@
import { expect } from "chai";
import Room from '../../src/Room.ts'
import { describe } from "pm2";
describe('Room', function () {
describe('')
xdescribe('Room', function () {
})

View File

@ -1,141 +0,0 @@
import chai from 'chai';
import dotenv from 'dotenv';
describe('onCbStart', () => {
let appContext;
let roomName;
let message;
let dbMock;
let clearTimeoutMock;
beforeEach(() => {
// Create a mock object for appContext
appContext = {
logger: {
log: sinon.stub(),
},
db: {
prepare: sinon.stub(),
},
roomTimers: {
[roomName]: {
offline: 'mockTimeout',
},
},
sound: {
alert: sinon.stub()
},
cb: {
getPlaylistUrl: sinon.stub()
},
pubsub: {
start: sinon.stub()
}
};
// Set up test data
roomName = 'testRoom';
message = {
name: 'testName',
id: 'testId',
encoding: 'testEncoding',
data: {
tid: 'testTid',
ts: 'testTs',
status: 'testStatus',
message: 'testMessage',
hash: 'testHash',
method: 'testMethod',
pub_ts: 'testPubTs',
},
};
// Create mock functions
dbMock = {
run: sinon.stub(),
};
appContext.db.prepare.returns(dbMock);
appContext.cb.getPlaylistUrl.resolves('testPlaylistUrl');
console.log('here are the mock')
console.log(appContext.cb.getPlaylistUrl)
// clearTimeoutMock = sinon.stub();
// // Stub global functions
// sinon.stub(global, 'clearTimeout').callsFake(clearTimeoutMock);
// // Stub async function using sinon
// sinon.stub(global, 'setTimeout').callsFake((callback) => {
// callback();
// return 'mockTimeout';
// });
});
afterEach(() => {
sinon.restore();
});
it('should insert into lifecycles and call start', async () => {
console.log(appContext)
console.log(`>>>>>>>>>>>>>>`)
console.log(appContext.pubsub.start)
console.log(message)
// Invoke the function
await onCbStart(appContext, roomName, message);
// Assert the logger was called with the correct arguments
sinon.assert.calledWith(
appContext.logger.log,
{ level: 'debug', message: `🟢🟢🟢 [STREAM START] ${JSON.stringify(message)}` }
);
// Assert the database prepared statement was called with the correct SQL query
sinon.assert.calledWith(
appContext.db.prepare,
'INSERT INTO lifecycles VALUES ($_room, $name, $id, $encoding, $data_tid, $data_ts, $data_status, $data_message, $data_hash, $data_method, $data_pub_ts)'
);
// Assert the database statement was run with the correct parameters
sinon.assert.calledWith(
dbMock.run,
sinon.match({
_room: roomName,
name: message.name || null,
id: message.id || null,
encoding: message.encoding || null,
data_tid: message.data?.tid || null,
data_ts: message.data?.status || null,
data_status: message.data?.status || null,
data_message: message.data?.message || null,
data_hash: message.data?.hash || null,
data_method: message.data?.method || null,
data_pub_ts: message.data?.pub_ts || null,
})
);
// Assert the soundAlert function was called
sinon.assert.calledOnce(appContext.sound.alert);
// // Assert the getPlaylistUrl function was called with the correct arguments
sinon.assert.calledWith(appContext.cb.getPlaylistUrl, appContext, roomName);
// Assert the start function was called with the correct arguments
sinon.assert.calledWith(
appContext.pubsub.start,
appContext,
roomName,
'testPlaylistUrl'
);
// // Assert clearTimeout was called with the correct argument
// sinon.assert.calledWith(clearTimeoutMock, 'mockTimeout');
});
});

View File

@ -1,45 +0,0 @@
import TuiTable from '../../src/TuiTable.js'
describe('TuiTable', function() {
it('should display a table', function() {
const tui = new TuiTable()
tui.addRow({
room: 'foo',
pubOrPvt: 'sure',
status: 'idk',
message: '???',
tip: '$',
title: '#',
silence: 'shhh'
})
// Spawn a worker
const worker1 = { room: 'Room 1', pubOrPass: 'Pub', status: 'Running', message: 'Working...', tip: 'Tip 1', title: 'Worker 1', silence: 'No' };
tui.addRow({
room: 'foo',
pubOrPvt: 'sure',
status: 'idk',
message: '???',
tip: '$',
title: '#',
silence: 'shhh'
});
// Simulate worker retirement after some time
setTimeout(() => {
// tui.removeRow(worker1);
tui.addRow({
room: 'foo',
pubOrPvt: 'sure',
status: 'idk',
message: '???',
tip: '$',
title: '#',
silence: 'shhh'
});
}, 1000);
})
})