Skip to content
Draft
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
321 changes: 321 additions & 0 deletions apps/discord-bot/DESIGN-thread-restore.md

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions apps/discord-bot/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@t3tools/discord-bot",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"browser-profile": "node --import tsx src/browserProfileCli.ts",
"dev": "node --watch --import tsx src/main.ts",
"start": "node --import tsx src/main.ts",
"typecheck": "tsgo --noEmit",
"test": "vp test run"
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@t3tools/client-runtime": "workspace:*",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
"dfx": "catalog:",
"effect": "catalog:",
"playwright-core": "1.60.0"
},
"devDependencies": {
"@effect/vitest": "catalog:",
"@types/node": "catalog:",
"tsx": "^4.20.5",
"vite-plus": "catalog:"
},
"engines": {
"node": "^22.16 || ^23.11 || >=24.10"
}
}
37 changes: 37 additions & 0 deletions apps/discord-bot/src/browser/BrowserAutomationHost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vite-plus/test";

import {
browserOperationDeadlineMs,
BrowserOperationTimeoutError,
withBrowserOperationDeadline,
} from "./BrowserAutomationHost.ts";

describe("browser automation host deadline", () => {
it("reserves time for delivering the response to the broker", () => {
expect(browserOperationDeadlineMs(15_000)).toBe(14_000);
expect(browserOperationDeadlineMs(500)).toBe(450);
});

it("rejects a stalled operation before the broker timeout", async () => {
const stalled = new Promise<never>(() => {});
let interrupted = false;

await expect(
withBrowserOperationDeadline(stalled, 10, () => {
interrupted = true;
}),
).rejects.toBeInstanceOf(BrowserOperationTimeoutError);
expect(interrupted).toBe(true);
});

it("does not interrupt an operation that completes before its deadline", async () => {
let interrupted = false;

await expect(
withBrowserOperationDeadline(Promise.resolve("complete"), 100, () => {
interrupted = true;
}),
).resolves.toBe("complete");
expect(interrupted).toBe(false);
});
});
162 changes: 162 additions & 0 deletions apps/discord-bot/src/browser/BrowserAutomationHost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import * as NodeTimersPromises from "node:timers/promises";

import {
type EnvironmentId,
type PreviewAutomationHost,
type PreviewAutomationHostFocus,
type PreviewAutomationResponse,
type PreviewAutomationStreamEvent,
type ThreadId,
} from "@t3tools/contracts";

import type { DiscordBotConfig } from "../config.ts";
import { BrowserRuntime } from "./BrowserRuntime.ts";

const SUPPORTED_OPERATIONS = [
"status",
"open",
"navigate",
"snapshot",
"click",
"type",
"press",
"scroll",
"evaluate",
"waitFor",
"recordingStart",
"recordingStop",
] as const;

const MAX_RESPONSE_RESERVE_MS = 1_000;
const RESPONSE_RESERVE_RATIO = 0.1;

export class BrowserOperationTimeoutError extends Error {
readonly timeoutMs: number;

constructor(timeoutMs: number) {
super(`Browser automation host timed out after ${timeoutMs}ms.`);
this.name = "BrowserOperationTimeoutError";
this.timeoutMs = timeoutMs;
}
}

export function browserOperationDeadlineMs(timeoutMs: number): number {
const reserve = Math.min(
MAX_RESPONSE_RESERVE_MS,
Math.max(1, timeoutMs * RESPONSE_RESERVE_RATIO),
);
return Math.max(1, Math.floor(timeoutMs - reserve));
}

export function withBrowserOperationDeadline<A>(
operation: Promise<A>,
timeoutMs: number,
onTimeout: () => void = () => {},
): Promise<A> {
const deadlineMs = browserOperationDeadlineMs(timeoutMs);
const controller = new AbortController();
const timeout = NodeTimersPromises.setTimeout(deadlineMs, undefined, {
signal: controller.signal,
ref: false,
}).then(() => {
onTimeout();
throw new BrowserOperationTimeoutError(deadlineMs);
});
return Promise.race([operation, timeout]).finally(() => controller.abort());
}

export class BrowserAutomationHost {
readonly #runtime: BrowserRuntime;
readonly #clientId: string;
readonly #environmentId: EnvironmentId;
#connectionId: PreviewAutomationHostFocus["connectionId"] | null = null;

private constructor(runtime: BrowserRuntime, profile: string, environmentId: EnvironmentId) {
this.#runtime = runtime;
this.#clientId = `discord-browser-${profile}`;
this.#environmentId = environmentId;
}

static async launch(
config: DiscordBotConfig,
environmentId: EnvironmentId,
): Promise<BrowserAutomationHost> {
if (!config.browserExecutablePath) {
throw new Error(
"T3_DISCORD_BROWSER_EXECUTABLE_PATH is required when browser automation is enabled.",
);
}
if (config.browserAllowedOrigins.length === 0) {
throw new Error(
"T3_DISCORD_BROWSER_ALLOWED_ORIGINS is required when browser automation is enabled.",
);
}
const runtime = await BrowserRuntime.launch({
dataDir: config.dataDir,
profile: config.browserProfile,
executablePath: config.browserExecutablePath,
ffmpegPath: config.browserFfmpegPath,
allowedOrigins: config.browserAllowedOrigins,
headless: true,
});
return new BrowserAutomationHost(runtime, config.browserProfile, environmentId);
}

registration(): PreviewAutomationHost {
return {
clientId: this.#clientId,
environmentId: this.#environmentId,
supportedOperations: [...SUPPORTED_OPERATIONS],
};
}

async consume(event: PreviewAutomationStreamEvent): Promise<PreviewAutomationResponse | null> {
if (event.type === "connected") {
this.#connectionId = event.connectionId;
return null;
}
const responseBase = {
clientId: this.#clientId,
connectionId: event.connectionId,
requestId: event.request.requestId,
} as const;
let response: PreviewAutomationResponse;
try {
const result = await withBrowserOperationDeadline(
this.#runtime.handle(event.request),
event.request.timeoutMs,
() => this.#runtime.interrupt(event.request),
);
response = { ...responseBase, ok: true, ...(result === undefined ? {} : { result }) };
} catch (cause) {
const message = cause instanceof Error ? cause.message : "Browser automation failed.";
response = {
...responseBase,
ok: false,
error: {
_tag:
cause instanceof BrowserOperationTimeoutError
? "PreviewAutomationTimeoutError"
: "PreviewAutomationExecutionError",
message,
},
};
}
return response;
}

claim(threadId: ThreadId): PreviewAutomationHostFocus | null {
if (this.#connectionId === null) return null;
return {
clientId: this.#clientId,
environmentId: this.#environmentId,
connectionId: this.#connectionId,
focused: true,
threadId,
};
}

close(): Promise<void> {
return this.#runtime.close();
}
}
20 changes: 20 additions & 0 deletions apps/discord-bot/src/browser/BrowserRecording.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodePath from "node:path";

import { describe, expect, it } from "vite-plus/test";

import { ffmpegRecordingArguments } from "./BrowserRecording.ts";

describe("browser recording", () => {
it("builds a shell-free MP4 encoding command", () => {
const args = ffmpegRecordingArguments({
framesDirectory: "/tmp/browser frames",
outputPath: "/tmp/artifacts/recording.mp4",
});

expect(args).toContain(NodePath.join("/tmp/browser frames", "frame-%08d.jpg"));
expect(args).toContain("libx264");
expect(args).toContain("yuv420p");
expect(args.at(-1)).toBe("/tmp/artifacts/recording.mp4");
});
});
Loading
Loading