diggylab 0.1.51 → 0.1.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +228 -228
  2. package/cli.js +759 -23
  3. package/package.json +1 -1
package/cli.js CHANGED
@@ -8,7 +8,12 @@ import { hostname, homedir } from "node:os";
8
8
  import { dirname as dirname3, join as join4 } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
 
11
- // ../../packages/shared/src/index.ts
11
+ // ../../packages/shared/dist/comment-publishing.js
12
+ var COMMENT_TEXT_MAX_LENGTH = 2e3;
13
+ var COMMENT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
14
+ var COMMENT_IMAGE_QUOTA_BYTES = 500 * 1024 * 1024;
15
+
16
+ // ../../packages/shared/dist/index.js
12
17
  var DESKTOP_AGENT_PROTOCOL_VERSION = 1;
13
18
  var DESKTOP_POST_COMMENTS_MAX_LIMIT = 200;
14
19
  var DESKTOP_POST_COMMENTS_REQUEST_TIMEOUT_MS = 3 * 60 * 1e3;
@@ -18,17 +23,22 @@ function isFacebookDomain(domain) {
18
23
  return Boolean(domain && FACEBOOK_HOST.test(domain.replace(/^\./, "")));
19
24
  }
20
25
  function normalizeSameSite(value) {
21
- if (typeof value !== "string") return void 0;
26
+ if (typeof value !== "string")
27
+ return void 0;
22
28
  const lower = value.toLowerCase().replace(/[^a-z]/g, "");
23
- if (lower === "lax") return "Lax";
24
- if (lower === "strict") return "Strict";
25
- if (lower === "none" || lower === "norestriction") return "None";
29
+ if (lower === "lax")
30
+ return "Lax";
31
+ if (lower === "strict")
32
+ return "Strict";
33
+ if (lower === "none" || lower === "norestriction")
34
+ return "None";
26
35
  return void 0;
27
36
  }
28
37
  function toCookie(value) {
29
38
  const name = typeof value.name === "string" ? value.name.trim() : "";
30
39
  const cookieValue = typeof value.value === "string" ? value.value : "";
31
- if (!name || !cookieValue) return null;
40
+ if (!name || !cookieValue)
41
+ return null;
32
42
  return {
33
43
  domain: typeof value.domain === "string" ? value.domain : void 0,
34
44
  expires: typeof value.expires === "number" ? value.expires : typeof value.expirationDate === "number" ? value.expirationDate : void 0,
@@ -44,7 +54,8 @@ function parseCookieHeader(input) {
44
54
  const header = input.replace(/^\s*cookie\s*:/i, "");
45
55
  return header.split(/[;\n]/).map((part) => part.trim()).filter(Boolean).map((part) => {
46
56
  const separator = part.indexOf("=");
47
- if (separator <= 0) return null;
57
+ if (separator <= 0)
58
+ return null;
48
59
  return { name: part.slice(0, separator).trim(), value: part.slice(separator + 1) };
49
60
  }).filter((cookie) => cookie !== null);
50
61
  }
@@ -54,14 +65,16 @@ function parseCookieTable(input) {
54
65
  const columns = line.split(" ");
55
66
  const name = columns[0]?.trim() ?? "";
56
67
  const value = columns[1];
57
- if (!value || !name || name === "Name") continue;
68
+ if (!value || !name || name === "Name")
69
+ continue;
58
70
  cookies.push({ domain: columns[2]?.trim(), name, value });
59
71
  }
60
72
  return cookies;
61
73
  }
62
74
  function parseCookieInput(input) {
63
75
  const trimmed = input.trim();
64
- if (!trimmed) return [];
76
+ if (!trimmed)
77
+ return [];
65
78
  if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
66
79
  return trimmed.includes(" ") ? parseCookieTable(trimmed) : parseCookieHeader(trimmed);
67
80
  }
@@ -762,6 +775,370 @@ function parseCommentDepth(label) {
762
775
  return /\brepl(?:y|ies)\b|trả lời|phản hồi/i.test(parentPart) ? 2 : 1;
763
776
  }
764
777
 
778
+ // ../../packages/facebook-scanner/dist/comment-publish-evidence.js
779
+ function readCommentPublishEvidence(body, parseComments) {
780
+ const evidence = { comments: [], declinedIds: /* @__PURE__ */ new Set(), createdIds: /* @__PURE__ */ new Set() };
781
+ const visit2 = (value, depth = 0) => {
782
+ if (!value || typeof value !== "object" || depth > 60)
783
+ return;
784
+ const node = value;
785
+ const creation = node.comment_create;
786
+ const createdId = commentId(creation?.comment?.legacy_fbid) ?? commentId(creation?.comment?.id);
787
+ if (createdId)
788
+ evidence.createdIds.add(createdId);
789
+ const id = commentId(node.legacy_fbid) ?? commentId(node.id);
790
+ if (id) {
791
+ const groupInfo = node.group_comment_info;
792
+ if (groupInfo?.declined_feedback_text != null)
793
+ evidence.declinedIds.add(id);
794
+ if (node.body || node.comment_body || node.preferred_body) {
795
+ evidence.comments.push(...parseComments(JSON.stringify({ ...node, __typename: "Comment", id })));
796
+ }
797
+ }
798
+ for (const child of Object.values(node))
799
+ visit2(child, depth + 1);
800
+ };
801
+ for (const line of body.split(/\r?\n/)) {
802
+ try {
803
+ visit2(JSON.parse(line.trim().replace(/^for\s*\(;;\);/, "")));
804
+ } catch {
805
+ }
806
+ }
807
+ return evidence;
808
+ }
809
+ function commentId(value) {
810
+ if (typeof value !== "string")
811
+ return void 0;
812
+ if (/^\d{1,100}$/.test(value))
813
+ return value;
814
+ const decoded = Buffer.from(value, "base64").toString("utf8");
815
+ return /^comment:\d+_(\d{1,100})$/.exec(decoded)?.[1];
816
+ }
817
+
818
+ // ../../packages/facebook-scanner/dist/comment-publisher.js
819
+ var SUBMIT_TIMEOUT_MS = 15e3;
820
+ var COMPOSER_TIMEOUT_MS = 1e4;
821
+ var IMAGE_UPLOAD_TIMEOUT_MS = 15e3;
822
+ var APPROVAL_PATTERN = /pending approval|awaiting approval|chờ duyệt|đang chờ duyệt|chờ quản trị viên/i;
823
+ var EXTENSION = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" };
824
+ async function publishCommentOnPage(page, options, config) {
825
+ const started = Date.now();
826
+ const diagnostic = {
827
+ stage: "COMPOSER",
828
+ mutationResponses: 0,
829
+ graphqlResponses: 0,
830
+ readErrors: 0,
831
+ comments: 0,
832
+ createdIds: 0,
833
+ declinedIds: 0,
834
+ reload: "NOT_STARTED",
835
+ durationMs: 0
836
+ };
837
+ try {
838
+ const result = await publishWithDiagnostic(page, options, config, diagnostic);
839
+ diagnostic.outcome = result.outcome;
840
+ diagnostic.errorCode = result.errorCode;
841
+ return result;
842
+ } catch (error) {
843
+ diagnostic.errorCode = options.signal?.aborted ? "PUBLISH_ABORTED" : "PUBLISH_EXCEPTION";
844
+ throw error;
845
+ } finally {
846
+ diagnostic.durationMs = Date.now() - started;
847
+ try {
848
+ options.onDiagnostic?.({ ...diagnostic });
849
+ } catch {
850
+ }
851
+ }
852
+ }
853
+ async function publishWithDiagnostic(page, options, config, diagnostic) {
854
+ const composer = await waitForComposer(page);
855
+ if (!composer)
856
+ return failed("COMPOSER_NOT_FOUND");
857
+ const fileInput = options.image ? await findFileInput(page, composer) : null;
858
+ if (options.image && !fileInput)
859
+ return failed("IMAGE_NOT_SUPPORTED");
860
+ assertNotAborted(options.signal);
861
+ const allowed = await options.beforeSubmit();
862
+ assertNotAborted(options.signal);
863
+ if (!allowed)
864
+ return failed("START_NOT_ALLOWED");
865
+ if (options.content) {
866
+ diagnostic.stage = "FILL";
867
+ await composer.fill(options.content);
868
+ }
869
+ {
870
+ if (options.image && fileInput) {
871
+ const currentFileInput = await findFileInput(page, composer);
872
+ if (!currentFileInput)
873
+ return failed("IMAGE_NOT_SUPPORTED");
874
+ const uploadDeadline = Date.now() + IMAGE_UPLOAD_TIMEOUT_MS;
875
+ const uploadResponse = page.waitForResponse((response2) => {
876
+ const url = new URL(response2.url());
877
+ return (url.hostname === "facebook.com" || url.hostname.endsWith(".facebook.com")) && url.pathname === "/ajax/ufi/upload/" && response2.request().method() === "POST";
878
+ }, { timeout: IMAGE_UPLOAD_TIMEOUT_MS }).catch(() => null);
879
+ await currentFileInput.setInputFiles({
880
+ buffer: options.image.buffer,
881
+ mimeType: options.image.contentType,
882
+ name: `comment.${EXTENSION[options.image.contentType]}`
883
+ });
884
+ const response = await uploadResponse;
885
+ if (!response || !response.ok())
886
+ return failed("IMAGE_UPLOAD_FAILED");
887
+ try {
888
+ let timer;
889
+ const body = await Promise.race([
890
+ response.text(),
891
+ new Promise((_resolve, reject) => {
892
+ timer = setTimeout(() => reject(new Error("IMAGE_UPLOAD_FAILED")), Math.max(0, uploadDeadline - Date.now()));
893
+ })
894
+ ]).finally(() => clearTimeout(timer));
895
+ const uploaded = JSON.parse(body.replace(/^for\s*\(;;\);/, ""));
896
+ const id = uploaded.payload?.fbid;
897
+ if (uploaded.error || !(typeof id === "number" && Number.isSafeInteger(id) && id > 0 || typeof id === "string" && /^[1-9]\d{0,99}$/.test(id)))
898
+ return failed("IMAGE_UPLOAD_FAILED");
899
+ } catch {
900
+ return failed("IMAGE_UPLOAD_FAILED");
901
+ }
902
+ }
903
+ const submit = await findSubmit(page, composer);
904
+ if (!submit)
905
+ return failed("SUBMIT_NOT_FOUND");
906
+ const evidence = { comments: [], declinedIds: /* @__PURE__ */ new Set(), createdIds: /* @__PURE__ */ new Set() };
907
+ const onResponse = (response) => {
908
+ if (!response.url().includes("/api/graphql/"))
909
+ return;
910
+ diagnostic.graphqlResponses += 1;
911
+ const name = new URLSearchParams(response.request().postData() ?? "").get("fb_api_req_friendly_name");
912
+ if (name !== "useCometUFICreateCommentMutation")
913
+ return;
914
+ diagnostic.mutationResponses += 1;
915
+ diagnostic.httpStatus = response.status();
916
+ if (response.status() >= 400)
917
+ return;
918
+ void response.text().then((body) => {
919
+ try {
920
+ diagnostic.stage = "READ_RESPONSE";
921
+ const received = readCommentPublishEvidence(body, config.parseGraphqlComments);
922
+ evidence.comments.push(...received.comments);
923
+ for (const id of received.declinedIds)
924
+ evidence.declinedIds.add(id);
925
+ for (const id of received.createdIds)
926
+ evidence.createdIds.add(id);
927
+ diagnostic.comments = evidence.comments.length;
928
+ diagnostic.createdIds = evidence.createdIds.size;
929
+ diagnostic.declinedIds = evidence.declinedIds.size;
930
+ } catch {
931
+ diagnostic.readErrors += 1;
932
+ }
933
+ }).catch(() => {
934
+ diagnostic.readErrors += 1;
935
+ });
936
+ };
937
+ page.on("response", onResponse);
938
+ try {
939
+ diagnostic.stage = "SUBMIT";
940
+ await submit.click({ timeout: 1e4 });
941
+ if (options.skipVerification) {
942
+ diagnostic.reload = "SKIPPED";
943
+ return { outcome: "UNKNOWN", submitAttempted: true, errorCode: "VERIFICATION_SKIPPED" };
944
+ }
945
+ if (diagnostic.stage === "SUBMIT")
946
+ diagnostic.stage = "WAITING_RESPONSE";
947
+ return await waitForEvidence(page, {
948
+ evidence,
949
+ parseComments: config.parseGraphqlComments,
950
+ diagnostic,
951
+ content: options.content,
952
+ hasImage: Boolean(options.image),
953
+ postUrl: options.postUrl
954
+ });
955
+ } finally {
956
+ page.off("response", onResponse);
957
+ }
958
+ }
959
+ }
960
+ async function waitForComposer(page) {
961
+ const deadline = Date.now() + COMPOSER_TIMEOUT_MS;
962
+ for (; ; ) {
963
+ const composer = await findComposer(page);
964
+ if (composer)
965
+ return composer;
966
+ if (Date.now() >= deadline)
967
+ return null;
968
+ await page.mouse.wheel(0, 800).catch(() => void 0);
969
+ await page.waitForTimeout(400);
970
+ }
971
+ }
972
+ async function findComposer(page) {
973
+ const handle = await page.evaluateHandle(() => {
974
+ const boxes = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"]')).filter((element2) => {
975
+ const style = window.getComputedStyle(element2);
976
+ const label = element2.getAttribute("aria-label") ?? "";
977
+ return style.display !== "none" && style.visibility !== "hidden" && !/reply|trả lời/i.test(label);
978
+ });
979
+ const composer = boxes.find((element2) => /comment|bình luận/i.test(element2.getAttribute("aria-label") ?? "")) ?? boxes[0];
980
+ return composer ?? null;
981
+ });
982
+ const element = handle.asElement();
983
+ if (!element)
984
+ return null;
985
+ return element;
986
+ }
987
+ async function findFileInput(page, composer) {
988
+ const handle = await composer.evaluateHandle((element2) => {
989
+ let node = element2;
990
+ for (let depth = 0; depth < 8 && node; depth += 1, node = node.parentElement) {
991
+ const input = node.querySelector('input[type="file"][accept*="image"]');
992
+ if (input) {
993
+ return input;
994
+ }
995
+ }
996
+ return null;
997
+ });
998
+ const element = handle.asElement();
999
+ return element;
1000
+ }
1001
+ async function findSubmit(page, composer) {
1002
+ const handle = await composer.evaluateHandle((element2) => {
1003
+ let node = element2;
1004
+ for (let depth = 0; depth < 8 && node; depth += 1, node = node.parentElement) {
1005
+ const button = Array.from(node.querySelectorAll('[role="button"], button')).find((candidate) => {
1006
+ const name = (candidate.getAttribute("aria-label") ?? candidate.textContent ?? "").trim();
1007
+ return /^(comment|post comment|bình luận|post|đăng|gửi)$/i.test(name) && candidate.getAttribute("aria-disabled") !== "true" && !candidate.disabled;
1008
+ });
1009
+ if (button) {
1010
+ return button;
1011
+ }
1012
+ }
1013
+ return null;
1014
+ });
1015
+ const element = handle.asElement();
1016
+ if (!element)
1017
+ return null;
1018
+ return element;
1019
+ }
1020
+ async function waitForEvidence(page, state) {
1021
+ const deadline = Date.now() + SUBMIT_TIMEOUT_MS;
1022
+ while (Date.now() < deadline) {
1023
+ const declinedCreatedId = [...state.evidence.createdIds].find((id) => state.evidence.declinedIds.has(id));
1024
+ if (declinedCreatedId) {
1025
+ state.diagnostic.commentId = declinedCreatedId;
1026
+ return {
1027
+ outcome: "UNKNOWN",
1028
+ submitAttempted: true,
1029
+ errorCode: "FACEBOOK_COMMENT_DECLINED",
1030
+ facebookCommentId: declinedCreatedId,
1031
+ commentUrl: `${state.postUrl}?comment_id=${encodeURIComponent(declinedCreatedId)}`
1032
+ };
1033
+ }
1034
+ const match = state.evidence.comments.find((comment) => /^\d{1,100}$/.test(comment.id ?? "") && (!state.evidence.createdIds.size || state.evidence.createdIds.has(comment.id ?? "")) && (state.content ? normalize(comment.text) === normalize(state.content) : true) && (!state.hasImage || (comment.attachmentImages?.length ?? 0) > 0));
1035
+ const submittedId = match?.id ?? (state.evidence.createdIds.size === 1 ? state.evidence.createdIds.values().next().value : void 0);
1036
+ if (submittedId) {
1037
+ state.diagnostic.commentId = submittedId;
1038
+ const identity = {
1039
+ commentUrl: `${state.postUrl}?comment_id=${encodeURIComponent(submittedId)}`,
1040
+ facebookCommentId: submittedId
1041
+ };
1042
+ if (state.evidence.declinedIds.has(submittedId)) {
1043
+ return { ...identity, outcome: "UNKNOWN", submitAttempted: true, errorCode: "FACEBOOK_COMMENT_DECLINED" };
1044
+ }
1045
+ try {
1046
+ state.diagnostic.stage = "RELOAD";
1047
+ const verification = await verifyAfterReload(page, state, submittedId);
1048
+ state.diagnostic.reload = verification === "PENDING" ? "MISSING" : verification;
1049
+ if (verification === "DECLINED") {
1050
+ return { ...identity, outcome: "UNKNOWN", submitAttempted: true, errorCode: "FACEBOOK_COMMENT_DECLINED" };
1051
+ }
1052
+ if (verification === "PENDING") {
1053
+ return { ...identity, outcome: "UNKNOWN", submitAttempted: true, errorCode: "FACEBOOK_APPROVAL_PENDING" };
1054
+ }
1055
+ if (verification === "CONFIRMED") {
1056
+ return { ...identity, outcome: "SUCCEEDED", submitAttempted: true };
1057
+ }
1058
+ } catch {
1059
+ state.diagnostic.reload = "ERROR";
1060
+ }
1061
+ return { ...identity, outcome: "UNKNOWN", submitAttempted: true, errorCode: "SUBMIT_UNCONFIRMED" };
1062
+ }
1063
+ const pending = await page.getByText(APPROVAL_PATTERN).count().catch(() => 0);
1064
+ if (pending > 0) {
1065
+ return { outcome: "UNKNOWN", submitAttempted: true, errorCode: "FACEBOOK_APPROVAL_PENDING" };
1066
+ }
1067
+ await page.waitForTimeout(500);
1068
+ }
1069
+ return { outcome: "UNKNOWN", submitAttempted: true, errorCode: "SUBMIT_UNCONFIRMED" };
1070
+ }
1071
+ async function verifyAfterReload(page, state, submittedId) {
1072
+ const received = [];
1073
+ let observing = true;
1074
+ let timer;
1075
+ const onResponse = (response) => {
1076
+ if (!response.url().includes("/api/graphql/") || response.status() >= 400)
1077
+ return;
1078
+ void response.text().then((body) => {
1079
+ if (observing)
1080
+ received.push(readCommentPublishEvidence(body, state.parseComments));
1081
+ }).catch(() => void 0);
1082
+ };
1083
+ page.on("response", onResponse);
1084
+ try {
1085
+ const commentUrl = new URL(state.postUrl);
1086
+ commentUrl.searchParams.set("comment_id", submittedId);
1087
+ await page.goto(commentUrl.href, { waitUntil: "domcontentloaded", timeout: 15e3 });
1088
+ const deadline = Date.now() + 3e3;
1089
+ const inspect = async () => {
1090
+ while (observing && Date.now() < deadline) {
1091
+ const visible = await page.evaluate(({ id, postUrl }) => {
1092
+ const target = new URL(postUrl);
1093
+ const found = Array.from(document.querySelectorAll('a[href*="comment_id="]')).some((link) => {
1094
+ const article = link.closest('[role="article"]');
1095
+ const visibility = { visibilityProperty: true, opacityProperty: true };
1096
+ if (!article || !link.checkVisibility(visibility) || !article.checkVisibility(visibility))
1097
+ return false;
1098
+ const url = new URL(link.href);
1099
+ return url.hostname === target.hostname && url.pathname.replace(/\/$/, "") === target.pathname.replace(/\/$/, "") && url.searchParams.get("comment_id") === id;
1100
+ });
1101
+ return {
1102
+ found,
1103
+ scripts: Array.from(document.querySelectorAll('script[type="application/json"]')).map((script) => script.textContent ?? "").join("\n")
1104
+ };
1105
+ }, { id: submittedId, postUrl: state.postUrl });
1106
+ if (!observing)
1107
+ return "MISSING";
1108
+ const evidence = [...received, readCommentPublishEvidence(visible.scripts, state.parseComments)];
1109
+ if (evidence.some((item) => item.declinedIds.has(submittedId)))
1110
+ return "DECLINED";
1111
+ if (await page.getByText(APPROVAL_PATTERN).count())
1112
+ return "PENDING";
1113
+ if (evidence.some((item) => item.comments.some((comment) => comment.id === submittedId && (!state.hasImage || (comment.attachmentImages?.length ?? 0) > 0))) || !state.hasImage && visible.found)
1114
+ return "CONFIRMED";
1115
+ await page.waitForTimeout(Math.min(200, Math.max(0, deadline - Date.now())));
1116
+ }
1117
+ return "MISSING";
1118
+ };
1119
+ return await Promise.race([
1120
+ inspect(),
1121
+ new Promise((resolve) => {
1122
+ timer = setTimeout(() => resolve("MISSING"), 3e3);
1123
+ })
1124
+ ]);
1125
+ } finally {
1126
+ observing = false;
1127
+ clearTimeout(timer);
1128
+ page.off("response", onResponse);
1129
+ }
1130
+ }
1131
+ function normalize(value) {
1132
+ return value.replace(/\s+/g, " ").trim();
1133
+ }
1134
+ function failed(errorCode) {
1135
+ return { errorCode, outcome: "FAILED", submitAttempted: false };
1136
+ }
1137
+ function assertNotAborted(signal) {
1138
+ if (signal?.aborted)
1139
+ throw new Error("SCAN_CANCELED");
1140
+ }
1141
+
765
1142
  // ../../packages/facebook-scanner/dist/session.js
766
1143
  async function detectFacebookSession(page, context) {
767
1144
  let url;
@@ -825,7 +1202,7 @@ var FacebookScanner = class {
825
1202
  });
826
1203
  const results = [];
827
1204
  for (const [index, groupUrl] of options.groupUrls.entries()) {
828
- assertNotAborted(options.signal);
1205
+ assertNotAborted2(options.signal);
829
1206
  const items = await this.scanGroup(browser, options, groupUrl, index);
830
1207
  results.push(items);
831
1208
  }
@@ -841,7 +1218,7 @@ var FacebookScanner = class {
841
1218
  const target = normalizePostUrl(options.postUrl);
842
1219
  if (!target)
843
1220
  throw new FacebookScannerError("POST_URL_INVALID");
844
- assertNotAborted(options.signal);
1221
+ assertNotAborted2(options.signal);
845
1222
  this.running = true;
846
1223
  let browser = null;
847
1224
  try {
@@ -876,7 +1253,7 @@ var FacebookScanner = class {
876
1253
  let idleRounds = 0;
877
1254
  let rounds = 0;
878
1255
  while (comments.length < options.limit && idleRounds < 3 && rounds < 20) {
879
- assertNotAborted(options.signal);
1256
+ assertNotAborted2(options.signal);
880
1257
  rounds += 1;
881
1258
  const previousCount = comments.length;
882
1259
  await page.waitForTimeout(1200);
@@ -893,7 +1270,7 @@ var FacebookScanner = class {
893
1270
  await page.mouse.wheel(0, 2e3);
894
1271
  }
895
1272
  }
896
- assertNotAborted(options.signal);
1273
+ assertNotAborted2(options.signal);
897
1274
  await drain(responseTasks, this.graphqlResponseTimeoutMs);
898
1275
  const domComments = await this.extractDomComments(page);
899
1276
  comments = mergeFacebookComments([...embeddedComments, ...graphqlComments, ...domComments], options.limit);
@@ -911,6 +1288,57 @@ var FacebookScanner = class {
911
1288
  this.running = false;
912
1289
  }
913
1290
  }
1291
+ async publishComment(options) {
1292
+ if (this.running)
1293
+ throw new FacebookScannerError("SCANNER_BUSY");
1294
+ const target = normalizePostUrl(options.postUrl);
1295
+ if (!target)
1296
+ throw new FacebookScannerError("POST_URL_INVALID");
1297
+ assertNotAborted2(options.signal);
1298
+ this.running = true;
1299
+ let browser = null;
1300
+ try {
1301
+ browser = await this.browserType.launch({
1302
+ headless: true,
1303
+ ...this.proxy ? { proxy: this.proxy } : {}
1304
+ });
1305
+ const context = await browser.newContext({ storageState: options.storageState });
1306
+ let page = null;
1307
+ const closeOnAbort = () => {
1308
+ void page?.close().catch(() => void 0);
1309
+ void context.close().catch(() => void 0);
1310
+ void browser?.close().catch(() => void 0);
1311
+ };
1312
+ options.signal?.addEventListener("abort", closeOnAbort, { once: true });
1313
+ if (options.signal?.aborted)
1314
+ closeOnAbort();
1315
+ try {
1316
+ page = await context.newPage();
1317
+ if (options.signal?.aborted)
1318
+ closeOnAbort();
1319
+ await page.route("**/*", (route) => ["font", "media"].includes(route.request().resourceType()) ? route.abort() : route.continue());
1320
+ try {
1321
+ await page.goto(target, { waitUntil: "domcontentloaded", timeout: 45e3 });
1322
+ } catch {
1323
+ throw new FacebookScannerError("NAVIGATION_FAILED");
1324
+ }
1325
+ const condition = await detectFacebookSession(page, context);
1326
+ if (condition !== "READY")
1327
+ throw new FacebookScannerError(condition);
1328
+ return await publishCommentOnPage(page, options, {
1329
+ parseGraphqlComments: this.parseGraphqlComments
1330
+ });
1331
+ } finally {
1332
+ options.signal?.removeEventListener("abort", closeOnAbort);
1333
+ await page?.unroute("**/*").catch(() => void 0);
1334
+ await page?.close().catch(() => void 0);
1335
+ await context.close().catch(() => void 0);
1336
+ }
1337
+ } finally {
1338
+ await browser?.close();
1339
+ this.running = false;
1340
+ }
1341
+ }
914
1342
  async scanGroup(browser, options, groupUrl, groupIndex) {
915
1343
  const context = await browser.newContext({ storageState: options.storageState });
916
1344
  let page = null;
@@ -940,7 +1368,7 @@ var FacebookScanner = class {
940
1368
  let items = [];
941
1369
  let idleRounds = 0;
942
1370
  while (items.length < options.limit && idleRounds < 3) {
943
- assertNotAborted(options.signal);
1371
+ assertNotAborted2(options.signal);
944
1372
  const previousCount = items.length;
945
1373
  await page.waitForTimeout(1500);
946
1374
  await drain(responseTasks, this.graphqlResponseTimeoutMs);
@@ -958,7 +1386,7 @@ var FacebookScanner = class {
958
1386
  if (items.length < options.limit)
959
1387
  await page.mouse.wheel(0, 2e3);
960
1388
  }
961
- assertNotAborted(options.signal);
1389
+ assertNotAborted2(options.signal);
962
1390
  await drain(responseTasks, this.graphqlResponseTimeoutMs);
963
1391
  items = mergeFacebookScanItems([...graphqlItems, ...domItems], options.limit);
964
1392
  if (!items.length)
@@ -974,7 +1402,7 @@ var FacebookScanner = class {
974
1402
  }
975
1403
  }
976
1404
  };
977
- function assertNotAborted(signal) {
1405
+ function assertNotAborted2(signal) {
978
1406
  if (signal?.aborted)
979
1407
  throw new FacebookScannerError("SCAN_CANCELED");
980
1408
  }
@@ -1620,7 +2048,7 @@ var DesktopAgentSession = class {
1620
2048
  state: this.reportedState,
1621
2049
  facebookReady: this.options.getFacebookReady(),
1622
2050
  currentJobId: this.reportedJobId,
1623
- capabilities: ["POST_COMMENTS", "POST_COMMENTS_MORE"]
2051
+ capabilities: ["POST_COMMENTS", "POST_COMMENTS_MORE", "POST_COMMENT_PUBLISH"]
1624
2052
  });
1625
2053
  this.handshake = "WELCOME";
1626
2054
  return;
@@ -1664,6 +2092,16 @@ var DesktopAgentSession = class {
1664
2092
  this.emitJob({ type: "STOP", ...envelope.payload });
1665
2093
  return;
1666
2094
  }
2095
+ if (envelope.type === "POST_COMMENT_PUBLISH") {
2096
+ const payload = envelope.payload;
2097
+ if (!isCommentPublish(payload)) {
2098
+ this.protocolFailure(socket, stored, generation);
2099
+ return;
2100
+ }
2101
+ if (Date.parse(payload.expiresAt) <= Date.now()) return;
2102
+ this.emitJob({ type: "COMMENT_PUBLISH", ...payload });
2103
+ return;
2104
+ }
1667
2105
  if (envelope.type === "POST_COMMENTS_FETCH") {
1668
2106
  if (!isPostCommentsFetch(envelope.payload)) {
1669
2107
  this.protocolFailure(socket, stored, generation);
@@ -1823,6 +2261,20 @@ function isPostCommentsFetch(value) {
1823
2261
  const payload = value;
1824
2262
  return typeof payload.requestId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(payload.requestId) && typeof payload.postUrl === "string" && payload.postUrl.length <= 2e3 && /^https:\/\/[\w.-]*facebook\.com\//i.test(payload.postUrl) && typeof payload.limit === "number" && Number.isInteger(payload.limit) && payload.limit > 0 && payload.limit <= DESKTOP_POST_COMMENTS_MAX_LIMIT;
1825
2263
  }
2264
+ function isCommentPublish(value) {
2265
+ if (!value || typeof value !== "object") return false;
2266
+ const payload = value;
2267
+ const image = payload.image;
2268
+ if (typeof payload.requestId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(payload.requestId) || typeof payload.postUrl !== "string" || payload.postUrl.length > 2e3 || !/^https:\/\/[\w.-]*facebook\.com\//i.test(payload.postUrl) || typeof payload.content !== "string" || payload.content.length > COMMENT_TEXT_MAX_LENGTH || payload.skipVerification !== void 0 && typeof payload.skipVerification !== "boolean" || typeof payload.expiresAt !== "string" || !Number.isFinite(Date.parse(payload.expiresAt))) {
2269
+ return false;
2270
+ }
2271
+ const hasImage = image !== null && image !== void 0;
2272
+ if (!payload.content.trim() && !hasImage) return false;
2273
+ if (!hasImage) return true;
2274
+ if (typeof image !== "object" || Array.isArray(image)) return false;
2275
+ const asset = image;
2276
+ return typeof asset.id === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(asset.id) && ["image/jpeg", "image/png", "image/webp"].includes(String(asset.contentType)) && typeof asset.byteSize === "number" && Number.isInteger(asset.byteSize) && asset.byteSize > 0 && asset.byteSize <= COMMENT_IMAGE_MAX_BYTES && typeof asset.sha256 === "string" && /^[0-9a-f]{64}$/i.test(asset.sha256);
2277
+ }
1826
2278
  function emptySnapshot(state) {
1827
2279
  return {
1828
2280
  state,
@@ -1852,6 +2304,184 @@ function readErrorCode(error) {
1852
2304
  return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "UNKNOWN_ERROR";
1853
2305
  }
1854
2306
 
2307
+ // src/main/desktop-comment-publish-runner.ts
2308
+ var DEFAULT_REPORT_ATTEMPTS = 3;
2309
+ var DesktopCommentPublishRunner = class {
2310
+ options;
2311
+ listeners = /* @__PURE__ */ new Set();
2312
+ stopController = new AbortController();
2313
+ currentController = null;
2314
+ activeRequestId = null;
2315
+ finishedRequestIds = /* @__PURE__ */ new Set();
2316
+ active = null;
2317
+ unsubscribe = null;
2318
+ stopped = false;
2319
+ constructor(options) {
2320
+ this.options = options;
2321
+ }
2322
+ start() {
2323
+ if (this.unsubscribe) return;
2324
+ this.unsubscribe = this.options.session.subscribeJobs((event) => this.handleEvent(event));
2325
+ }
2326
+ stop() {
2327
+ this.stopped = true;
2328
+ this.currentController?.abort();
2329
+ this.stopController.abort();
2330
+ this.unsubscribe?.();
2331
+ this.unsubscribe = null;
2332
+ }
2333
+ isRunning() {
2334
+ return this.active !== null;
2335
+ }
2336
+ subscribe(listener) {
2337
+ this.listeners.add(listener);
2338
+ return () => this.listeners.delete(listener);
2339
+ }
2340
+ async whenIdle() {
2341
+ await this.active;
2342
+ }
2343
+ handleEvent(event) {
2344
+ if (event.type !== "COMMENT_PUBLISH" || this.stopped) return;
2345
+ if (this.activeRequestId === event.requestId || this.finishedRequestIds.has(event.requestId)) return;
2346
+ if (this.active) {
2347
+ void this.reportOnly(event, "AGENT_BUSY", "FAILED");
2348
+ return;
2349
+ }
2350
+ this.active = this.run(event).finally(() => {
2351
+ this.active = null;
2352
+ });
2353
+ }
2354
+ async run(request) {
2355
+ const controller = new AbortController();
2356
+ const timeout = this.options.setTimer(
2357
+ () => controller.abort(),
2358
+ this.options.publishTimeoutMs ?? 3 * 60 * 1e3
2359
+ );
2360
+ this.currentController = controller;
2361
+ this.activeRequestId = request.requestId;
2362
+ this.emit({ type: "STARTED", requestId: request.requestId, postUrl: request.postUrl });
2363
+ let granted = false;
2364
+ try {
2365
+ if (this.options.isScanActive()) {
2366
+ await this.reportOnly(request, "SCANNER_BUSY", "FAILED");
2367
+ return;
2368
+ }
2369
+ if (this.options.isCommentFetchActive()) {
2370
+ await this.reportOnly(request, "COMMENT_FETCH_ACTIVE", "FAILED");
2371
+ return;
2372
+ }
2373
+ if (this.options.isLoginActive()) {
2374
+ await this.reportOnly(request, "LOGIN_ACTIVE", "FAILED");
2375
+ return;
2376
+ }
2377
+ let image;
2378
+ if (request.image) {
2379
+ try {
2380
+ image = await this.options.http.downloadCommentImage(request.requestId, request.image, controller.signal);
2381
+ } catch {
2382
+ await this.reportOnly(request, "IMAGE_DOWNLOAD_FAILED", "FAILED");
2383
+ return;
2384
+ }
2385
+ }
2386
+ const storageState = await this.options.facebookState.load();
2387
+ if (!storageState) {
2388
+ await this.reportOnly(request, "FACEBOOK_SESSION_MISSING", "FAILED");
2389
+ return;
2390
+ }
2391
+ let result;
2392
+ try {
2393
+ result = await this.options.scanner.publishComment({
2394
+ onDiagnostic: (diagnostic) => this.emit({ type: "DIAGNOSTIC", requestId: request.requestId, diagnostic }),
2395
+ beforeSubmit: async () => {
2396
+ const started = await this.options.http.startCommentPublish(request.requestId, controller.signal);
2397
+ granted = started.allowed;
2398
+ return started.allowed;
2399
+ },
2400
+ content: request.content,
2401
+ skipVerification: request.skipVerification,
2402
+ ...image && request.image ? { image: { buffer: image, contentType: request.image.contentType } } : {},
2403
+ postUrl: request.postUrl,
2404
+ signal: controller.signal,
2405
+ storageState
2406
+ });
2407
+ } catch {
2408
+ await this.reportOnly(
2409
+ request,
2410
+ granted ? "SUBMIT_UNCONFIRMED" : "PUBLISH_START_FAILED",
2411
+ granted ? "UNKNOWN" : "FAILED"
2412
+ );
2413
+ return;
2414
+ }
2415
+ await this.report(request, {
2416
+ ...result.commentUrl ? { commentUrl: result.commentUrl } : {},
2417
+ ...result.errorCode ? { errorCode: result.errorCode } : {},
2418
+ ...result.facebookCommentId ? { facebookCommentId: result.facebookCommentId } : {},
2419
+ outcome: result.outcome,
2420
+ submitAttempted: result.submitAttempted
2421
+ });
2422
+ } finally {
2423
+ this.options.clearTimer(timeout);
2424
+ if (this.currentController === controller) this.currentController = null;
2425
+ this.activeRequestId = null;
2426
+ this.finishedRequestIds.add(request.requestId);
2427
+ if (this.finishedRequestIds.size > 100) {
2428
+ const oldest = this.finishedRequestIds.values().next().value;
2429
+ if (oldest) this.finishedRequestIds.delete(oldest);
2430
+ }
2431
+ }
2432
+ }
2433
+ async reportOnly(request, errorCode, outcome) {
2434
+ await this.report(request, { errorCode, outcome, submitAttempted: outcome === "UNKNOWN" });
2435
+ }
2436
+ async report(request, result) {
2437
+ const attempts = Math.max(1, this.options.reportAttempts ?? DEFAULT_REPORT_ATTEMPTS);
2438
+ let delivered = false;
2439
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
2440
+ try {
2441
+ await this.options.http.reportCommentPublish(request.requestId, result);
2442
+ delivered = true;
2443
+ break;
2444
+ } catch {
2445
+ if (attempt === attempts || this.stopController.signal.aborted) break;
2446
+ try {
2447
+ await this.options.wait(1e3 * 2 ** (attempt - 1), this.stopController.signal);
2448
+ } catch {
2449
+ break;
2450
+ }
2451
+ }
2452
+ }
2453
+ if (!delivered) {
2454
+ this.emit({
2455
+ type: "REPORT_FAILED",
2456
+ errorCode: "RESULT_REPORT_FAILED",
2457
+ requestId: request.requestId,
2458
+ postUrl: request.postUrl
2459
+ });
2460
+ } else {
2461
+ if (result.outcome === "SUCCEEDED") {
2462
+ this.emit({ type: "SUCCEEDED", requestId: request.requestId, postUrl: request.postUrl });
2463
+ } else if (result.outcome === "UNKNOWN") {
2464
+ this.emit({
2465
+ type: "UNKNOWN",
2466
+ errorCode: result.errorCode ?? "UNKNOWN",
2467
+ requestId: request.requestId,
2468
+ postUrl: request.postUrl
2469
+ });
2470
+ } else {
2471
+ this.emit({
2472
+ type: "FAILED",
2473
+ errorCode: result.errorCode ?? "FAILED",
2474
+ requestId: request.requestId,
2475
+ postUrl: request.postUrl
2476
+ });
2477
+ }
2478
+ }
2479
+ }
2480
+ emit(event) {
2481
+ for (const listener of this.listeners) listener(event);
2482
+ }
2483
+ };
2484
+
1855
2485
  // src/main/desktop-comment-runner.ts
1856
2486
  var DEFAULT_FETCH_TIMEOUT_MS = 2 * 60 * 1e3;
1857
2487
  var DEFAULT_MAX_QUEUE = 5;
@@ -2049,7 +2679,60 @@ var DesktopScanHttp = class {
2049
2679
  signal
2050
2680
  );
2051
2681
  }
2682
+ startCommentPublish(requestId, signal) {
2683
+ return this.signedPost(
2684
+ `/leads/desktop-agent/comment-publish-requests/${requestId}/start`,
2685
+ {},
2686
+ signal
2687
+ );
2688
+ }
2689
+ reportCommentPublish(requestId, result, signal) {
2690
+ return this.signedPost(
2691
+ `/leads/desktop-agent/comment-publish-requests/${requestId}/result`,
2692
+ result,
2693
+ signal
2694
+ );
2695
+ }
2696
+ async downloadCommentImage(requestId, metadata, signal) {
2697
+ const response = await this.signedRequest(
2698
+ `/leads/desktop-agent/comment-publish-requests/${requestId}/image`,
2699
+ {},
2700
+ signal,
2701
+ "image"
2702
+ );
2703
+ const lengthHeader = response.headers.get("content-length");
2704
+ if (lengthHeader !== null) {
2705
+ const length = Number(lengthHeader);
2706
+ if (!Number.isFinite(length) || length > COMMENT_IMAGE_MAX_BYTES || length !== metadata.byteSize) {
2707
+ throw new DesktopScanHttpError("IMAGE_INVALID");
2708
+ }
2709
+ }
2710
+ if (!response.body) throw new DesktopScanHttpError("IMAGE_INVALID");
2711
+ const reader = response.body.getReader();
2712
+ const chunks = [];
2713
+ let size = 0;
2714
+ for (; ; ) {
2715
+ const { done, value } = await reader.read();
2716
+ if (done) break;
2717
+ size += value.byteLength;
2718
+ if (size > COMMENT_IMAGE_MAX_BYTES || size > metadata.byteSize) {
2719
+ await reader.cancel();
2720
+ throw new DesktopScanHttpError("IMAGE_INVALID");
2721
+ }
2722
+ chunks.push(value);
2723
+ }
2724
+ const bytes = Buffer.concat(chunks, size);
2725
+ if (bytes.byteLength !== metadata.byteSize) throw new DesktopScanHttpError("IMAGE_INVALID");
2726
+ if (createHash("sha256").update(bytes).digest("hex") !== metadata.sha256) {
2727
+ throw new DesktopScanHttpError("IMAGE_INVALID");
2728
+ }
2729
+ return bytes;
2730
+ }
2052
2731
  async signedPost(path, businessPayload, signal) {
2732
+ const response = await this.signedRequest(path, businessPayload, signal);
2733
+ return readBoundedJson2(response);
2734
+ }
2735
+ async signedRequest(path, businessPayload, signal, responseType = "json") {
2053
2736
  const credential = this.getCredential();
2054
2737
  if (!credential) throw new DesktopScanHttpError("CREDENTIAL_UNAVAILABLE");
2055
2738
  const signedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -2083,21 +2766,26 @@ var DesktopScanHttp = class {
2083
2766
  "content-type": "application/json"
2084
2767
  },
2085
2768
  method: "POST",
2769
+ redirect: "error",
2086
2770
  signal: requestSignal
2087
2771
  });
2088
2772
  } catch {
2089
2773
  throw new DesktopScanHttpError("NETWORK_ERROR", true);
2090
2774
  }
2091
- const contentType = response.headers.get("content-type")?.split(";", 1)[0];
2092
- if (contentType !== "application/json") throw new DesktopScanHttpError("RESPONSE_INVALID");
2093
- const value = await readBoundedJson2(response);
2094
2775
  if (!response.ok) {
2095
2776
  throw new DesktopScanHttpError(
2096
2777
  response.status >= 500 ? "SERVER_UNAVAILABLE" : response.status === 413 ? "BATCH_TOO_LARGE" : "BATCH_REJECTED",
2097
2778
  response.status >= 500
2098
2779
  );
2099
2780
  }
2100
- return value;
2781
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0] ?? "";
2782
+ if (responseType === "json" && contentType !== "application/json") {
2783
+ throw new DesktopScanHttpError("RESPONSE_INVALID");
2784
+ }
2785
+ if (responseType === "image" && !contentType.startsWith("image/")) {
2786
+ throw new DesktopScanHttpError("IMAGE_INVALID");
2787
+ }
2788
+ return response;
2101
2789
  }
2102
2790
  };
2103
2791
  async function readBoundedJson2(response) {
@@ -2314,7 +3002,7 @@ var DesktopScanRunner = class {
2314
3002
  return true;
2315
3003
  }
2316
3004
  async handleEvent(event) {
2317
- if (event.type === "COMMENTS") return;
3005
+ if (event.type === "COMMENTS" || event.type === "COMMENT_PUBLISH") return;
2318
3006
  if (event.type === "AVAILABLE") {
2319
3007
  await this.claimIfReady(event.jobId);
2320
3008
  return;
@@ -3696,6 +4384,7 @@ async function commandRun(runtime, io) {
3696
4384
  browserType: runtime.browserType,
3697
4385
  parseGraphqlBody: createGraphqlDumpParser(process.env.FACEBOOK_SCAN_DUMP_DIR)
3698
4386
  });
4387
+ let publishRunner = null;
3699
4388
  const commentRunner = new DesktopCommentRunner({
3700
4389
  clearTimer: (timer) => clearTimeout(timer),
3701
4390
  facebookState: runtime.facebook,
@@ -3715,7 +4404,7 @@ async function commandRun(runtime, io) {
3715
4404
  clearTimer: (timer) => clearTimeout(timer),
3716
4405
  facebookState: runtime.facebook,
3717
4406
  http: scanHttp,
3718
- isCommentFetchActive: () => commentRunner.isRunning(),
4407
+ isCommentFetchActive: () => commentRunner.isRunning() || (publishRunner?.isRunning() ?? false),
3719
4408
  isLoginActive: () => false,
3720
4409
  now: Date.now,
3721
4410
  randomUuid: () => randomUUID3(),
@@ -3732,6 +4421,22 @@ async function commandRun(runtime, io) {
3732
4421
  spool: runtime.spool,
3733
4422
  wait: abortableDelay
3734
4423
  });
4424
+ publishRunner = new DesktopCommentPublishRunner({
4425
+ clearTimer: (timer) => clearTimeout(timer),
4426
+ facebookState: runtime.facebook,
4427
+ http: scanHttp,
4428
+ isCommentFetchActive: () => commentRunner.isRunning(),
4429
+ isLoginActive: () => false,
4430
+ isScanActive: () => scanRunner.isRunning(),
4431
+ scanner,
4432
+ session,
4433
+ setTimer: (callback, delay) => {
4434
+ const timer = setTimeout(callback, delay);
4435
+ timer.unref();
4436
+ return timer;
4437
+ },
4438
+ wait: abortableDelay
4439
+ });
3735
4440
  let onReplaced = () => void 0;
3736
4441
  const replaced = new Promise((resolve) => {
3737
4442
  onReplaced = resolve;
@@ -3751,6 +4456,7 @@ async function commandRun(runtime, io) {
3751
4456
  });
3752
4457
  scanRunner.start();
3753
4458
  commentRunner.start();
4459
+ publishRunner.start();
3754
4460
  const unsubscribeComments = commentRunner.subscribe((event) => {
3755
4461
  if (event.type === "STARTED") {
3756
4462
  io.log(`${stamp()} comments: fetching ${event.postUrl}`);
@@ -3774,6 +4480,34 @@ async function commandRun(runtime, io) {
3774
4480
  level: "WARN"
3775
4481
  });
3776
4482
  });
4483
+ const unsubscribePublish = publishRunner.subscribe((event) => {
4484
+ if (event.type === "DIAGNOSTIC") {
4485
+ const message = JSON.stringify({ requestId: event.requestId, ...event.diagnostic });
4486
+ io.log(`${stamp()} comments: diagnostic ${message}`);
4487
+ recordActivity(message, "COMMENT_PUBLISH_DIAGNOSTIC", { category: "COMMENTS" });
4488
+ return;
4489
+ }
4490
+ if (event.type === "STARTED") {
4491
+ io.log(`${stamp()} comments: publishing on ${event.postUrl}`);
4492
+ recordActivity(`Publishing a comment on ${event.postUrl}.`, "COMMENT_PUBLISH_STARTED", {
4493
+ category: "COMMENTS"
4494
+ });
4495
+ return;
4496
+ }
4497
+ if (event.type === "SUCCEEDED") {
4498
+ io.log(`${stamp()} comments: published`);
4499
+ recordActivity(`Published a comment on ${event.postUrl}.`, "COMMENT_PUBLISH_SUCCEEDED", {
4500
+ category: "COMMENTS"
4501
+ });
4502
+ return;
4503
+ }
4504
+ io.log(`${stamp()} comments: publish ${event.type.toLowerCase()} (${event.errorCode})`);
4505
+ recordActivity(
4506
+ `Comment publish ${event.type.toLowerCase()} (${event.errorCode}) on ${event.postUrl}.`,
4507
+ event.errorCode,
4508
+ { category: "COMMENTS", level: "WARN" }
4509
+ );
4510
+ });
3777
4511
  await scanRunner.recover();
3778
4512
  await session.start();
3779
4513
  io.log(`Worker running as "${stored.device.name}" (API ${runtime.endpoint.httpOrigin}). Press Ctrl+C to stop.`);
@@ -3790,7 +4524,9 @@ async function commandRun(runtime, io) {
3790
4524
  clearInterval(keepAlive);
3791
4525
  session.stop();
3792
4526
  commentRunner.stop();
4527
+ publishRunner.stop();
3793
4528
  unsubscribeComments();
4529
+ unsubscribePublish();
3794
4530
  scanRunner.stop();
3795
4531
  }
3796
4532
  return 0;