Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0cfd9b6
Add Facebook to the account type selection view
micahflee Feb 7, 2025
c3f35ed
Add FacebookAccount table
micahflee Feb 7, 2025
1783898
Make X database functions more DRY too
micahflee Feb 7, 2025
f2a5455
Rename AccountXView to XView, and rename AccountXViewModel to XViewModel
micahflee Feb 7, 2025
e5812ba
Start implementing FacebookViewModel, and merge some shared code with…
micahflee Feb 7, 2025
cbe103f
Add Facebook automation error types and plausible event types
micahflee Feb 7, 2025
9b19b04
Comment out stuff that is not implemented yet
micahflee Feb 7, 2025
c281fb4
Add FacebookAccountController to the main process, and hook up IPC
micahflee Feb 7, 2025
fdc2cfc
Make the Facebook state loop start running
micahflee Feb 7, 2025
c9201e4
Start implementing Facebook login flow
micahflee Feb 7, 2025
d6039ff
Write test for findCurrentUserInitialData to find info about the curr…
micahflee Feb 8, 2025
84ea669
Add findProfilePictureURI and test
micahflee Feb 8, 2025
0fc0e29
Save the FB profile image as a data URI
micahflee Feb 8, 2025
58e0b53
Merge branch 'main' into 380-facebook-platform
micahflee Feb 8, 2025
1f4dc94
Call Musk and Zuckerberg oligarchs again
micahflee Feb 11, 2025
cc40d0e
Fix fetching Facebook profile image
micahflee Feb 11, 2025
e7cda1b
Show Facebook profile image in sidebar
micahflee Feb 11, 2025
a23908a
Move openFolder and getArchiveInfo from XAccountController into archi…
micahflee Feb 11, 2025
39419dd
Show the Facebook wizard
micahflee Feb 11, 2025
3414b9b
Support Facebook usernames in automation error reports
micahflee Feb 11, 2025
0f35aad
Set facebook account data folder to be `{accountID} {name}`
micahflee Feb 11, 2025
4687c26
Remove "American" because Musk is also South African
micahflee Feb 13, 2025
24abbd9
Remove the U2FNotice component and make separate notices for X and Fa…
micahflee Feb 13, 2025
f056d24
Use real X logo, and billionaires again
micahflee Feb 13, 2025
517e632
Add feature flags for Facebook and Bluesky
micahflee Feb 14, 2025
86875cf
Add a not implemented warning to facebook wizard
micahflee Feb 14, 2025
3ce1ccf
Stub isFeatureEnabled IPC call
micahflee Feb 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions src/account_facebook/facebook_account_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import path from 'path'

import fetch from 'node-fetch';
import { session } from 'electron'
import log from 'electron-log/main';
import Database from 'better-sqlite3'

import {
getAccountDataPath,
} from '../util'
import {
FacebookAccount,
FacebookJob,
FacebookProgress,
emptyFacebookProgress,
} from '../shared_types'
import {
runMigrations,
getAccount,
exec,
getConfig,
setConfig,
} from '../database'
import { IMITMController } from '../mitm';
import {
FacebookJobRow,
convertFacebookJobRowToFacebookJob,
} from './types'

export class FacebookAccountController {
private accountUUID: string = "";
// Making this public so it can be accessed in tests
public account: FacebookAccount | null = null;
private accountID: number = 0;
private accountDataPath: string = "";

// Making this public so it can be accessed in tests
public db: Database.Database | null = null;

public mitmController: IMITMController;
private progress: FacebookProgress = emptyFacebookProgress();

private cookies: Record<string, string> = {};

constructor(accountID: number, mitmController: IMITMController) {
this.mitmController = mitmController;

this.accountID = accountID;
this.refreshAccount();

// Monitor web request metadata
const ses = session.fromPartition(`persist:account-${this.accountID}`);
ses.webRequest.onCompleted((_details) => {
// TODO: Monitor for rate limits
});

ses.webRequest.onSendHeaders((details) => {
// Keep track of cookies
if (details.url.startsWith("https://www.facebook.com/") && details.requestHeaders) {
this.cookies = {};

const cookieHeader = details.requestHeaders['Cookie'];
if (cookieHeader) {
const cookies = cookieHeader.split(';');
cookies.forEach((cookie) => {
const parts = cookie.split('=');
if (parts.length == 2) {
this.cookies[parts[0].trim()] = parts[1].trim();
}
});
}
}
});
}

cleanup() {
if (this.db) {
this.db.close();
this.db = null;
}
}

refreshAccount() {
// Load the account
const account = getAccount(this.accountID);
if (!account) {
log.error(`FacebookAccountController.refreshAccount: account ${this.accountID} not found`);
return;
}

// Make sure it's a Facebook account
if (account.type != "Facebook") {
log.error(`FacebookAccountController.refreshAccount: account ${this.accountID} is not a Facebook account`);
return;
}

// Get the account UUID
this.accountUUID = account.uuid;
log.debug(`FacebookAccountController.refreshAccount: accountUUID=${this.accountUUID}`);

// Load the Facebook account
this.account = account.facebookAccount;
if (!this.account) {
log.error(`FacebookAccountController.refreshAccount: xAccount ${this.accountID} not found`);
return;
}
}

initDB() {
if (!this.account || !this.account.accountID) {
log.error("FacebookAccountController: cannot initialize the database because the account is not found, or the account Facebook ID is not found", this.account, this.account?.accountID);
return;
}

// Make sure the account data folder exists
this.accountDataPath = getAccountDataPath('X', this.account.name);
log.info(`FacebookAccountController.initDB: accountDataPath=${this.accountDataPath}`);

// Open the database
this.db = new Database(path.join(this.accountDataPath, 'data.sqlite3'), {});
this.db.pragma('journal_mode = WAL');
runMigrations(this.db, [
// Create the tables
{
name: "initial",
sql: [
`CREATE TABLE job (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jobType TEXT NOT NULL,
status TEXT NOT NULL,
scheduledAt DATETIME NOT NULL,
startedAt DATETIME,
finishedAt DATETIME,
progressJSON TEXT,
error TEXT
);`,
`CREATE TABLE config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL
);`
]
},
])
log.info("FacebookAccountController.initDB: database initialized");
}

resetProgress(): FacebookProgress {
log.debug("FacebookAccountController.resetProgress");
this.progress = emptyFacebookProgress();
return this.progress;
}

createJobs(jobTypes: string[]): FacebookJob[] {
if (!this.db) {
this.initDB();
}

// Cancel pending jobs
exec(this.db, "UPDATE job SET status = ? WHERE status = ?", ["canceled", "pending"]);

// Create new pending jobs
jobTypes.forEach((jobType) => {
exec(this.db, 'INSERT INTO job (jobType, status, scheduledAt) VALUES (?, ?, ?)', [
jobType,
'pending',
new Date(),
]);
});

// Select pending jobs
const jobs: FacebookJobRow[] = exec(this.db, "SELECT * FROM job WHERE status = ? ORDER BY id", ["pending"], "all") as FacebookJobRow[];
return jobs.map(convertFacebookJobRowToFacebookJob);
}

updateJob(job: FacebookJob) {
if (!this.db) {
this.initDB();
}

exec(
this.db,
'UPDATE job SET status = ?, startedAt = ?, finishedAt = ?, progressJSON = ?, error = ? WHERE id = ?',
[job.status, job.startedAt ? job.startedAt : null, job.finishedAt ? job.finishedAt : null, job.progressJSON, job.error, job.id]
);
}

async archiveBuild() {
if (!this.db) {
this.initDB();
}

if (!this.account) {
return false;
}

log.info("FacebookAccountController.archiveBuild: building archive");

// TODO: implement
}

async syncProgress(progressJSON: string) {
this.progress = JSON.parse(progressJSON);
}

async getProgress(): Promise<FacebookProgress> {
return this.progress;
}

async getCookie(name: string): Promise<string | null> {
return this.cookies[name] || null;
}

async getProfileImageDataURI(profilePictureURI: string): Promise<string> {
log.info("FacebookAccountController.getProfileImageDataURI: profilePictureURI", profilePictureURI);
try {
const response = await fetch(profilePictureURI, {});
if (!response.ok) {
return "";
}
const buffer = await response.buffer();
log.info("FacebookAccountController.getProfileImageDataURI: buffer", buffer);
return `data:${response.headers.get('content-type')};base64,${buffer.toString('base64')}`;
} catch (e) {
log.error("FacebookAccountController.getProfileImageDataURI: error", e);
return "";
}
}

async getConfig(key: string): Promise<string | null> {
return getConfig(key, this.db);
}

async setConfig(key: string, value: string) {
return setConfig(key, value, this.db);
}
}
3 changes: 3 additions & 0 deletions src/account_facebook/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './types';
export * from './facebook_account_controller';
export * from './ipc';
113 changes: 113 additions & 0 deletions src/account_facebook/ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { ipcMain } from 'electron'

import { FacebookAccountController } from './facebook_account_controller';

import {
FacebookJob,
FacebookProgress,
} from '../shared_types'
import { getMITMController } from '../mitm';
import { packageExceptionForReport } from '../util'

const controllers: Record<number, FacebookAccountController> = {};

const getFacebookAccountController = (accountID: number): FacebookAccountController => {
if (!controllers[accountID]) {
controllers[accountID] = new FacebookAccountController(accountID, getMITMController(accountID));
}
controllers[accountID].refreshAccount();
return controllers[accountID];
}

export const defineIPCFacebook = () => {
ipcMain.handle('Facebook:resetProgress', async (_, accountID: number): Promise<FacebookProgress> => {
try {
const controller = getFacebookAccountController(accountID);
return controller.resetProgress();
} catch (error) {
throw new Error(packageExceptionForReport(error as Error));
}
});

ipcMain.handle('Facebook:createJobs', async (_, accountID: number, jobTypes: string[]): Promise<FacebookJob[]> => {
try {
const controller = getFacebookAccountController(accountID);
return controller.createJobs(jobTypes);
} catch (error) {
throw new Error(packageExceptionForReport(error as Error));
}
});

ipcMain.handle('Facebook:updateJob', async (_, accountID: number, jobJSON: string) => {
try {
const controller = getFacebookAccountController(accountID);
const job = JSON.parse(jobJSON) as FacebookJob;
controller.updateJob(job);
} catch (error) {
throw new Error(packageExceptionForReport(error as Error));
}
});

ipcMain.handle('Facebook:archiveBuild', async (_, accountID: number): Promise<void> => {
try {
const controller = getFacebookAccountController(accountID);
await controller.archiveBuild();
} catch (error) {
throw new Error(packageExceptionForReport(error as Error));
}
});

ipcMain.handle('Facebook:syncProgress', async (_, accountID: number, progressJSON: string) => {
try {
const controller = getFacebookAccountController(accountID);
await controller.syncProgress(progressJSON);
} catch (error) {
throw new Error(packageExceptionForReport(error as Error));
}
});

ipcMain.handle('Facebook:getProgress', async (_, accountID: number): Promise<FacebookProgress> => {
try {
const controller = getFacebookAccountController(accountID);
return await controller.getProgress();
} catch (error) {
throw new Error(packageExceptionForReport(error as Error));
}
});

ipcMain.handle('Facebook:getCookie', async (_, accountID: number, name: string): Promise<string | null> => {
try {
const controller = getFacebookAccountController(accountID);
return await controller.getCookie(name);
} catch (error) {
throw new Error(packageExceptionForReport(error as Error));
}
});

ipcMain.handle('Facebook:getProfileImageDataURI', async (_, accountID: number, profilePictureURI: string): Promise<string> => {
try {
const controller = getFacebookAccountController(accountID);
return await controller.getProfileImageDataURI(profilePictureURI);
} catch (error) {
throw new Error(packageExceptionForReport(error as Error));
}
});

ipcMain.handle('Facebook:getConfig', async (_, accountID: number, key: string): Promise<string | null> => {
try {
const controller = getFacebookAccountController(accountID);
return await controller.getConfig(key);
} catch (error) {
throw new Error(packageExceptionForReport(error as Error));
}
});

ipcMain.handle('Facebook:setConfig', async (_, accountID: number, key: string, value: string): Promise<void> => {
try {
const controller = getFacebookAccountController(accountID);
return await controller.setConfig(key, value);
} catch (error) {
throw new Error(packageExceptionForReport(error as Error));
}
});
};
31 changes: 31 additions & 0 deletions src/account_facebook/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import { FacebookJob } from '../shared_types'

// Models

export interface FacebookJobRow {
id: number;
jobType: string;
status: string;
scheduledAt: string;
startedAt: string | null;
finishedAt: string | null;
progressJSON: string | null;
error: string | null;
}

// Converters

export function convertFacebookJobRowToFacebookJob(row: FacebookJobRow): FacebookJob {
return {
id: row.id,
jobType: row.jobType,
status: row.status,
scheduledAt: new Date(row.scheduledAt),
startedAt: row.startedAt ? new Date(row.startedAt) : null,
finishedAt: row.finishedAt ? new Date(row.finishedAt) : null,
progressJSON: row.progressJSON ? JSON.parse(row.progressJSON) : null,
error: row.error,
};
}
Loading
Loading