openzca 0.1.22 → 0.1.24
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.
- package/README.md +26 -0
- package/dist/cli.js +880 -310
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -272,12 +272,38 @@ Listener resilience override:
|
|
|
272
272
|
- `OPENZCA_LISTEN_DOWNLOAD_QUOTE_MEDIA`: download quoted attachment URLs (if present) into inbound media cache.
|
|
273
273
|
- Default: enabled.
|
|
274
274
|
- Set to `0` to keep only quote metadata/URLs without downloading.
|
|
275
|
+
- `OPENZCA_LISTEN_ENFORCE_SINGLE_OWNER`: enforce one `listen` owner process per profile.
|
|
276
|
+
- Default: enabled.
|
|
277
|
+
- Set to `0` to allow multiple listeners on the same profile (not recommended).
|
|
278
|
+
- `OPENZCA_LISTEN_IPC`: expose local IPC socket from `listen` so `msg upload` can reuse the active websocket session.
|
|
279
|
+
- Default: enabled.
|
|
280
|
+
- Set to `0` to disable IPC.
|
|
281
|
+
- `OPENZCA_LISTEN_KEEPALIVE_RESTART_DELAY_MS`: when `--keep-alive` is on, restart listener after close code `1000`/`3000` with this delay.
|
|
282
|
+
- Default: `2000`.
|
|
283
|
+
- `OPENZCA_LISTEN_KEEPALIVE_RESTART_ON_ANY_CLOSE`: force keepalive fallback restart for any close code.
|
|
284
|
+
- Default: disabled.
|
|
285
|
+
- Set to `1` if your environment closes with non-retry codes.
|
|
275
286
|
|
|
276
287
|
Supervised mode notes:
|
|
277
288
|
|
|
278
289
|
- Use `listen --supervised --raw` when an external process manager owns restart logic.
|
|
279
290
|
- In supervised mode, internal websocket retry ownership is disabled (equivalent to forcing `retryOnClose=false`).
|
|
280
291
|
|
|
292
|
+
Upload/listener coordination overrides:
|
|
293
|
+
|
|
294
|
+
- `OPENZCA_UPLOAD_IPC`: try upload via active listener IPC first.
|
|
295
|
+
- Default: enabled.
|
|
296
|
+
- Set to `0` to disable IPC path.
|
|
297
|
+
- `OPENZCA_UPLOAD_IPC_CONNECT_TIMEOUT_MS`: timeout for connecting to listener IPC socket.
|
|
298
|
+
- Default: `1000`.
|
|
299
|
+
- `OPENZCA_UPLOAD_IPC_TIMEOUT_MS`: timeout waiting for listener IPC upload response.
|
|
300
|
+
- Default: `OPENZCA_UPLOAD_TIMEOUT_MS + 5000`.
|
|
301
|
+
- `OPENZCA_UPLOAD_IPC_HANDLER_TIMEOUT_MS`: timeout applied by listener IPC while executing upload.
|
|
302
|
+
- Default: same as `OPENZCA_UPLOAD_TIMEOUT_MS` (120000 if unset).
|
|
303
|
+
- `OPENZCA_UPLOAD_ENFORCE_SINGLE_OWNER`: when an active listener owner exists but IPC is unavailable, fail fast instead of starting a second listener.
|
|
304
|
+
- Default: enabled.
|
|
305
|
+
- Set to `0` to allow fallback listener startup (may disconnect active listener due duplicate websocket ownership).
|
|
306
|
+
|
|
281
307
|
### account — Multi-account profiles
|
|
282
308
|
|
|
283
309
|
| Command | Description |
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createRequire } from "module";
|
|
|
5
5
|
import { spawn as spawn2 } from "child_process";
|
|
6
6
|
import fsSync from "fs";
|
|
7
7
|
import fs4 from "fs/promises";
|
|
8
|
+
import net from "net";
|
|
8
9
|
import os3 from "os";
|
|
9
10
|
import path4 from "path";
|
|
10
11
|
import util from "util";
|
|
@@ -739,6 +740,439 @@ function collectIdsFromCacheEntries(entries, keys) {
|
|
|
739
740
|
}
|
|
740
741
|
return ids;
|
|
741
742
|
}
|
|
743
|
+
function getListenerOwnerLockPath(profile) {
|
|
744
|
+
return path4.join(getProfileDir(profile), "listener-owner.json");
|
|
745
|
+
}
|
|
746
|
+
function getListenIpcSocketPath(profile) {
|
|
747
|
+
if (process.platform === "win32") {
|
|
748
|
+
const safe = profile.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
749
|
+
return `\\\\.\\pipe\\openzca-listen-${safe}`;
|
|
750
|
+
}
|
|
751
|
+
return path4.join(getProfileDir(profile), "listen.sock");
|
|
752
|
+
}
|
|
753
|
+
function parsePositiveIntFromUnknown(value) {
|
|
754
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
755
|
+
return Math.trunc(value);
|
|
756
|
+
}
|
|
757
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
758
|
+
const parsed = Number.parseInt(value.trim(), 10);
|
|
759
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
760
|
+
return parsed;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
function isProcessAlive(pid) {
|
|
766
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
767
|
+
try {
|
|
768
|
+
process.kill(pid, 0);
|
|
769
|
+
return true;
|
|
770
|
+
} catch (error) {
|
|
771
|
+
const code = error.code;
|
|
772
|
+
if (code === "EPERM") return true;
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
async function readListenerOwnerRecord(lockPath) {
|
|
777
|
+
try {
|
|
778
|
+
const raw = await fs4.readFile(lockPath, "utf8");
|
|
779
|
+
const parsed = JSON.parse(raw);
|
|
780
|
+
const pid = parsePositiveIntFromUnknown(parsed.pid);
|
|
781
|
+
if (!pid) return null;
|
|
782
|
+
return {
|
|
783
|
+
pid,
|
|
784
|
+
profile: String(parsed.profile ?? ""),
|
|
785
|
+
sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId : void 0,
|
|
786
|
+
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : ""
|
|
787
|
+
};
|
|
788
|
+
} catch (error) {
|
|
789
|
+
const code = error.code;
|
|
790
|
+
if (code === "ENOENT") return null;
|
|
791
|
+
throw error;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
async function readActiveListenerOwner(profile) {
|
|
795
|
+
const lockPath = getListenerOwnerLockPath(profile);
|
|
796
|
+
const record = await readListenerOwnerRecord(lockPath);
|
|
797
|
+
if (!record) {
|
|
798
|
+
await fs4.rm(lockPath, { force: true });
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
if (!isProcessAlive(record.pid)) {
|
|
802
|
+
await fs4.rm(lockPath, { force: true });
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
return record;
|
|
806
|
+
}
|
|
807
|
+
async function acquireListenerOwnerLock(profile, sessionId, command) {
|
|
808
|
+
await ensureProfile(profile);
|
|
809
|
+
const lockPath = getListenerOwnerLockPath(profile);
|
|
810
|
+
const record = {
|
|
811
|
+
pid: process.pid,
|
|
812
|
+
profile,
|
|
813
|
+
sessionId,
|
|
814
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
815
|
+
};
|
|
816
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
817
|
+
try {
|
|
818
|
+
await fs4.writeFile(lockPath, `${JSON.stringify(record, null, 2)}
|
|
819
|
+
`, {
|
|
820
|
+
encoding: "utf8",
|
|
821
|
+
flag: "wx"
|
|
822
|
+
});
|
|
823
|
+
let released = false;
|
|
824
|
+
return {
|
|
825
|
+
lockPath,
|
|
826
|
+
release: async () => {
|
|
827
|
+
if (released) return;
|
|
828
|
+
released = true;
|
|
829
|
+
const current = await readListenerOwnerRecord(lockPath);
|
|
830
|
+
if (current && current.pid !== process.pid) return;
|
|
831
|
+
await fs4.rm(lockPath, { force: true });
|
|
832
|
+
writeDebugLine(
|
|
833
|
+
"listen.owner.released",
|
|
834
|
+
{
|
|
835
|
+
profile,
|
|
836
|
+
lockPath,
|
|
837
|
+
pid: process.pid
|
|
838
|
+
},
|
|
839
|
+
command
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
} catch (error) {
|
|
844
|
+
const code = error.code;
|
|
845
|
+
if (code !== "EEXIST") throw error;
|
|
846
|
+
const owner = await readActiveListenerOwner(profile);
|
|
847
|
+
if (owner) {
|
|
848
|
+
throw new Error(
|
|
849
|
+
`Another openzca listener already owns profile "${profile}" (pid ${owner.pid}).`
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
await fs4.rm(lockPath, { force: true });
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
throw new Error(`Unable to acquire listener ownership for profile "${profile}".`);
|
|
856
|
+
}
|
|
857
|
+
async function startListenerIpcServer(api, profile, sessionId, command) {
|
|
858
|
+
if (!parseBooleanFromEnv("OPENZCA_LISTEN_IPC", true)) {
|
|
859
|
+
writeDebugLine(
|
|
860
|
+
"listen.ipc.disabled",
|
|
861
|
+
{
|
|
862
|
+
profile
|
|
863
|
+
},
|
|
864
|
+
command
|
|
865
|
+
);
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
const socketPath = getListenIpcSocketPath(profile);
|
|
869
|
+
if (process.platform !== "win32") {
|
|
870
|
+
await fs4.rm(socketPath, { force: true });
|
|
871
|
+
}
|
|
872
|
+
const uploadTimeoutMs = parsePositiveIntFromEnv(
|
|
873
|
+
"OPENZCA_UPLOAD_IPC_HANDLER_TIMEOUT_MS",
|
|
874
|
+
parsePositiveIntFromEnv("OPENZCA_UPLOAD_TIMEOUT_MS", 12e4)
|
|
875
|
+
);
|
|
876
|
+
const server = net.createServer((socket) => {
|
|
877
|
+
socket.setEncoding("utf8");
|
|
878
|
+
let buffer = "";
|
|
879
|
+
let done = false;
|
|
880
|
+
const sendResponse = (response) => {
|
|
881
|
+
if (done) return;
|
|
882
|
+
done = true;
|
|
883
|
+
socket.end(`${JSON.stringify(response)}
|
|
884
|
+
`);
|
|
885
|
+
};
|
|
886
|
+
const fail = (requestId, message) => {
|
|
887
|
+
sendResponse({
|
|
888
|
+
kind: "upload_result",
|
|
889
|
+
requestId,
|
|
890
|
+
ok: false,
|
|
891
|
+
error: message
|
|
892
|
+
});
|
|
893
|
+
};
|
|
894
|
+
const handleRequest = async (line) => {
|
|
895
|
+
if (done) return;
|
|
896
|
+
let parsed;
|
|
897
|
+
try {
|
|
898
|
+
parsed = JSON.parse(line);
|
|
899
|
+
} catch {
|
|
900
|
+
fail("", "Invalid JSON request.");
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (parsed.kind !== "upload") {
|
|
904
|
+
fail(parsed.requestId || "", "Unsupported IPC request kind.");
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (parsed.profile !== profile) {
|
|
908
|
+
fail(parsed.requestId, "Profile mismatch.");
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
if (!parsed.threadId || !Array.isArray(parsed.attachments) || parsed.attachments.length === 0) {
|
|
912
|
+
fail(parsed.requestId, "Invalid upload payload.");
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const threadType = parsed.threadType === "group" ? ThreadType.Group : ThreadType.User;
|
|
916
|
+
const requestTimeoutMs = parsePositiveIntFromUnknown(parsed.uploadTimeoutMs) ?? uploadTimeoutMs;
|
|
917
|
+
writeDebugLine(
|
|
918
|
+
"listen.ipc.upload.start",
|
|
919
|
+
{
|
|
920
|
+
profile,
|
|
921
|
+
sessionId,
|
|
922
|
+
requestId: parsed.requestId,
|
|
923
|
+
threadId: parsed.threadId,
|
|
924
|
+
threadType: parsed.threadType,
|
|
925
|
+
attachmentCount: parsed.attachments.length,
|
|
926
|
+
timeoutMs: requestTimeoutMs
|
|
927
|
+
},
|
|
928
|
+
command
|
|
929
|
+
);
|
|
930
|
+
try {
|
|
931
|
+
const response = await withTimeout(
|
|
932
|
+
api.sendMessage(
|
|
933
|
+
{
|
|
934
|
+
msg: "",
|
|
935
|
+
attachments: parsed.attachments
|
|
936
|
+
},
|
|
937
|
+
parsed.threadId,
|
|
938
|
+
threadType
|
|
939
|
+
),
|
|
940
|
+
requestTimeoutMs,
|
|
941
|
+
`Timed out waiting ${requestTimeoutMs}ms for IPC upload completion.`
|
|
942
|
+
);
|
|
943
|
+
sendResponse({
|
|
944
|
+
kind: "upload_result",
|
|
945
|
+
requestId: parsed.requestId,
|
|
946
|
+
ok: true,
|
|
947
|
+
response
|
|
948
|
+
});
|
|
949
|
+
writeDebugLine(
|
|
950
|
+
"listen.ipc.upload.done",
|
|
951
|
+
{
|
|
952
|
+
profile,
|
|
953
|
+
sessionId,
|
|
954
|
+
requestId: parsed.requestId,
|
|
955
|
+
threadId: parsed.threadId,
|
|
956
|
+
threadType: parsed.threadType
|
|
957
|
+
},
|
|
958
|
+
command
|
|
959
|
+
);
|
|
960
|
+
} catch (error) {
|
|
961
|
+
fail(parsed.requestId, toErrorText(error));
|
|
962
|
+
writeDebugLine(
|
|
963
|
+
"listen.ipc.upload.error",
|
|
964
|
+
{
|
|
965
|
+
profile,
|
|
966
|
+
sessionId,
|
|
967
|
+
requestId: parsed.requestId,
|
|
968
|
+
threadId: parsed.threadId,
|
|
969
|
+
threadType: parsed.threadType,
|
|
970
|
+
message: toErrorText(error)
|
|
971
|
+
},
|
|
972
|
+
command
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
socket.on("data", (chunk) => {
|
|
977
|
+
if (done) return;
|
|
978
|
+
buffer += chunk;
|
|
979
|
+
if (buffer.length > 2e6) {
|
|
980
|
+
fail("", "IPC request too large.");
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
984
|
+
if (newlineIndex === -1) return;
|
|
985
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
986
|
+
buffer = "";
|
|
987
|
+
if (!line) {
|
|
988
|
+
fail("", "Empty IPC request.");
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
void handleRequest(line);
|
|
992
|
+
});
|
|
993
|
+
socket.on("error", (error) => {
|
|
994
|
+
writeDebugLine(
|
|
995
|
+
"listen.ipc.socket_error",
|
|
996
|
+
{
|
|
997
|
+
profile,
|
|
998
|
+
sessionId,
|
|
999
|
+
message: toErrorText(error)
|
|
1000
|
+
},
|
|
1001
|
+
command
|
|
1002
|
+
);
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
server.on("error", (error) => {
|
|
1006
|
+
writeDebugLine(
|
|
1007
|
+
"listen.ipc.server_error",
|
|
1008
|
+
{
|
|
1009
|
+
profile,
|
|
1010
|
+
sessionId,
|
|
1011
|
+
message: toErrorText(error)
|
|
1012
|
+
},
|
|
1013
|
+
command
|
|
1014
|
+
);
|
|
1015
|
+
});
|
|
1016
|
+
await new Promise((resolve, reject) => {
|
|
1017
|
+
server.once("error", reject);
|
|
1018
|
+
server.listen(socketPath, () => {
|
|
1019
|
+
server.off("error", reject);
|
|
1020
|
+
resolve();
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
writeDebugLine(
|
|
1024
|
+
"listen.ipc.started",
|
|
1025
|
+
{
|
|
1026
|
+
profile,
|
|
1027
|
+
sessionId,
|
|
1028
|
+
socketPath
|
|
1029
|
+
},
|
|
1030
|
+
command
|
|
1031
|
+
);
|
|
1032
|
+
let closed = false;
|
|
1033
|
+
return {
|
|
1034
|
+
socketPath,
|
|
1035
|
+
close: async () => {
|
|
1036
|
+
if (closed) return;
|
|
1037
|
+
closed = true;
|
|
1038
|
+
await new Promise((resolve) => {
|
|
1039
|
+
server.close(() => resolve());
|
|
1040
|
+
});
|
|
1041
|
+
if (process.platform !== "win32") {
|
|
1042
|
+
await fs4.rm(socketPath, { force: true });
|
|
1043
|
+
}
|
|
1044
|
+
writeDebugLine(
|
|
1045
|
+
"listen.ipc.stopped",
|
|
1046
|
+
{
|
|
1047
|
+
profile,
|
|
1048
|
+
sessionId,
|
|
1049
|
+
socketPath
|
|
1050
|
+
},
|
|
1051
|
+
command
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
async function tryUploadViaListenerIpc(profile, threadId, threadType, attachments, command) {
|
|
1057
|
+
if (!parseBooleanFromEnv("OPENZCA_UPLOAD_IPC", true)) {
|
|
1058
|
+
return { handled: false, reason: "ipc_disabled" };
|
|
1059
|
+
}
|
|
1060
|
+
const socketPath = getListenIpcSocketPath(profile);
|
|
1061
|
+
const requestId = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}`;
|
|
1062
|
+
const connectTimeoutMs = parsePositiveIntFromEnv("OPENZCA_UPLOAD_IPC_CONNECT_TIMEOUT_MS", 1e3);
|
|
1063
|
+
const requestTimeoutMs = parsePositiveIntFromEnv(
|
|
1064
|
+
"OPENZCA_UPLOAD_IPC_TIMEOUT_MS",
|
|
1065
|
+
parsePositiveIntFromEnv("OPENZCA_UPLOAD_TIMEOUT_MS", 12e4) + 5e3
|
|
1066
|
+
);
|
|
1067
|
+
writeDebugLine(
|
|
1068
|
+
"msg.upload.ipc.try",
|
|
1069
|
+
{
|
|
1070
|
+
profile,
|
|
1071
|
+
threadId,
|
|
1072
|
+
threadType: threadType === ThreadType.Group ? "group" : "user",
|
|
1073
|
+
attachmentCount: attachments.length,
|
|
1074
|
+
socketPath,
|
|
1075
|
+
requestId,
|
|
1076
|
+
connectTimeoutMs,
|
|
1077
|
+
requestTimeoutMs
|
|
1078
|
+
},
|
|
1079
|
+
command
|
|
1080
|
+
);
|
|
1081
|
+
return await new Promise((resolve, reject) => {
|
|
1082
|
+
const socket = net.createConnection(socketPath);
|
|
1083
|
+
let connected = false;
|
|
1084
|
+
let settled = false;
|
|
1085
|
+
let responseBuffer = "";
|
|
1086
|
+
let requestSent = false;
|
|
1087
|
+
const finish = (result, error) => {
|
|
1088
|
+
if (settled) return;
|
|
1089
|
+
settled = true;
|
|
1090
|
+
clearTimeout(connectTimer);
|
|
1091
|
+
clearTimeout(requestTimer);
|
|
1092
|
+
if (!socket.destroyed) {
|
|
1093
|
+
socket.destroy();
|
|
1094
|
+
}
|
|
1095
|
+
if (error) {
|
|
1096
|
+
reject(error);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
resolve(result ?? { handled: false, reason: "unknown" });
|
|
1100
|
+
};
|
|
1101
|
+
const connectTimer = setTimeout(() => {
|
|
1102
|
+
finish(void 0, new Error(`Timed out waiting ${connectTimeoutMs}ms to connect upload IPC.`));
|
|
1103
|
+
}, connectTimeoutMs);
|
|
1104
|
+
const requestTimer = setTimeout(() => {
|
|
1105
|
+
finish(void 0, new Error(`Timed out waiting ${requestTimeoutMs}ms for upload IPC response.`));
|
|
1106
|
+
}, requestTimeoutMs);
|
|
1107
|
+
socket.setEncoding("utf8");
|
|
1108
|
+
socket.on("connect", () => {
|
|
1109
|
+
connected = true;
|
|
1110
|
+
clearTimeout(connectTimer);
|
|
1111
|
+
const payload = {
|
|
1112
|
+
kind: "upload",
|
|
1113
|
+
requestId,
|
|
1114
|
+
profile,
|
|
1115
|
+
threadId,
|
|
1116
|
+
threadType: threadType === ThreadType.Group ? "group" : "user",
|
|
1117
|
+
attachments
|
|
1118
|
+
};
|
|
1119
|
+
socket.write(`${JSON.stringify(payload)}
|
|
1120
|
+
`);
|
|
1121
|
+
requestSent = true;
|
|
1122
|
+
});
|
|
1123
|
+
socket.on("data", (chunk) => {
|
|
1124
|
+
responseBuffer += chunk;
|
|
1125
|
+
const newlineIndex = responseBuffer.indexOf("\n");
|
|
1126
|
+
if (newlineIndex === -1) return;
|
|
1127
|
+
const line = responseBuffer.slice(0, newlineIndex).trim();
|
|
1128
|
+
if (!line) {
|
|
1129
|
+
finish(void 0, new Error("Upload IPC returned empty response."));
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
let parsed;
|
|
1133
|
+
try {
|
|
1134
|
+
parsed = JSON.parse(line);
|
|
1135
|
+
} catch {
|
|
1136
|
+
finish(void 0, new Error("Upload IPC returned invalid JSON."));
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (parsed.kind !== "upload_result" || parsed.requestId !== requestId) {
|
|
1140
|
+
finish(void 0, new Error("Upload IPC returned mismatched response."));
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
if (!parsed.ok) {
|
|
1144
|
+
finish(void 0, new Error(parsed.error || "Upload IPC failed."));
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
finish({
|
|
1148
|
+
handled: true,
|
|
1149
|
+
response: parsed.response
|
|
1150
|
+
});
|
|
1151
|
+
});
|
|
1152
|
+
socket.on("error", (error) => {
|
|
1153
|
+
const code = error.code;
|
|
1154
|
+
if (!connected && !requestSent && (code === "ENOENT" || code === "ECONNREFUSED")) {
|
|
1155
|
+
finish({
|
|
1156
|
+
handled: false,
|
|
1157
|
+
reason: code.toLowerCase()
|
|
1158
|
+
});
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
finish(void 0, error);
|
|
1162
|
+
});
|
|
1163
|
+
socket.on("close", () => {
|
|
1164
|
+
if (settled) return;
|
|
1165
|
+
if (!connected && !requestSent) {
|
|
1166
|
+
finish({
|
|
1167
|
+
handled: false,
|
|
1168
|
+
reason: "socket_closed_before_connect"
|
|
1169
|
+
});
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
finish(void 0, new Error("Upload IPC connection closed before response."));
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
742
1176
|
async function resolveUploadThreadType(api, profile, threadId, groupFlag, command) {
|
|
743
1177
|
if (groupFlag) {
|
|
744
1178
|
return { type: ThreadType.Group, reason: "explicit_group_flag" };
|
|
@@ -2462,6 +2896,43 @@ msg.command("upload <arg1> [arg2]").option("-u, --url <url>", "File URL (repeata
|
|
|
2462
2896
|
);
|
|
2463
2897
|
}
|
|
2464
2898
|
await assertFilesExist(attachments);
|
|
2899
|
+
const ipcResult = await tryUploadViaListenerIpc(
|
|
2900
|
+
profile,
|
|
2901
|
+
threadId,
|
|
2902
|
+
threadResolution.type,
|
|
2903
|
+
attachments,
|
|
2904
|
+
command
|
|
2905
|
+
);
|
|
2906
|
+
if (ipcResult.handled) {
|
|
2907
|
+
writeDebugLine(
|
|
2908
|
+
"msg.upload.ipc.done",
|
|
2909
|
+
{
|
|
2910
|
+
threadId,
|
|
2911
|
+
threadType: threadResolution.type === ThreadType.Group ? "group" : "user"
|
|
2912
|
+
},
|
|
2913
|
+
command
|
|
2914
|
+
);
|
|
2915
|
+
output(ipcResult.response, false);
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2918
|
+
writeDebugLine(
|
|
2919
|
+
"msg.upload.ipc.fallback",
|
|
2920
|
+
{
|
|
2921
|
+
threadId,
|
|
2922
|
+
threadType: threadResolution.type === ThreadType.Group ? "group" : "user",
|
|
2923
|
+
reason: ipcResult.reason
|
|
2924
|
+
},
|
|
2925
|
+
command
|
|
2926
|
+
);
|
|
2927
|
+
const enforceSingleOwner = parseBooleanFromEnv("OPENZCA_UPLOAD_ENFORCE_SINGLE_OWNER", true);
|
|
2928
|
+
if (enforceSingleOwner) {
|
|
2929
|
+
const owner = await readActiveListenerOwner(profile);
|
|
2930
|
+
if (owner && owner.pid !== process.pid) {
|
|
2931
|
+
throw new Error(
|
|
2932
|
+
`Active listener owner detected for profile "${profile}" (pid ${owner.pid}), but upload IPC is unavailable. Restart \`openzca listen\` with latest version or set OPENZCA_UPLOAD_ENFORCE_SINGLE_OWNER=0 to allow fallback listener startup.`
|
|
2933
|
+
);
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2465
2936
|
const response = await withUploadListener(
|
|
2466
2937
|
api,
|
|
2467
2938
|
command,
|
|
@@ -3124,6 +3595,14 @@ program.command("listen").description("Listen for real-time incoming messages").
|
|
|
3124
3595
|
) ?? 3e4;
|
|
3125
3596
|
const lifecycleEventsEnabled = supervised && Boolean(opts.raw);
|
|
3126
3597
|
const recycleEnabled = !supervised && Boolean(opts.keepAlive) && recycleMs > 0;
|
|
3598
|
+
const keepAliveRestartDelayMs = parsePositiveIntFromEnv(
|
|
3599
|
+
"OPENZCA_LISTEN_KEEPALIVE_RESTART_DELAY_MS",
|
|
3600
|
+
2e3
|
|
3601
|
+
);
|
|
3602
|
+
const keepAliveRestartOnAnyClose = parseBooleanFromEnv(
|
|
3603
|
+
"OPENZCA_LISTEN_KEEPALIVE_RESTART_ON_ANY_CLOSE",
|
|
3604
|
+
false
|
|
3605
|
+
);
|
|
3127
3606
|
const recycleExitCode = 75;
|
|
3128
3607
|
const includeReplyContext = parseToggleDefaultTrue(
|
|
3129
3608
|
process.env.OPENZCA_LISTEN_INCLUDE_QUOTE_CONTEXT
|
|
@@ -3145,212 +3624,229 @@ program.command("listen").description("Listen for real-time incoming messages").
|
|
|
3145
3624
|
})
|
|
3146
3625
|
);
|
|
3147
3626
|
};
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
"content-type": "application/json"
|
|
3627
|
+
const enforceSingleOwner = parseBooleanFromEnv("OPENZCA_LISTEN_ENFORCE_SINGLE_OWNER", true);
|
|
3628
|
+
let ownerLock = null;
|
|
3629
|
+
let ipcServer = null;
|
|
3630
|
+
let ipcSocketPath;
|
|
3631
|
+
let resourcesCleaned = false;
|
|
3632
|
+
const cleanupListenResources = async () => {
|
|
3633
|
+
if (resourcesCleaned) return;
|
|
3634
|
+
resourcesCleaned = true;
|
|
3635
|
+
if (ipcServer) {
|
|
3636
|
+
await ipcServer.close();
|
|
3637
|
+
ipcServer = null;
|
|
3638
|
+
}
|
|
3639
|
+
if (ownerLock) {
|
|
3640
|
+
await ownerLock.release();
|
|
3641
|
+
ownerLock = null;
|
|
3642
|
+
}
|
|
3643
|
+
};
|
|
3644
|
+
const unregisterResourceCleanup = registerShutdownCallback(async () => {
|
|
3645
|
+
await cleanupListenResources();
|
|
3646
|
+
});
|
|
3647
|
+
try {
|
|
3648
|
+
if (enforceSingleOwner) {
|
|
3649
|
+
ownerLock = await acquireListenerOwnerLock(profile, sessionId, command);
|
|
3650
|
+
writeDebugLine(
|
|
3651
|
+
"listen.owner.acquired",
|
|
3652
|
+
{
|
|
3653
|
+
profile,
|
|
3654
|
+
lockPath: ownerLock.lockPath,
|
|
3655
|
+
pid: process.pid,
|
|
3656
|
+
sessionId
|
|
3179
3657
|
},
|
|
3180
|
-
|
|
3181
|
-
});
|
|
3182
|
-
if (!response.ok) {
|
|
3183
|
-
console.error(`Webhook response: ${response.status}`);
|
|
3184
|
-
}
|
|
3185
|
-
} catch (error) {
|
|
3186
|
-
console.error(
|
|
3187
|
-
`Webhook failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3658
|
+
command
|
|
3188
3659
|
);
|
|
3189
3660
|
}
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
console.log("
|
|
3193
|
-
|
|
3194
|
-
"
|
|
3195
|
-
|
|
3196
|
-
profile,
|
|
3197
|
-
sessionId
|
|
3198
|
-
},
|
|
3199
|
-
command
|
|
3200
|
-
);
|
|
3201
|
-
emitLifecycle("connected");
|
|
3202
|
-
});
|
|
3203
|
-
api.listener.on("message", async (message) => {
|
|
3204
|
-
const messageData = message.data;
|
|
3205
|
-
const rawContent = messageData.content;
|
|
3206
|
-
const msgType = getStringCandidate(messageData, ["msgType"]);
|
|
3207
|
-
let quote = normalizeQuoteContext(messageData.quote);
|
|
3208
|
-
const parsedContent = normalizeStructuredContent(rawContent);
|
|
3209
|
-
const hasParsedStructuredContent = parsedContent !== rawContent;
|
|
3210
|
-
const rawText = typeof rawContent === "string" ? rawContent : "";
|
|
3211
|
-
const mediaKind = detectInboundMediaKind(msgType, parsedContent);
|
|
3212
|
-
const maxMediaFiles = parseMaxInboundMediaFiles();
|
|
3213
|
-
const remoteMediaUrls = mediaKind && maxMediaFiles > 0 ? resolvePreferredMediaUrls(mediaKind, parsedContent).slice(0, maxMediaFiles) : [];
|
|
3214
|
-
const quoteRemoteMediaUrls = quote && downloadQuoteMedia && maxMediaFiles > 0 ? (quote.mediaUrls ?? []).slice(0, maxMediaFiles) : [];
|
|
3661
|
+
ipcServer = await startListenerIpcServer(api, profile, sessionId, command);
|
|
3662
|
+
ipcSocketPath = ipcServer?.socketPath;
|
|
3663
|
+
console.log("Listening... Press Ctrl+C to stop.");
|
|
3664
|
+
if (supervised && opts.keepAlive) {
|
|
3665
|
+
console.error("Warning: --supervised ignores internal --keep-alive reconnect ownership.");
|
|
3666
|
+
}
|
|
3215
3667
|
writeDebugLine(
|
|
3216
|
-
"listen.
|
|
3668
|
+
"listen.start",
|
|
3217
3669
|
{
|
|
3218
3670
|
profile,
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3671
|
+
mediaDir: resolveInboundMediaDir(profile),
|
|
3672
|
+
maxMediaBytes: parseMaxInboundMediaBytes(),
|
|
3673
|
+
maxMediaFiles: parseMaxInboundMediaFiles(),
|
|
3674
|
+
includeMediaUrl: process.env.OPENZCA_LISTEN_INCLUDE_MEDIA_URL?.trim() ?? null,
|
|
3675
|
+
keepAlive: Boolean(opts.keepAlive),
|
|
3676
|
+
keepAliveRestartDelayMs: Boolean(opts.keepAlive) ? keepAliveRestartDelayMs : void 0,
|
|
3677
|
+
keepAliveRestartOnAnyClose: Boolean(opts.keepAlive) ? keepAliveRestartOnAnyClose : void 0,
|
|
3678
|
+
supervised,
|
|
3679
|
+
lifecycleEventsEnabled,
|
|
3680
|
+
heartbeatMs: lifecycleEventsEnabled ? heartbeatMs : void 0,
|
|
3681
|
+
recycleMs: recycleEnabled ? recycleMs : void 0,
|
|
3682
|
+
includeReplyContext,
|
|
3683
|
+
downloadQuoteMedia,
|
|
3684
|
+
sessionId,
|
|
3685
|
+
singleOwner: enforceSingleOwner,
|
|
3686
|
+
ipcSocketPath
|
|
3229
3687
|
},
|
|
3230
3688
|
command
|
|
3231
3689
|
);
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
const localEntries = mediaEntries.filter((entry) => Boolean(entry.mediaPath));
|
|
3253
|
-
const mediaPaths = localEntries.map((entry) => entry.mediaPath);
|
|
3254
|
-
const mediaUrls = localEntries.length > 0 ? localEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value)) : mediaEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value));
|
|
3255
|
-
const mediaTypes = localEntries.length > 0 ? localEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value)) : mediaEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value));
|
|
3256
|
-
const mediaPath = mediaPaths[0];
|
|
3257
|
-
const mediaUrl = mediaUrls[0];
|
|
3258
|
-
const mediaType = mediaTypes[0];
|
|
3259
|
-
const quoteLocalEntries = quoteMediaEntries.filter((entry) => Boolean(entry.mediaPath));
|
|
3260
|
-
const quoteMediaPaths = quoteLocalEntries.map((entry) => entry.mediaPath);
|
|
3261
|
-
const quoteMediaUrls = quoteLocalEntries.length > 0 ? quoteLocalEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value)) : quoteMediaEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value));
|
|
3262
|
-
const quoteMediaTypes = quoteLocalEntries.length > 0 ? quoteLocalEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value)) : quoteMediaEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value));
|
|
3263
|
-
const quoteMediaPath = quoteMediaPaths[0];
|
|
3264
|
-
const quoteMediaUrl = quoteMediaUrls[0];
|
|
3265
|
-
const quoteMediaType = quoteMediaTypes[0];
|
|
3266
|
-
if (quote) {
|
|
3267
|
-
quote = {
|
|
3268
|
-
...quote,
|
|
3269
|
-
mediaPath: quoteMediaPath,
|
|
3270
|
-
mediaPaths: quoteMediaPaths.length > 0 ? quoteMediaPaths : void 0,
|
|
3271
|
-
mediaUrl: quoteMediaUrl,
|
|
3272
|
-
mediaUrls: quoteMediaUrls.length > 0 ? quoteMediaUrls : quote.mediaUrls,
|
|
3273
|
-
mediaType: quoteMediaType,
|
|
3274
|
-
mediaTypes: quoteMediaTypes.length > 0 ? quoteMediaTypes : void 0
|
|
3275
|
-
};
|
|
3276
|
-
}
|
|
3277
|
-
const replyContextText = includeReplyContext && quote ? buildReplyContextText(quote) : "";
|
|
3278
|
-
const replyMediaText = includeReplyContext && quoteMediaEntries.length > 0 ? buildReplyMediaAttachedText({ mediaEntries: quoteMediaEntries }) : "";
|
|
3279
|
-
const caption = rawText.trim().length > 0 && !hasParsedStructuredContent ? rawText.trim() : summarizeStructuredContent(msgType, parsedContent);
|
|
3280
|
-
let processedText = mediaEntries.length ? buildMediaAttachedText({
|
|
3281
|
-
mediaEntries,
|
|
3282
|
-
fallbackKind: mediaKind,
|
|
3283
|
-
caption
|
|
3284
|
-
}) : rawText.trim().length > 0 && !hasParsedStructuredContent ? rawText : summarizeStructuredContent(msgType, parsedContent);
|
|
3285
|
-
if (!processedText.trim() && !replyContextText && !replyMediaText) return;
|
|
3286
|
-
if (opts.prefix && processedText.trim().length > 0) {
|
|
3287
|
-
if (!processedText.startsWith(opts.prefix)) return;
|
|
3288
|
-
processedText = processedText.slice(opts.prefix.length).trimStart();
|
|
3690
|
+
emitLifecycle("session_id");
|
|
3691
|
+
let keepAliveRestartTimer = null;
|
|
3692
|
+
async function emitWebhook(payload) {
|
|
3693
|
+
if (!opts.webhook) return;
|
|
3694
|
+
try {
|
|
3695
|
+
const response = await fetch(opts.webhook, {
|
|
3696
|
+
method: "POST",
|
|
3697
|
+
headers: {
|
|
3698
|
+
"content-type": "application/json"
|
|
3699
|
+
},
|
|
3700
|
+
body: JSON.stringify(payload)
|
|
3701
|
+
});
|
|
3702
|
+
if (!response.ok) {
|
|
3703
|
+
console.error(`Webhook response: ${response.status}`);
|
|
3704
|
+
}
|
|
3705
|
+
} catch (error) {
|
|
3706
|
+
console.error(
|
|
3707
|
+
`Webhook failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3708
|
+
);
|
|
3709
|
+
}
|
|
3289
3710
|
}
|
|
3290
|
-
|
|
3291
|
-
|
|
3711
|
+
api.listener.on("connected", () => {
|
|
3712
|
+
console.log("Connected to Zalo websocket.");
|
|
3713
|
+
if (keepAliveRestartTimer) {
|
|
3714
|
+
clearTimeout(keepAliveRestartTimer);
|
|
3715
|
+
keepAliveRestartTimer = null;
|
|
3716
|
+
}
|
|
3717
|
+
writeDebugLine(
|
|
3718
|
+
"listen.connected",
|
|
3719
|
+
{
|
|
3720
|
+
profile,
|
|
3721
|
+
sessionId
|
|
3722
|
+
},
|
|
3723
|
+
command
|
|
3724
|
+
);
|
|
3725
|
+
emitLifecycle("connected");
|
|
3726
|
+
});
|
|
3727
|
+
api.listener.on("message", async (message) => {
|
|
3728
|
+
const messageData = message.data;
|
|
3729
|
+
const rawContent = messageData.content;
|
|
3730
|
+
const msgType = getStringCandidate(messageData, ["msgType"]);
|
|
3731
|
+
let quote = normalizeQuoteContext(messageData.quote);
|
|
3732
|
+
const parsedContent = normalizeStructuredContent(rawContent);
|
|
3733
|
+
const hasParsedStructuredContent = parsedContent !== rawContent;
|
|
3734
|
+
const rawText = typeof rawContent === "string" ? rawContent : "";
|
|
3735
|
+
const mediaKind = detectInboundMediaKind(msgType, parsedContent);
|
|
3736
|
+
const maxMediaFiles = parseMaxInboundMediaFiles();
|
|
3737
|
+
const remoteMediaUrls = mediaKind && maxMediaFiles > 0 ? resolvePreferredMediaUrls(mediaKind, parsedContent).slice(0, maxMediaFiles) : [];
|
|
3738
|
+
const quoteRemoteMediaUrls = quote && downloadQuoteMedia && maxMediaFiles > 0 ? (quote.mediaUrls ?? []).slice(0, maxMediaFiles) : [];
|
|
3739
|
+
writeDebugLine(
|
|
3740
|
+
"listen.media.detected",
|
|
3741
|
+
{
|
|
3742
|
+
profile,
|
|
3743
|
+
threadId: message.threadId,
|
|
3744
|
+
msgType: msgType || void 0,
|
|
3745
|
+
mediaKind,
|
|
3746
|
+
hasParsedStructuredContent,
|
|
3747
|
+
remoteMediaUrls,
|
|
3748
|
+
hasQuote: Boolean(quote),
|
|
3749
|
+
quoteOwnerId: quote?.ownerId,
|
|
3750
|
+
quoteGlobalMsgId: quote?.globalMsgId,
|
|
3751
|
+
quoteCliMsgId: quote?.cliMsgId,
|
|
3752
|
+
quoteRemoteMediaUrls
|
|
3753
|
+
},
|
|
3754
|
+
command
|
|
3755
|
+
);
|
|
3756
|
+
const [mediaEntries, quoteMediaEntries] = await Promise.all([
|
|
3757
|
+
mediaKind ? cacheRemoteMediaEntries({
|
|
3758
|
+
profile,
|
|
3759
|
+
urls: remoteMediaUrls,
|
|
3760
|
+
kind: mediaKind,
|
|
3761
|
+
command,
|
|
3762
|
+
warningLabel: "inbound media",
|
|
3763
|
+
debugErrorEvent: "listen.media.cache_error",
|
|
3764
|
+
debugUrlKey: "mediaUrl"
|
|
3765
|
+
}) : Promise.resolve([]),
|
|
3766
|
+
cacheRemoteMediaEntries({
|
|
3767
|
+
profile,
|
|
3768
|
+
urls: quoteRemoteMediaUrls,
|
|
3769
|
+
kind: "file",
|
|
3770
|
+
command,
|
|
3771
|
+
warningLabel: "quoted media",
|
|
3772
|
+
debugErrorEvent: "listen.quote_media.cache_error",
|
|
3773
|
+
debugUrlKey: "quoteMediaUrl"
|
|
3774
|
+
})
|
|
3775
|
+
]);
|
|
3776
|
+
const localEntries = mediaEntries.filter((entry) => Boolean(entry.mediaPath));
|
|
3777
|
+
const mediaPaths = localEntries.map((entry) => entry.mediaPath);
|
|
3778
|
+
const mediaUrls = localEntries.length > 0 ? localEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value)) : mediaEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value));
|
|
3779
|
+
const mediaTypes = localEntries.length > 0 ? localEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value)) : mediaEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value));
|
|
3780
|
+
const mediaPath = mediaPaths[0];
|
|
3781
|
+
const mediaUrl = mediaUrls[0];
|
|
3782
|
+
const mediaType = mediaTypes[0];
|
|
3783
|
+
const quoteLocalEntries = quoteMediaEntries.filter((entry) => Boolean(entry.mediaPath));
|
|
3784
|
+
const quoteMediaPaths = quoteLocalEntries.map((entry) => entry.mediaPath);
|
|
3785
|
+
const quoteMediaUrls = quoteLocalEntries.length > 0 ? quoteLocalEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value)) : quoteMediaEntries.map((entry) => entry.mediaUrl).filter((value) => Boolean(value));
|
|
3786
|
+
const quoteMediaTypes = quoteLocalEntries.length > 0 ? quoteLocalEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value)) : quoteMediaEntries.map((entry) => entry.mediaType).filter((value) => Boolean(value));
|
|
3787
|
+
const quoteMediaPath = quoteMediaPaths[0];
|
|
3788
|
+
const quoteMediaUrl = quoteMediaUrls[0];
|
|
3789
|
+
const quoteMediaType = quoteMediaTypes[0];
|
|
3790
|
+
if (quote) {
|
|
3791
|
+
quote = {
|
|
3792
|
+
...quote,
|
|
3793
|
+
mediaPath: quoteMediaPath,
|
|
3794
|
+
mediaPaths: quoteMediaPaths.length > 0 ? quoteMediaPaths : void 0,
|
|
3795
|
+
mediaUrl: quoteMediaUrl,
|
|
3796
|
+
mediaUrls: quoteMediaUrls.length > 0 ? quoteMediaUrls : quote.mediaUrls,
|
|
3797
|
+
mediaType: quoteMediaType,
|
|
3798
|
+
mediaTypes: quoteMediaTypes.length > 0 ? quoteMediaTypes : void 0
|
|
3799
|
+
};
|
|
3800
|
+
}
|
|
3801
|
+
const replyContextText = includeReplyContext && quote ? buildReplyContextText(quote) : "";
|
|
3802
|
+
const replyMediaText = includeReplyContext && quoteMediaEntries.length > 0 ? buildReplyMediaAttachedText({ mediaEntries: quoteMediaEntries }) : "";
|
|
3803
|
+
const caption = rawText.trim().length > 0 && !hasParsedStructuredContent ? rawText.trim() : summarizeStructuredContent(msgType, parsedContent);
|
|
3804
|
+
let processedText = mediaEntries.length ? buildMediaAttachedText({
|
|
3805
|
+
mediaEntries,
|
|
3806
|
+
fallbackKind: mediaKind,
|
|
3807
|
+
caption
|
|
3808
|
+
}) : rawText.trim().length > 0 && !hasParsedStructuredContent ? rawText : summarizeStructuredContent(msgType, parsedContent);
|
|
3809
|
+
if (!processedText.trim() && !replyContextText && !replyMediaText) return;
|
|
3810
|
+
if (opts.prefix && processedText.trim().length > 0) {
|
|
3811
|
+
if (!processedText.startsWith(opts.prefix)) return;
|
|
3812
|
+
processedText = processedText.slice(opts.prefix.length).trimStart();
|
|
3813
|
+
}
|
|
3814
|
+
if (replyMediaText) {
|
|
3815
|
+
processedText = processedText.trim() ? `${processedText}
|
|
3292
3816
|
${replyMediaText}` : replyMediaText;
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3817
|
+
}
|
|
3818
|
+
if (replyContextText) {
|
|
3819
|
+
processedText = processedText.trim() ? `${processedText}
|
|
3296
3820
|
${replyContextText}` : replyContextText;
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
threadId: message.threadId,
|
|
3319
|
-
targetId: message.threadId,
|
|
3320
|
-
conversationId: message.threadId,
|
|
3321
|
-
msgId: message.data.msgId,
|
|
3322
|
-
cliMsgId: message.data.cliMsgId,
|
|
3323
|
-
content: processedText,
|
|
3324
|
-
type: message.type,
|
|
3325
|
-
timestamp,
|
|
3326
|
-
msgType: msgType || void 0,
|
|
3327
|
-
quote: quote ?? void 0,
|
|
3328
|
-
quoteMediaPath,
|
|
3329
|
-
quoteMediaPaths: quoteMediaPaths.length > 0 ? quoteMediaPaths : void 0,
|
|
3330
|
-
quoteMediaUrl,
|
|
3331
|
-
quoteMediaUrls: quoteMediaUrls.length > 0 ? quoteMediaUrls : void 0,
|
|
3332
|
-
quoteMediaType,
|
|
3333
|
-
quoteMediaTypes: quoteMediaTypes.length > 0 ? quoteMediaTypes : void 0,
|
|
3334
|
-
mediaPath,
|
|
3335
|
-
mediaPaths: mediaPaths.length > 0 ? mediaPaths : void 0,
|
|
3336
|
-
mediaUrl,
|
|
3337
|
-
mediaUrls: mediaUrls.length > 0 ? mediaUrls : void 0,
|
|
3338
|
-
mediaType,
|
|
3339
|
-
mediaTypes: mediaTypes.length > 0 ? mediaTypes : void 0,
|
|
3340
|
-
mediaKind: mediaKind ?? void 0,
|
|
3341
|
-
mentions: mentions.length > 0 ? mentions : void 0,
|
|
3342
|
-
mentionIds: mentionIds.length > 0 ? mentionIds : void 0,
|
|
3343
|
-
metadata: {
|
|
3344
|
-
isGroup: message.type === ThreadType.Group,
|
|
3345
|
-
chatType,
|
|
3821
|
+
}
|
|
3822
|
+
const chatType = message.type === ThreadType.Group ? "group" : "user";
|
|
3823
|
+
const senderId = getStringCandidate(messageData, ["uidFrom"]) || message.data.uidFrom;
|
|
3824
|
+
const senderDisplayNameRaw = getStringCandidate(messageData, [
|
|
3825
|
+
"dName",
|
|
3826
|
+
"fromD",
|
|
3827
|
+
"senderName",
|
|
3828
|
+
"displayName"
|
|
3829
|
+
]);
|
|
3830
|
+
const senderDisplayName = senderDisplayNameRaw || void 0;
|
|
3831
|
+
const senderNameForMetadata = message.type === ThreadType.Group ? senderDisplayName : void 0;
|
|
3832
|
+
const toId = getStringCandidate(messageData, ["idTo"]) || void 0;
|
|
3833
|
+
const threadName = typeof messageData.threadName === "string" ? messageData.threadName : typeof messageData.tName === "string" ? messageData.tName : void 0;
|
|
3834
|
+
const mentions = extractInboundMentions({
|
|
3835
|
+
messageData,
|
|
3836
|
+
parsedContent,
|
|
3837
|
+
rawText
|
|
3838
|
+
});
|
|
3839
|
+
const mentionIds = mentions.map((item) => item.uid);
|
|
3840
|
+
const timestamp = toEpochSeconds(message.data.ts);
|
|
3841
|
+
const payload = {
|
|
3346
3842
|
threadId: message.threadId,
|
|
3347
3843
|
targetId: message.threadId,
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3844
|
+
conversationId: message.threadId,
|
|
3845
|
+
msgId: message.data.msgId,
|
|
3846
|
+
cliMsgId: message.data.cliMsgId,
|
|
3847
|
+
content: processedText,
|
|
3848
|
+
type: message.type,
|
|
3849
|
+
timestamp,
|
|
3354
3850
|
msgType: msgType || void 0,
|
|
3355
3851
|
quote: quote ?? void 0,
|
|
3356
3852
|
quoteMediaPath,
|
|
@@ -3359,7 +3855,6 @@ ${replyContextText}` : replyContextText;
|
|
|
3359
3855
|
quoteMediaUrls: quoteMediaUrls.length > 0 ? quoteMediaUrls : void 0,
|
|
3360
3856
|
quoteMediaType,
|
|
3361
3857
|
quoteMediaTypes: quoteMediaTypes.length > 0 ? quoteMediaTypes : void 0,
|
|
3362
|
-
timestamp,
|
|
3363
3858
|
mediaPath,
|
|
3364
3859
|
mediaPaths: mediaPaths.length > 0 ? mediaPaths : void 0,
|
|
3365
3860
|
mediaUrl,
|
|
@@ -3369,147 +3864,222 @@ ${replyContextText}` : replyContextText;
|
|
|
3369
3864
|
mediaKind: mediaKind ?? void 0,
|
|
3370
3865
|
mentions: mentions.length > 0 ? mentions : void 0,
|
|
3371
3866
|
mentionIds: mentionIds.length > 0 ? mentionIds : void 0,
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3867
|
+
metadata: {
|
|
3868
|
+
isGroup: message.type === ThreadType.Group,
|
|
3869
|
+
chatType,
|
|
3870
|
+
threadId: message.threadId,
|
|
3871
|
+
targetId: message.threadId,
|
|
3872
|
+
threadName,
|
|
3873
|
+
senderName: senderNameForMetadata,
|
|
3874
|
+
senderDisplayName,
|
|
3875
|
+
senderId,
|
|
3876
|
+
fromId: senderId,
|
|
3877
|
+
toId,
|
|
3878
|
+
msgType: msgType || void 0,
|
|
3879
|
+
quote: quote ?? void 0,
|
|
3880
|
+
quoteMediaPath,
|
|
3881
|
+
quoteMediaPaths: quoteMediaPaths.length > 0 ? quoteMediaPaths : void 0,
|
|
3882
|
+
quoteMediaUrl,
|
|
3883
|
+
quoteMediaUrls: quoteMediaUrls.length > 0 ? quoteMediaUrls : void 0,
|
|
3884
|
+
quoteMediaType,
|
|
3885
|
+
quoteMediaTypes: quoteMediaTypes.length > 0 ? quoteMediaTypes : void 0,
|
|
3886
|
+
timestamp,
|
|
3887
|
+
mediaPath,
|
|
3888
|
+
mediaPaths: mediaPaths.length > 0 ? mediaPaths : void 0,
|
|
3889
|
+
mediaUrl,
|
|
3890
|
+
mediaUrls: mediaUrls.length > 0 ? mediaUrls : void 0,
|
|
3891
|
+
mediaType,
|
|
3892
|
+
mediaTypes: mediaTypes.length > 0 ? mediaTypes : void 0,
|
|
3893
|
+
mediaKind: mediaKind ?? void 0,
|
|
3894
|
+
mentions: mentions.length > 0 ? mentions : void 0,
|
|
3895
|
+
mentionIds: mentionIds.length > 0 ? mentionIds : void 0,
|
|
3896
|
+
mentionCount: mentions.length > 0 ? mentions.length : void 0
|
|
3897
|
+
},
|
|
3898
|
+
// Backward-compatible convenience fields.
|
|
3899
|
+
chatType,
|
|
3900
|
+
senderId,
|
|
3901
|
+
senderName: senderDisplayName,
|
|
3902
|
+
senderDisplayName,
|
|
3903
|
+
toId,
|
|
3904
|
+
ts: message.data.ts
|
|
3905
|
+
};
|
|
3906
|
+
if (opts.raw) {
|
|
3907
|
+
console.log(JSON.stringify(payload));
|
|
3908
|
+
} else {
|
|
3909
|
+
console.log(
|
|
3910
|
+
`[${chatType}] ${payload.senderName || payload.senderId} -> ${payload.threadId}: ${payload.content}`
|
|
3400
3911
|
);
|
|
3401
3912
|
}
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
message: error instanceof Error ? error.message : String(error)
|
|
3416
|
-
});
|
|
3417
|
-
console.error(
|
|
3418
|
-
`Listener error: ${error instanceof Error ? error.message : String(error)}`
|
|
3419
|
-
);
|
|
3420
|
-
});
|
|
3421
|
-
await new Promise((resolve) => {
|
|
3422
|
-
let settled = false;
|
|
3423
|
-
let recycleTimer = null;
|
|
3424
|
-
let recycleForceExitTimer = null;
|
|
3425
|
-
let heartbeatTimer = null;
|
|
3426
|
-
let recyclePendingExit = false;
|
|
3427
|
-
let unregisterShutdown = () => {
|
|
3428
|
-
};
|
|
3429
|
-
const finish = () => {
|
|
3430
|
-
if (settled) return;
|
|
3431
|
-
settled = true;
|
|
3432
|
-
if (recycleTimer) {
|
|
3433
|
-
clearTimeout(recycleTimer);
|
|
3434
|
-
recycleTimer = null;
|
|
3435
|
-
}
|
|
3436
|
-
if (recycleForceExitTimer && !recyclePendingExit) {
|
|
3437
|
-
clearTimeout(recycleForceExitTimer);
|
|
3438
|
-
recycleForceExitTimer = null;
|
|
3439
|
-
}
|
|
3440
|
-
if (heartbeatTimer) {
|
|
3441
|
-
clearInterval(heartbeatTimer);
|
|
3442
|
-
heartbeatTimer = null;
|
|
3913
|
+
await emitWebhook(payload);
|
|
3914
|
+
if (opts.echo && rawText.trim().length > 0) {
|
|
3915
|
+
try {
|
|
3916
|
+
await api.sendMessage(
|
|
3917
|
+
{ msg: processedText },
|
|
3918
|
+
message.threadId,
|
|
3919
|
+
message.type
|
|
3920
|
+
);
|
|
3921
|
+
} catch (error) {
|
|
3922
|
+
console.error(
|
|
3923
|
+
`Echo failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3924
|
+
);
|
|
3925
|
+
}
|
|
3443
3926
|
}
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
};
|
|
3447
|
-
resolve();
|
|
3448
|
-
};
|
|
3449
|
-
api.listener.on("closed", (code, reason) => {
|
|
3450
|
-
console.log(`Listener closed (${code}) ${reason || ""}`);
|
|
3927
|
+
});
|
|
3928
|
+
api.listener.on("error", (error) => {
|
|
3451
3929
|
writeDebugLine(
|
|
3452
|
-
"listen.
|
|
3930
|
+
"listen.error",
|
|
3453
3931
|
{
|
|
3454
3932
|
profile,
|
|
3455
|
-
|
|
3456
|
-
reason: reason || void 0,
|
|
3933
|
+
message: error instanceof Error ? error.message : String(error),
|
|
3457
3934
|
sessionId
|
|
3458
3935
|
},
|
|
3459
3936
|
command
|
|
3460
3937
|
);
|
|
3461
|
-
emitLifecycle("
|
|
3462
|
-
|
|
3463
|
-
reason: reason || void 0
|
|
3938
|
+
emitLifecycle("error", {
|
|
3939
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3464
3940
|
});
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3941
|
+
console.error(
|
|
3942
|
+
`Listener error: ${error instanceof Error ? error.message : String(error)}`
|
|
3943
|
+
);
|
|
3468
3944
|
});
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3945
|
+
await new Promise((resolve) => {
|
|
3946
|
+
let settled = false;
|
|
3947
|
+
let recycleTimer = null;
|
|
3948
|
+
let recycleForceExitTimer = null;
|
|
3949
|
+
let heartbeatTimer = null;
|
|
3950
|
+
let recyclePendingExit = false;
|
|
3951
|
+
let unregisterShutdown = () => {
|
|
3952
|
+
};
|
|
3953
|
+
const finish = () => {
|
|
3954
|
+
if (settled) return;
|
|
3955
|
+
settled = true;
|
|
3956
|
+
if (recycleTimer) {
|
|
3957
|
+
clearTimeout(recycleTimer);
|
|
3958
|
+
recycleTimer = null;
|
|
3959
|
+
}
|
|
3960
|
+
if (recycleForceExitTimer && !recyclePendingExit) {
|
|
3961
|
+
clearTimeout(recycleForceExitTimer);
|
|
3962
|
+
recycleForceExitTimer = null;
|
|
3963
|
+
}
|
|
3964
|
+
if (heartbeatTimer) {
|
|
3965
|
+
clearInterval(heartbeatTimer);
|
|
3966
|
+
heartbeatTimer = null;
|
|
3967
|
+
}
|
|
3968
|
+
if (keepAliveRestartTimer) {
|
|
3969
|
+
clearTimeout(keepAliveRestartTimer);
|
|
3970
|
+
keepAliveRestartTimer = null;
|
|
3971
|
+
}
|
|
3972
|
+
unregisterShutdown();
|
|
3973
|
+
unregisterShutdown = () => {
|
|
3974
|
+
};
|
|
3975
|
+
resolve();
|
|
3976
|
+
};
|
|
3977
|
+
api.listener.on("closed", (code, reason) => {
|
|
3978
|
+
console.log(`Listener closed (${code}) ${reason || ""}`);
|
|
3487
3979
|
writeDebugLine(
|
|
3488
|
-
"listen.
|
|
3980
|
+
"listen.closed",
|
|
3489
3981
|
{
|
|
3490
3982
|
profile,
|
|
3491
|
-
|
|
3492
|
-
|
|
3983
|
+
code,
|
|
3984
|
+
reason: reason || void 0,
|
|
3493
3985
|
sessionId
|
|
3494
3986
|
},
|
|
3495
3987
|
command
|
|
3496
3988
|
);
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3989
|
+
emitLifecycle("closed", {
|
|
3990
|
+
code,
|
|
3991
|
+
reason: reason || void 0
|
|
3992
|
+
});
|
|
3993
|
+
if (!opts.keepAlive || supervised) {
|
|
3994
|
+
finish();
|
|
3995
|
+
return;
|
|
3996
|
+
}
|
|
3997
|
+
const shouldRestart = keepAliveRestartOnAnyClose || code === 1e3 || code === 3e3;
|
|
3998
|
+
if (!shouldRestart) return;
|
|
3999
|
+
if (keepAliveRestartTimer) {
|
|
4000
|
+
clearTimeout(keepAliveRestartTimer);
|
|
4001
|
+
}
|
|
4002
|
+
keepAliveRestartTimer = setTimeout(() => {
|
|
4003
|
+
keepAliveRestartTimer = null;
|
|
4004
|
+
writeDebugLine(
|
|
4005
|
+
"listen.keepalive.restart",
|
|
4006
|
+
{
|
|
4007
|
+
profile,
|
|
4008
|
+
code,
|
|
4009
|
+
reason: reason || void 0,
|
|
4010
|
+
delayMs: keepAliveRestartDelayMs,
|
|
4011
|
+
sessionId
|
|
4012
|
+
},
|
|
4013
|
+
command
|
|
4014
|
+
);
|
|
4015
|
+
try {
|
|
4016
|
+
api.listener.start({ retryOnClose: true });
|
|
4017
|
+
} catch (error) {
|
|
4018
|
+
if (!isListenerAlreadyStarted(error)) {
|
|
4019
|
+
writeDebugLine(
|
|
4020
|
+
"listen.keepalive.restart_error",
|
|
4021
|
+
{
|
|
4022
|
+
profile,
|
|
4023
|
+
code,
|
|
4024
|
+
reason: reason || void 0,
|
|
4025
|
+
delayMs: keepAliveRestartDelayMs,
|
|
4026
|
+
message: toErrorText(error),
|
|
4027
|
+
sessionId
|
|
4028
|
+
},
|
|
4029
|
+
command
|
|
4030
|
+
);
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
}, keepAliveRestartDelayMs);
|
|
4034
|
+
});
|
|
4035
|
+
const onSignal = () => {
|
|
3504
4036
|
try {
|
|
3505
4037
|
api.listener.stop();
|
|
3506
4038
|
} catch {
|
|
3507
4039
|
}
|
|
3508
4040
|
finish();
|
|
3509
|
-
}
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
4041
|
+
};
|
|
4042
|
+
unregisterShutdown = registerShutdownCallback(onSignal);
|
|
4043
|
+
if (lifecycleEventsEnabled && heartbeatMs > 0) {
|
|
4044
|
+
heartbeatTimer = setInterval(() => {
|
|
4045
|
+
emitLifecycle("heartbeat");
|
|
4046
|
+
}, heartbeatMs);
|
|
4047
|
+
}
|
|
4048
|
+
if (recycleEnabled) {
|
|
4049
|
+
recycleTimer = setTimeout(() => {
|
|
4050
|
+
console.error(
|
|
4051
|
+
`Listener recycle triggered after ${recycleMs}ms to prevent stale session.`
|
|
4052
|
+
);
|
|
4053
|
+
writeDebugLine(
|
|
4054
|
+
"listen.recycle",
|
|
4055
|
+
{
|
|
4056
|
+
profile,
|
|
4057
|
+
recycleMs,
|
|
4058
|
+
exitCode: recycleExitCode,
|
|
4059
|
+
sessionId
|
|
4060
|
+
},
|
|
4061
|
+
command
|
|
4062
|
+
);
|
|
4063
|
+
process.exitCode = recycleExitCode;
|
|
4064
|
+
recyclePendingExit = true;
|
|
4065
|
+
recycleForceExitTimer = setTimeout(() => {
|
|
4066
|
+
recycleForceExitTimer = null;
|
|
4067
|
+
process.exit(recycleExitCode);
|
|
4068
|
+
}, 3e3);
|
|
4069
|
+
recycleForceExitTimer.unref();
|
|
4070
|
+
try {
|
|
4071
|
+
api.listener.stop();
|
|
4072
|
+
} catch {
|
|
4073
|
+
}
|
|
4074
|
+
finish();
|
|
4075
|
+
}, recycleMs);
|
|
4076
|
+
}
|
|
4077
|
+
api.listener.start({ retryOnClose: supervised ? false : Boolean(opts.keepAlive) });
|
|
4078
|
+
});
|
|
4079
|
+
} finally {
|
|
4080
|
+
unregisterResourceCleanup();
|
|
4081
|
+
await cleanupListenResources();
|
|
4082
|
+
}
|
|
3513
4083
|
}
|
|
3514
4084
|
)
|
|
3515
4085
|
);
|