Add SQL outbox for MAX sends

This commit is contained in:
sevenhill
2026-07-05 13:47:07 +03:00
parent 5d33320e3d
commit 6dd5c7c6fb
8 changed files with 462 additions and 123 deletions
+102 -53
View File
@@ -14,9 +14,11 @@ const domStickerPrefix = "qmax-dom-sticker:";
const domEmojiPrefix = "qmax-dom-emoji:";
let context;
let contextInitialization;
let page;
let lastError = null;
let pageOperation = Promise.resolve();
const pagesByRole = new Map();
const pageOperations = new Map();
const networkLog = [];
const app = express();
@@ -227,9 +229,9 @@ app.get("/media/fetch", async (req, res) => {
}
const media = await withPageLock(async () => {
const p = await ensurePage();
const p = await ensurePage("incoming");
return await fetchMediaThroughMaxSession(p, mediaUrl);
});
}, "incoming");
res.setHeader("Content-Type", media.contentType || "application/octet-stream");
res.setHeader("Content-Length", String(media.buffer.length));
@@ -242,8 +244,8 @@ app.get("/media/fetch", async (req, res) => {
app.get("/updates", async (_req, res) => {
try {
const result = await withPageLock(async () => {
const p = await ensurePage();
const currentStatus = await status();
const p = await ensurePage("incoming");
const currentStatus = await status(null, p);
if (!currentStatus.isAuthorized) {
return [];
}
@@ -251,7 +253,7 @@ app.get("/updates", async (_req, res) => {
await clearSidebarSearch(p);
await resetSidebarListToTop(p);
return await extractUpdates(p);
});
}, "incoming");
res.json(result);
} catch (error) {
res.status(500).json(errorStatus(error));
@@ -262,8 +264,8 @@ app.post("/chat/history", async (req, res) => {
try {
const externalChatId = String(req.body?.externalChatId || "").trim();
const result = await withPageLock(async () => {
const p = await ensurePage();
const currentStatus = await status();
const p = await ensurePage("incoming");
const currentStatus = await status(null, p);
if (!currentStatus.isAuthorized) {
return null;
}
@@ -277,7 +279,7 @@ app.post("/chat/history", async (req, res) => {
await scrollOpenChatToLatest(p);
return await extractOpenChatHistory(p, externalChatId);
});
}, "incoming");
if (!result) {
return res.status(409).json({ error: "MAX is not authorized." });
@@ -293,8 +295,8 @@ app.post("/chat/clear-composer", async (req, res) => {
try {
const externalChatId = String(req.body?.externalChatId || "").trim();
const result = await withPageLock(async () => {
const p = await ensurePage();
const currentStatus = await status();
const p = await ensurePage("outgoing");
const currentStatus = await status(null, p);
if (!currentStatus.isAuthorized) {
return { success: false, error: "MAX is not authorized.", composerText: null };
}
@@ -308,7 +310,7 @@ app.post("/chat/clear-composer", async (req, res) => {
const success = await clearComposerInput(p);
return { success, error: success ? null : "MAX composer was not cleared.", composerText: await readComposerText(p) };
});
}, "outgoing");
res.json(result);
} catch (error) {
@@ -325,8 +327,8 @@ app.post("/chat/draft-text", async (req, res) => {
}
const result = await withPageLock(async () => {
const p = await ensurePage();
const currentStatus = await status();
const p = await ensurePage("outgoing");
const currentStatus = await status(null, p);
if (!currentStatus.isAuthorized) {
return { success: false, error: "MAX is not authorized.", composerText: null };
}
@@ -340,7 +342,7 @@ app.post("/chat/draft-text", async (req, res) => {
const success = await fillComposerText(p, text, true);
return { success, error: success ? null : "MAX composer was not filled.", composerText: await readComposerText(p) };
});
}, "outgoing");
res.json(result);
} catch (error) {
@@ -352,8 +354,8 @@ app.post("/chat/presence", async (req, res) => {
try {
const externalChatId = String(req.body?.externalChatId || "").trim();
const result = await withPageLock(async () => {
const p = await ensurePage();
const currentStatus = await status();
const p = await ensurePage("incoming");
const currentStatus = await status(null, p);
if (!currentStatus.isAuthorized) {
return { isTyping: false, statusText: null, updatedAt: new Date().toISOString() };
}
@@ -363,7 +365,7 @@ app.post("/chat/presence", async (req, res) => {
}
return await extractOpenChatPresence(p);
});
}, "incoming");
res.json(result);
} catch (error) {
@@ -380,7 +382,7 @@ app.post("/send/text", async (req, res) => {
}
const result = await withPageLock(async () => {
const p = await ensurePage();
const p = await ensurePage("outgoing");
if (externalChatId) {
const opened = await ensureDomChatOpen(p, externalChatId, 8000, true);
@@ -394,7 +396,7 @@ app.post("/send/text", async (req, res) => {
return { success: false, externalMessageId: null, error: "MAX did not confirm the outgoing text message." };
}
return { success: true, externalMessageId, error: null };
});
}, "outgoing");
res.json(result);
} catch (error) {
res.json({ success: false, externalMessageId: null, error: String(error?.message || error) });
@@ -411,7 +413,7 @@ app.post("/send/attachment", async (req, res) => {
}
const result = await withPageLock(async () => {
const p = await ensurePage();
const p = await ensurePage("outgoing");
if (externalChatId) {
const opened = await ensureDomChatOpen(p, externalChatId, 8000, true);
if (!opened) {
@@ -425,7 +427,7 @@ app.post("/send/attachment", async (req, res) => {
}
return { success: true, externalMessageId, error: null };
});
}, "outgoing");
res.json(result);
} catch (error) {
@@ -444,9 +446,9 @@ app.post("/message/edit", async (req, res) => {
}
const result = await withPageLock(async () => {
const p = await ensurePage();
const p = await ensurePage("outgoing");
return await editMessageInMax(p, externalChatId, externalMessageId, currentText, text);
});
}, "outgoing");
res.json(result);
} catch (error) {
res.json({ success: false, error: String(error?.message || error) });
@@ -463,9 +465,9 @@ app.post("/message/delete", async (req, res) => {
}
const result = await withPageLock(async () => {
const p = await ensurePage();
const p = await ensurePage("outgoing");
return await deleteMessageInMax(p, externalChatId, externalMessageId, currentText);
});
}, "outgoing");
res.json(result);
} catch (error) {
res.json({ success: false, error: String(error?.message || error) });
@@ -483,9 +485,9 @@ app.post("/message/reaction", async (req, res) => {
}
const result = await withPageLock(async () => {
const p = await ensurePage();
const p = await ensurePage("outgoing");
return await setMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji);
});
}, "outgoing");
res.json(result);
} catch (error) {
res.json({ success: false, error: String(error?.message || error) });
@@ -503,9 +505,9 @@ app.delete("/message/reaction", async (req, res) => {
}
const result = await withPageLock(async () => {
const p = await ensurePage();
const p = await ensurePage("outgoing");
return await clearMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji);
});
}, "outgoing");
res.json(result);
} catch (error) {
res.json({ success: false, error: String(error?.message || error) });
@@ -516,32 +518,70 @@ app.listen(port, () => {
console.log(`QMAX MAX worker listening on ${port}, headless=${headless}`);
});
async function ensurePage() {
if (page) return page;
await cleanupChromiumProfileLocks(userDataDir);
context = await chromium.launchPersistentContext(userDataDir, {
headless,
acceptDownloads: true,
locale: "ru-RU",
timezoneId: process.env.MAX_TIMEZONE_ID || "Europe/Moscow",
viewport: { width: 1280, height: 900 },
args: ["--disable-dev-shm-usage", "--no-sandbox"]
});
await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: maxBaseUrl }).catch(() => {});
page = context.pages()[0] || await context.newPage();
attachNetworkCapture(page);
if (!page.url() || page.url() === "about:blank") {
await page.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
async function ensurePage(role = "default") {
const normalizedRole = String(role || "default");
const existing = pagesByRole.get(normalizedRole);
if (existing && !existing.isClosed()) return existing;
await ensureContext();
if (normalizedRole === "default") {
if (!page || page.isClosed()) {
page = context.pages().find((candidate) => !candidate.isClosed()) || await context.newPage();
pagesByRole.set("default", page);
attachNetworkCapture(page);
if (!page.url() || page.url() === "about:blank") {
await page.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
}
}
return page;
}
return page;
const rolePage = await context.newPage();
pagesByRole.set(normalizedRole, rolePage);
attachNetworkCapture(rolePage);
await rolePage.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
return rolePage;
}
async function withPageLock(operation) {
const previousOperation = pageOperation;
async function ensureContext() {
if (context) return;
if (!contextInitialization) {
contextInitialization = (async () => {
await cleanupChromiumProfileLocks(userDataDir);
context = await chromium.launchPersistentContext(userDataDir, {
headless,
acceptDownloads: true,
locale: "ru-RU",
timezoneId: process.env.MAX_TIMEZONE_ID || "Europe/Moscow",
viewport: { width: 1280, height: 900 },
args: ["--disable-dev-shm-usage", "--no-sandbox"]
});
await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: maxBaseUrl }).catch(() => {});
page = context.pages()[0] || await context.newPage();
pagesByRole.set("default", page);
attachNetworkCapture(page);
if (!page.url() || page.url() === "about:blank") {
await page.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
}
})().catch((error) => {
contextInitialization = null;
throw error;
});
}
await contextInitialization;
}
async function withPageLock(operation, role = "default") {
const normalizedRole = String(role || "default");
const previousOperation = pageOperations.get(normalizedRole) || Promise.resolve();
let release;
pageOperation = new Promise((resolve) => {
const nextOperation = new Promise((resolve) => {
release = resolve;
});
pageOperations.set(normalizedRole, nextOperation);
await previousOperation.catch(() => {});
try {
@@ -551,9 +591,9 @@ async function withPageLock(operation) {
}
}
async function status(overrideStatus = null) {
async function status(overrideStatus = null, statusPage = null) {
try {
const p = await ensurePage();
const p = statusPage || await ensurePage();
const title = await safeTitle(p);
const url = p.url();
const bodyText = await safeBodyText(p);
@@ -575,17 +615,26 @@ async function status(overrideStatus = null) {
function errorStatus(error) {
lastError = String(error?.message || error);
const fallbackPage = firstKnownPage();
return {
mode: "Worker",
isAuthorized: false,
status: "WorkerError",
url: page?.url?.() || null,
url: fallbackPage?.url?.() || null,
title: null,
lastError,
updatedAt: new Date().toISOString()
};
}
function firstKnownPage() {
return page ||
pagesByRole.get("default") ||
pagesByRole.get("incoming") ||
pagesByRole.get("outgoing") ||
null;
}
function isAllowedMediaUrl(value) {
const raw = String(value || "").trim();
if (!raw) return false;