Sfoglia il codice sorgente

This is a combination of 2 commits.

factor out uploading to prevent duplicate messages

actually fixing the problem, fix friend images
Mike L 4 anni fa
parent
commit
7e955a3d8e
15 ha cambiato i file con 312 aggiunte e 165 eliminazioni
  1. 2 3
      README.md
  2. 0 1
      config.example.json
  3. 1 0
      dist/command.js
  4. 1 0
      dist/datetime.js
  5. 2 3
      dist/helper.js
  6. 2 1
      dist/loggers.js
  7. 1 2
      dist/main.js
  8. 57 18
      dist/mirai.js
  9. 38 25
      dist/twitter.js
  10. 46 29
      dist/webshot.js
  11. 4 3
      package.json
  12. 1 2
      src/main.ts
  13. 51 15
      src/mirai.ts
  14. 47 25
      src/twitter.ts
  15. 59 38
      src/webshot.ts

+ 2 - 3
README.md

@@ -6,7 +6,7 @@
 
 ## 主要区别
 
-- 去除了 Redis,发送的图片会在本地缓存(请视情况删除)
+- 去除了 Redis
 - 图片使用 [sharp](https://github.com/lovell/sharp) 压缩为 JPEG
 - 机器人的 QQ 号码必须手动填写
 
@@ -19,7 +19,6 @@
 | mirai_access_token | Mirai HTTP API authKey(需保持和插件一致,插件在未配置对应<br />项目时会在 console 给出当前设定值,请将该值填在此处) | (必填) |
 | mirai_http_host | Mirai HTTP API 插件服务端地址 | 127.0.0.1 |
 | mirai_http_port | Mirai HTTP API 插件服务端口 | 8080 |
-| mirai_http_base_dir | Mirai HTTP API 插件起始目录,图片会保存到此目录下的 /images <br />文件夹中。默认设定认为用户已将本应用安装到同一目录下 | .(本应用的工作目录) |
 | mirai_bot_qq | Mirai HTTP API 登录的目标机器人 QQ 号 | 10000(示例值,必填) |
 | twitter_consumer_key | Twitter App consumer_key | (必填) |
 | twitter_consumer_secret |  Twitter App consumer_secret | (必填) |
@@ -40,5 +39,5 @@
 
 ## Todo
 
-- 重新实现基于 hash 的文件缓存,设定自动清理陈旧图片
+- 重新实现基于 hash 的文件缓存
 - 添加选项对时间线进行过滤

+ 0 - 1
config.example.json

@@ -2,7 +2,6 @@
   "mirai_access_token": "",
   "mirai_http_host": "127.0.0.1",
   "mirai_http_port": 8080,
-  "mirai_http_base_dir": ".",
   "mirai_bot_qq": 10000,
   "twitter_consumer_key": "",
   "twitter_consumer_secret": "",

+ 1 - 0
dist/command.js

@@ -1,5 +1,6 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
+exports.unsub = exports.list = exports.sub = void 0;
 const fs = require("fs");
 const path = require("path");
 const datetime_1 = require("./datetime");

+ 1 - 0
dist/datetime.js

@@ -1,5 +1,6 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
+exports.relativeDate = void 0;
 function relativeDate(dtstr) {
     if (!dtstr)
         return '暂无数据';

+ 2 - 3
dist/helper.js

@@ -1,14 +1,13 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
 function default_1(message) {
-    var _a, _b;
     message = message.trim();
     message = message.replace('\\\\', '\\0x5c');
     message = message.replace('\\\"', '\\0x22');
     message = message.replace('\\\'', '\\0x27');
     const strs = message.match(/'[\s\S]*?'|"[\s\S]*?"|\S*\[CQ:[\s\S]*?\]\S*|\S+/mg);
-    const cmd = ((_a = strs) === null || _a === void 0 ? void 0 : _a.length) ? strs[0].length ? strs[0].substring(0, 1) === '/' ? strs[0].substring(1) : '' : '' : '';
-    const args = (_b = strs) === null || _b === void 0 ? void 0 : _b.slice(1).map(arg => {
+    const cmd = (strs === null || strs === void 0 ? void 0 : strs.length) ? strs[0].length ? strs[0].substring(0, 1) === '/' ? strs[0].substring(1) : '' : '' : '';
+    const args = strs === null || strs === void 0 ? void 0 : strs.slice(1).map(arg => {
         arg = arg.replace(/^["']+|["']+$/g, '');
         arg = arg.replace('\\0x27', '\\\'');
         arg = arg.replace('\\0x22', '\\\"');

+ 2 - 1
dist/loggers.js

@@ -1,5 +1,6 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
+exports.setLogLevels = exports.getLogger = void 0;
 const log4js_1 = require("log4js");
 const loggers = [];
 function getLogger(category) {
@@ -10,6 +11,6 @@ function getLogger(category) {
 }
 exports.getLogger = getLogger;
 function setLogLevels(level) {
-    loggers.forEach((l) => l.level = (level !== null && level !== void 0 ? level : 'info'));
+    loggers.forEach((l) => l.level = level !== null && level !== void 0 ? level : 'info');
 }
 exports.setLogLevels = setLogLevels;

+ 1 - 2
dist/main.js

@@ -25,7 +25,7 @@ const sections = [
         header: 'Documentation',
         content: [
             'Project home: {underline https://github.com/CL-Jeremy/mirai-twitter-bot}',
-            'Example config: {underline https://github.com/CL-Jeremy/mirai-twitter-bot/blob/master/config.example.json}',
+            'Example config: {underline https://git.io/JJ0jN}',
         ],
     },
 ];
@@ -136,7 +136,6 @@ const worker = new twitter_1.default({
     workInterval: config.work_interval,
     bot: qq,
     webshotDelay: config.webshot_delay,
-    webshotOutDir: config.mirai_http_base_dir + '/images',
     mode: config.mode,
 });
 worker.launch();

+ 57 - 18
dist/mirai.js

@@ -9,9 +9,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
     });
 };
 Object.defineProperty(exports, "__esModule", { value: true });
+exports.Message = void 0;
 const axios_1 = require("axios");
 const mirai_ts_1 = require("mirai-ts");
 const message_1 = require("mirai-ts/dist/message");
+const temp = require("temp");
 const helper_1 = require("./helper");
 const loggers_1 = require("./loggers");
 const logger = loggers_1.getLogger('qqbot');
@@ -23,33 +25,70 @@ const ChatTypeMap = {
 exports.Message = message_1.default;
 class default_1 {
     constructor(opt) {
-        this.sendTo = (subscriber, msg, timeout = -1) => (() => {
+        this.sendTo = (subscriber, msg) => (() => {
+            switch (subscriber.chatType) {
+                case 'group':
+                    return this.bot.api.sendGroupMessage(msg, subscriber.chatID);
+                case 'private':
+                    return this.bot.api.sendFriendMessage(msg, subscriber.chatID);
+            }
+        })()
+            .then(response => {
+            logger.info(`pushing data to ${subscriber.chatID} was successful, response:`);
+            logger.info(response);
+        })
+            .catch(reason => {
+            logger.error(`error pushing data to ${subscriber.chatID}, reason: ${reason}`);
+            throw Error(reason);
+        });
+        this.uploadPic = (img, timeout = -1) => {
             if (timeout)
                 timeout = Math.floor(timeout);
             if (timeout === 0 || timeout < -1) {
                 return Promise.reject('Error: timeout must be greater than 0ms');
             }
+            let imgFile;
+            if (img.imageId !== '')
+                return Promise.resolve();
+            if (img.url !== '') {
+                if (img.url.split(':')[0] !== 'data') {
+                    return Promise.reject('Error: URL must be of protocol "data"');
+                }
+                if (img.url.split(',')[0].split(';')[1] !== 'base64') {
+                    return Promise.reject('Error: data URL must be of encoding "base64"');
+                }
+                temp.track();
+                try {
+                    const tempFileStream = temp.createWriteStream();
+                    tempFileStream.write(img.url.split(',')[1], 'base64');
+                    tempFileStream.end();
+                    if (typeof (tempFileStream.path) === 'string')
+                        imgFile = tempFileStream.path;
+                }
+                catch (error) {
+                    logger.error(error);
+                }
+            }
             try {
                 this.bot.axios.defaults.timeout = timeout === -1 ? 0 : timeout;
-                switch (subscriber.chatType) {
-                    case 'group':
-                        return this.bot.api.sendGroupMessage(msg, subscriber.chatID);
-                    case 'private':
-                        return this.bot.api.sendFriendMessage(msg, subscriber.chatID);
-                }
+                logger.info(`uploading ${img.path}...`);
+                return this.bot.api.uploadImage('group', imgFile || img.path)
+                    .then(response => {
+                    logger.info(`uploading ${img.path} as group image was successful, response:`);
+                    logger.info(JSON.stringify(response));
+                    img.url = '';
+                    img.path = response.path.split(/[/\\]/).slice(-1)[0];
+                })
+                    .catch(reason => {
+                    logger.error(`error uploading ${img.path}, reason: ${reason}`);
+                    throw Error(reason);
+                });
             }
             finally {
+                temp.cleanup();
                 this.bot.axios.defaults.timeout = 0;
             }
-        })()
-            .then(response => {
-            logger.info(`pushing data to ${subscriber.chatID} was successful, response:`);
-            logger.info(response);
-        })
-            .catch(reason => {
-            logger.error(`error pushing data to ${subscriber.chatID}, reason: ${reason}`);
-            throw Error(reason);
-        });
+        };
         this.initBot = () => {
             this.bot = new mirai_ts_1.default({
                 authKey: this.botInfo.access_token,
@@ -93,7 +132,7 @@ class default_1 {
         // TODO doesn't work if connection is dropped after connection
         this.listen = (logMsg) => {
             if (logMsg !== '') {
-                logger.warn((logMsg !== null && logMsg !== void 0 ? logMsg : 'Listening...'));
+                logger.warn(logMsg !== null && logMsg !== void 0 ? logMsg : 'Listening...');
             }
             axios_1.default.get(`http://${this.botInfo.host}:${this.botInfo.port}/about`)
                 .then(() => __awaiter(this, void 0, void 0, function* () {
@@ -109,7 +148,7 @@ class default_1 {
             });
         };
         this.login = (logMsg) => __awaiter(this, void 0, void 0, function* () {
-            logger.warn((logMsg !== null && logMsg !== void 0 ? logMsg : 'Logging in...'));
+            logger.warn(logMsg !== null && logMsg !== void 0 ? logMsg : 'Logging in...');
             yield this.bot.login(this.botInfo.bot_id)
                 .then(() => logger.warn(`Logged in as ${this.botInfo.bot_id}`))
                 .catch(() => {

+ 38 - 25
dist/twitter.js

@@ -4,13 +4,12 @@ const fs = require("fs");
 const path = require("path");
 const Twitter = require("twitter");
 const loggers_1 = require("./loggers");
-const mirai_1 = require("./mirai");
 const webshot_1 = require("./webshot");
 const logger = loggers_1.getLogger('twitter');
 class default_1 {
     constructor(opt) {
         this.launch = () => {
-            this.webshot = new webshot_1.default(this.webshotOutDir, this.mode, () => setTimeout(this.work, this.workInterval * 1000));
+            this.webshot = new webshot_1.default(this.mode, () => setTimeout(this.work, this.workInterval * 1000));
         };
         this.work = () => {
             const lock = this.lock;
@@ -99,8 +98,8 @@ class default_1 {
                 if (currentThread.offset === '0')
                     tweets.splice(1);
                 const maxCount = 3;
-                let sendTimeout = 10000;
-                const retryTimeout = 1500;
+                const uploadTimeout = 10000;
+                const retryInterval = 1500;
                 const ordinal = (n) => {
                     switch ((~~(n / 10) % 10 === 1) ? 0 : n % 10) {
                         case 1:
@@ -113,32 +112,47 @@ class default_1 {
                             return `${n}th`;
                     }
                 };
+                const retryOnError = (doWork, onRetry) => new Promise(resolve => {
+                    const retry = (reason, count) => {
+                        setTimeout(() => {
+                            let terminate = false;
+                            onRetry(reason, count, defaultValue => { terminate = true; resolve(defaultValue); });
+                            if (!terminate)
+                                doWork().then(resolve).catch(error => retry(error, count + 1));
+                        }, retryInterval);
+                    };
+                    doWork().then(resolve).catch(error => retry(error, 1));
+                });
+                const uploader = (message, lastResort) => {
+                    let timeout = uploadTimeout;
+                    return retryOnError(() => this.bot.uploadPic(message, timeout).then(() => message), (_, count, terminate) => {
+                        if (count <= maxCount) {
+                            timeout *= (count + 2) / (count + 1);
+                            logger.warn(`retry uploading for the ${ordinal(count)} time...`);
+                        }
+                        else {
+                            logger.warn(`${count - 1} consecutive failures while uploading, trying plain text instead...`);
+                            terminate(lastResort());
+                        }
+                    });
+                };
                 const sendTweets = (msg, text, author) => {
                     currentThread.subscribers.forEach(subscriber => {
                         logger.info(`pushing data of thread ${currentFeed} to ${JSON.stringify(subscriber)}`);
-                        const retry = (reason, count) => {
-                            if (count <= maxCount)
-                                sendTimeout *= (count + 2) / (count + 1);
-                            setTimeout(() => {
-                                msg.forEach((message, pos) => {
-                                    if (count > maxCount && message.type === 'Image') {
-                                        if (pos === 0) {
-                                            logger.warn(`${count - 1} consecutive failures sending webshot, trying plain text instead...`);
-                                            msg[pos] = mirai_1.Message.Plain(author + text);
-                                        }
-                                        else {
-                                            msg[pos] = mirai_1.Message.Plain(`[失败的图片:${message.path}]`);
-                                        }
-                                    }
-                                });
+                        retryOnError(() => this.bot.sendTo(subscriber, msg), (_, count, terminate) => {
+                            if (count <= maxCount) {
                                 logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);
-                                this.bot.sendTo(subscriber, msg, sendTimeout).catch(error => retry(error, count + 1));
-                            }, retryTimeout);
-                        };
-                        this.bot.sendTo(subscriber, msg, sendTimeout).catch(error => retry(error, 1));
+                            }
+                            else {
+                                logger.warn(`${count - 1} consecutive failures while sending` +
+                                    'message chain, trying plain text instead...');
+                                terminate(this.bot.sendTo(subscriber, author + text));
+                            }
+                        });
                     });
                 };
-                return this.webshot(tweets, sendTweets, this.webshotDelay).then(updateDate).then(updateOffset);
+                return this.webshot(tweets, uploader, sendTweets, this.webshotDelay)
+                    .then(updateDate).then(updateOffset);
             })
                 .then(() => {
                 lock.workon++;
@@ -162,7 +176,6 @@ class default_1 {
         this.workInterval = opt.workInterval;
         this.bot = opt.bot;
         this.webshotDelay = opt.webshotDelay;
-        this.webshotOutDir = opt.webshotOutDir;
         this.mode = opt.mode;
     }
 }

+ 46 - 29
dist/webshot.js

@@ -2,16 +2,12 @@
 Object.defineProperty(exports, "__esModule", { value: true });
 const axios_1 = require("axios");
 const CallableInstance = require("callable-instance");
-const fs_1 = require("fs");
 const html_entities_1 = require("html-entities");
 const pngjs_1 = require("pngjs");
 const puppeteer = require("puppeteer");
 const sharp = require("sharp");
 const loggers_1 = require("./loggers");
 const mirai_1 = require("./mirai");
-const writeOutTo = (path, data) => new Promise(resolve => {
-    data.pipe(fs_1.createWriteStream(path)).on('close', () => resolve(path));
-});
 const xmlEntities = new html_entities_1.XmlEntities();
 const typeInZH = {
     photo: '[图片]',
@@ -19,11 +15,8 @@ const typeInZH = {
     animated_gif: '[GIF]',
 };
 const logger = loggers_1.getLogger('webshot');
-const mkdirP = dir => { if (!fs_1.existsSync(dir))
-    fs_1.mkdirSync(dir, { recursive: true }); };
-const baseName = path => path.split(/[/\\]/).slice(-1)[0];
 class Webshot extends CallableInstance {
-    constructor(outDir, mode, onready) {
+    constructor(mode, onready) {
         super('webshot');
         // use local Chromium
         this.connect = (onready) => puppeteer.connect({ browserURL: 'http://127.0.0.1:9222' })
@@ -42,7 +35,9 @@ class Webshot extends CallableInstance {
         };
         this.renderWebshot = (url, height, webshotDelay) => {
             const jpeg = (data) => data.pipe(sharp()).jpeg({ quality: 90, trellisQuantisation: true });
-            const writeOutPic = (pic) => writeOutTo(`${this.outDir}/${url.replace(/[:\/]/g, '_')}.jpg`, pic);
+            const sharpToBase64 = (pic) => new Promise(resolve => {
+                pic.toBuffer().then(buffer => resolve(`data:image/jpg;base64,${buffer.toString('base64')}`));
+            });
             const promise = new Promise((resolve, reject) => {
                 const width = 720;
                 const zoomFactor = 2;
@@ -129,20 +124,20 @@ class Webshot extends CallableInstance {
                                     this.data = this.data.slice(0, idx(this.width, boundary));
                                     this.height = boundary;
                                 }
-                                writeOutPic(jpeg(this.pack())).then(path => {
+                                sharpToBase64(jpeg(this.pack())).then(base64 => {
                                     logger.info(`finished webshot for ${url}`);
-                                    resolve({ path, boundary });
+                                    resolve({ base64, boundary });
                                 });
                             }
                             else if (height >= 8 * 1920) {
                                 logger.warn('too large, consider as a bug, returning');
-                                writeOutPic(jpeg(this.pack())).then(path => {
-                                    resolve({ path, boundary: 0 });
+                                sharpToBase64(jpeg(this.pack())).then(base64 => {
+                                    resolve({ base64, boundary: 0 });
                                 });
                             }
                             else {
                                 logger.info('unable to find boundary, try shooting a larger image');
-                                resolve({ path: '', boundary });
+                                resolve({ base64: '', boundary });
                             }
                         }).parse(screenshot);
                     })
@@ -154,16 +149,16 @@ class Webshot extends CallableInstance {
                 if (data.boundary === null)
                     return this.renderWebshot(url, height + 1920, webshotDelay);
                 else
-                    return data.path;
+                    return data.base64;
             }).catch(error => new Promise(resolve => this.reconnect(error, resolve))
                 .then(() => this.renderWebshot(url, height, webshotDelay)));
         };
-        this.fetchImage = (url, tag) => new Promise(resolve => {
+        this.fetchImage = (url) => new Promise(resolve => {
             logger.info(`fetching ${url}`);
             axios_1.default({
                 method: 'get',
                 url,
-                responseType: 'stream',
+                responseType: 'arraybuffer',
             }).then(res => {
                 if (res.status === 200) {
                     logger.info(`successfully fetched ${url}`);
@@ -178,10 +173,18 @@ class Webshot extends CallableInstance {
                 resolve();
             });
         }).then(data => {
-            const imgName = `${tag}${baseName(url.replace(/(\.[a-z]+):(.*)/, '$1__$2$1'))}`;
-            return writeOutTo(`${this.outDir}/${imgName}`, data);
+            const mimetype = (ext => {
+                switch (ext) {
+                    case 'jpg':
+                        return 'image/jpeg';
+                    case 'png':
+                        return 'image/png';
+                    case 'gif':
+                        return 'image/gif';
+                }
+            })(url.match(/(\.[a-z]+):(.*)/)[1]);
+            return `data:${mimetype};base64,${Buffer.from(data).toString('base64')}`;
         });
-        mkdirP(this.outDir = outDir);
         // tslint:disable-next-line: no-conditional-assignment
         if (this.mode = mode) {
             onready();
@@ -190,7 +193,7 @@ class Webshot extends CallableInstance {
             this.connect(onready);
         }
     }
-    webshot(tweets, callback, webshotDelay) {
+    webshot(tweets, uploader, callback, webshotDelay) {
         let promise = new Promise(resolve => {
             resolve();
         });
@@ -223,18 +226,27 @@ class Webshot extends CallableInstance {
             if (this.mode === 0) {
                 const url = `https://mobile.twitter.com/${twi.user.screen_name}/status/${twi.id_str}`;
                 promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
-                    .then(webshotFilePath => {
-                    if (webshotFilePath)
-                        messageChain.push(mirai_1.Message.Image('', '', baseName(webshotFilePath)));
+                    .then(base64url => {
+                    if (base64url) {
+                        return uploader(mirai_1.Message.Image('', base64url, url), () => mirai_1.Message.Plain(author + text));
+                    }
+                })
+                    .then(msg => {
+                    if (msg)
+                        messageChain.push(msg);
                 });
             }
             // fetch extra images
             if (1 - this.mode % 2) {
                 if (originTwi.extended_entities) {
-                    originTwi.extended_entities.media.forEach(media => promise = promise.then(() => this.fetchImage(media.media_url_https + ':orig', `${twi.user.screen_name}-${twi.id_str}--`)
-                        .then(path => {
-                        messageChain.push(mirai_1.Message.Image('', '', baseName(path)));
-                    })));
+                    originTwi.extended_entities.media.forEach(media => {
+                        const url = media.media_url_https + ':orig';
+                        promise = promise.then(() => this.fetchImage(url))
+                            .then(base64url => uploader(mirai_1.Message.Image('', base64url, url), () => mirai_1.Message.Plain(`[失败的图片:${url}]`)))
+                            .then(msg => {
+                            messageChain.push(msg);
+                        });
+                    });
                 }
             }
             // append URLs, if any
@@ -252,7 +264,12 @@ class Webshot extends CallableInstance {
             }
             promise.then(() => {
                 logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
-                logger.info(JSON.stringify(messageChain));
+                logger.info(JSON.stringify(messageChain.map(message => {
+                    if (message.type === 'Image' && message.url.startsWith('data:')) {
+                        return mirai_1.Message.Image(message.imageId, 'data:[...]', message.path);
+                    }
+                    return message;
+                })));
                 callback(messageChain, xmlEntities.decode(text), author);
             });
         });

+ 4 - 3
package.json

@@ -32,17 +32,17 @@
   "dependencies": {
     "callable-instance": "^2.0.0",
     "command-line-usage": "^5.0.5",
-    "cq-websocket": "1.2.6",
     "html-entities": "^1.3.1",
     "log4js": "^6.3.0",
-    "mirai-ts": "^0.3.1",
+    "mirai-ts": "^0.4.6",
     "pngjs": "^5.0.0",
     "puppeteer": "^2.1.0",
     "read-all-stream": "^3.1.0",
     "sha1": "^1.1.1",
     "sharp": "^0.25.4",
+    "temp": "^0.9.1",
     "twitter": "^1.7.1",
-    "typescript": "^2.9.2"
+    "typescript": "^3.9.7"
   },
   "devDependencies": {
     "@types/node": "^10.17.27",
@@ -50,6 +50,7 @@
     "@types/puppeteer": "^1.5.0",
     "@types/redis": "^2.8.6",
     "@types/sharp": "^0.25.0",
+    "@types/temp": "^0.8.34",
     "@types/twitter": "^1.7.0",
     "tslint": "^5.10.0",
     "tslint-config-prettier": "^1.13.0",

+ 1 - 2
src/main.ts

@@ -27,7 +27,7 @@ const sections = [
     header: 'Documentation',
     content: [
       'Project home: {underline https://github.com/CL-Jeremy/mirai-twitter-bot}',
-      'Example config: {underline https://github.com/CL-Jeremy/mirai-twitter-bot/blob/master/config.example.json}',
+      'Example config: {underline https://git.io/JJ0jN}',
     ],
   },
 ];
@@ -145,7 +145,6 @@ const worker = new Worker({
   workInterval: config.work_interval,
   bot: qq,
   webshotDelay: config.webshot_delay,
-  webshotOutDir: config.mirai_http_base_dir + '/images',
   mode: config.mode,
 });
 worker.launch();

+ 51 - 15
src/mirai.ts

@@ -1,6 +1,7 @@
 import axios from 'axios';
 import Mirai, { MessageType } from 'mirai-ts';
 import MiraiMessage from 'mirai-ts/dist/message';
+import * as temp from 'temp';
 
 import command from './helper';
 import { getLogger } from './loggers';
@@ -31,22 +32,13 @@ export default class {
   private botInfo: IQQProps;
   public bot: Mirai;
 
-  public sendTo = (subscriber: IChat, msg: string | MessageChain, timeout = -1) =>
+  public sendTo = (subscriber: IChat, msg: string | MessageChain) =>
     (() => {
-      if (timeout) timeout = Math.floor(timeout);
-      if (timeout === 0 || timeout < -1) {
-        return Promise.reject('Error: timeout must be greater than 0ms');
-      }
-      try {
-        this.bot.axios.defaults.timeout = timeout === -1 ? 0 : timeout;
-        switch (subscriber.chatType) {
-          case 'group':
-            return this.bot.api.sendGroupMessage(msg, subscriber.chatID);
-          case 'private':
-            return this.bot.api.sendFriendMessage(msg, subscriber.chatID);
-        }
-      } finally {
-        this.bot.axios.defaults.timeout = 0;
+      switch (subscriber.chatType) {
+        case 'group':
+          return this.bot.api.sendGroupMessage(msg, subscriber.chatID);
+        case 'private':
+          return this.bot.api.sendFriendMessage(msg, subscriber.chatID);
       }
     })()
     .then(response => {
@@ -58,6 +50,50 @@ export default class {
       throw Error(reason);
     })
 
+  public uploadPic = (img: MessageType.Image, timeout = -1) => {
+    if (timeout) timeout = Math.floor(timeout);
+    if (timeout === 0 || timeout < -1) {
+      return Promise.reject('Error: timeout must be greater than 0ms');
+    }
+    let imgFile: string;
+    if (img.imageId !== '') return Promise.resolve();
+    if (img.url !== '') {
+      if (img.url.split(':')[0] !== 'data') {
+        return Promise.reject('Error: URL must be of protocol "data"');
+      }
+      if (img.url.split(',')[0].split(';')[1] !== 'base64') {
+        return Promise.reject('Error: data URL must be of encoding "base64"');
+      }
+      temp.track();
+      try {
+        const tempFileStream = temp.createWriteStream();
+        tempFileStream.write(img.url.split(',')[1], 'base64');
+        tempFileStream.end();
+        if (typeof(tempFileStream.path) === 'string') imgFile = tempFileStream.path;
+      } catch (error) {
+        logger.error(error);
+      }
+    }
+    try {
+      this.bot.axios.defaults.timeout = timeout === -1 ? 0 : timeout;
+      logger.info(`uploading ${img.path}...`);
+      return this.bot.api.uploadImage('group', imgFile || img.path)
+      .then(response => { // workaround for https://github.com/mamoe/mirai/issues/194
+        logger.info(`uploading ${img.path} as group image was successful, response:`);
+        logger.info(JSON.stringify(response));
+        img.url = '';
+        img.path = (response.path as string).split(/[/\\]/).slice(-1)[0];
+      })
+      .catch(reason => {
+        logger.error(`error uploading ${img.path}, reason: ${reason}`);
+        throw Error(reason);
+      });
+    } finally {
+      temp.cleanup();
+      this.bot.axios.defaults.timeout = 0;
+    }
+  }
+
   private initBot = () => {
     this.bot = new Mirai({
       authKey: this.botInfo.access_token,

+ 47 - 25
src/twitter.ts

@@ -13,7 +13,6 @@ interface IWorkerOption {
   bot: QQBot;
   workInterval: number;
   webshotDelay: number;
-  webshotOutDir: string;
   consumer_key: string;
   consumer_secret: string;
   access_token_key: string;
@@ -48,7 +47,6 @@ export default class {
   private workInterval: number;
   private bot: QQBot;
   private webshotDelay: number;
-  private webshotOutDir: string;
   private webshot: Webshot;
   private mode: number;
 
@@ -64,13 +62,11 @@ export default class {
     this.workInterval = opt.workInterval;
     this.bot = opt.bot;
     this.webshotDelay = opt.webshotDelay;
-    this.webshotOutDir = opt.webshotOutDir;
     this.mode = opt.mode;
   }
 
   public launch = () => {
     this.webshot = new Webshot(
-      this.webshotOutDir,
       this.mode,
       () => setTimeout(this.work, this.workInterval * 1000)
     );
@@ -157,8 +153,8 @@ export default class {
       if (currentThread.offset === '0') tweets.splice(1);
 
       const maxCount = 3;
-      let sendTimeout = 10000;
-      const retryTimeout = 1500;
+      const uploadTimeout = 10000;
+      const retryInterval = 1500;
       const ordinal = (n: number) => {
         switch ((~~(n / 10) % 10 === 1) ? 0 : n % 10) {
           case 1:
@@ -171,30 +167,57 @@ export default class {
             return `${n}th`;
         }
       };
+
+      const retryOnError = <T, U>(
+        doWork: () => Promise<T>,
+        onRetry: (error, count: number, terminate: (defaultValue: U) => void) => void
+      ) => new Promise<T | U>(resolve => {
+        const retry = (reason, count: number) => {
+          setTimeout(() => {
+            let terminate = false;
+            onRetry(reason, count, defaultValue => { terminate = true; resolve(defaultValue); });
+            if (!terminate) doWork().then(resolve).catch(error => retry(error, count + 1));
+          }, retryInterval);
+        };
+        doWork().then(resolve).catch(error => retry(error, 1));
+      });
+
+      const uploader = (
+        message: ReturnType<typeof Message.Image>,
+        lastResort: (...args) => ReturnType<typeof Message.Plain>
+      ) => {
+        let timeout = uploadTimeout;
+        return retryOnError(() =>
+          this.bot.uploadPic(message, timeout).then(() => message),
+        (_, count, terminate: (defaultValue: ReturnType<typeof Message.Plain>) => void) => {
+          if (count <= maxCount) {
+            timeout *= (count + 2) / (count + 1);
+            logger.warn(`retry uploading for the ${ordinal(count)} time...`);
+          } else {
+            logger.warn(`${count - 1} consecutive failures while uploading, trying plain text instead...`);
+            terminate(lastResort());
+          }
+        });
+      };
+
       const sendTweets = (msg: MessageChain, text: string, author: string) => {
         currentThread.subscribers.forEach(subscriber => {
           logger.info(`pushing data of thread ${currentFeed} to ${JSON.stringify(subscriber)}`);
-          const retry = (reason, count: number) => { // workaround for https://github.com/mamoe/mirai/issues/194
-            if (count <= maxCount) sendTimeout *= (count + 2) / (count + 1);
-            setTimeout(() => {
-              (msg as MessageChain).forEach((message, pos) => {
-                if (count > maxCount && message.type === 'Image') {
-                  if (pos === 0) {
-                    logger.warn(`${count - 1} consecutive failures sending webshot, trying plain text instead...`);
-                    msg[pos] = Message.Plain(author + text);
-                  } else {
-                    msg[pos] = Message.Plain(`[失败的图片:${message.path}]`);
-                  }
-                }
-              });
+          retryOnError(
+            () => this.bot.sendTo(subscriber, msg),
+          (_, count, terminate: (doNothing: Promise<void>) => void) => {
+            if (count <= maxCount) {
               logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);
-              this.bot.sendTo(subscriber, msg, sendTimeout).catch(error => retry(error, count + 1));
-            }, retryTimeout);
-          };
-          this.bot.sendTo(subscriber, msg, sendTimeout).catch(error => retry(error, 1));
+            } else {
+              logger.warn(`${count - 1} consecutive failures while sending` +
+                'message chain, trying plain text instead...');
+              terminate(this.bot.sendTo(subscriber, author + text));
+            }
+          });
         });
       };
-      return this.webshot(tweets, sendTweets, this.webshotDelay).then(updateDate).then(updateOffset);
+      return this.webshot(tweets, uploader, sendTweets, this.webshotDelay)
+      .then(updateDate).then(updateOffset);
     })
       .then(() => {
         lock.workon++;
@@ -205,6 +228,5 @@ export default class {
           this.work();
         }, timeout);
       });
-
   }
 }

+ 59 - 38
src/webshot.ts

@@ -1,22 +1,16 @@
 import axios from 'axios';
 import * as CallableInstance from 'callable-instance';
-import { createWriteStream, existsSync, mkdirSync } from 'fs';
 import { XmlEntities } from 'html-entities';
 import { PNG } from 'pngjs';
 import * as puppeteer from 'puppeteer';
 import { Browser } from 'puppeteer';
 import * as sharp from 'sharp';
-import { Stream } from 'stream';
+import { Readable } from 'stream';
 
 import { getLogger } from './loggers';
 import { Message, MessageChain } from './mirai';
 import { Tweets } from './twitter';
 
-const writeOutTo = (path: string, data: Stream) =>
-  new Promise<string>(resolve => {
-    data.pipe(createWriteStream(path)).on('close', () => resolve(path));
-  });
-
 const xmlEntities = new XmlEntities();
 
 const typeInZH = {
@@ -27,18 +21,17 @@ const typeInZH = {
 
 const logger = getLogger('webshot');
 
-const mkdirP = dir => { if (!existsSync(dir)) mkdirSync(dir, {recursive: true}); };
-const baseName = path => path.split(/[/\\]/).slice(-1)[0];
-
-class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Promise<void>> {
+class Webshot
+extends CallableInstance<
+  [Tweets, (...args) => Promise<any>, (...args) => void, number],
+  Promise<void>
+> {
 
   private browser: Browser;
-  private outDir: string;
   private mode: number;
 
-  constructor(outDir: string, mode: number, onready?: () => any) {
+  constructor(mode: number, onready?: () => any) {
     super('webshot');
-    mkdirP(this.outDir = outDir);
     // tslint:disable-next-line: no-conditional-assignment
     if (this.mode = mode) {
       onready();
@@ -64,9 +57,11 @@ class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Prom
   }
 
   private renderWebshot = (url: string, height: number, webshotDelay: number): Promise<string> => {
-    const jpeg = (data: Stream) => data.pipe(sharp()).jpeg({quality: 90, trellisQuantisation: true});
-    const writeOutPic = (pic: Stream) => writeOutTo(`${this.outDir}/${url.replace(/[:\/]/g, '_')}.jpg`, pic);
-    const promise = new Promise<{ path: string, boundary: null | number }>((resolve, reject) => {
+    const jpeg = (data: Readable) => data.pipe(sharp()).jpeg({quality: 90, trellisQuantisation: true});
+    const sharpToBase64 = (pic: sharp.Sharp) => new Promise<string>(resolve => {
+      pic.toBuffer().then(buffer => resolve(`data:image/jpg;base64,${buffer.toString('base64')}`));
+    });
+    const promise = new Promise<{ base64: string, boundary: null | number }>((resolve, reject) => {
       const width = 720;
       const zoomFactor = 2;
       logger.info(`shooting ${width}*${height} webshot for ${url}`);
@@ -154,18 +149,18 @@ class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Prom
                     this.height = boundary;
                   }
 
-                  writeOutPic(jpeg(this.pack())).then(path => {
+                  sharpToBase64(jpeg(this.pack())).then(base64 => {
                     logger.info(`finished webshot for ${url}`);
-                    resolve({path, boundary});
+                    resolve({base64, boundary});
                   });
                 } else if (height >= 8 * 1920) {
                   logger.warn('too large, consider as a bug, returning');
-                  writeOutPic(jpeg(this.pack())).then(path => {
-                    resolve({path, boundary: 0});
+                  sharpToBase64(jpeg(this.pack())).then(base64 => {
+                    resolve({base64, boundary: 0});
                   });
                 } else {
                   logger.info('unable to find boundary, try shooting a larger image');
-                  resolve({path: '', boundary});
+                  resolve({base64: '', boundary});
                 }
               }).parse(screenshot);
             })
@@ -175,20 +170,20 @@ class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Prom
     });
     return promise.then(data => {
       if (data.boundary === null) return this.renderWebshot(url, height + 1920, webshotDelay);
-      else return data.path;
+      else return data.base64;
     }).catch(error =>
       new Promise(resolve => this.reconnect(error, resolve))
       .then(() => this.renderWebshot(url, height, webshotDelay))
     );
   }
 
-  private fetchImage = (url: string, tag: string): Promise<string> =>
-    new Promise<Stream>(resolve => {
+  private fetchImage = (url: string): Promise<string> =>
+    new Promise<ArrayBuffer>(resolve => {
       logger.info(`fetching ${url}`);
       axios({
         method: 'get',
         url,
-        responseType: 'stream',
+        responseType: 'arraybuffer',
       }).then(res => {
         if (res.status === 200) {
             logger.info(`successfully fetched ${url}`);
@@ -202,12 +197,25 @@ class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Prom
         resolve();
       });
     }).then(data => {
-      const imgName = `${tag}${baseName(url.replace(/(\.[a-z]+):(.*)/, '$1__$2$1'))}`;
-      return writeOutTo(`${this.outDir}/${imgName}`, data);
+      const mimetype = (ext => {
+        switch (ext) {
+          case 'jpg':
+            return 'image/jpeg';
+          case 'png':
+            return 'image/png';
+          case 'gif':
+            return 'image/gif';
+        }
+      })(url.match(/(\.[a-z]+):(.*)/)[1]);
+      return `data:${mimetype};base64,${Buffer.from(data).toString('base64')}`;
     })
 
   public webshot(
     tweets: Tweets,
+    uploader: (
+      img: ReturnType<typeof Message.Image>,
+      lastResort: (...args) => ReturnType<typeof Message.Plain>)
+      => Promise<ReturnType<typeof Message.Image | typeof Message.Plain>>,
     callback: (msgs: MessageChain, text: string, author: string) => void,
     webshotDelay: number
   ): Promise<void> {
@@ -245,20 +253,28 @@ class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Prom
       if (this.mode === 0) {
         const url = `https://mobile.twitter.com/${twi.user.screen_name}/status/${twi.id_str}`;
         promise = promise.then(() => this.renderWebshot(url, 1920, webshotDelay))
-          .then(webshotFilePath => {
-            if (webshotFilePath) messageChain.push(Message.Image('', '', baseName(webshotFilePath)));
+          .then(base64url => {
+            if (base64url) {
+              return uploader(Message.Image('', base64url, url), () => Message.Plain(author + text));
+            }
+          })
+          .then(msg => {
+            if (msg) messageChain.push(msg);
           });
       }
       // fetch extra images
       if (1 - this.mode % 2) {
         if (originTwi.extended_entities) {
-          originTwi.extended_entities.media.forEach(media =>
-            promise = promise.then(() =>
-              this.fetchImage(media.media_url_https + ':orig', `${twi.user.screen_name}-${twi.id_str}--`)
-              .then(path => {
-                messageChain.push(Message.Image('', '', baseName(path)));
-              })
-          ));
+          originTwi.extended_entities.media.forEach(media => {
+            const url = media.media_url_https + ':orig';
+            promise = promise.then(() => this.fetchImage(url))
+              .then(base64url =>
+                uploader(Message.Image('', base64url, url), () => Message.Plain(`[失败的图片:${url}]`))
+              )
+              .then(msg => {
+                messageChain.push(msg);
+              });
+          });
         }
       }
       // append URLs, if any
@@ -276,7 +292,12 @@ class Webshot extends CallableInstance<[Tweets, (...args) => void, number], Prom
       }
       promise.then(() => {
         logger.info(`done working on ${twi.user.screen_name}/${twi.id_str}, message chain:`);
-        logger.info(JSON.stringify(messageChain));
+        logger.info(JSON.stringify(messageChain.map(message => {
+          if (message.type === 'Image' && message.url.startsWith('data:')) {
+            return Message.Image(message.imageId, 'data:[...]', message.path);
+          }
+          return message;
+        })));
         callback(messageChain, xmlEntities.decode(text), author);
       });
     });