opengraph stuff
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { simpleParser } from 'mailparser';
|
||||
import type { EmailSummary, EmailMessage } from 'types';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getUserEmailDir } from '@@/data-path';
|
||||
|
||||
type CacheEntry = {
|
||||
summaries: EmailSummary[];
|
||||
fileCount: number;
|
||||
};
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
const parseHeadersOnly = async (filePath: string, id: string): Promise<EmailSummary | null> => {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const parsed = await simpleParser(buffer, { skipHtmlToText: true, skipTextToHtml: true, skipImageLinks: true });
|
||||
|
||||
const text = parsed.text ?? '';
|
||||
const snippet = text.slice(0, 120).replace(/\s+/g, ' ').trim();
|
||||
|
||||
return {
|
||||
id,
|
||||
from: parsed.from?.text ?? '',
|
||||
to: parsed.to ? (Array.isArray(parsed.to) ? parsed.to.map((a) => a.text).join(', ') : parsed.to.text) : '',
|
||||
subject: parsed.subject ?? '(no subject)',
|
||||
date: (parsed.date ?? new Date()).toISOString(),
|
||||
snippet,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const emailRouter = createRouter();
|
||||
|
||||
emailRouter.get('/messages', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const dir = getUserEmailDir(email);
|
||||
|
||||
let filenames: string[];
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
filenames = entries.filter((f) => f.endsWith('.eml'));
|
||||
} catch {
|
||||
return ctx.json({ messages: [], total: 0 });
|
||||
}
|
||||
|
||||
const cached = cache.get(email);
|
||||
if (cached && cached.fileCount === filenames.length) {
|
||||
const page = Number(ctx.req.query('page') ?? '1');
|
||||
const limit = Number(ctx.req.query('limit') ?? '50');
|
||||
const start = (page - 1) * limit;
|
||||
return ctx.json({ messages: cached.summaries.slice(start, start + limit), total: cached.summaries.length });
|
||||
}
|
||||
|
||||
const summaries: EmailSummary[] = [];
|
||||
for (const filename of filenames) {
|
||||
const id = filename.replace(/\.eml$/, '');
|
||||
const summary = await parseHeadersOnly(join(dir, filename), id);
|
||||
if (summary) summaries.push(summary);
|
||||
}
|
||||
|
||||
summaries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
|
||||
cache.set(email, { summaries, fileCount: filenames.length });
|
||||
|
||||
const page = Number(ctx.req.query('page') ?? '1');
|
||||
const limit = Number(ctx.req.query('limit') ?? '50');
|
||||
const start = (page - 1) * limit;
|
||||
return ctx.json({ messages: summaries.slice(start, start + limit), total: summaries.length });
|
||||
});
|
||||
|
||||
emailRouter.get('/messages/:id', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const id = ctx.req.param('id');
|
||||
const filePath = join(getUserEmailDir(email), `${id}.eml`);
|
||||
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return ctx.text('Not found', 404);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const parsed = await simpleParser(buffer);
|
||||
|
||||
const attachments = (parsed.attachments ?? []).map((a) => ({
|
||||
filename: a.filename ?? 'unknown',
|
||||
size: a.size,
|
||||
contentType: a.contentType,
|
||||
}));
|
||||
|
||||
const message: EmailMessage = {
|
||||
id,
|
||||
from: parsed.from?.text ?? '',
|
||||
to: parsed.to ? (Array.isArray(parsed.to) ? parsed.to.map((a) => a.text).join(', ') : parsed.to.text) : '',
|
||||
cc: parsed.cc ? (Array.isArray(parsed.cc) ? parsed.cc.map((a) => a.text).join(', ') : parsed.cc.text) : undefined,
|
||||
subject: parsed.subject ?? '(no subject)',
|
||||
date: (parsed.date ?? new Date()).toISOString(),
|
||||
snippet: (parsed.text ?? '').slice(0, 120).replace(/\s+/g, ' ').trim(),
|
||||
html: parsed.html || undefined,
|
||||
text: parsed.text || undefined,
|
||||
attachments,
|
||||
};
|
||||
|
||||
return ctx.json(message);
|
||||
} catch {
|
||||
return ctx.text('Failed to parse email', 500);
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { join, relative } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import type { Subprocess } from "bun";
|
||||
import type { PiEvent, MessageCost } from "./types";
|
||||
import { readApiKeys } from "../server-settings/pi-mono";
|
||||
import { readSearxngConfig } from "../server-settings/searxng";
|
||||
import { PI_CONFIG_DIR, DATA_PATH, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
|
||||
import { PI_CONFIG_DIR, DATA_PATH, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
|
||||
import { ensureDockerContainer } from "../terminal/websocket";
|
||||
import { logger } from "./logger";
|
||||
import { parseFrontmatter } from "../skills/skills";
|
||||
@@ -153,6 +154,14 @@ function buildResourcesEnv(): string {
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
function getGoogleConfigPath(): string {
|
||||
return join(homedir(), '.config', 'officer.dev', 'google-oauth.json');
|
||||
}
|
||||
|
||||
function getGoogleTokenPath(email: string): string {
|
||||
return join(DATA_PATH, email, 'integrations', 'google.json');
|
||||
}
|
||||
|
||||
type SandboxOptions = {
|
||||
userId: number;
|
||||
username: string;
|
||||
@@ -203,12 +212,19 @@ export async function spawnPi(
|
||||
|
||||
const resourcesEnv = buildResourcesEnv();
|
||||
|
||||
const googleConfigHost = getGoogleConfigPath();
|
||||
const googleTokenHost = join(DATA_PATH, sandbox.email, 'integrations');
|
||||
|
||||
const envFlags = [
|
||||
'-e', `PI_CODING_AGENT_DIR=${containerPiConfig}`,
|
||||
'-e', `HOME=${containerHome}`,
|
||||
'-e', `OFFICER_USER_HOME=${containerHome}`,
|
||||
'-e', `OFFICER_USER_ROOT=/officer/user`,
|
||||
'-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`,
|
||||
'-e', `PI_SEARXNG_URL=${searxng.url}`,
|
||||
'-e', `OFFICER_RESOURCES=${resourcesEnv}`,
|
||||
'-e', `OFFICER_GOOGLE_CONFIG_PATH=/officer/google-oauth.json`,
|
||||
'-e', `OFFICER_GOOGLE_TOKEN_PATH=/officer/user/integrations/google.json`,
|
||||
];
|
||||
for (const [key, value] of Object.entries(storedKeys)) {
|
||||
if (value?.trim()) envFlags.push('-e', `${key}=${value.trim()}`);
|
||||
@@ -258,7 +274,7 @@ export async function spawnPi(
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs, PI_SEARXNG_URL: searxng.url, OFFICER_RESOURCES: buildResourcesEnv() },
|
||||
env: { ...process.env, ...storedKeys, HOME: getHomeDir(email), OFFICER_USER_HOME: getHomeDir(email), OFFICER_USER_ROOT: join(DATA_PATH, email), PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs, PI_SEARXNG_URL: searxng.url, OFFICER_RESOURCES: buildResourcesEnv(), OFFICER_GOOGLE_CONFIG_PATH: getGoogleConfigPath(), OFFICER_GOOGLE_TOKEN_PATH: getGoogleTokenPath(email) },
|
||||
});
|
||||
|
||||
logger.info('Spawned Pi locally', {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { enqueue, cancelJob, readJob, listAllJobs } from '../../queue';
|
||||
import { NOT_FOUND } from '../../custom-errors';
|
||||
|
||||
export const queueRouter = createRouter();
|
||||
|
||||
queueRouter.get('/jobs', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const lane = ctx.req.query('lane');
|
||||
const type = ctx.req.query('type');
|
||||
const status = ctx.req.query('status');
|
||||
|
||||
let jobs = await listAllJobs();
|
||||
jobs = jobs.filter((j) => j.userId === user.email);
|
||||
|
||||
if (lane) jobs = jobs.filter((j) => j.lane === lane);
|
||||
if (type) jobs = jobs.filter((j) => j.type === type);
|
||||
if (status) jobs = jobs.filter((j) => j.status === status);
|
||||
|
||||
return ctx.json(jobs);
|
||||
});
|
||||
|
||||
queueRouter.get('/jobs/:id', async (ctx) => {
|
||||
const job = await readJob(ctx.req.param('id'));
|
||||
if (!job) throw NOT_FOUND('Job not found');
|
||||
return ctx.json(job);
|
||||
});
|
||||
|
||||
queueRouter.post('/jobs', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.get('body');
|
||||
const { lane, type, meta } = body as { lane: string; type: string; meta?: Record<string, unknown> };
|
||||
|
||||
const job = await enqueue({ lane, type, userId: user.email, meta });
|
||||
return ctx.json(job, 201);
|
||||
});
|
||||
|
||||
queueRouter.delete('/jobs/:id', async (ctx) => {
|
||||
const job = await cancelJob(ctx.req.param('id'));
|
||||
if (!job) throw NOT_FOUND('Job not found');
|
||||
return ctx.json(job);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { mkdirSync, statSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH } from '@@/data-path';
|
||||
@@ -101,9 +102,9 @@ const ensureDockerImage = () => {
|
||||
dockerImageReady = true;
|
||||
};
|
||||
|
||||
// Check whether a container already has the officer resource mounts.
|
||||
// We test for the global skills dir as a proxy for all mounts being present.
|
||||
const containerHasResourceMounts = (dockerId: string): boolean => {
|
||||
// Check whether a container has all expected volume mounts.
|
||||
// Tests for multiple mount sources — if any is missing, the container should be recreated.
|
||||
const containerHasExpectedMounts = (dockerId: string): boolean => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [dockerPath, 'inspect', '--format', '{{range .Mounts}}{{.Source}}\n{{end}}', dockerId],
|
||||
@@ -111,7 +112,8 @@ const containerHasResourceMounts = (dockerId: string): boolean => {
|
||||
stderr: 'ignore',
|
||||
});
|
||||
if (result.exitCode !== 0) return false;
|
||||
return result.stdout.toString().includes(getGlobalSkillsDir());
|
||||
const mounts = result.stdout.toString();
|
||||
return mounts.includes(getGlobalSkillsDir()) && mounts.includes('google-oauth.json');
|
||||
};
|
||||
|
||||
const startDockerSidecar = (port: number, homeDir: string, userId: number, username: string, email: string): { dockerId: string } => {
|
||||
@@ -136,6 +138,11 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, usern
|
||||
}
|
||||
|
||||
const containerHome = `/home/${username}`;
|
||||
const googleConfigHost = join(homedir(), '.config', 'officer.dev', 'google-oauth.json');
|
||||
const googleMounts: string[] = existsSync(googleConfigHost)
|
||||
? ['-v', `${googleConfigHost}:/officer/google-oauth.json:ro`]
|
||||
: [];
|
||||
|
||||
const run = Bun.spawnSync({
|
||||
cmd: [
|
||||
dockerPath,
|
||||
@@ -164,6 +171,8 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, usern
|
||||
'-v', `${getUserSkillsDir(email)}:/officer/user/skills:ro`,
|
||||
'-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`,
|
||||
'-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`,
|
||||
...googleMounts,
|
||||
'-v', `${join(DATA_PATH, email, 'integrations')}:/officer/user/integrations:ro`,
|
||||
'-w', containerHome,
|
||||
tag,
|
||||
],
|
||||
@@ -231,13 +240,14 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD
|
||||
// Ensure user-specific resource dirs exist before mounting (Docker creates them as root if missing)
|
||||
mkdirSync(getUserSkillsDir(email), { recursive: true });
|
||||
mkdirSync(getUserToolsDir(email), { recursive: true });
|
||||
mkdirSync(join(DATA_PATH, email, 'integrations'), { recursive: true });
|
||||
|
||||
const map = await loadContainerMap();
|
||||
const existing = map[email];
|
||||
|
||||
if (existing && dockerContainerRunning(existing.dockerId)) {
|
||||
// Recreate if resource mounts are missing (e.g. first run after feature was added)
|
||||
if (!containerHasResourceMounts(existing.dockerId)) {
|
||||
if (!containerHasExpectedMounts(existing.dockerId)) {
|
||||
console.log(`[terminal] recreating container for ${email} — resource mounts missing`);
|
||||
stopDockerSidecar(existing.dockerId);
|
||||
} else {
|
||||
@@ -246,7 +256,7 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD
|
||||
}
|
||||
|
||||
if (existing && dockerContainerExists(existing.dockerId)) {
|
||||
if (!containerHasResourceMounts(existing.dockerId)) {
|
||||
if (!containerHasExpectedMounts(existing.dockerId)) {
|
||||
stopDockerSidecar(existing.dockerId);
|
||||
} else if (dockerStart(existing.dockerId)) {
|
||||
return existing;
|
||||
|
||||
Reference in New Issue
Block a user