Mike L 4 anos atrás
pai
commit
ae26bd1737
20 arquivos alterados com 483 adições e 498 exclusões
  1. 6 9
      config.example.json
  2. 2 3
      dist/command.js
  3. 0 97
      dist/cqhttp.js
  4. 3 2
      dist/helper.js
  5. 15 0
      dist/loggers.js
  6. 25 34
      dist/main.js
  7. 108 0
      dist/mirai.js
  8. 6 48
      dist/twitter.js
  9. 66 41
      dist/webshot.js
  10. 6 5
      package.json
  11. 3 4
      src/command.ts
  12. 0 117
      src/cqhttp.ts
  13. 2 2
      src/helper.ts
  14. 14 0
      src/loggers.ts
  15. 25 34
      src/main.ts
  16. 121 0
      src/mirai.ts
  17. 1 6
      src/model.d.ts
  18. 8 50
      src/twitter.ts
  19. 71 46
      src/webshot.ts
  20. 1 0
      tsconfig.json

+ 6 - 9
config.example.json

@@ -1,7 +1,8 @@
 {
-  "cq_ws_host": "127.0.0.1",
-  "cq_ws_port": 6700,
-  "cq_access_token": "",
+  "mirai_access_token": "",
+  "mirai_http_host": "127.0.0.1",
+  "mirai_http_port": 8080,
+  "mirai_bot_qq": 10000,
   "twitter_consumer_key": "",
   "twitter_consumer_secret": "",
   "twitter_access_token_key": "",
@@ -10,9 +11,5 @@
   "work_interval": 60,
   "webshot_delay": 5000,
   "lockfile": "subscriber.lock",
-  "loglevel": "info",
-  "redis": true,
-  "redis_host": "127.0.0.1",
-  "redis_port": 6379,
-  "redis_expire_time": 43200
-}
+  "loglevel": "info"
+}

+ 2 - 3
dist/command.js

@@ -1,11 +1,10 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
 const fs = require("fs");
-const log4js = require("log4js");
 const path = require("path");
 const datetime_1 = require("./datetime");
-const logger = log4js.getLogger('command');
-logger.level = global.loglevel;
+const loggers_1 = require("./loggers");
+const logger = loggers_1.getLogger('command');
 function parseLink(link) {
     let match = link.match(/twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/);
     if (match) {

+ 0 - 97
dist/cqhttp.js

@@ -1,97 +0,0 @@
-"use strict";
-Object.defineProperty(exports, "__esModule", { value: true });
-const CQWebsocket = require("cq-websocket");
-const log4js = require("log4js");
-const helper_1 = require("./helper");
-const logger = log4js.getLogger('cq-websocket');
-logger.level = global.loglevel;
-class default_1 {
-    constructor(opt) {
-        this.retryInterval = 1000;
-        this.initWebsocket = () => {
-            this.bot = new CQWebsocket({
-                access_token: this.botInfo.access_token,
-                enableAPI: true,
-                enableEvent: true,
-                host: this.botInfo.host,
-                port: this.botInfo.port,
-            });
-            this.bot.on('socket.connect', () => {
-                logger.info('websocket connected');
-                this.retryInterval = 1000;
-            });
-            this.bot.on('socket.close', () => {
-                logger.error('websocket closed');
-                this.reconnect();
-            });
-            this.bot.on('socket.error', () => {
-                logger.error('websocket connect error');
-                this.reconnect();
-            });
-            this.bot.on('message', (e, context) => {
-                e.cancel();
-                const chat = {
-                    chatType: context.message_type,
-                    chatID: 0,
-                };
-                switch (context.message_type) {
-                    case "private" /* Private */:
-                        chat.chatID = context.user_id;
-                        break;
-                    case "group" /* Group */:
-                        chat.chatID = context.group_id;
-                        break;
-                    case "discuss" /* Discuss */:
-                        chat.chatID = context.discuss_id;
-                }
-                const cmdObj = helper_1.default(context.raw_message);
-                switch (cmdObj.cmd) {
-                    case 'twitter_sub':
-                    case 'twitter_subscribe':
-                        return this.botInfo.sub(chat, cmdObj.args);
-                    case 'twitter_unsub':
-                    case 'twitter_unsubscribe':
-                        return this.botInfo.unsub(chat, cmdObj.args);
-                    case 'ping':
-                    case 'twitter':
-                        return this.botInfo.list(chat, cmdObj.args);
-                    case 'help':
-                        return `推特搬运机器人:
-/twitter - 查询当前聊天中的订阅
-/twitter_subscribe [链接] - 订阅 Twitter 搬运
-/twitter_unsubscribe [链接] - 退订 Twitter 搬运`;
-                }
-            });
-            this.bot.on('api.send.pre', (type, apiRequest) => {
-                logger.debug(`sending request ${type}: ${JSON.stringify(apiRequest)}`);
-            });
-            this.bot.on('api.send.post', (type) => {
-                logger.debug(`sent request ${type}`);
-            });
-            this.bot.on('api.response', (type, result) => {
-                if (result.retcode !== 0)
-                    logger.warn(`${type} respond: ${JSON.stringify(result)}`);
-                else
-                    logger.debug(`${type} respond: ${JSON.stringify(result)}`);
-            });
-        };
-        this.connect = () => {
-            this.initWebsocket();
-            logger.warn('connecting to websocket...');
-            this.bot.connect();
-        };
-        this.reconnect = () => {
-            this.retryInterval *= 2;
-            if (this.retryInterval > 300000)
-                this.retryInterval = 300000;
-            logger.warn(`retrying in ${this.retryInterval / 1000}s...`);
-            setTimeout(() => {
-                logger.warn('reconnecting to websocket...');
-                this.connect();
-            }, this.retryInterval);
-        };
-        logger.warn(`init cqwebsocket for ${opt.host}:${opt.port}, with access_token ${opt.access_token}`);
-        this.botInfo = opt;
-    }
-}
-exports.default = default_1;

+ 3 - 2
dist/helper.js

@@ -1,13 +1,14 @@
 "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 = strs ? strs.length ? strs[0].length ? strs[0].substring(0, 1) === '/' ? strs[0].substring(1) : '' : '' : '' : '';
-    const args = strs.slice(1).map(arg => {
+    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 => {
         arg = arg.replace(/^["']+|["']+$/g, '');
         arg = arg.replace('\\0x27', '\\\'');
         arg = arg.replace('\\0x22', '\\\"');

+ 15 - 0
dist/loggers.js

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

+ 25 - 34
dist/main.js

@@ -3,27 +3,29 @@
 Object.defineProperty(exports, "__esModule", { value: true });
 const commandLineUsage = require("command-line-usage");
 const fs = require("fs");
-const log4js = require("log4js");
 const path = require("path");
-const logger = log4js.getLogger();
-logger.level = 'info';
+const command_1 = require("./command");
+const loggers_1 = require("./loggers");
+const mirai_1 = require("./mirai");
+const twitter_1 = require("./twitter");
+const logger = loggers_1.getLogger();
 const sections = [
     {
-        header: 'CQHTTP Twitter Bot',
+        header: 'MiraiTS Twitter Bot',
         content: 'The QQ Bot that forwards twitters.',
     },
     {
         header: 'Synopsis',
         content: [
-            '$ cqhttp-twitter-bot {underline config.json}',
-            '$ cqhttp-twitter-bot {bold --help}',
+            '$ mirai-twitter-bot {underline config.json}',
+            '$ mirai-twitter-bot {bold --help}',
         ],
     },
     {
         header: 'Documentation',
         content: [
-            'Project home: {underline https://github.com/rikakomoe/cqhttp-twitter-bot}',
-            'Example config: {underline https://qwqq.pw/qpfhg}',
+            '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}',
         ],
     },
 ];
@@ -50,17 +52,17 @@ if (config.twitter_consumer_key === undefined ||
     console.log('twitter_consumer_key twitter_consumer_secret twitter_access_token_key twitter_access_token_secret are required');
     process.exit(1);
 }
-if (config.cq_ws_host === undefined) {
-    config.cq_ws_host = '127.0.0.1';
-    logger.warn('cq_ws_host is undefined, use 127.0.0.1 as default');
+if (config.mirai_http_host === undefined) {
+    config.mirai_http_host = '127.0.0.1';
+    logger.warn('mirai_http_host is undefined, use 127.0.0.1 as default');
 }
-if (config.cq_ws_port === undefined) {
-    config.cq_ws_port = 6700;
-    logger.warn('cq_ws_port is undefined, use 6700 as default');
+if (config.mirai_http_port === undefined) {
+    config.mirai_http_port = 8080;
+    logger.warn('mirai_http_port is undefined, use 8080 as default');
 }
-if (config.cq_access_token === undefined) {
-    config.cq_access_token = '';
-    logger.warn('cq_access_token is undefined, use empty string as default');
+if (config.mirai_access_token === undefined) {
+    config.mirai_access_token = '';
+    logger.warn('mirai_access_token is undefined, use empty string as default');
 }
 if (config.lockfile === undefined) {
     config.lockfile = 'subscriber.lock';
@@ -77,18 +79,7 @@ if (config.loglevel === undefined) {
 if (typeof config.mode !== 'number') {
     config.mode = 0;
 }
-let redisConfig;
-if (config.redis) {
-    redisConfig = {
-        redisHost: config.redis_host || '127.0.0.1',
-        redisPort: config.redis_port || 6379,
-        redisExpireTime: config.redis_expire_time || 43200,
-    };
-}
-global.loglevel = config.loglevel;
-const command_1 = require("./command");
-const cqhttp_1 = require("./cqhttp");
-const twitter_1 = require("./twitter");
+loggers_1.setLogLevels(config.loglevel);
 let lock;
 if (fs.existsSync(path.resolve(config.lockfile))) {
     try {
@@ -126,10 +117,11 @@ else {
 Object.keys(lock.threads).forEach(key => {
     lock.threads[key].offset = -1;
 });
-const qq = new cqhttp_1.default({
-    access_token: config.cq_access_token,
-    host: config.cq_ws_host,
-    port: config.cq_ws_port,
+const qq = new mirai_1.default({
+    access_token: config.mirai_access_token,
+    host: config.mirai_http_host,
+    port: config.mirai_http_port,
+    bot_id: config.mirai_bot_qq,
     list: (c, a) => command_1.list(c, a, lock),
     sub: (c, a) => command_1.sub(c, a, lock, config.lockfile),
     unsub: (c, a) => command_1.unsub(c, a, lock, config.lockfile),
@@ -144,7 +136,6 @@ const worker = new twitter_1.default({
     workInterval: config.work_interval,
     bot: qq,
     webshotDelay: config.webshot_delay,
-    redis: redisConfig,
     mode: config.mode,
 });
 worker.launch();

+ 108 - 0
dist/mirai.js

@@ -0,0 +1,108 @@
+"use strict";
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+    return new (P || (P = Promise))(function (resolve, reject) {
+        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+        step((generator = generator.apply(thisArg, _arguments || [])).next());
+    });
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const axios_1 = require("axios");
+const mirai_ts_1 = require("mirai-ts");
+const helper_1 = require("./helper");
+const loggers_1 = require("./loggers");
+const logger = loggers_1.getLogger('qqbot');
+const ChatTypeMap = {
+    GroupMessage: "group" /* Group */,
+    FriendMessage: "private" /* Private */,
+    TempMessage: "temp" /* Temp */,
+};
+class default_1 {
+    constructor(opt) {
+        this.sendTo = (subscriber, msg) => {
+            switch (subscriber.chatType) {
+                case 'group':
+                    return this.bot.api.sendGroupMessage(msg, subscriber.chatID)
+                        .catch(reason => logger.error(`error pushing data to ${subscriber.chatID}, reason: ${reason}`));
+                case 'private':
+                    return this.bot.api.sendFriendMessage(msg, subscriber.chatID)
+                        .catch(reason => logger.error(`error pushing data to ${subscriber.chatID}, reason: ${reason}`));
+            }
+        };
+        this.initBot = () => {
+            this.bot = new mirai_ts_1.default({
+                authKey: this.botInfo.access_token,
+                enableWebsocket: false,
+                host: this.botInfo.host,
+                port: this.botInfo.port,
+            });
+            this.bot.on('message', (msg) => {
+                const chat = {
+                    chatType: ChatTypeMap[msg.type],
+                    chatID: 0,
+                };
+                if (msg.type === 'FriendMessage') {
+                    chat.chatID = msg.sender.id;
+                }
+                else if (msg.type === 'GroupMessage') {
+                    chat.chatID = msg.sender.group.id;
+                }
+                const cmdObj = helper_1.default(msg.plain);
+                switch (cmdObj.cmd) {
+                    case 'twitter_sub':
+                    case 'twitter_subscribe':
+                        msg.reply(this.botInfo.sub(chat, cmdObj.args));
+                        break;
+                    case 'twitter_unsub':
+                    case 'twitter_unsubscribe':
+                        msg.reply(this.botInfo.unsub(chat, cmdObj.args));
+                        break;
+                    case 'ping':
+                    case 'twitter':
+                        msg.reply(this.botInfo.list(chat, cmdObj.args));
+                        break;
+                    case 'help':
+                        msg.reply(`推特搬运机器人:
+/twitter - 查询当前聊天中的订阅
+/twitter_subscribe [链接] - 订阅 Twitter 搬运
+/twitter_unsubscribe [链接] - 退订 Twitter 搬运`);
+                }
+            });
+        };
+        this.listen = (logMsg) => {
+            if (logMsg !== '') {
+                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* () {
+                if (logMsg !== '') {
+                    this.bot.listen();
+                    yield this.login();
+                }
+                setTimeout(() => this.listen(''), 5000);
+            }))
+                .catch(() => {
+                logger.error(`Error connecting to bot provider at ${this.botInfo.host}:${this.botInfo.port}`);
+                setTimeout(() => this.listen('Retry listening...'), 2500);
+            });
+        };
+        this.login = (logMsg) => __awaiter(this, void 0, void 0, function* () {
+            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(() => {
+                logger.error(`Cannot log in. Do you have a bot logged in as ${this.botInfo.bot_id}?`);
+                setTimeout(() => this.login('Retry logging in...'), 2500);
+            });
+        });
+        this.connect = () => {
+            this.initBot();
+            this.listen();
+        };
+        logger.warn(`Initialized mirai-ts for ${opt.host}:${opt.port} with access_token ${opt.access_token}`);
+        this.botInfo = opt;
+    }
+}
+exports.default = default_1;

+ 6 - 48
dist/twitter.js

@@ -1,23 +1,17 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
 const fs = require("fs");
-const log4js = require("log4js");
+const html_entities_1 = require("html-entities");
 const path = require("path");
-const redis = require("redis");
 const sha1 = require("sha1");
 const Twitter = require("twitter");
+const loggers_1 = require("./loggers");
 const webshot_1 = require("./webshot");
-const logger = log4js.getLogger('twitter');
-logger.level = global.loglevel;
+const logger = loggers_1.getLogger('twitter');
+const entities = new html_entities_1.XmlEntities();
 class default_1 {
     constructor(opt) {
         this.launch = () => {
-            if (this.redisConfig) {
-                this.redisClient = redis.createClient({
-                    host: this.redisConfig.redisHost,
-                    port: this.redisConfig.redisPort,
-                });
-            }
             this.webshot = new webshot_1.default(() => setTimeout(this.work, this.workInterval * 1000));
         };
         this.work = () => {
@@ -76,13 +70,7 @@ class default_1 {
                                 logger.warn(`error on fetching tweets for ${lock.feed[lock.workon]}: ${JSON.stringify(error)}`);
                                 lock.threads[lock.feed[lock.workon]].subscribers.forEach(subscriber => {
                                     logger.info(`sending notfound message of ${lock.feed[lock.workon]} to ${JSON.stringify(subscriber)}`);
-                                    this.bot.bot('send_msg', {
-                                        message_type: subscriber.chatType,
-                                        user_id: subscriber.chatID,
-                                        group_id: subscriber.chatID,
-                                        discuss_id: subscriber.chatID,
-                                        message: `链接 ${lock.feed[lock.workon]} 指向的用户或列表不存在,请退订。`,
-                                    });
+                                    this.bot.sendTo(subscriber, `链接 ${lock.feed[lock.workon]} 指向的用户或列表不存在,请退订。`);
                                 });
                             }
                             else {
@@ -114,36 +102,7 @@ class default_1 {
                         logger.debug(hash);
                         hash = sha1(hash);
                         logger.debug(hash);
-                        const send = () => {
-                            this.bot.bot('send_msg', {
-                                message_type: subscriber.chatType,
-                                user_id: subscriber.chatID,
-                                group_id: subscriber.chatID,
-                                discuss_id: subscriber.chatID,
-                                message: this.mode === 0 ? msg : author + text,
-                            });
-                        };
-                        if (this.redisClient) {
-                            this.redisClient.exists(hash, (err, res) => {
-                                logger.debug('redis: ', res);
-                                if (err) {
-                                    logger.error('redis error: ', err);
-                                }
-                                else if (res) {
-                                    logger.info('key hash exists, skip this subscriber');
-                                    return;
-                                }
-                                send();
-                                this.redisClient.set(hash, 'true', 'EX', this.redisConfig.redisExpireTime, (setErr, setRes) => {
-                                    logger.debug('redis: ', setRes);
-                                    if (setErr) {
-                                        logger.error('redis error: ', setErr);
-                                    }
-                                });
-                            });
-                        }
-                        else
-                            send();
+                        this.bot.sendTo(subscriber, this.mode === 0 ? msg : author + entities.decode(entities.decode(text)));
                     });
                 }, this.webshotDelay)
                     .then(() => {
@@ -173,7 +132,6 @@ class default_1 {
         this.workInterval = opt.workInterval;
         this.bot = opt.bot;
         this.webshotDelay = opt.webshotDelay;
-        this.redisConfig = opt.redis;
         this.mode = opt.mode;
     }
 }

+ 66 - 41
dist/webshot.js

@@ -1,18 +1,35 @@
 "use strict";
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+    return new (P || (P = Promise))(function (resolve, reject) {
+        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+        step((generator = generator.apply(thisArg, _arguments || [])).next());
+    });
+};
 Object.defineProperty(exports, "__esModule", { value: true });
 const CallableInstance = require("callable-instance");
-const https = require("https");
-const log4js = require("log4js");
+const fs_1 = require("fs");
+const message_1 = require("mirai-ts/dist/message");
 const pngjs_1 = require("pngjs");
 const puppeteer = require("puppeteer");
-const read = require("read-all-stream");
+// import * as read from 'read-all-stream';
+const loggers_1 = require("./loggers");
 const typeInZH = {
     photo: '[图片]',
     video: '[视频]',
     animated_gif: '[GIF]',
 };
-const logger = log4js.getLogger('webshot');
-logger.level = global.loglevel;
+const logger = loggers_1.getLogger('webshot');
+const tempDir = '/tmp/mirai-twitter-bot/pics/';
+const mkTempDir = () => { if (!fs_1.existsSync(tempDir))
+    fs_1.mkdirSync(tempDir, { recursive: true }); };
+const writeTempFile = (url, png) => __awaiter(void 0, void 0, void 0, function* () {
+    const path = tempDir + url.replace(/[:\/]/g, '_') + '.png';
+    yield new Promise(resolve => png.pipe(fs_1.createWriteStream(path)).on('close', resolve));
+    return path;
+});
 class Webshot extends CallableInstance {
     constructor(onready) {
         super('webshot');
@@ -29,7 +46,7 @@ class Webshot extends CallableInstance {
                         isMobile: true,
                     }))
                         .then(() => page.setBypassCSP(true))
-                        .then(() => page.goto(url))
+                        .then(() => page.goto(url, { waitUntil: 'load', timeout: 150000 }))
                         // hide header, "more options" button, like and retweet count
                         .then(() => page.addStyleTag({
                         content: 'header{display:none!important}path[d=\'M20.207 7.043a1 1 0 0 0-1.414 0L12 13.836 5.207 7.043a1 1 0 0 0-1.414 1.414l7.5 7.5a.996.996 0 0 0 1.414 0l7.5-7.5a1 1 0 0 0 0-1.414z\'],div[role=\'button\']{display: none;}',
@@ -40,13 +57,14 @@ class Webshot extends CallableInstance {
                     }))
                         .then(() => page.screenshot())
                         .then(screenshot => {
+                        mkTempDir();
                         new pngjs_1.PNG({
                             filterType: 4,
                         }).on('parsed', function () {
                             // remove comment area
                             let boundary = null;
                             let x = 0;
-                            for (let y = 0; y < this.height; y++) {
+                            for (let y = 0; y < this.height - 3; y++) {
                                 const idx = (this.width * y + x) << 2;
                                 if (this.data[idx] !== 255) {
                                     boundary = y;
@@ -86,20 +104,20 @@ class Webshot extends CallableInstance {
                                     this.data = this.data.slice(0, (this.width * boundary) << 2);
                                     this.height = boundary;
                                 }
-                                read(this.pack(), 'base64').then(data => {
+                                writeTempFile(url, this.pack()).then(data => {
                                     logger.info(`finished webshot for ${url}`);
                                     resolve({ data, boundary });
                                 });
                             }
                             else if (height >= 8 * 1920) {
                                 logger.warn('too large, consider as a bug, returning');
-                                read(this.pack(), 'base64').then(data => {
+                                writeTempFile(url, this.pack()).then(data => {
                                     logger.info(`finished webshot for ${url}`);
                                     resolve({ data, boundary: 0 });
                                 });
                             }
                             else {
-                                logger.info('unable to found boundary, try shooting a larger image');
+                                logger.info('unable to find boundary, try shooting a larger image');
                                 resolve({ data: '', boundary });
                             }
                         }).parse(screenshot);
@@ -114,25 +132,19 @@ class Webshot extends CallableInstance {
                     return data.data;
             });
         };
-        this.fetchImage = (url) => new Promise(resolve => {
-            logger.info(`fetching ${url}`);
-            https.get(url, res => {
-                if (res.statusCode === 200) {
-                    read(res, 'base64').then(data => {
-                        logger.info(`successfully fetched ${url}`);
-                        resolve(data);
-                    });
-                }
-                else {
-                    logger.error(`failed to fetch ${url}: ${res.statusCode}`);
-                    resolve();
-                }
-            }).on('error', (err) => {
-                logger.error(`failed to fetch ${url}: ${err.message}`);
-                resolve();
-            });
-        });
-        puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox', '--lang=zh-CN,zh'] })
+        puppeteer.launch({
+            args: [
+                '--no-sandbox',
+                '--disable-setuid-sandbox',
+                '--disable-dev-shm-usage',
+                '--disable-accelerated-2d-canvas',
+                '--no-first-run',
+                '--no-zygote',
+                '--single-process',
+                '--disable-gpu',
+                '--lang=ja-JP,ja',
+            ]
+        })
             .then(browser => this.browser = browser)
             .then(() => {
             logger.info('launched puppeteer browser');
@@ -140,6 +152,24 @@ class Webshot extends CallableInstance {
                 onready();
         });
     }
+    // private fetchImage = (url: string): Promise<string> =>
+    //   new Promise<string>(resolve => {
+    //     logger.info(`fetching ${url}`);
+    //     https.get(url, res => {
+    //       if (res.statusCode === 200) {
+    //         read(res, 'base64').then(data => {
+    //           logger.info(`successfully fetched ${url}`);
+    //           resolve(data);
+    //         });
+    //       } else {
+    //         logger.error(`failed to fetch ${url}: ${res.statusCode}`);
+    //         resolve();
+    //       }
+    //     }).on('error', (err) => {
+    //       logger.error(`failed to fetch ${url}: ${err.message}`);
+    //       resolve();
+    //     });
+    //   })
     webshot(mode, tweets, callback, webshotDelay) {
         let promise = new Promise(resolve => {
             resolve();
@@ -149,21 +179,17 @@ class Webshot extends CallableInstance {
                 logger.info(`working on ${twi.user.screen_name}/${twi.id_str}`);
             });
             const originTwi = twi.retweeted_status || twi;
-            let cqstr = '';
+            const messageChain = [];
             if (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(base64Webshot => {
-                    if (base64Webshot)
-                        cqstr += `[CQ:image,file=base64://${base64Webshot}]`;
+                    .then(webshotFilePath => {
+                    if (webshotFilePath)
+                        messageChain.push(message_1.default.Image('', `file://${webshotFilePath}`));
                 });
                 if (originTwi.extended_entities) {
                     originTwi.extended_entities.media.forEach(media => {
-                        promise = promise.then(() => this.fetchImage(media.media_url_https))
-                            .then(base64Image => {
-                            if (base64Image)
-                                cqstr += `[CQ:image,file=base64://${base64Image}]`;
-                        });
+                        messageChain.push(message_1.default.Image('', media.media_url_https));
                     });
                 }
                 if (originTwi.entities && originTwi.entities.urls && originTwi.entities.urls.length) {
@@ -172,8 +198,7 @@ class Webshot extends CallableInstance {
                             .filter(urlObj => urlObj.indices[0] < originTwi.display_text_range[1])
                             .map(urlObj => urlObj.expanded_url);
                         if (urls.length) {
-                            cqstr += '\n';
-                            cqstr += urls.join('\n');
+                            messageChain.push(message_1.default.Plain(urls.join('\n')));
                         }
                     });
                 }
@@ -199,7 +224,7 @@ class Webshot extends CallableInstance {
                 author = author.replace(/&/gm, '&amp;')
                     .replace(/\[/gm, '&#91;')
                     .replace(/\]/gm, '&#93;');
-                callback(cqstr, text, author);
+                callback(messageChain, text, author);
             });
         });
         return promise;

+ 6 - 5
package.json

@@ -28,20 +28,21 @@
     "lint": "tslint --fix -c tslint.json --project ./"
   },
   "dependencies": {
-    "callable-instance": "^1.0.0",
+    "callable-instance": "^2.0.0",
     "command-line-usage": "^5.0.5",
     "cq-websocket": "1.2.6",
-    "log4js": "^2.10.0",
+    "html-entities": "^1.3.1",
+    "log4js": "^6.3.0",
+    "mirai-ts": "^0.3.1",
     "pngjs": "^3.3.3",
-    "puppeteer": "^1.5.0",
+    "puppeteer": "^2.1.0",
     "read-all-stream": "^3.1.0",
-    "redis": "^2.8.0",
     "sha1": "^1.1.1",
     "twitter": "^1.7.1",
     "typescript": "^2.9.2"
   },
   "devDependencies": {
-    "@types/node": "^10.5.1",
+    "@types/node": "^10.17.27",
     "@types/pngjs": "^3.3.2",
     "@types/puppeteer": "^1.5.0",
     "@types/redis": "^2.8.6",

+ 3 - 4
src/command.ts

@@ -1,11 +1,10 @@
 import * as fs from 'fs';
-import * as log4js from 'log4js';
 import * as path from 'path';
 
 import { relativeDate } from './datetime';
+import { getLogger } from './loggers';
 
-const logger = log4js.getLogger('command');
-logger.level = (global as any).loglevel;
+const logger = getLogger('command');
 
 function parseLink(link: string): { link: string, match: string[] } | undefined {
   let match = link.match(/twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/);
@@ -114,4 +113,4 @@ function list(chat: IChat, args: string[], lock: ILock): string {
   return '此聊天中订阅的链接:\n' + links.join('\n');
 }
 
-export {sub, list, unsub};
+export { sub, list, unsub };

+ 0 - 117
src/cqhttp.ts

@@ -1,117 +0,0 @@
-import * as CQWebsocket from 'cq-websocket';
-import * as log4js from 'log4js';
-
-import command from './helper';
-
-const logger = log4js.getLogger('cq-websocket');
-logger.level = (global as any).loglevel;
-
-interface IQQProps {
-  access_token: string;
-  host: string;
-  port: number;
-  list(chat: IChat, args: string[]): string;
-  sub(chat: IChat, args: string[]): string;
-  unsub(chat: IChat, args: string[]): string;
-}
-
-export default class {
-
-  private botInfo: IQQProps;
-  public bot: CQWebsocket;
-  private retryInterval = 1000;
-
-  private initWebsocket = () => {
-    this.bot = new CQWebsocket({
-      access_token: this.botInfo.access_token,
-      enableAPI: true,
-      enableEvent: true,
-      host: this.botInfo.host,
-      port: this.botInfo.port,
-    });
-
-    this.bot.on('socket.connect', () => {
-      logger.info('websocket connected');
-      this.retryInterval = 1000;
-    });
-
-    this.bot.on('socket.close', () => {
-      logger.error('websocket closed');
-      this.reconnect();
-    });
-
-    this.bot.on('socket.error', () => {
-      logger.error('websocket connect error');
-      this.reconnect();
-    });
-
-    this.bot.on('message', (e, context) => {
-      e.cancel();
-      const chat: IChat = {
-        chatType: context.message_type,
-        chatID: 0,
-      };
-      switch (context.message_type) {
-        case ChatType.Private:
-          chat.chatID = context.user_id;
-          break;
-        case ChatType.Group:
-          chat.chatID = context.group_id;
-          break;
-        case ChatType.Discuss:
-          chat.chatID = context.discuss_id;
-      }
-      const cmdObj = command(context.raw_message);
-      switch (cmdObj.cmd) {
-        case 'twitter_sub':
-        case 'twitter_subscribe':
-          return this.botInfo.sub(chat, cmdObj.args);
-        case 'twitter_unsub':
-        case 'twitter_unsubscribe':
-          return this.botInfo.unsub(chat, cmdObj.args);
-        case 'ping':
-        case 'twitter':
-          return this.botInfo.list(chat, cmdObj.args);
-        case 'help':
-          return `推特搬运机器人:
-/twitter - 查询当前聊天中的订阅
-/twitter_subscribe [链接] - 订阅 Twitter 搬运
-/twitter_unsubscribe [链接] - 退订 Twitter 搬运`;
-      }
-    });
-
-    this.bot.on('api.send.pre', (type, apiRequest) => {
-      logger.debug(`sending request ${type}: ${JSON.stringify(apiRequest)}`);
-    });
-
-    this.bot.on('api.send.post', (type) => {
-      logger.debug(`sent request ${type}`);
-    });
-
-    this.bot.on('api.response', (type, result) => {
-      if (result.retcode !== 0) logger.warn(`${type} respond: ${JSON.stringify(result)}`);
-      else logger.debug(`${type} respond: ${JSON.stringify(result)}`);
-    });
-}
-
-  public connect = () => {
-    this.initWebsocket();
-    logger.warn('connecting to websocket...');
-    this.bot.connect();
-  }
-
-  private reconnect = () => {
-    this.retryInterval *= 2;
-    if (this.retryInterval > 300000) this.retryInterval = 300000;
-    logger.warn(`retrying in ${this.retryInterval / 1000}s...`);
-    setTimeout(() => {
-      logger.warn('reconnecting to websocket...');
-      this.connect();
-    }, this.retryInterval);
-  }
-
-  constructor(opt: IQQProps) {
-    logger.warn(`init cqwebsocket for ${opt.host}:${opt.port}, with access_token ${opt.access_token}`);
-    this.botInfo = opt;
-  }
-}

+ 2 - 2
src/helper.ts

@@ -9,8 +9,8 @@ export default function (message: string): ICommand {
   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 = strs ? strs.length ? strs[0].length ? strs[0].substring(0, 1) === '/' ? strs[0].substring(1) : '' : '' : '' : '';
-  const args = strs.slice(1).map(arg => {
+  const cmd = strs?.length ? strs[0].length ? strs[0].substring(0, 1) === '/' ? strs[0].substring(1) : '' : '' : '';
+  const args = strs?.slice(1).map(arg => {
     arg = arg.replace(/^["']+|["']+$/g, '');
     arg = arg.replace('\\0x27', '\\\'');
     arg = arg.replace('\\0x22', '\\\"');

+ 14 - 0
src/loggers.ts

@@ -0,0 +1,14 @@
+import { getLogger as _getLogger, Logger } from 'log4js';
+
+const loggers: Logger[] = [];
+
+export function getLogger(category?: string): Logger {
+    const l = _getLogger(category);
+    l.level = 'info';
+    loggers.push(l);
+    return l;
+}
+
+export function setLogLevels(level?: string): void {
+    loggers.forEach((l: Logger) => l.level = level ?? 'info');
+}

+ 25 - 34
src/main.ts

@@ -2,29 +2,32 @@
 
 import * as commandLineUsage from 'command-line-usage';
 import * as fs from 'fs';
-import * as log4js from 'log4js';
 import * as path from 'path';
 
-const logger = log4js.getLogger();
-logger.level = 'info';
+import { list, sub, unsub } from './command';
+import { getLogger, setLogLevels } from './loggers';
+import QQBot from './mirai';
+import Worker from './twitter';
+
+const logger = getLogger();
 
 const sections = [
   {
-    header: 'CQHTTP Twitter Bot',
+    header: 'MiraiTS Twitter Bot',
     content: 'The QQ Bot that forwards twitters.',
   },
   {
     header: 'Synopsis',
     content: [
-      '$ cqhttp-twitter-bot {underline config.json}',
-      '$ cqhttp-twitter-bot {bold --help}',
+      '$ mirai-twitter-bot {underline config.json}',
+      '$ mirai-twitter-bot {bold --help}',
     ],
   },
   {
     header: 'Documentation',
     content: [
-      'Project home: {underline https://github.com/rikakomoe/cqhttp-twitter-bot}',
-      'Example config: {underline https://qwqq.pw/qpfhg}',
+      '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}',
     ],
   },
 ];
@@ -56,17 +59,17 @@ if (config.twitter_consumer_key === undefined ||
   console.log('twitter_consumer_key twitter_consumer_secret twitter_access_token_key twitter_access_token_secret are required');
   process.exit(1);
 }
-if (config.cq_ws_host === undefined) {
-  config.cq_ws_host = '127.0.0.1';
-  logger.warn('cq_ws_host is undefined, use 127.0.0.1 as default');
+if (config.mirai_http_host === undefined) {
+  config.mirai_http_host = '127.0.0.1';
+  logger.warn('mirai_http_host is undefined, use 127.0.0.1 as default');
 }
-if (config.cq_ws_port === undefined) {
-  config.cq_ws_port = 6700;
-  logger.warn('cq_ws_port is undefined, use 6700 as default');
+if (config.mirai_http_port === undefined) {
+  config.mirai_http_port = 8080;
+  logger.warn('mirai_http_port is undefined, use 8080 as default');
 }
-if (config.cq_access_token === undefined) {
-  config.cq_access_token = '';
-  logger.warn('cq_access_token is undefined, use empty string as default');
+if (config.mirai_access_token === undefined) {
+  config.mirai_access_token = '';
+  logger.warn('mirai_access_token is undefined, use empty string as default');
 }
 if (config.lockfile === undefined) {
   config.lockfile = 'subscriber.lock';
@@ -83,20 +86,8 @@ if (config.loglevel === undefined) {
 if (typeof config.mode !== 'number') {
   config.mode = 0;
 }
-let redisConfig: IRedisConfig;
-if (config.redis) {
-  redisConfig = {
-    redisHost: config.redis_host || '127.0.0.1',
-    redisPort: config.redis_port || 6379,
-    redisExpireTime: config.redis_expire_time || 43200,
-  };
-}
-
-(global as any).loglevel = config.loglevel;
 
-import { list, sub, unsub } from './command';
-import QQBot from './cqhttp';
-import Worker from './twitter';
+setLogLevels(config.loglevel);
 
 let lock: ILock;
 if (fs.existsSync(path.resolve(config.lockfile))) {
@@ -135,9 +126,10 @@ Object.keys(lock.threads).forEach(key => {
 });
 
 const qq = new QQBot({
-  access_token: config.cq_access_token,
-  host: config.cq_ws_host,
-  port: config.cq_ws_port,
+  access_token: config.mirai_access_token,
+  host: config.mirai_http_host,
+  port: config.mirai_http_port,
+  bot_id: config.mirai_bot_qq,
   list: (c, a) => list(c, a, lock),
   sub: (c, a) => sub(c, a, lock, config.lockfile),
   unsub: (c, a) => unsub(c, a, lock, config.lockfile),
@@ -153,7 +145,6 @@ const worker = new Worker({
   workInterval: config.work_interval,
   bot: qq,
   webshotDelay: config.webshot_delay,
-  redis: redisConfig,
   mode: config.mode,
 });
 worker.launch();

+ 121 - 0
src/mirai.ts

@@ -0,0 +1,121 @@
+import axios from 'axios';
+import Mirai, { MessageType } from 'mirai-ts';
+
+import command from './helper';
+import { getLogger } from './loggers';
+
+const logger = getLogger('qqbot');
+
+interface IQQProps {
+  access_token: string;
+  host: string;
+  port: number;
+  bot_id: number;
+  list(chat: IChat, args: string[]): string;
+  sub(chat: IChat, args: string[]): string;
+  unsub(chat: IChat, args: string[]): string;
+}
+
+const ChatTypeMap: Record<MessageType.ChatMessageType, ChatType> = {
+  GroupMessage: ChatType.Group,
+  FriendMessage: ChatType.Private,
+  TempMessage: ChatType.Temp,
+};
+
+export default class {
+
+  private botInfo: IQQProps;
+  public bot: Mirai;
+
+  public sendTo = (subscriber: IChat, msg) => {
+    switch (subscriber.chatType) {
+      case 'group':
+        return this.bot.api.sendGroupMessage(msg, subscriber.chatID)
+        .catch(reason => 
+          logger.error(`error pushing data to ${subscriber.chatID}, reason: ${reason}`));
+      case 'private':
+        return this.bot.api.sendFriendMessage(msg, subscriber.chatID)
+        .catch(reason => 
+          logger.error(`error pushing data to ${subscriber.chatID}, reason: ${reason}`));
+    }
+  }
+
+  private initBot = () => {
+    this.bot = new Mirai({
+      authKey: this.botInfo.access_token,
+      enableWebsocket: false,
+      host: this.botInfo.host,
+      port: this.botInfo.port,
+    });
+
+    this.bot.on('message', (msg) => {
+      const chat: IChat = {
+        chatType: ChatTypeMap[msg.type],
+        chatID: 0,
+      };
+      if (msg.type === 'FriendMessage') {
+          chat.chatID = msg.sender.id;
+      } else if (msg.type === 'GroupMessage') {
+          chat.chatID = msg.sender.group.id;
+      }
+      const cmdObj = command(msg.plain);
+      switch (cmdObj.cmd) {
+        case 'twitter_sub':
+        case 'twitter_subscribe':
+          msg.reply(this.botInfo.sub(chat, cmdObj.args));
+          break;
+        case 'twitter_unsub':
+        case 'twitter_unsubscribe':
+          msg.reply(this.botInfo.unsub(chat, cmdObj.args));
+          break;
+        case 'ping':
+        case 'twitter':
+          msg.reply(this.botInfo.list(chat, cmdObj.args));
+          break;
+        case 'help':
+          msg.reply(`推特搬运机器人:
+/twitter - 查询当前聊天中的订阅
+/twitter_subscribe [链接] - 订阅 Twitter 搬运
+/twitter_unsubscribe [链接] - 退订 Twitter 搬运`);
+      }
+    });
+}
+
+  private listen = (logMsg?: string) => {
+    if (logMsg !== '') {
+      logger.warn(logMsg ?? 'Listening...');
+    }
+    axios.get(`http://${this.botInfo.host}:${this.botInfo.port}/about`)
+    .then(async () => {
+      if (logMsg !== '') {
+        this.bot.listen();
+        await this.login();
+      }
+      setTimeout(() => this.listen(''), 5000);
+    })
+    .catch(() => {
+      logger.error(`Error connecting to bot provider at ${this.botInfo.host}:${this.botInfo.port}`);
+      setTimeout(() => this.listen('Retry listening...'), 2500);
+    });
+  }
+
+  private login = async (logMsg?: string) => {
+    logger.warn(logMsg ?? 'Logging in...');
+    await this.bot.login(this.botInfo.bot_id)
+    .then(() => logger.warn(`Logged in as ${this.botInfo.bot_id}`))
+    .catch(() => {
+      logger.error(`Cannot log in. Do you have a bot logged in as ${this.botInfo.bot_id}?`);
+      setTimeout(() => this.login('Retry logging in...'), 2500);
+    });
+  }
+
+  public connect = () => {
+    this.initBot();
+    this.listen();
+  }
+
+  constructor(opt: IQQProps) {
+    logger.warn(`Initialized mirai-ts for ${opt.host}:${opt.port} with access_token ${opt.access_token}`);
+    this.botInfo = opt;
+  }
+}

+ 1 - 6
src/model.d.ts

@@ -2,6 +2,7 @@ declare const enum ChatType {
   Private = 'private',
   Group = 'group',
   Discuss = 'discuss',
+  Temp = 'temp'
 }
 
 interface IChat {
@@ -21,9 +22,3 @@ interface ILock {
       }
   }
 }
-
-interface IRedisConfig {
-  redisHost: string,
-  redisPort: number,
-  redisExpireTime: number
-}

+ 8 - 50
src/twitter.ts

@@ -1,12 +1,11 @@
 import * as fs from 'fs';
-import * as log4js from 'log4js';
+import { XmlEntities } from 'html-entities';
 import * as path from 'path';
-import * as redis from 'redis';
-import { RedisClient } from 'redis';
 import * as sha1 from 'sha1';
 import * as Twitter from 'twitter';
 
-import QQBot from './cqhttp';
+import { getLogger } from './loggers';
+import QQBot from './mirai';
 import Webshot from './webshot';
 
 interface IWorkerOption {
@@ -19,12 +18,12 @@ interface IWorkerOption {
   consumer_secret: string;
   access_token_key: string;
   access_token_secret: string;
-  redis: IRedisConfig;
   mode: number;
 }
 
-const logger = log4js.getLogger('twitter');
-logger.level = (global as any).loglevel;
+const logger = getLogger('twitter');
+
+const entities = new XmlEntities();
 
 export default class {
 
@@ -35,8 +34,6 @@ export default class {
   private bot: QQBot;
   private webshotDelay: number;
   private webshot: Webshot;
-  private redisConfig: IRedisConfig;
-  private redisClient: RedisClient;
   private mode: number;
 
   constructor(opt: IWorkerOption) {
@@ -51,17 +48,10 @@ export default class {
     this.workInterval = opt.workInterval;
     this.bot = opt.bot;
     this.webshotDelay = opt.webshotDelay;
-    this.redisConfig = opt.redis;
     this.mode = opt.mode;
   }
 
   public launch = () => {
-    if (this.redisConfig) {
-      this.redisClient = redis.createClient({
-        host: this.redisConfig.redisHost,
-        port: this.redisConfig.redisPort,
-      });
-    }
     this.webshot = new Webshot(() => setTimeout(this.work, this.workInterval * 1000));
   }
 
@@ -120,13 +110,7 @@ export default class {
               logger.warn(`error on fetching tweets for ${lock.feed[lock.workon]}: ${JSON.stringify(error)}`);
               lock.threads[lock.feed[lock.workon]].subscribers.forEach(subscriber => {
                 logger.info(`sending notfound message of ${lock.feed[lock.workon]} to ${JSON.stringify(subscriber)}`);
-                this.bot.bot('send_msg', {
-                  message_type: subscriber.chatType,
-                  user_id: subscriber.chatID,
-                  group_id: subscriber.chatID,
-                  discuss_id: subscriber.chatID,
-                  message: `链接 ${lock.feed[lock.workon]} 指向的用户或列表不存在,请退订。`,
-                });
+                this.bot.sendTo(subscriber, `链接 ${lock.feed[lock.workon]} 指向的用户或列表不存在,请退订。`);
               });
             } else {
               logger.error(`unhandled error on fetching tweets for ${lock.feed[lock.workon]}: ${JSON.stringify(error)}`);
@@ -155,33 +139,7 @@ export default class {
           logger.debug(hash);
           hash = sha1(hash);
           logger.debug(hash);
-          const send = () => {
-            this.bot.bot('send_msg', {
-              message_type: subscriber.chatType,
-              user_id: subscriber.chatID,
-              group_id: subscriber.chatID,
-              discuss_id: subscriber.chatID,
-              message: this.mode === 0 ? msg : author + text,
-            });
-          };
-          if (this.redisClient) {
-            this.redisClient.exists(hash, (err, res) => {
-              logger.debug('redis: ', res);
-              if (err) {
-                logger.error('redis error: ', err);
-              } else if (res) {
-                logger.info('key hash exists, skip this subscriber');
-                return;
-              }
-              send();
-              this.redisClient.set(hash, 'true', 'EX', this.redisConfig.redisExpireTime, (setErr, setRes) => {
-                logger.debug('redis: ', setRes);
-                if (setErr) {
-                  logger.error('redis error: ', setErr);
-                }
-              });
-            });
-          } else send();
+          this.bot.sendTo(subscriber, this.mode === 0 ? msg : author + entities.decode(entities.decode(text)));
         });
       }, this.webshotDelay)
         .then(() => {

+ 71 - 46
src/webshot.ts

@@ -1,10 +1,14 @@
 import * as CallableInstance from 'callable-instance';
-import * as https from 'https';
-import * as log4js from 'log4js';
+import { createWriteStream, existsSync, mkdirSync } from 'fs';
+// import * as https from 'https';
+import { MessageType } from 'mirai-ts';
+import Message from 'mirai-ts/dist/message';
 import { PNG } from 'pngjs';
 import * as puppeteer from 'puppeteer';
 import { Browser } from 'puppeteer';
-import * as read from 'read-all-stream';
+// import * as read from 'read-all-stream';
+
+import { getLogger } from './loggers';
 
 const typeInZH = {
   photo: '[图片]',
@@ -12,21 +16,41 @@ const typeInZH = {
   animated_gif: '[GIF]',
 };
 
-const logger = log4js.getLogger('webshot');
-logger.level = (global as any).loglevel;
+const logger = getLogger('webshot');
+
+const tempDir = '/tmp/mirai-twitter-bot/pics/';
+const mkTempDir = () => { if (!existsSync(tempDir)) mkdirSync(tempDir, {recursive: true}); };
+const writeTempFile = async (url: string, png: PNG) => {
+  const path = tempDir + url.replace(/[:\/]/g, '_') + '.png';
+  await new Promise(resolve => png.pipe(createWriteStream(path)).on('close', resolve));
+  return path;
+};
 
-class Webshot extends CallableInstance {
+type MessageChain = MessageType.MessageChain;
+
+class Webshot extends CallableInstance<[number], Promise<void>> {
 
   private browser: Browser;
 
   constructor(onready?: () => any) {
     super('webshot');
-    puppeteer.launch({args: ['--no-sandbox', '--disable-setuid-sandbox', '--lang=zh-CN,zh']})
-      .then(browser => this.browser = browser)
-      .then(() => {
-        logger.info('launched puppeteer browser');
-        if (onready) onready();
-      });
+    puppeteer.launch({
+      args: [
+        '--no-sandbox',
+        '--disable-setuid-sandbox',
+        '--disable-dev-shm-usage',
+        '--disable-accelerated-2d-canvas',
+        '--no-first-run',
+        '--no-zygote',
+        '--single-process', // <- this one doesn't works in Windows
+        '--disable-gpu',
+        '--lang=ja-JP,ja',
+      ]})
+    .then(browser => this.browser = browser)
+    .then(() => {
+      logger.info('launched puppeteer browser');
+      if (onready) onready();
+    });
   }
 
   private renderWebshot = (url: string, height: number, webshotDelay: number): Promise<string> => {
@@ -42,7 +66,7 @@ class Webshot extends CallableInstance {
               isMobile: true,
             }))
             .then(() => page.setBypassCSP(true))
-            .then(() => page.goto(url))
+            .then(() => page.goto(url, {waitUntil: 'load', timeout: 150000}))
             // hide header, "more options" button, like and retweet count
             .then(() => page.addStyleTag({
               content: 'header{display:none!important}path[d=\'M20.207 7.043a1 1 0 0 0-1.414 0L12 13.836 5.207 7.043a1 1 0 0 0-1.414 1.414l7.5 7.5a.996.996 0 0 0 1.414 0l7.5-7.5a1 1 0 0 0 0-1.414z\'],div[role=\'button\']{display: none;}',
@@ -53,13 +77,14 @@ class Webshot extends CallableInstance {
             }))
             .then(() => page.screenshot())
             .then(screenshot => {
+              mkTempDir();
               new PNG({
                 filterType: 4,
               }).on('parsed', function () {
                 // remove comment area
                 let boundary = null;
                 let x = 0;
-                for (let y = 0; y < this.height; y++) {
+                for (let y = 0; y < this.height - 3; y++) {
                   const idx = (this.width * y + x) << 2;
                   if (this.data[idx] !== 255) {
                     boundary = y;
@@ -100,18 +125,18 @@ class Webshot extends CallableInstance {
                     this.height = boundary;
                   }
 
-                  read(this.pack(), 'base64').then(data => {
+                  writeTempFile(url, this.pack()).then(data => {
                     logger.info(`finished webshot for ${url}`);
                     resolve({data, boundary});
                   });
                 } else if (height >= 8 * 1920) {
                   logger.warn('too large, consider as a bug, returning');
-                  read(this.pack(), 'base64').then(data => {
+                  writeTempFile(url, this.pack()).then(data => {
                     logger.info(`finished webshot for ${url}`);
                     resolve({data, boundary: 0});
                   });
                 } else {
-                  logger.info('unable to found boundary, try shooting a larger image');
+                  logger.info('unable to find boundary, try shooting a larger image');
                   resolve({data: '', boundary});
                 }
               }).parse(screenshot);
@@ -125,26 +150,30 @@ class Webshot extends CallableInstance {
     });
   }
 
-  private fetchImage = (url: string): Promise<string> =>
-    new Promise<string>(resolve => {
-      logger.info(`fetching ${url}`);
-      https.get(url, res => {
-        if (res.statusCode === 200) {
-          read(res, 'base64').then(data => {
-            logger.info(`successfully fetched ${url}`);
-            resolve(data);
-          });
-        } else {
-          logger.error(`failed to fetch ${url}: ${res.statusCode}`);
-          resolve();
-        }
-      }).on('error', (err) => {
-        logger.error(`failed to fetch ${url}: ${err.message}`);
-        resolve();
-      });
-    })
+  // private fetchImage = (url: string): Promise<string> =>
+  //   new Promise<string>(resolve => {
+  //     logger.info(`fetching ${url}`);
+  //     https.get(url, res => {
+  //       if (res.statusCode === 200) {
+  //         read(res, 'base64').then(data => {
+  //           logger.info(`successfully fetched ${url}`);
+  //           resolve(data);
+  //         });
+  //       } else {
+  //         logger.error(`failed to fetch ${url}: ${res.statusCode}`);
+  //         resolve();
+  //       }
+  //     }).on('error', (err) => {
+  //       logger.error(`failed to fetch ${url}: ${err.message}`);
+  //       resolve();
+  //     });
+  //   })
 
-  public webshot(mode, tweets, callback, webshotDelay: number): Promise<void> {
+  public webshot(
+    mode, tweets,
+    callback: (pics: MessageChain, text: string, author: string) => void,
+    webshotDelay: number
+  ): Promise<void> {
     let promise = new Promise<void>(resolve => {
       resolve();
     });
@@ -153,19 +182,16 @@ class Webshot extends CallableInstance {
         logger.info(`working on ${twi.user.screen_name}/${twi.id_str}`);
       });
       const originTwi = twi.retweeted_status || twi;
-      let cqstr = '';
+      const messageChain: MessageChain = [];
       if (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(base64Webshot => {
-            if (base64Webshot) cqstr += `[CQ:image,file=base64://${base64Webshot}]`;
+          .then(webshotFilePath => {
+            if (webshotFilePath) messageChain.push(Message.Image('', `file://${webshotFilePath}`));
           });
         if (originTwi.extended_entities) {
           originTwi.extended_entities.media.forEach(media => {
-            promise = promise.then(() => this.fetchImage(media.media_url_https))
-              .then(base64Image => {
-                if (base64Image) cqstr += `[CQ:image,file=base64://${base64Image}]`;
-              });
+            messageChain.push(Message.Image('', media.media_url_https));
           });
         }
         if (originTwi.entities && originTwi.entities.urls && originTwi.entities.urls.length) {
@@ -174,8 +200,7 @@ class Webshot extends CallableInstance {
               .filter(urlObj => urlObj.indices[0] < originTwi.display_text_range[1])
               .map(urlObj => urlObj.expanded_url);
             if (urls.length) {
-              cqstr += '\n';
-              cqstr += urls.join('\n');
+              messageChain.push(Message.Plain(urls.join('\n')));
             }
           });
         }
@@ -200,7 +225,7 @@ class Webshot extends CallableInstance {
         author = author.replace(/&/gm, '&amp;')
           .replace(/\[/gm, '&#91;')
           .replace(/\]/gm, '&#93;');
-        callback(cqstr, text, author);
+        callback(messageChain, text, author);
       });
     });
     return promise;

+ 1 - 0
tsconfig.json

@@ -9,6 +9,7 @@
   "compilerOptions": {
     "module": "commonjs",
     "target": "es6",
+    "outDir": "dist",
     "noUnusedLocals": true,
     "allowJs": true,
     "allowSyntheticDefaultImports": true