Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
node_modules
dist
session
newsession
/session/
/newsession/
**buttons.js
**req.ts
**qr_code_session.png
Expand Down Expand Up @@ -69,6 +69,7 @@ packages/*/generated/
**/generated/*.js
**/generated/*.d.ts
**/generated/*.map
*.mdx
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Compiled JavaScript files in source directories
# packages/*/src/**/*.js
Expand Down
4 changes: 2 additions & 2 deletions integrations/node-red/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
"build:runtime": "pnpm run clean:runtime && tsc -p tsconfig.runtime.json",
"build:runtime:watch": "tsc -p tsconfig.runtime.watch.json --watch --preserveWatchOutput",
"build": "rimraf dist && pnpm run copy && pnpm run build:editor && pnpm run build:runtime",
"clean:editor": "rimraf dist/*.html && rimraf dist/*.js",
"clean:runtime": "rimraf dist/*.js",
"clean:editor": "rimraf --glob \"dist/*.html\" \"dist/*.js\"",
"clean:runtime": "rimraf --glob \"dist/*.js\"",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest --passWithNoTests",
"dev": "rimraf dist && pnpm run copy && concurrently --names 'COPY,EDITOR,RUNTIME,TEST' --prefix '({name})' --prefix-colors 'yellow.bold,cyan.bold,greenBright.bold,magenta.bold' 'onchange -v \"src/**/*.png\" \"src/**/*.svg\" -- pnpm run copy' 'pnpm run build:editor:watch' 'pnpm run build:runtime:watch' 'sleep 10; pnpm run test:watch'",
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,18 @@
"sdks/*"
],
"scripts": {
"build": "turbo build --filter='!@open-wa/legacy' --filter='!@open-wa/legacy-documented' --filter='!docs'",
"dev": "turbo dev --concurrency 100 --filter='!@open-wa/legacy' --filter='!@open-wa/legacy-documented'",
"test": "turbo test --filter='!@open-wa/legacy' --filter='!@open-wa/legacy-documented'",
"build": "turbo build --filter=\"!docs\"",
"dev": "turbo dev --concurrency 100 --filter=\"!docs\"",
"test": "turbo test --filter=\"!docs\"",
"lint": "oxlint .",
"lint:fix": "oxlint . --fix",
"clean": "turbo clean && rimraf node_modules",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"typecheck": "turbo typecheck --filter='!@open-wa/legacy' --filter='!@open-wa/legacy-documented'",
"typecheck": "turbo typecheck --filter=\"!docs\"",
"changeset": "changeset",
"version-packages": "changeset version",
"repomix": "npx repomix@latest",
"publish-packages": "turbo run build --filter='!@open-wa/legacy' --filter='!@open-wa/legacy-documented' && changeset publish",
"publish-packages": "turbo run build --filter=\"!docs\" && changeset publish",
"release-image": "node tools/release-image.js"
},
"devDependencies": {
Expand Down
196 changes: 196 additions & 0 deletions packages/wa-automate/src/session/SessionManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { createLogger } from '@open-wa/logger';
import { LocalSessionCompression, S3SyncManager } from '@open-wa/session-sync';
import type { S3Config } from '@open-wa/session-sync';
import type { Config } from '@open-wa/config';

type SessionManagerConfig = {
sessionId: string;
dataDir: string;
s3Config?: S3Config;
syncInterval?: number;
enableLocalCompression?: boolean;
enableS3Backup?: boolean;
};

export class SessionManager {
private config: SessionManagerConfig;
private logger = createLogger({ component: 'SessionManager' });
private localCompression: LocalSessionCompression | null = null;
private s3Sync: S3SyncManager | null = null;
private syncTimer: NodeJS.Timeout | null = null;
private syncInFlight = false;

constructor(config: SessionManagerConfig) {
this.config = config;
this.logger.info('SessionManager initialized', { sessionId: config.sessionId });
}

async start(): Promise<void> {
if (this.config.enableLocalCompression !== false) {
this.localCompression = new LocalSessionCompression({
sessionPath: this.config.dataDir,
sessionId: this.config.sessionId,
intervalMs: this.config.syncInterval || 600_000,
});
await this.localCompression.start();
this.logger.info('Local session compression started');
}

// S3 backups upload the `.data.zst` artifact produced by LocalSessionCompression.
// If local compression is disabled, periodic S3 sync would target a non-existent file.
if (this.config.enableLocalCompression !== false && this.config.enableS3Backup && this.config.s3Config) {
this.s3Sync = new S3SyncManager(this.config.s3Config);
this.startPeriodicSync();
this.logger.info('S3 session sync started');
}
}

async stop(): Promise<void> {
if (this.localCompression) {
try {
await this.localCompression.stop();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn('Local session compression stop failed', { error: message });
}
this.localCompression = null;
}

if (this.syncTimer) {
clearTimeout(this.syncTimer);
this.syncTimer = null;
}
this.syncInFlight = false;

if (this.s3Sync) {
this.s3Sync = null;
}
Comment thread
t0g3pii marked this conversation as resolved.

this.logger.info('SessionManager stopped');
}

async backupSession(): Promise<string | null> {
if (!this.s3Sync) {
this.logger.warn('S3 sync not configured');
return null;
}

const sessionZstPath = `${this.config.dataDir}/${this.config.sessionId}.data.zst`;
try {
const filename = await this.s3Sync.backupSession(sessionZstPath);
this.logger.info('Session backed up to S3', { filename });
return filename;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error('Failed to backup session to S3', { error: message });
throw error;
}
}

async restoreSession(filename: string): Promise<void> {
if (!this.s3Sync) {
this.logger.warn('S3 sync not configured');
throw new Error('S3 sync not configured for session restore');
}

try {
await this.s3Sync.restoreSession(filename, this.config.dataDir);
this.logger.info('Session restored from S3', { filename });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error('Failed to restore session from S3', { error: message });
throw error;
}
}

async getSessionBackupUrl(filename: string, expiresIn = 3600): Promise<string | null> {
if (!this.s3Sync) {
this.logger.warn('S3 sync not configured');
return null;
}

try {
const url = await this.s3Sync.getDownloadUrl(filename, expiresIn);
this.logger.info('Generated session backup URL', { filename, expiresIn });
return url;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error('Failed to generate backup URL', { error: message });
throw error;
}
}

private startPeriodicSync(): void {
if (!this.s3Sync) return;

const syncInterval = this.config.syncInterval || 600_000;

// Defensive: stop any previous schedule before starting a new one
if (this.syncTimer) {
clearTimeout(this.syncTimer);
this.syncTimer = null;
}

const tick = async () => {
if (!this.s3Sync) return;

if (!this.syncInFlight) {
this.syncInFlight = true;
try {
await this.backupSession();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error('Periodic sync failed', { error: message });
} finally {
this.syncInFlight = false;
}
}

if (!this.s3Sync) return;
this.syncTimer = setTimeout(() => void tick(), syncInterval);
};

this.syncTimer = setTimeout(() => void tick(), syncInterval);
}

private static parseS3Config(input: unknown): S3Config | null {
if (!input || typeof input !== 'object') return null;

const obj = input as Record<string, unknown>;
const bucket = obj.bucket;
const region = obj.region;
const accessKeyId = obj.accessKeyId;
const secretAccessKey = obj.secretAccessKey;

const hasRequired =
typeof bucket === 'string' &&
bucket.length > 0 &&
typeof region === 'string' &&
region.length > 0 &&
typeof accessKeyId === 'string' &&
accessKeyId.length > 0 &&
typeof secretAccessKey === 'string' &&
secretAccessKey.length > 0;

if (!hasRequired) return null;

const endpoint = typeof obj.endpoint === 'string' ? obj.endpoint : undefined;
const host = typeof obj.host === 'string' ? obj.host : undefined;
const url = typeof obj.url === 'string' ? obj.url : undefined;

return { bucket, region, accessKeyId, secretAccessKey, endpoint, host, url };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

static createFromConfig(clientConfig: Config): SessionManager {
const validatedS3Config = SessionManager.parseS3Config(clientConfig.s3Sync);
return new SessionManager({
sessionId: clientConfig.sessionId || 'session',
dataDir: clientConfig.sessionDataPath || './.wwebjs',
s3Config: validatedS3Config ?? undefined,
syncInterval: clientConfig.s3Sync?.syncInterval,
enableLocalCompression: clientConfig.s3Sync?.enableLocalCompression !== false,
enableS3Backup: validatedS3Config !== null,
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}