Add SQL outbox for MAX sends
This commit is contained in:
+102
-53
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user