= json_encode($c["_last"], JSON_PRETTY_PRINT) ?>70 |
= json_encode($c["poolToDelete"], JSON_PRETTY_PRINT) ?>81 |
= str_replace(["<", ">"], ["<", ">"], $c["logText"]) ?>87 |
${digest[0]}`; 39 | else if (digest[0].length > 1) itemStr += `
${digest[0]}`; 40 | else itemStr += "\n"; 41 | out += itemStr; 42 | } 43 | // Success 44 | { 45 | const s = secret.misc.deliverPushMessage; 46 | if (s === true) msg.receiver = secret.class.push; 47 | if (s.tgid) msg.receiver = s; 48 | } 49 | return out.replaceAll("&", "&"); 50 | } catch (e) { 51 | wxLogger.warn(`Error occurred when reading xml detail from posts.`); 52 | return 0; 53 | } 54 | } 55 | 56 | async function parseCardMsg(rawContent, isOfficial = true) { 57 | const {wxLogger, secret} = env; 58 | const ps = await parseXML(rawContent.replaceAll("<", "<").replaceAll(">", ">").replaceAll("
${text}`, true, "HTML").then(() => { 220 | }); 221 | } 222 | }; 223 | while (retries < maxRetries) { 224 | try { 225 | const res = await func(); 226 | errorStat = 0; 227 | return res; 228 | } catch (error) { 229 | const noNeedRetry = (error.code === 'ETELEGRAM') && !(error.message.includes("retry after")); 230 | let errorMessage = `MsgSendFail:` + error.message.replace(/(Error:)/g, '').trim() + ` ${err_suffix}`; 231 | if (noNeedRetry) return doWarn(errorMessage); // no more retries! 232 | else doWarn(`(${retries + 1}/${maxRetries})` + errorMessage); 233 | await delay(retryDelay); 234 | retries++; 235 | } 236 | } 237 | // If the maximum number of retries is reached, you can handle it here if needed. 238 | tgLogger.warn("Retry failed. Could not complete the Telegram operation."); 239 | }; 240 | 241 | // function logErrorDuringTGSend(err) { 242 | // let err2 = err.toString().replaceAll("Error:", ""); 243 | // tgLogger.warn(`tgMsgSendFail: ${err2}`); 244 | // } 245 | 246 | module.exports = { 247 | tgbot, 248 | tgBotDo 249 | } -------------------------------------------------------------------------------- /config/def.conf.js: -------------------------------------------------------------------------------- 1 | // noinspection SpellCheckingInspection 2 | // ------------- 3 | // Configuration File, updated upon every version update: 4 | 5 | // Instruction: 6 | // The following abbreviation is used: 7 | // - wx: WeChat; tg: Telegram; tg cmds: Telegram Bot Commands; ct: ctBridgeBot; C2C: 'Chat to Chat'; tgid: Telegram Chat ID; 8 | // And inside 'user.conf.js', please just copy any part if modified by you, the two config files would be added together. 9 | 10 | module.exports = { 11 | ctToken: 'EnterYourCtTokenHere##############', 12 | tgbot: { 13 | botToken: '5000:ABCDE', 14 | botName: '@your_bot_username_ending_in_bot', 15 | tgAllowList: [5000000001], 16 | webHookUrlPrefix: 'https://your.domain/webHook', 17 | statusReport: { 18 | // Status Report Page function, see detail in docs. Not essential for most users. 19 | switch: "off", 20 | host: "your.domain", 21 | path: "/ctBot/rp.php" 22 | }, 23 | polling: { 24 | pollFailNoticeThres: 3, 25 | // Polling interval, which determines how often the bot checks for new messages on tg. 26 | // Set to smaller values (in ms) to get faster response, but it may cause tg API rate limit. 27 | interval: 2000, 28 | }, 29 | }, 30 | class: { 31 | "def": { 32 | "tgid": -100000, 33 | }, 34 | "push": { 35 | "tgid": -10000, 36 | }, 37 | "C2C": [ 38 | { 39 | "tgid": -1001006, 40 | "wx": ["wx Contact 1's name", true], 41 | "flag": "", 42 | }, 43 | ], 44 | // Below is a more recommended way for defining a supergroup containing many different chats. 45 | "C2C_generator": { 46 | // If you want to use `/create_topic` then remind the order of tgids, and the position of anchor. 47 | "-1001888888888": [ 48 | /* |autoCreateTopic Anchor| */ 49 | [1, "name of group 1 in wechat", "Group", "flags_here_if_you_need_it"], 50 | [4, "name of person 1", "Person", ""], 51 | ], 52 | }, 53 | }, 54 | filtering: { 55 | 56 | // Use this, only if you didn't bind some contacts in C2C, but you often chat with them. 57 | // Now this option is NOT recommended to use, unless you'll use find function very often. 58 | wxFindNameReplaceList: [ 59 | //["Shortened Name 1", "Original Name 1"], 60 | ], 61 | // mainly used to replace wx-side emoji to universal emoji 62 | wxContentReplaceList: [ 63 | ["[Pout]", "{😠}"], 64 | ["[Facepalm]", "{😹}"], 65 | ["[Hurt]", "{😭}"], 66 | ], 67 | // mainly used to replace universal emoji to wx-side emoji 68 | tgContentReplaceList: [ 69 | ["😡", "[Pout]"], 70 | ["😄", "[Doge]"], 71 | ["😏", "[Onlooker]"], 72 | ["😣", "[Panic]"], 73 | ["😮💨", "[Sigh]"], 74 | ], 75 | wxNameFilterStrategy: { 76 | // You can choose to use either 'blackList' or 'whiteList' (only one can be activated at a time) 77 | useBlackList: true, 78 | blackList: [ 79 | "美团", 80 | ], 81 | whiteList: [], 82 | }, 83 | wxMessageExcludeKeyword: [], 84 | wxPostOriginBlackList: [ 85 | "不接收消息的订阅号名称列表", 86 | ], 87 | }, 88 | notification: { 89 | // Remember to change the two '(YourBarkAddress)'! 90 | // Maybe you could use apis provided by 'api.day.app', from the Bark developer. 91 | baseUrl: "https://(YourBarkAddress)/BridgeBot_WARN[ct]/", 92 | default_arg: "?group=ctBridge&icon=https://ccdn.ryancc.top/bot.jpg", 93 | prompt_network_problematic: "Several network connectivity problems appeared. Please settle that immediately.", 94 | prompt_relogin_required: "Your previous login credential have already expired. Please re-login soon!", 95 | prompt_wx_stuck: "The WX puppet seems stuck, please check console and restart program soon!", 96 | prompt_wx_suspended: "The WX puppet was probably disconnected by Tencent server, please launch your mobile WeChat!", 97 | prompt_network_issue_happened: "ctBridgeBot is facing network issue, that causing message delay!", 98 | incoming_call_webhook: name => `https://(YourBarkAddress)/BridgeBot_Call/You have a incoming call from ${encodeURIComponent(name)} In WeChat.?sound=minuet&level=timeSensitive&group=ctBridge&icon=https://ccdn.ryancc.top/call.jpg`, 99 | send_relogin_via_tg: 1, 100 | 101 | }, 102 | misc: { 103 | wxDownloadDir: "", // Like C:\...\WeChat Files\wxid_*****\FileStorage\File, no trailing \ 104 | /* ------------ [ Delivery Options ] ------------ */ 105 | 106 | // s=false, no delivery 107 | // s=true, send to Push channel [defined in 'root.class'] 108 | // s=
${c}|→ `,
292 |
293 | // For person chat, if wx-side msg have quoted msg, then we'll use these two nickname to replace the raw contact name.
294 | // Or, set this to null, to disable this feature.
295 | quotedMsgSuffixLineInPersonChat: ["YOU", "ta"],
296 |
297 | officialAccountParser: a => `[Official Account Card]${a.nickname} , from ${a.province} ${a.city}, operator ${a.certinfo || ""}`,
298 | personCardParser: a => `📇[Person Card]${a.nickname} , from ${a.province} ${a.city}, sex ${a.sex === 1 ? "Male" : (a.sex === 0 ? "(Female)?" : "")}`,
299 |
300 | // what nickname will system message use in group show up, like tickle message.
301 | systemMsgTitleInRoom: "(System)",
302 | // If a sticker with former delivery found, then run this func to get formatted text.
303 | stickerWithLink: (url_p, flib, md5) => flib.hint ?
304 | `🌁(${md5}) ${flib.hint}` : `🌁(${md5})`,
305 | stickerSkipped: md5 => `[Sticker](${md5})`,
306 | // What should display when new topic created automatically.
307 | newTopicCreated: () => `📌Topic Created.\nYour conversation starts here.`,
308 |
309 | // better keep an extra space at the end, if `add_identifier_to_merged_image` is on.
310 | C2C_group_mediaCaption: name => `from [${name}] `,
311 |
312 | tgTextQuoteAddition: (quoted, original) => `(回复「${quoted}」)\n${original}`,
313 |
314 | // If you want to override /help return text, change this to a function like common.js/TGBotHelpCmdText.
315 | // Please remind if you do so, then for new commands you must add them manually to /help text.
316 | override_help_text: false,
317 |
318 | // System notifications
319 |
320 | // When sending wx login QRCode to tg, this text is used:
321 | wxLoginQRCodeHint: "Please scan this QRCode to login WeChat.",
322 |
323 | // If you want to disable any of these replacements here,
324 | // please search for 'secret.misc.titles' in BotIndex.js and put corresponding
325 | // original text here (wrapped with []), to disable replacement here.
326 | unsupportedSticker: "{-🧩-}",
327 | recvCall: "{📞📲}",
328 | recvSplitBill: "{💰✂️📥, 👋}",
329 | recvTransfer: "{💰📥}",
330 | acceptTransfer: "{💰📥, ✅}",
331 | msgTypeNotSupported: "{📩❎, 👉📱}",
332 | },
333 | txyun: {
334 | switch: "off",
335 | secretId: "---",
336 | secretKey: "---",
337 | },
338 | upyun: {
339 | switch: "off",
340 | password: "----",
341 | webFilePathPrefix: "/Bucket____name/ctBotAsset/stickerTG",
342 | operatorName: "----",
343 | urlPrefix: "https://---.test.upcdn.net",
344 | urlPathPrefix: "/ctBotAsset/stickerTG"
345 | }
346 | };
--------------------------------------------------------------------------------
/src/tgProcessor.js:
--------------------------------------------------------------------------------
1 | // noinspection JSUnreachableSwitchBranches
2 |
3 | const dayjs = require("dayjs");
4 | const {tgBotDo} = require("./init-tg");
5 | const secret = require("../config/confLoader");
6 | const nativeEmojiMap = require('../config/native_emoji_map.js');
7 | const sharp = require("sharp");
8 | const {PassThrough} = require("node:stream");
9 | const fs = require("node:fs");
10 |
11 | let env;
12 |
13 | // async function a() {
14 | // const {} = env;
15 | // }
16 |
17 | async function mergeToPrev_tgMsg(msg, isGroup, content, name = "", isText) {
18 | const {state, defLogger, tgBotDo, secret} = env;
19 | // Time-based identifier
20 | const timed_id = Date.now().toString(16).slice(-5, -1);
21 | if (!isText) {
22 | const DTypeName = ((value) => {
23 | const DTypes = {Image: 2, Audio: 3, File: 5, Push: 6};
24 | for (const name in DTypes) if (DTypes[name] === value) return name;
25 | return "Media";
26 | })(msg.DType);
27 | // Temporary override 'content' to inject into merged msg in this function
28 | if ((secret.misc.add_identifier_to_merged_image - !isGroup) && DTypeName === "Image") {
29 | content = `[${DTypeName}] %${timed_id}`;
30 | defLogger.trace(`[${DTypeName}] %${timed_id} is added to content.`);
31 | msg.media_identifier = timed_id;
32 | } else if (DTypeName === "File") content = `[${DTypeName}] ${msg.payload.filename}`;
33 | else content = `[${DTypeName}]`;
34 | }
35 | const word = isGroup ? "Room" : "Person";
36 | const _ = isGroup ? state.preRoom : state.prePerson;
37 | // the 'newFirstTitle' is 0 when inside C2C
38 | const newFirstTitle = (msg.receiver.wx) ? 0 : (isGroup ? _.topic : msg.dname);
39 | const who = isGroup ? `${name}/${_.topic}` : name;
40 | const newItemTitle = (() => {
41 | const s = secret.c11n.titleForSameTalkerInMergedRoomMsg;
42 | if (s === false || (isGroup && _.lastTalker !== name)) {
43 | _.talkerCount = 0;
44 | _.lastTalker = name;
45 | const notDropTitle = secret.misc.PutStampBeforeFirstMergedMsg || isGroup;
46 | return notDropTitle ? `[${isGroup ? msg.dname : dayjs().format("H:mm:ss")}]` : '';
47 | }
48 | _.talkerCount++;
49 | if (typeof s === "function") return s(_.talkerCount);
50 | defLogger.error(`Invalid configuration found for {settings.c11n.titleForSameTalkerInMergedRoomMsg}!`);
51 | return `|→ `;
52 | })();
53 | msg[`pre${word}NeedUpdate`] = false;
54 | content = filterMsgText(content, {isGroup, peerName: name});
55 | // from same talker check complete, ready to merge
56 | if (_.firstWord === "") {
57 | // Already merged, so just append newer to last
58 | const newString = `${_.msgText}\n${newItemTitle} ${content}`;
59 | _.msgText = newString;
60 | _.tgMsg = await tgBotDo.EditMessageText(newString, _.tgMsg, _.receiver);
61 | // defLogger.debug(`Merged msg from ${word}: ${who}, "${content}" into former.`);
62 | defLogger.debug(`(${who}) 🔗+ -->📂: "${content}"`);
63 | return isText; // !isText?false:true
64 | } else {
65 | // Ready to modify first msg, refactoring it.
66 | ///* newFirstTitle = 0 --> C2C msg, do not need header */
67 | const newString = (newFirstTitle === 0 ? `` : `📨⛓️ [#${newFirstTitle}] - - - -\n`) +
68 | `${_.firstWord}\n${newItemTitle} ${content}`;
69 | _.msgText = newString;
70 | _.firstWord = "";
71 | _.tgMsg = await tgBotDo.EditMessageText(newString, _.tgMsg, _.receiver);
72 | // Ref: wxLogger.debug(`📥WX(${tmplc})\t--[Text]-->TG, "${content}".`);
73 | defLogger.debug(`(${who}) 🔗---->📂: "${content}"`);
74 | //defLogger.debug(`Merged msg from ${word}: ${who}, "${content}" into first.`);
75 | return isText;
76 | }
77 | }
78 |
79 | async function replyWithTips(tipMode = "", target = null, timeout = 6, additional = null) {
80 | const {tgLogger, state, defLogger, tgBotDo} = env;
81 | let message = "", form = {};
82 | switch (tipMode) {
83 | // cannot use this now!
84 | // case "needRelogin":
85 | // message = `Your WX credential expired, please refer to log or go with this [QRServer] link:\n${additional}`;
86 | // timeout = 180;
87 | // break;
88 | case "globalCmdToC2C":
89 | message = `You sent a global command to a C2C chat. The operation has been blocked and please check.`;
90 | break;
91 | case "replyCmdToNormal":
92 | message = `Invalid pointer! Are you missing target for this command? `;
93 | break;
94 | case "C2CNotFound":
95 | message = `Your C2C peer could not be found. Please Check!`;
96 | break;
97 | case "wrongMYSTAT_setter":
98 | message = `You sent a global command to a C2C chat. The operation has been blocked and please check.`;
99 | break;
100 | case "mystat_changed":
101 | message = `Changed myStat into ${additional}.`;
102 | break;
103 | case "lockStateChange":
104 | message = `Now conversation lock state is ${additional}.`;
105 | break;
106 | case "softReboot":
107 | message = `Soft Reboot Successful.\nReason: ${additional}`;
108 | form = {reply_markup: {}};
109 | break;
110 | case "nothingToDo":
111 | message = `Nothing to do upon your message, ${target}`;
112 | break;
113 | case "dropCmdAutoOff":
114 | message = `The 'drop' lock has been on for ${secret.misc.keep_drop_on_x5s * 5}s, thus been switched off automatically.`;
115 | break;
116 | case "audioProcessFail":
117 | message = `Audio transcript request received, But error occurred when processing.`;
118 | break;
119 | case "alreadySetStickerHint":
120 | message = `Successfully set hint for Sticker (${additional})!`;
121 | break;
122 | case "notEnabledInConfig":
123 | message = `One or more action interrupted as something is not configured properly. See log for detail.`;
124 | break;
125 | case "setMediaSpoilerFail":
126 | message = `Error occurred while setting spoiler for former message :\n${additional} `;
127 | break;
128 | case "setAsLastAndLocked":
129 | message = `Already set '${additional}' as last talker and locked.`;
130 | break;
131 | case "autoCreateTopicFail":
132 | message = `Attempt of '/create_topic' failed.\t Reason: ${additional}.`;
133 | timeout = 60;
134 | break;
135 | case "autoCreateTopicSuccess":
136 | message = `Successfully created topic. \n${additional}`;
137 | break;
138 | case "genericFail":
139 | message = `Your action is not completed due to some errors. ${additional ?? ""}`;
140 | timeout = 60;
141 | break;
142 | default:
143 | tgLogger.error(`Wrong call of tg replyWithTips() with invalid 'tipMode'. Please check arguments.\n${tipMode}\t${target}`);
144 | return;
145 | }
146 | try {
147 | const tgMsg = await tgBotDo.SendMessage(target, message, true, "HTML", form);
148 | defLogger.info(`Sent out following tips: {${message}}`);
149 | if (timeout !== 0) {
150 | tgLogger.debug(`Added message #${tgMsg.message_id} to poolToDelete with timer (${timeout})sec.`);
151 | state.poolToDelete.push({tgMsg: tgMsg, toDelTs: (dayjs().unix()) + timeout, receiver: target});
152 | }
153 | } catch (e) {
154 | defLogger.warn(`Sending Tip failed in post-check, please check!`);
155 | }
156 | // if (timeout !== 0) state.poolToDelete.add(tgMsg, timeout);
157 |
158 | }
159 |
160 | async function addSelfReplyTs(name = null) {
161 | const {processor, state, defLogger, secret} = env;
162 | if (name === null) name = state.last.name;
163 | if (isPreRoomValid(state.preRoom, name, false, secret.misc.mergeResetTimeout.forGroup) && state.preRoom.firstWord === "") {
164 | // preRoom valid and already merged (more than 2 msg)
165 | const _ = state.preRoom;
166 | const newString = `${_.msgText}\n← [${dayjs().format("H:mm:ss")}] {My Reply}`;
167 | if (secret.misc.addSelfReplyTimestampToRoomMergedMsg) {
168 | _.tgMsg = await tgBotDo.EditMessageText(newString, _.tgMsg, _.receiver);
169 | defLogger.debug(`Delivered myself reply stamp into Room:${_.topic} 's former message, and cleared its preRoom.`);
170 | }
171 | // at first this function is used to add reply timestamp on merged msg when user reply, but it became a resetter for merge
172 | // after user reply. now because of a neglect, the preRoom have no 'stat', which will cause a bug.
173 | state.preRoom = {
174 | firstWord: "",
175 | tgMsg: null,
176 | topic: "",
177 | msgText: "",
178 | lastTalker: "",
179 | stat: {
180 | "tsStarted": 0,
181 | "mediaCount": 0,
182 | "messageCount": 0,
183 | },
184 | };
185 | } else {
186 | if (secret.misc.addSelfReplyTimestampToRoomMergedMsg) defLogger.debug(`PreRoom not valid, skip delivering myself reply stamp into former message.`);
187 | }
188 | }
189 |
190 | async function filterPhoto(path) {
191 | const {defLogger} = env;
192 | const metadata = await sharp(path).metadata();
193 | let {width, height} = metadata;
194 | let resizeOptions = null;
195 |
196 | // Check aspect ratio --- stat 2
197 | const ratio = width / height;
198 | if (ratio > 4 || ratio < 1 / 4) {
199 |
200 | defLogger.debug(`Photo [${width}x${height}, ${ratio.toFixed(2)}] exceeds ratio limit 3. Sending as file instead.`);
201 | return {stat: 2, stream: fs.createReadStream(path)};
202 | }
203 |
204 | // Check if width + height exceeds 8000px --- stat 1
205 | if (width + height > 8000) {
206 | const scale = 8000 / (width + height);
207 | resizeOptions = {
208 | width: Math.round(width * scale),
209 | height: Math.round(height * scale),
210 | fit: "inside",
211 | };
212 |
213 | defLogger.debug(`Shrinking photo [${width}x${height}] to [${resizeOptions.width}x${resizeOptions.height}]`);
214 | const passThrough = new PassThrough();
215 | const pipeline = sharp(path);
216 | if (resizeOptions) {
217 | pipeline.resize(resizeOptions);
218 | }
219 | pipeline.pipe(passThrough);
220 | return {stat: 1, stream: passThrough};
221 | }
222 |
223 | return {stat: 0, stream: fs.createReadStream(path)};
224 | }
225 |
226 | function filterMsgText(inText, args = {}) {
227 | const {state, defLogger} = env;
228 | let txt = inText;
229 | let appender = "";
230 | txt = txt.replaceAll("
/g;
236 | txt = txt.replace(qqemojiRegex, (match, emojiId, text) => {
237 | text = text.replace('_web', '');
238 | return text;
239 | });
240 |
241 | // Process emoji (WeChat modified, native emoji)
242 | let flag = 0;
243 | let emojiRegex = /
/g;
244 | txt = txt.replace(emojiRegex, (match, emojiId, text) => {
245 | flag = 1;
246 | return `[emoji${emojiId}]`; // Replace with bracketed form
247 | });
248 |
249 | // Iterate over nativeEmojiMap and replace bracketed emojis
250 | if (flag) {
251 | const timerLabel = `wx Emoji processor | #${process.uptime().toFixed(2)} used`;
252 | console.time(timerLabel);
253 | for (let key in nativeEmojiMap) {
254 | // Regexp is much slower than regular replacement!
255 | // In my test on a 10th gen-i5 machine, it takes 42s to complete a single check.
256 | // ####################let regex = new RegExp(key, 'g');
257 | const val = nativeEmojiMap[key][0]
258 | txt = txt.replaceAll(key, val);
259 | // This logging below causes many useless logs in logfile! removing.
260 | // defLogger.trace(`[Verbose] replaced '${key}' to '${val}' in WX message.`);
261 | }
262 | console.timeEnd(timerLabel);
263 | }
264 | } // END: Emoji dual processor
265 |
266 |
267 | // process quoted message
268 | if (/「(.{1,20}):\n?([\s\S]*)」\n- - - - - - - - - - - - - - -\n/.test(txt)) {
269 | // Filter Wx ReplyTo / Quote Parameter: (quote-ee name must within [1,10])
270 | const match = txt.match(/「(.{1,20}):\n?([\s\S]*)」\n- - - - - - - - - - - - - - -\n/);
271 | // 0 is all match, 1 is orig-msg sender, 2 is orig-msg
272 | const origMsgClip = (match[2].length > 8) ? match[2].substring(0, 8) : match[2];
273 | // In clip, we do not need