Browse Source

Merge branch 'master' into fleets

Mike L 4 years ago
parent
commit
72961c3bf9
21 changed files with 783 additions and 544 deletions
  1. 248 0
      .eslintrc.js
  2. 1 1
      .gitignore
  3. 5 4
      config.example.json
  4. 5 5
      dist/command.js
  5. 52 56
      dist/gifski.js
  6. 32 47
      dist/main.js
  7. 19 17
      dist/mirai.js
  8. 9 8
      dist/twitter.js
  9. 2 2
      dist/utils.js
  10. 6 9
      dist/webshot.js
  11. 16 7
      package.json
  12. 5 2
      src/command.ts
  13. 48 48
      src/gifski.ts
  14. 6 5
      src/loggers.ts
  15. 40 50
      src/main.ts
  16. 84 79
      src/mirai.ts
  17. 15 16
      src/model.d.ts
  18. 120 117
      src/twitter.ts
  19. 8 11
      src/utils.ts
  20. 57 58
      src/webshot.ts
  21. 5 2
      tsconfig.json

+ 248 - 0
.eslintrc.js

@@ -0,0 +1,248 @@
+/* eslint-disable id-blacklist */
+module.exports = {
+  ignorePatterns: [
+    '*.js',
+    '!.eslintrc.js',
+  ],
+  env: {
+    browser: true,
+    es6: true,
+    node: true,
+  },
+  extends: [
+    'plugin:@typescript-eslint/recommended-requiring-type-checking',
+  ],
+  parser: '@typescript-eslint/parser',
+  parserOptions: {
+    project: 'tsconfig.json',
+    sourceType: 'module',
+  },
+  plugins: [
+    'eslint-plugin-import',
+    'eslint-plugin-prefer-arrow',
+    '@typescript-eslint',
+  ],
+  rules: {
+    '@typescript-eslint/adjacent-overload-signatures': 'error',
+    '@typescript-eslint/array-type': [
+      'error',
+      {
+        default: 'array',
+      },
+    ],
+    '@typescript-eslint/ban-types': [
+      'error',
+      {
+        types: {
+          Object: {
+            message: 'Avoid using the `Object` type. Did you mean `object`?',
+          },
+          Function: {
+            message: 'Avoid using the `Function` type. Prefer a specific function type, like `() => void`.',
+          },
+          Boolean: {
+            message: 'Avoid using the `Boolean` type. Did you mean `boolean`?',
+          },
+          Number: {
+            message: 'Avoid using the `Number` type. Did you mean `number`?',
+          },
+          String: {
+            message: 'Avoid using the `String` type. Did you mean `string`?',
+          },
+          Symbol: {
+            message: 'Avoid using the `Symbol` type. Did you mean `symbol`?',
+          },
+        },
+      },
+    ],
+    '@typescript-eslint/consistent-type-assertions': 'error',
+    '@typescript-eslint/dot-notation': 'error',
+    '@typescript-eslint/indent': [
+      'error',
+      2,
+    ],
+    '@typescript-eslint/member-delimiter-style': [
+      'error',
+      {
+        multiline: {
+          delimiter: 'comma',
+          requireLast: true,
+        },
+        singleline: {
+          delimiter: 'comma',
+          requireLast: false,
+        },
+        overrides: {
+          interface: {
+            multiline: {
+              delimiter: 'semi',
+              requireLast: true,
+            },
+          },
+        },
+      },
+    ],
+    '@typescript-eslint/member-ordering': 'off',
+    '@typescript-eslint/restrict-template-expressions': 'off',
+    '@typescript-eslint/no-empty-function': 'error',
+    '@typescript-eslint/no-empty-interface': 'error',
+    '@typescript-eslint/no-explicit-any': 'off',
+    '@typescript-eslint/no-floating-promises': 'off',
+    '@typescript-eslint/no-misused-new': 'error',
+    '@typescript-eslint/no-namespace': 'error',
+    '@typescript-eslint/no-parameter-properties': 'off',
+    '@typescript-eslint/no-require-imports': 'error',
+    '@typescript-eslint/no-shadow': [
+      'error',
+      {
+        hoist: 'all',
+      },
+    ],
+    '@typescript-eslint/no-unused-expressions': 'error',
+    '@typescript-eslint/no-use-before-define': 'off',
+    '@typescript-eslint/no-var-requires': 'off',
+    '@typescript-eslint/prefer-for-of': 'error',
+    '@typescript-eslint/prefer-function-type': 'error',
+    '@typescript-eslint/prefer-namespace-keyword': 'error',
+    '@typescript-eslint/quotes': [
+      'error',
+      'single',
+      {
+        avoidEscape: true,
+      },
+    ],
+    '@typescript-eslint/semi': [
+      'error',
+      'always',
+    ],
+    '@typescript-eslint/triple-slash-reference': [
+      'error',
+      {
+        path: 'always',
+        types: 'prefer-import',
+        lib: 'always',
+      },
+    ],
+    '@typescript-eslint/type-annotation-spacing': 'off',
+    '@typescript-eslint/unified-signatures': 'error',
+    'arrow-body-style': [
+      'error',
+      'as-needed',
+    ],
+    'arrow-parens': [
+      'off',
+      'always',
+    ],
+    'brace-style': [
+      'error',
+      '1tbs',
+      {
+        allowSingleLine: true,
+      },
+    ],
+    'capitalized-comments': [
+      'error',
+      'never',
+    ],
+    'comma-dangle': [
+      'error',
+      {
+        objects: 'always-multiline',
+        arrays: 'always-multiline',
+        functions: 'never',
+        imports: 'never',
+        exports: 'never',
+      },
+    ],
+    complexity: 'off',
+    'constructor-super': 'error',
+    curly: [
+      'error',
+      'multi-line',
+    ],
+    'eol-last': 'error',
+    eqeqeq: [
+      'error',
+      'smart',
+    ],
+    'guard-for-in': 'error',
+    'id-blacklist': [
+      'error',
+      'any',
+      'Number',
+      'number',
+      'String',
+      'string',
+      'Boolean',
+      'boolean',
+      'Undefined',
+      'undefined',
+    ],
+    'id-match': 'error',
+    'import/order': 'error',
+    'linebreak-style': [
+      'error',
+      'unix',
+    ],
+    'max-classes-per-file': 'off',
+    'max-len': 'off',
+    'new-parens': 'off',
+    'newline-per-chained-call': 'off',
+    'no-bitwise': 'off',
+    'no-caller': 'error',
+    'no-cond-assign': 'error',
+    'no-console': 'off',
+    'no-debugger': 'error',
+    'no-empty': 'error',
+    'no-eval': 'error',
+    'no-extra-semi': 'off',
+    'no-fallthrough': 'off',
+    'no-invalid-this': 'off',
+    'no-irregular-whitespace': 'error',
+    'no-multiple-empty-lines': 'error',
+    'no-new-wrappers': 'error',
+    'no-throw-literal': 'error',
+    'no-trailing-spaces': 'off',
+    'no-undef-init': 'error',
+    'no-underscore-dangle': 'off',
+    'no-unsafe-finally': 'error',
+    'no-unused-labels': 'error',
+    'no-var': 'error',
+    'object-shorthand': 'error',
+    'one-var': [
+      'error',
+      'never',
+    ],
+    'prefer-arrow/prefer-arrow-functions': 'error',
+    'prefer-const': 'error',
+    'quote-props': [
+      'error',
+      'as-needed',
+    ],
+    radix: 'error',
+    'react/jsx-curly-spacing': 'off',
+    'react/jsx-equals-spacing': 'off',
+    'react/jsx-tag-spacing': [
+      'off',
+      {
+        afterOpening: 'allow',
+        closingSlash: 'allow',
+      },
+    ],
+    'react/jsx-wrap-multilines': 'off',
+    'space-before-function-paren': [
+      'error',
+      {
+        anonymous: 'always',
+        named: 'never',
+        asyncArrow: 'always',
+      },
+    ],
+    'space-in-parens': [
+      'error',
+      'never',
+    ],
+    'use-isnan': 'error',
+    'valid-typeof': 'off',
+  },
+};

+ 1 - 1
.gitignore

@@ -1,4 +1,4 @@
-yarn-error.log
+*.log
 node_modules
 .idea
 *.lock

+ 5 - 4
config.example.json

@@ -1,8 +1,8 @@
 {
-  "mirai_access_token": "",
-  "mirai_http_host": "127.0.0.1",
-  "mirai_http_port": 8080,
-  "mirai_bot_qq": 10000,
+  "cq_access_token": "",
+  "cq_http_host": "127.0.0.1",
+  "cq_http_port": 5700,
+  "cq_bot_qq": 10000,
   "twitter_consumer_key": "",
   "twitter_consumer_secret": "",
   "twitter_access_token_key": "",
@@ -10,6 +10,7 @@
   "twitter_private_auth_token": "",
   "twitter_private_csrf_token": "",
   "mode": 0,
+  "playwright_ws_spec_endpoint": "http://127.0.0.1:8080/playwright-ws.json",
   "resume_on_start": false,
   "work_interval": 60,
   "webshot_delay": 10000,

+ 5 - 5
dist/command.js

@@ -28,8 +28,8 @@ function parseCmd(message) {
 }
 exports.parseCmd = parseCmd;
 function parseLink(link) {
-    const match = link.match(/twitter.com\/([^\/?#]+)/) ||
-        link.match(/^([^\/?#]+)$/);
+    const match = /twitter.com\/([^\/?#]+)/.exec(link) ||
+        /^([^\/?#]+)$/.exec(link);
     if (match)
         return [match[1]];
     return;
@@ -49,7 +49,7 @@ function linkFinder(checkedMatch, chat, lock) {
     return [link, index];
 }
 function sub(chat, args, reply, lock, lockfile) {
-    if (chat.chatType === "temp" /* Temp */) {
+    if (chat.chatType === "temp") {
         return reply('请先添加机器人为好友。');
     }
     if (args.length === 0) {
@@ -90,7 +90,7 @@ function sub(chat, args, reply, lock, lockfile) {
 }
 exports.sub = sub;
 function unsub(chat, args, reply, lock, lockfile) {
-    if (chat.chatType === "temp" /* Temp */) {
+    if (chat.chatType === "temp") {
         return reply('请先添加机器人为好友。');
     }
     if (args.length === 0) {
@@ -112,7 +112,7 @@ function unsub(chat, args, reply, lock, lockfile) {
 }
 exports.unsub = unsub;
 function list(chat, _, reply, lock) {
-    if (chat.chatType === "temp" /* Temp */) {
+    if (chat.chatType === "temp") {
         return reply('请先添加机器人为好友。');
     }
     const links = [];

+ 52 - 56
dist/gifski.js

@@ -16,62 +16,58 @@ const loggers_1 = require("./loggers");
 const logger = loggers_1.getLogger('gifski');
 const sizeLimit = 10 * Math.pow(2, 20);
 const roundToEven = (n) => Math.ceil(n / 2) * 2;
-function default_1(data, targetWidth) {
-    return __awaiter(this, void 0, void 0, function* () {
-        const outputFilePath = temp.path({ suffix: '.gif' });
-        temp.track();
-        try {
-            const inputFile = temp.openSync();
-            fs_1.writeSync(inputFile.fd, Buffer.from(data));
-            fs_1.closeSync(inputFile.fd);
-            logger.info(`saved video file to ${inputFile.path}, starting gif conversion...`);
-            const args = [
-                inputFile.path,
-                '-o',
-                outputFilePath,
-                '--fps',
-                '12.5',
-                '--quiet',
-                '--quality',
-                '90',
-            ];
-            if (typeof (targetWidth) === 'number') {
-                args.push('--width', roundToEven(targetWidth).toString());
-            }
-            logger.info(` gifski ${args.join(' ')}`);
-            const gifskiSpawn = child_process_1.spawn('gifski', args);
-            const gifskiResult = new Promise((resolve, reject) => {
-                const sizeChecker = setInterval(() => {
-                    if (fs_1.existsSync(outputFilePath) && fs_1.statSync(outputFilePath).size > sizeLimit)
-                        gifskiSpawn.kill();
-                }, 5000);
-                gifskiSpawn.on('exit', () => {
-                    clearInterval(sizeChecker);
-                    if (!fs_1.existsSync(outputFilePath))
-                        reject('no file was created on exit');
-                    logger.info(`gif conversion succeeded, file path: ${outputFilePath}`);
-                    resolve(fs_1.readFileSync(outputFilePath).buffer);
-                });
+exports.default = (data, targetWidth) => __awaiter(void 0, void 0, void 0, function* () {
+    const outputFilePath = temp.path({ suffix: '.gif' });
+    temp.track();
+    try {
+        const inputFile = temp.openSync();
+        fs_1.writeSync(inputFile.fd, Buffer.from(data));
+        fs_1.closeSync(inputFile.fd);
+        logger.info(`saved video file to ${inputFile.path}, starting gif conversion...`);
+        const args = [
+            inputFile.path,
+            '-o',
+            outputFilePath,
+            '--fps',
+            '12.5',
+            '--quiet',
+            '--quality',
+            '90',
+        ];
+        if (typeof (targetWidth) === 'number') {
+            args.push('--width', roundToEven(targetWidth).toString());
+        }
+        logger.info(` gifski ${args.join(' ')}`);
+        const gifskiSpawn = child_process_1.spawn('gifski', args);
+        const gifskiResult = new Promise((resolve, reject) => {
+            const sizeChecker = setInterval(() => {
+                if (fs_1.existsSync(outputFilePath) && fs_1.statSync(outputFilePath).size > sizeLimit)
+                    gifskiSpawn.kill();
+            }, 5000);
+            gifskiSpawn.on('exit', () => {
+                clearInterval(sizeChecker);
+                if (!fs_1.existsSync(outputFilePath) || fs_1.statSync(outputFilePath).size === 0)
+                    return reject('no file was created on exit');
+                logger.info(`gif conversion succeeded, file path: ${outputFilePath}`);
+                resolve(fs_1.readFileSync(outputFilePath).buffer);
             });
-            const stderr = [];
-            gifskiSpawn.stderr.on('data', errdata => {
+        });
+        const stderr = [];
+        gifskiSpawn.stderr.on('data', errdata => stderr.push(errdata));
+        gifskiSpawn.stderr.on('end', () => {
+            if (stderr.length !== 0) {
                 if (!gifskiSpawn.killed)
                     gifskiSpawn.kill();
-                stderr.concat(errdata);
-            });
-            gifskiSpawn.stderr.on('end', () => {
-                if (stderr.length !== 0)
-                    throw Error(Buffer.concat(stderr).toString());
-            });
-            return yield gifskiResult;
-        }
-        catch (error) {
-            logger.error('error converting video to gif' + error ? `message: ${error}` : '');
-            throw Error('error converting video to gif');
-        }
-        finally {
-            temp.cleanup();
-        }
-    });
-}
-exports.default = default_1;
+                throw Error(Buffer.concat(stderr).toString());
+            }
+        });
+        return yield gifskiResult;
+    }
+    catch (error) {
+        logger.error(`error converting video to gif ${error ? `message: ${error}` : ''}`);
+        throw Error('error converting video to gif');
+    }
+    finally {
+        temp.cleanup();
+    }
+});

+ 32 - 47
dist/main.js

@@ -1,9 +1,10 @@
 #!/usr/bin/env node
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
-const commandLineUsage = require("command-line-usage");
 const fs = require("fs");
 const path = require("path");
+const commandLineUsage = require("command-line-usage");
+const exampleConfig = require("../config.example.json");
 const command_1 = require("./command");
 const loggers_1 = require("./loggers");
 const mirai_1 = require("./mirai");
@@ -38,50 +39,33 @@ if (args.length === 0 || args[0] === 'help' || args[0] === '-h' || args[0] === '
 const configPath = args[0];
 let config;
 try {
-    config = require(path.resolve(configPath));
+    config = JSON.parse(fs.readFileSync(path.resolve(configPath), 'utf8'));
 }
 catch (e) {
     console.log('Failed to parse config file: ', configPath);
     console.log(usage);
     process.exit(1);
 }
-if (config.twitter_consumer_key === undefined ||
-    config.twitter_consumer_secret === undefined ||
-    config.twitter_access_token_key === undefined ||
-    config.twitter_access_token_secret === undefined) {
-    console.log('twitter_consumer_key twitter_consumer_secret twitter_access_token_key twitter_access_token_secret are required');
+const requiredFields = [
+    'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
+];
+const warningFields = [
+    'cq_http_host', 'cq_http_port', 'cq_access_token',
+];
+const optionalFields = [
+    'lockfile', 'work_interval', 'webshot_delay', 'loglevel', 'mode', 'resume_on_start',
+].concat(warningFields);
+if (requiredFields.some((value) => config[value] === undefined)) {
+    console.log(`${requiredFields.join(', ')} are required`);
     process.exit(1);
 }
-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.mirai_http_port === undefined) {
-    config.mirai_http_port = 8080;
-    logger.warn('mirai_http_port is undefined, use 8080 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';
-}
-if (config.work_interval === undefined) {
-    config.work_interval = 60;
-}
-if (config.webshot_delay === undefined) {
-    config.webshot_delay = 10000;
-}
-if (config.loglevel === undefined) {
-    config.loglevel = 'info';
-}
-if (typeof config.mode !== 'number') {
-    config.mode = 0;
-}
-if (typeof config.resume_on_start !== 'boolean') {
-    config.resume_on_start = false;
-}
+optionalFields.forEach(key => {
+    if (config[key] === undefined || typeof (config[key]) !== typeof (exampleConfig[key])) {
+        if (key in warningFields)
+            logger.warn(`${key} is undefined, use ${exampleConfig[key] || 'empty string'} as default`);
+        config[key] = exampleConfig[key];
+    }
+});
 loggers_1.setLogLevels(config.loglevel);
 let lock;
 if (fs.existsSync(path.resolve(config.lockfile))) {
@@ -123,27 +107,28 @@ if (!config.resume_on_start) {
     });
 }
 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,
+    access_token: config.cq_access_token,
+    host: config.cq_http_host,
+    port: config.cq_http_port,
+    bot_id: config.cq_bot_qq,
     list: (c, a, cb) => command_1.list(c, a, cb, lock),
     sub: (c, a, cb) => command_1.sub(c, a, cb, lock, config.lockfile),
     unsub: (c, a, cb) => command_1.unsub(c, a, cb, lock, config.lockfile),
 });
 const worker = new twitter_1.default({
-    consumer_key: config.twitter_consumer_key,
-    consumer_secret: config.twitter_consumer_secret,
-    access_token_key: config.twitter_access_token_key,
-    access_token_secret: config.twitter_access_token_secret,
-    private_auth_token: config.twitter_private_auth_token,
-    private_csrf_token: config.twitter_private_csrf_token,
+    consumerKey: config.twitter_consumer_key,
+    consumerSecret: config.twitter_consumer_secret,
+    accessTokenKey: config.twitter_access_token_key,
+    accessTokenSecret: config.twitter_access_token_secret,
+    privateAuthToken: config.twitter_private_auth_token,
+    privateCsrfToken: config.twitter_private_csrf_token,
     lock,
     lockfile: config.lockfile,
     workInterval: config.work_interval,
     bot: qq,
     webshotDelay: config.webshot_delay,
     mode: config.mode,
+    wsUrl: config.playwright_ws_spec_endpoint,
 });
 worker.launch();
 qq.connect();

+ 19 - 17
dist/mirai.js

@@ -10,8 +10,9 @@ 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 fs_1 = require("fs");
+const util_1 = require("util");
+const axios_1 = require("axios");
 const mirai_ts_1 = require("mirai-ts");
 const message_1 = require("mirai-ts/dist/message");
 const temp = require("temp");
@@ -26,20 +27,19 @@ class default_1 {
                 case 'FriendMessage':
                     return {
                         chatID: msg.sender.id,
-                        chatType: "private" /* Private */,
+                        chatType: "private",
                     };
                 case 'GroupMessage':
                     return {
                         chatID: msg.sender.group.id,
-                        chatType: "group" /* Group */,
+                        chatType: "group",
                     };
                 case 'TempMessage':
                     const friendList = yield this.bot.api.friendList();
-                    // already befriended
                     if (friendList.some(friendItem => friendItem.id === msg.sender.id)) {
                         return {
                             chatID: msg.sender.id,
-                            chatType: "private" /* Private */,
+                            chatType: "private",
                         };
                     }
                     return {
@@ -47,7 +47,7 @@ class default_1 {
                             qq: msg.sender.id,
                             group: msg.sender.group.id,
                         },
-                        chatType: "temp" /* Temp */,
+                        chatType: "temp",
                     };
             }
         });
@@ -57,7 +57,6 @@ class default_1 {
                     return this.bot.api.sendGroupMessage(msg, subscriber.chatID);
                 case 'private':
                     return this.bot.api.sendFriendMessage(msg, subscriber.chatID);
-                // currently disabled
                 case 'temp':
                     return this.bot.api.sendTempMessage(msg, subscriber.chatID.qq, subscriber.chatID.group);
             }
@@ -76,10 +75,10 @@ class default_1 {
             if (timeout === 0 || timeout < -1) {
                 return Promise.reject('Error: timeout must be greater than 0ms');
             }
-            let imgFile;
+            let imgFilePath;
             if (img.imageId !== '')
                 return Promise.resolve();
-            if (img.url !== '') {
+            else if (img.url !== '') {
                 if (img.url.split(':')[0] !== 'data') {
                     return Promise.reject('Error: URL must be of protocol "data"');
                 }
@@ -91,21 +90,23 @@ class default_1 {
                     const tempFile = temp.openSync();
                     fs_1.writeSync(tempFile.fd, Buffer.from(img.url.split(',')[1], 'base64'));
                     fs_1.closeSync(tempFile.fd);
-                    imgFile = tempFile.path;
+                    imgFilePath = tempFile.path;
                 }
                 catch (error) {
                     logger.error(error);
                 }
             }
+            else
+                imgFilePath = img.path;
             try {
                 this.bot.axios.defaults.timeout = timeout === -1 ? 0 : timeout;
                 logger.info(`uploading ${JSON.stringify(exports.Message.Image(img.imageId, `${img.url.split(',')[0]},[...]`, img.path))}...`);
-                return this.bot.api.uploadImage('group', imgFile || img.path)
+                return this.bot.api.uploadImage('group', fs_1.createReadStream(imgFilePath))
                     .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];
+                    img.path = (response.path).split(/[/\\]/).slice(-1)[0];
                 })
                     .catch(reason => {
                     logger.error(`error uploading ${img.path}, reason: ${reason}`);
@@ -130,7 +131,7 @@ class default_1 {
                 this.bot.api.groupList()
                     .then((groupList) => {
                     if (groupList.some(groupItem => groupItem.id === evt.groupId)) {
-                        evt.respond(0); // allow
+                        evt.respond(0);
                         return logger.info(`accepted friend request from ${evt.fromId} (from group ${evt.groupId})`);
                     }
                     logger.warn(`received friend request from ${evt.fromId} (from group ${evt.groupId})`);
@@ -142,7 +143,7 @@ class default_1 {
                 this.bot.api.friendList()
                     .then((friendList) => {
                     if (friendList.some(friendItem => friendItem.id = evt.fromId)) {
-                        evt.respond(0); // allow
+                        evt.respond(0);
                         return logger.info(`accepted group invitation from ${evt.fromId} (friend)`);
                     }
                     logger.warn(`received group invitation from ${evt.fromId} (unknown)`);
@@ -170,15 +171,16 @@ class default_1 {
                         this.botInfo.list(chat, cmdObj.args, msg.reply);
                         break;
                     case 'help':
-                        msg.reply(`推特故事搬运机器人:
+                        if (cmdObj.args.length === 0) {
+                            msg.reply(`推特故事搬运机器人:
 /twitterfleets - 查询当前聊天中的推特故事订阅
 /twitterfleets_view〈链接〉- 查看该用户当前可见的所有 Fleets
 /twitterfleets_subscribe [链接] - 订阅 Twitter Fleets 搬运
 /twitterfleets_unsubscribe [链接] - 退订 Twitter Fleets 搬运`);
+                        }
                 }
             }));
         };
-        // TODO doesn't work if connection is dropped after connection
         this.listen = (logMsg) => {
             if (logMsg !== '') {
                 logger.warn(logMsg !== null && logMsg !== void 0 ? logMsg : 'Listening...');
@@ -202,7 +204,7 @@ class default_1 {
                 .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);
+                return util_1.promisify(setTimeout)(2500).then(() => this.login('Retry logging in...'));
             });
         });
         this.connect = () => {

+ 9 - 8
dist/twitter.js

@@ -74,7 +74,7 @@ const retryOnError = (doWork, onRetry) => new Promise(resolve => {
 class default_1 {
     constructor(opt) {
         this.launch = () => {
-            this.webshot = new webshot_1.default(this.mode, () => setTimeout(this.work, this.workInterval * 1000));
+            this.webshot = new webshot_1.default(this.wsUrl, this.mode, () => setTimeout(this.work, this.workInterval * 1000));
         };
         this.queryUser = (username) => this.client.get('users/show', { screen_name: username })
             .then((user) => {
@@ -146,9 +146,9 @@ class default_1 {
             const currentFeed = lock.feed[lock.workon];
             logger.debug(`pulling feed ${currentFeed}`);
             let user;
-            let match = currentFeed.match(/https:\/\/twitter.com\/([^\/]+)/);
+            let match = /https:\/\/twitter.com\/([^\/]+)/.exec(currentFeed);
             if (match)
-                match = lock.threads[currentFeed].permaFeed.match(/https:\/\/twitter.com\/i\/user\/([^\/]+)/);
+                match = /https:\/\/twitter.com\/i\/user\/([^\/]+)/.exec(lock.threads[currentFeed].permaFeed);
             if (!match) {
                 logger.error(`cannot get endpoint for feed ${currentFeed}`);
                 return;
@@ -195,22 +195,23 @@ class default_1 {
             });
         };
         this.client = new Twitter({
-            consumer_key: opt.consumer_key,
-            consumer_secret: opt.consumer_secret,
-            access_token_key: opt.access_token_key,
-            access_token_secret: opt.access_token_secret,
+            consumer_key: opt.consumerKey,
+            consumer_secret: opt.consumerSecret,
+            access_token_key: opt.accessTokenKey,
+            access_token_secret: opt.accessTokenSecret,
         });
         this.privateClient = new Twitter({
             bearer_token: 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
         });
         this.privateClient.request = request.defaults({
-            headers: Object.assign(Object.assign({}, this.privateClient.options.request_options.headers), { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: `auth_token=${opt.private_auth_token}; ct0=${opt.private_csrf_token};`, 'X-CSRF-Token': opt.private_csrf_token }),
+            headers: Object.assign(Object.assign({}, this.privateClient.options.request_options.headers), { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: `auth_token=${opt.privateAuthToken}; ct0=${opt.privateCsrfToken};`, 'X-CSRF-Token': opt.privateCsrfToken }),
         });
         this.lockfile = opt.lockfile;
         this.lock = opt.lock;
         this.workInterval = opt.workInterval;
         this.bot = opt.bot;
         this.mode = opt.mode;
+        this.wsUrl = opt.wsUrl;
         ScreenNameNormalizer._queryUser = this.queryUser;
         exports.sendAllFleets = (username, receiver) => {
             this.client.get('users/show', { screen_name: username })

+ 2 - 2
dist/utils.js

@@ -38,9 +38,9 @@ const bigNumLShift = (num, by) => {
         throw Error('cannot perform right shift');
     const at = Math.trunc((52 - by) / 10) * 3;
     const [high, low] = splitBigNumAt(num, at).map(n => n * Math.pow(2, by));
-    return bigNumPlus(high + '0'.repeat(at), low.toString());
+    return bigNumPlus(`${high} ${'0'.repeat(at)}`, low.toString());
 };
-const parseBigNum = (str) => ((str === null || str === void 0 ? void 0 : str.match(/^-?\d+$/)) || [''])[0].replace(/^(-)?0*/, '$1');
+const parseBigNum = (str) => (/^-?\d+$/.exec(str) || [''])[0].replace(/^(-)?0*/, '$1');
 exports.BigNumOps = {
     splitAt: splitBigNumAt,
     plus: bigNumPlus,

+ 6 - 9
dist/webshot.js

@@ -30,11 +30,11 @@ const typeInZH = {
 };
 const logger = loggers_1.getLogger('webshot');
 class Webshot extends CallableInstance {
-    constructor(mode, onready) {
+    constructor(_wsUrl, mode, onready) {
         super('webshot');
         this.fetchMedia = (url) => {
             const gif = (data) => {
-                const matchDims = url.match(/\/(\d+)x(\d+)\//);
+                const matchDims = /\/(\d+)x(\d+)\//.exec(url);
                 if (matchDims) {
                     const [width, height] = matchDims.slice(1).map(Number);
                     const factor = width + height > 1600 ? 0.375 : 0.5;
@@ -59,7 +59,7 @@ class Webshot extends CallableInstance {
                         reject();
                     }
                 }).catch(err => {
-                    logger.error(`failed to fetch ${url}: ${err.message}`);
+                    logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
                     reject();
                 });
             }).then(data => {
@@ -79,7 +79,7 @@ class Webshot extends CallableInstance {
                                 throw Error(err);
                             }
                     }
-                }))(((_a = url.match(/\?format=([a-z]+)&/)) !== null && _a !== void 0 ? _a : url.match(/.*\/.*\.([^?]+)/))[1])
+                }))(((_a = (/\?format=([a-z]+)&/.exec(url))) !== null && _a !== void 0 ? _a : (/.*\/.*\.([^?]+)/.exec(url)))[1])
                     .catch(() => {
                     logger.warn('unable to find MIME type of fetched media, failing this fetch');
                     throw Error();
@@ -99,13 +99,10 @@ class Webshot extends CallableInstance {
                 logger.info(`working on ${user.screen_name}/${fleet.fleet_id}`);
             });
             const messageChain = [];
-            // text processing
             const author = `${user.name} (@${user.screen_name}):\n`;
             const date = `${new Date(fleet.created_at)}\n`;
             let text = (_b = author + date + ((_a = fleet.media_bounding_boxes) === null || _a === void 0 ? void 0 : _a.map(box => box.entity.value).join('\n'))) !== null && _b !== void 0 ? _b : '';
             messageChain.push(mirai_1.Message.Plain(author + date));
-            // fetch extra entities
-            // tslint:disable-next-line: curly
             if (1 - this.mode % 2)
                 promise = promise.then(() => {
                     const media = fleet.media_entity;
@@ -118,10 +115,10 @@ class Webshot extends CallableInstance {
                         media.type = fleet.media_key.media_category === 'TWEET_VIDEO' ? 'video' : 'animated_gif';
                         media.video_info = media.media_info.video_info;
                         text += `[${typeInZH[media.type].type}]`;
-                        url = media.video_info.variants // bitrate -> bit_rate
+                        url = media.video_info.variants
                             .filter(variant => variant.bit_rate !== undefined)
                             .sort((var1, var2) => var2.bit_rate - var1.bit_rate)
-                            .map(variant => variant.url)[0]; // largest video
+                            .map(variant => variant.url)[0];
                     }
                     const altMessage = mirai_1.Message.Plain(`\n[失败的${typeInZH[media.type].type}:${url}]`);
                     return this.fetchMedia(url)

+ 16 - 7
package.json

@@ -1,6 +1,6 @@
 {
   "name": "@CL-Jeremy/mirai-twitter-bot",
-  "version": "0.3.3",
+  "version": "0.4.0",
   "description": "Mirai Twitter Bot",
   "main": "./dist/main.js",
   "bin": {
@@ -26,8 +26,8 @@
   },
   "homepage": "https://github.com/CL-Jeremy/mirai-twitter-bot",
   "scripts": {
-    "build": "rm -rf dist && npx tsc --outDir dist",
-    "lint": "npx tslint --fix -c tslint.json --project ./"
+    "build": "rm -rf dist && npx tsc --outDir d && mv d/src dist && rm -rf d",
+    "lint": "npx eslint --fix --ext .ts ./"
   },
   "dependencies": {
     "axios": "^0.21.1",
@@ -35,25 +35,34 @@
     "command-line-usage": "^5.0.5",
     "html-entities": "^1.3.1",
     "log4js": "^6.3.0",
-    "mirai-ts": "^0.7.10",
+    "mirai-ts": "github:CL-Jeremy/mirai-ts#upload-file-built",
+    "playwright": "^1.9.1",
     "pngjs": "^5.0.0",
     "puppeteer": "^2.1.0",
     "read-all-stream": "^3.1.0",
+    "request": "^2.72.0",
     "sha1": "^1.1.1",
     "sharp": "^0.25.4",
     "temp": "^0.9.1",
     "twitter": "^1.7.1",
-    "typescript": "^4.1.3"
+    "typescript": "^4.2.3"
   },
   "devDependencies": {
-    "@types/node": "^10.17.27",
+    "@types/command-line-usage": "^5.0.1",
+    "@types/node": "^14.14.22",
     "@types/pngjs": "^3.4.2",
     "@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",
+    "@typescript-eslint/eslint-plugin": "^4.22.0",
+    "@typescript-eslint/parser": "^4.22.0",
+    "eslint": "^7.25.0",
+    "eslint-plugin-import": "^2.22.1",
+    "eslint-plugin-jsdoc": "^32.3.1",
+    "eslint-plugin-prefer-arrow": "^1.2.3",
+    "eslint-plugin-react": "^7.23.2",
     "tslint-config-prettier": "^1.13.0",
     "twitter-d": "^0.4.0"
   }

+ 5 - 2
src/command.ts

@@ -1,3 +1,6 @@
+/* eslint-disable @typescript-eslint/no-unsafe-return */
+/* eslint-disable @typescript-eslint/member-delimiter-style */
+/* eslint-disable prefer-arrow/prefer-arrow-functions */
 import * as fs from 'fs';
 import * as path from 'path';
 
@@ -32,8 +35,8 @@ function parseCmd(message: string): {
 
 function parseLink(link: string): string[] {
   const match =
-    link.match(/twitter.com\/([^\/?#]+)/) ||
-    link.match(/^([^\/?#]+)$/);
+    /twitter.com\/([^\/?#]+)/.exec(link) ||
+    /^([^\/?#]+)$/.exec(link);
   if (match) return [match[1]];
   return;
 }

+ 48 - 48
src/gifski.ts

@@ -9,53 +9,53 @@ const logger = getLogger('gifski');
 const sizeLimit = 10 * 2 ** 20;
 const roundToEven = (n: number) => Math.ceil(n / 2) * 2;
 
-export default async function (data: ArrayBuffer, targetWidth?: number) {
-    const outputFilePath = temp.path({suffix: '.gif'});
-    temp.track();
-    try {
-      const inputFile = temp.openSync();
-      writeSync(inputFile.fd, Buffer.from(data));
-      closeSync(inputFile.fd);
-      logger.info(`saved video file to ${inputFile.path}, starting gif conversion...`);
-      const args = [
-        inputFile.path,
-        '-o',
-        outputFilePath,
-        '--fps',
-        '12.5',
-        '--quiet',
-        '--quality',
-        '90',
-      ];
-      if (typeof(targetWidth) === 'number') {
-        args.push('--width', roundToEven(targetWidth).toString());
-      }
-      logger.info(` gifski ${args.join(' ')}`);
-      const gifskiSpawn = spawn('gifski', args);
-      const gifskiResult = new Promise<ArrayBufferLike>((resolve, reject) => {
-        const sizeChecker = setInterval(() => {
-          if (existsSync(outputFilePath) && statSync(outputFilePath).size > sizeLimit) gifskiSpawn.kill();
-        }, 5000);
-        gifskiSpawn.on('exit', () => {
-          clearInterval(sizeChecker);
-          if (!existsSync(outputFilePath)) reject('no file was created on exit');
-          logger.info(`gif conversion succeeded, file path: ${outputFilePath}`);
-          resolve(readFileSync(outputFilePath).buffer);
-        });
+export default async (data: ArrayBuffer, targetWidth?: number) => {
+  const outputFilePath = temp.path({suffix: '.gif'});
+  temp.track();
+  try {
+    const inputFile = temp.openSync();
+    writeSync(inputFile.fd, Buffer.from(data));
+    closeSync(inputFile.fd);
+    logger.info(`saved video file to ${inputFile.path}, starting gif conversion...`);
+    const args = [
+      inputFile.path,
+      '-o',
+      outputFilePath,
+      '--fps',
+      '12.5',
+      '--quiet',
+      '--quality',
+      '90',
+    ];
+    if (typeof(targetWidth) === 'number') {
+      args.push('--width', roundToEven(targetWidth).toString());
+    }
+    logger.info(` gifski ${args.join(' ')}`);
+    const gifskiSpawn = spawn('gifski', args);
+    const gifskiResult = new Promise<ArrayBufferLike>((resolve, reject) => {
+      const sizeChecker = setInterval(() => {
+        if (existsSync(outputFilePath) && statSync(outputFilePath).size > sizeLimit) gifskiSpawn.kill();
+      }, 5000);
+      gifskiSpawn.on('exit', () => {
+        clearInterval(sizeChecker);
+        if (!existsSync(outputFilePath) || statSync(outputFilePath).size === 0) return reject('no file was created on exit');
+        logger.info(`gif conversion succeeded, file path: ${outputFilePath}`);
+        resolve(readFileSync(outputFilePath).buffer);
       });
-      const stderr = [];
-      gifskiSpawn.stderr.on('data', errdata => {
+    });
+    const stderr = [];
+    gifskiSpawn.stderr.on('data', errdata => stderr.push(errdata));
+    gifskiSpawn.stderr.on('end', () => {
+      if (stderr.length !== 0) {
         if (!gifskiSpawn.killed) gifskiSpawn.kill();
-        stderr.concat(errdata);
-      });
-      gifskiSpawn.stderr.on('end', () => {
-        if (stderr.length !== 0) throw Error(Buffer.concat(stderr).toString());
-      });
-      return await gifskiResult;
-    } catch (error) {
-      logger.error('error converting video to gif' + error ? `message: ${error}` : '');
-      throw Error('error converting video to gif');
-    } finally {
-      temp.cleanup();
-    }
-}
+        throw Error(Buffer.concat(stderr).toString());
+      }
+    });
+    return await gifskiResult;
+  } catch (error) {
+    logger.error(`error converting video to gif ${error ? `message: ${error}` : ''}`);
+    throw Error('error converting video to gif');
+  } finally {
+    temp.cleanup();
+  }
+};

+ 6 - 5
src/loggers.ts

@@ -1,14 +1,15 @@
+/* eslint-disable prefer-arrow/prefer-arrow-functions */
 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;
+  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');
+  loggers.forEach((l: Logger) => l.level = level ?? 'info');
 }

+ 40 - 50
src/main.ts

@@ -1,9 +1,11 @@
 #!/usr/bin/env node
 
-import * as commandLineUsage from 'command-line-usage';
 import * as fs from 'fs';
 import * as path from 'path';
 
+import * as commandLineUsage from 'command-line-usage';
+
+import * as exampleConfig from '../config.example.json';
 import { list, sub, unsub } from './command';
 import { getLogger, setLogLevels } from './loggers';
 import QQBot from './mirai';
@@ -11,7 +13,7 @@ import Worker from './twitter';
 
 const logger = getLogger();
 
-const sections = [
+const sections: commandLineUsage.Section[] = [
   {
     header: 'MiraiTS Twitter Bot',
     content: 'The QQ Bot that forwards twitters.',
@@ -43,59 +45,46 @@ if (args.length === 0 || args[0] === 'help' || args[0] === '-h' || args[0] === '
 
 const configPath = args[0];
 
-let config;
+type Config = typeof exampleConfig;
+let config: Config;
 try {
-  config = require(path.resolve(configPath));
+  config = JSON.parse(fs.readFileSync(path.resolve(configPath), 'utf8')) as Config;
 } catch (e) {
   console.log('Failed to parse config file: ', configPath);
   console.log(usage);
   process.exit(1);
 }
 
-if (config.twitter_consumer_key === undefined ||
-  config.twitter_consumer_secret === undefined ||
-  config.twitter_access_token_key === undefined ||
-  config.twitter_access_token_secret === undefined) {
-  console.log('twitter_consumer_key twitter_consumer_secret twitter_access_token_key twitter_access_token_secret are required');
+const requiredFields = [
+  'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
+];
+
+const warningFields = [
+  'cq_http_host', 'cq_http_port', 'cq_access_token',
+];
+
+const optionalFields = [
+  'lockfile', 'work_interval', 'webshot_delay', 'loglevel', 'mode', 'resume_on_start',
+].concat(warningFields);
+
+if (requiredFields.some((value) => config[value] === undefined)) {
+  console.log(`${requiredFields.join(', ')} are required`);
   process.exit(1);
 }
-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.mirai_http_port === undefined) {
-  config.mirai_http_port = 8080;
-  logger.warn('mirai_http_port is undefined, use 8080 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';
-}
-if (config.work_interval === undefined) {
-  config.work_interval = 60;
-}
-if (config.webshot_delay === undefined) {
-  config.webshot_delay = 10000;
-}
-if (config.loglevel === undefined) {
-  config.loglevel = 'info';
-}
-if (typeof config.mode !== 'number') {
-  config.mode = 0;
-}
-if (typeof config.resume_on_start !== 'boolean') {
-  config.resume_on_start = false;
-}
+
+optionalFields.forEach(key => {
+  if (config[key] === undefined || typeof(config[key]) !== typeof (exampleConfig[key])) {
+    if (key in warningFields) logger.warn(`${key} is undefined, use ${exampleConfig[key] || 'empty string'} as default`);
+    config[key] = exampleConfig[key as keyof Config];
+  }
+});
 
 setLogLevels(config.loglevel);
 
 let lock: ILock;
 if (fs.existsSync(path.resolve(config.lockfile))) {
   try {
-    lock = JSON.parse(fs.readFileSync(path.resolve(config.lockfile), 'utf8'));
+    lock = JSON.parse(fs.readFileSync(path.resolve(config.lockfile), 'utf8')) as ILock;
   } catch (err) {
     logger.error(`Failed to parse lockfile ${config.lockfile}: `, err);
     lock = {
@@ -131,28 +120,29 @@ if (!config.resume_on_start) {
 }
 
 const qq = new QQBot({
-  access_token: config.mirai_access_token,
-  host: config.mirai_http_host,
-  port: config.mirai_http_port,
-  bot_id: config.mirai_bot_qq,
+  access_token: config.cq_access_token,
+  host: config.cq_http_host,
+  port: config.cq_http_port,
+  bot_id: config.cq_bot_qq,
   list: (c, a, cb) => list(c, a, cb, lock),
   sub: (c, a, cb) => sub(c, a, cb, lock, config.lockfile),
   unsub: (c, a, cb) => unsub(c, a, cb, lock, config.lockfile),
 });
 
 const worker = new Worker({
-  consumer_key: config.twitter_consumer_key,
-  consumer_secret: config.twitter_consumer_secret,
-  access_token_key: config.twitter_access_token_key,
-  access_token_secret: config.twitter_access_token_secret,
-  private_auth_token: config.twitter_private_auth_token,
-  private_csrf_token: config.twitter_private_csrf_token,
+  consumerKey: config.twitter_consumer_key,
+  consumerSecret: config.twitter_consumer_secret,
+  accessTokenKey: config.twitter_access_token_key,
+  accessTokenSecret: config.twitter_access_token_secret,
+  privateAuthToken: config.twitter_private_auth_token,
+  privateCsrfToken: config.twitter_private_csrf_token,
   lock,
   lockfile: config.lockfile,
   workInterval: config.work_interval,
   bot: qq,
   webshotDelay: config.webshot_delay,
   mode: config.mode,
+  wsUrl: config.playwright_ws_spec_endpoint,
 });
 worker.launch();
 

+ 84 - 79
src/mirai.ts

@@ -1,5 +1,7 @@
+import { closeSync, createReadStream, writeSync } from 'fs';
+import { promisify } from 'util';
+
 import axios from 'axios';
-import { closeSync, writeSync } from 'fs';
 import Mirai, { MessageType } from 'mirai-ts';
 import MiraiMessage from 'mirai-ts/dist/message';
 import * as temp from 'temp';
@@ -40,11 +42,11 @@ export default class {
           chatType: ChatType.Group,
         };
       case 'TempMessage':
-        const friendList: [{
+        const friendList: {
           id: number,
           nickname: string,
           remark: string,
-        }] = await this.bot.api.friendList();
+        }[] = await this.bot.api.friendList();
         // already befriended
         if (friendList.some(friendItem => friendItem.id === msg.sender.id)) {
           return {
@@ -60,20 +62,19 @@ export default class {
           chatType: ChatType.Temp,
         };
     }
-  }
-
-  public sendTo = (subscriber: IChat, msg: string | MessageChain) =>
-    (() => {
-      switch (subscriber.chatType) {
-        case 'group':
-          return this.bot.api.sendGroupMessage(msg, subscriber.chatID);
-        case 'private':
-          return this.bot.api.sendFriendMessage(msg, subscriber.chatID);
+  };
+
+  public sendTo = (subscriber: IChat, msg: string | MessageChain) => (() => {
+    switch (subscriber.chatType) {
+      case 'group':
+        return this.bot.api.sendGroupMessage(msg, subscriber.chatID);
+      case 'private':
+        return this.bot.api.sendFriendMessage(msg, subscriber.chatID);
         // currently disabled
-        case 'temp':
-          return this.bot.api.sendTempMessage(msg, subscriber.chatID.qq, subscriber.chatID.group);
-      }
-    })()
+      case 'temp':
+        return this.bot.api.sendTempMessage(msg, subscriber.chatID.qq, subscriber.chatID.group);
+    }
+  })()
     .then(response => {
       logger.info(`pushing data to ${JSON.stringify(subscriber.chatID)} was successful, response:`);
       logger.info(response);
@@ -81,16 +82,16 @@ export default class {
     .catch(reason => {
       logger.error(`error pushing data to ${JSON.stringify(subscriber.chatID)}, reason: ${reason}`);
       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;
+    let imgFilePath: string;
     if (img.imageId !== '') return Promise.resolve();
-    if (img.url !== '') {
+    else if (img.url !== '') {
       if (img.url.split(':')[0] !== 'data') {
         return Promise.reject('Error: URL must be of protocol "data"');
       }
@@ -102,32 +103,32 @@ export default class {
         const tempFile = temp.openSync();
         writeSync(tempFile.fd, Buffer.from(img.url.split(',')[1], 'base64'));
         closeSync(tempFile.fd);
-        imgFile = tempFile.path;
+        imgFilePath = tempFile.path;
       } catch (error) {
         logger.error(error);
       }
-    }
+    } else imgFilePath = img.path;
     try {
       this.bot.axios.defaults.timeout = timeout === -1 ? 0 : timeout;
       logger.info(`uploading ${JSON.stringify(
         Message.Image(img.imageId, `${img.url.split(',')[0]},[...]`, 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);
-      });
+      return this.bot.api.uploadImage('group', createReadStream(imgFilePath))
+        .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).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({
@@ -142,35 +143,35 @@ export default class {
     this.bot.on('NewFriendRequestEvent', evt => {
       logger.debug(`detected new friend request event: ${JSON.stringify(evt)}`);
       this.bot.api.groupList()
-      .then((groupList: [{
-        id: number,
-        name: string,
-        permission: 'OWNER' | 'ADMINISTRATOR' | 'MEMBER',
-      }]) => {
-        if (groupList.some(groupItem => groupItem.id === evt.groupId)) {
-          evt.respond(0); // allow
-          return logger.info(`accepted friend request from ${evt.fromId} (from group ${evt.groupId})`);
-        }
-        logger.warn(`received friend request from ${evt.fromId} (from group ${evt.groupId})`);
-        logger.warn('please manually accept this friend request');
-      });
+        .then((groupList: [{
+          id: number,
+          name: string,
+          permission: 'OWNER' | 'ADMINISTRATOR' | 'MEMBER',
+        }]) => {
+          if (groupList.some(groupItem => groupItem.id === evt.groupId)) {
+            evt.respond(0);
+            return logger.info(`accepted friend request from ${evt.fromId} (from group ${evt.groupId})`);
+          }
+          logger.warn(`received friend request from ${evt.fromId} (from group ${evt.groupId})`);
+          logger.warn('please manually accept this friend request');
+        });
     });
 
     this.bot.on('BotInvitedJoinGroupRequestEvent', evt => {
       logger.debug(`detected group invitation event: ${JSON.stringify(evt)}`);
       this.bot.api.friendList()
-      .then((friendList: [{
-        id: number,
-        nickname: string,
-        remark: string,
-      }]) => {
-        if (friendList.some(friendItem => friendItem.id = evt.fromId)) {
-          evt.respond(0); // allow
-          return logger.info(`accepted group invitation from ${evt.fromId} (friend)`);
-        }
-        logger.warn(`received group invitation from ${evt.fromId} (unknown)`);
-        logger.warn('please manually accept this group invitation');
-      });
+        .then((friendList: [{
+          id: number,
+          nickname: string,
+          remark: string,
+        }]) => {
+          if (friendList.some(friendItem => friendItem.id = evt.fromId)) {
+            evt.respond(0);
+            return logger.info(`accepted group invitation from ${evt.fromId} (friend)`);
+          }
+          logger.warn(`received group invitation from ${evt.fromId} (unknown)`);
+          logger.warn('please manually accept this group invitation');
+        });
     });
 
     this.bot.on('message', async msg => {
@@ -194,48 +195,52 @@ export default class {
           this.botInfo.list(chat, cmdObj.args, msg.reply);
           break;
         case 'help':
-          msg.reply(`推特故事搬运机器人:
+          if (cmdObj.args.length === 0) {
+            msg.reply(`推特故事搬运机器人:
 /twitterfleets - 查询当前聊天中的推特故事订阅
 /twitterfleets_view〈链接〉- 查看该用户当前可见的所有 Fleets
 /twitterfleets_subscribe [链接] - 订阅 Twitter Fleets 搬运
 /twitterfleets_unsubscribe [链接] - 退订 Twitter Fleets 搬运`);
+          }
       }
     });
-}
+  };
 
-  // TODO doesn't work if connection is dropped after connection
+  /**
+   * @todo doesn't work if connection is dropped after connection
+   */
   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);
-    });
-  }
+      .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.link(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);
-    });
-  }
+      .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}?`);
+        return promisify(setTimeout)(2500).then(() => this.login('Retry logging in...'));
+      });
+  };
 
   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}`);

+ 15 - 16
src/model.d.ts

@@ -5,32 +5,31 @@ declare const enum ChatType {
 }
 
 interface IPrivateChat {
-  chatID: number,
-  chatType: ChatType.Private,
+  chatID: number;
+  chatType: ChatType.Private;
 }
 
 interface IGroupChat {
-  chatID: number,
-  chatType: ChatType.Group,
+  chatID: number;
+  chatType: ChatType.Group;
 }
 
 interface ITempChat {
-  chatID: {qq: number, group: number},
-  chatType: ChatType.Temp,
+  chatID: {qq: number, group: number};
+  chatType: ChatType.Temp;
 }
 
 type IChat = IPrivateChat | IGroupChat | ITempChat;
 
 interface ILock {
-  workon: number,
-  feed: string[],
+  workon: number;
+  feed: string[];
   threads: {
-    [key: string]:
-      {
-        permaFeed: string,
-        offset: string,
-        updatedAt: string,
-        subscribers: IChat[],
-      }
-  }
+    [key: string]: {
+      permaFeed: string,
+      offset: string,
+      updatedAt: string,
+      subscribers: IChat[],
+    },
+  };
 }

+ 120 - 117
src/twitter.ts

@@ -15,13 +15,14 @@ interface IWorkerOption {
   bot: QQBot;
   workInterval: number;
   webshotDelay: number;
-  consumer_key: string;
-  consumer_secret: string;
-  access_token_key: string;
-  access_token_secret: string;
-  private_csrf_token: string;
-  private_auth_token: string;
+  consumerKey: string;
+  consumerSecret: string;
+  accessTokenKey: string;
+  accessTokenSecret: string;
+  privateCsrfToken: string;
+  privateAuthToken: string;
   mode: number;
+  wsUrl: string;
 }
 
 export class ScreenNameNormalizer {
@@ -29,7 +30,7 @@ export class ScreenNameNormalizer {
   // tslint:disable-next-line: variable-name
   public static _queryUser: (username: string) => Promise<string>;
 
-  public static permaFeeds = {};
+  public static permaFeeds: {[key: string]: string} = {};
 
   public static savePermaFeedForUser(user: FullUser) {
     this.permaFeeds[`https://twitter.com/${user.screen_name}`] = `https://twitter.com/i/user/${user.id_str}`;
@@ -40,13 +41,13 @@ export class ScreenNameNormalizer {
   public static async normalizeLive(username: string) {
     if (this._queryUser) {
       return await this._queryUser(username)
-      .catch((err: {code: number, message: string}[]) => {
-        if (err[0].code !== 50) {
-          logger.warn(`error looking up user: ${err[0].message}`);
-          return username;
-        }
-        return null;
-      });
+        .catch((err: {code: number, message: string}[]) => {
+          if (err[0].code !== 50) {
+            logger.warn(`error looking up user: ${err[0].message}`);
+            return username;
+          }
+          return null;
+        });
     }
     return this.normalize(username);
   }
@@ -92,7 +93,7 @@ export type MediaEntity = TwitterTypes.MediaEntity;
 type TwitterMod = {
   -readonly [K in keyof Twitter]: Twitter[K];
 } & {
-  options?: any;
+  options?: Twitter.Options,
 };
 
 interface IFleet {
@@ -102,21 +103,21 @@ interface IFleet {
   fleet_id: string;
   fleet_thread_id: string;
   media_bounding_boxes: [{
-    anchor_point_x: number;
-    anchor_point_y: number;
-    width: number;
-    height: number;
-    rotation: number;
+    anchor_point_x: number,
+    anchor_point_y: number,
+    width: number,
+    height: number,
+    rotation: number,
     entity: {
-        type: string;
-        value: any;
-    }
+      type: string,
+      value: any,
+    },
   }];
   media_entity: MediaEntity;
   media_key: {
-    media_category: 'TWEET_IMAGE' | 'TWEET_VIDEO';
-    media_id: number;
-    media_id_str: string;
+    media_category: 'TWEET_IMAGE' | 'TWEET_VIDEO',
+    media_id: number,
+    media_id_str: string,
   };
   mentions: any;
   mentions_str: any;
@@ -144,13 +145,14 @@ export default class {
   private webshotDelay: number;
   private webshot: Webshot;
   private mode: number;
+  private wsUrl: string;
 
   constructor(opt: IWorkerOption) {
     this.client = new Twitter({
-      consumer_key: opt.consumer_key,
-      consumer_secret: opt.consumer_secret,
-      access_token_key: opt.access_token_key,
-      access_token_secret: opt.access_token_secret,
+      consumer_key: opt.consumerKey,
+      consumer_secret: opt.consumerSecret,
+      access_token_key: opt.accessTokenKey,
+      access_token_secret: opt.accessTokenSecret,
     });
     this.privateClient = new Twitter({
       bearer_token: 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
@@ -159,8 +161,8 @@ export default class {
       headers: {
         ...this.privateClient.options.request_options.headers,
         'Content-Type': 'application/x-www-form-urlencoded',
-        Cookie: `auth_token=${opt.private_auth_token}; ct0=${opt.private_csrf_token};`,
-        'X-CSRF-Token': opt.private_csrf_token,
+        Cookie: `auth_token=${opt.privateAuthToken}; ct0=${opt.privateCsrfToken};`,
+        'X-CSRF-Token': opt.privateCsrfToken,
       },
     });
     this.lockfile = opt.lockfile;
@@ -168,46 +170,48 @@ export default class {
     this.workInterval = opt.workInterval;
     this.bot = opt.bot;
     this.mode = opt.mode;
+    this.wsUrl = opt.wsUrl;
     ScreenNameNormalizer._queryUser = this.queryUser;
     sendAllFleets = (username, receiver) => {
       this.client.get('users/show', {screen_name: username})
-      .then((user: FullUser) => {
-        const feed = `https://twitter.com/${user.screen_name}`;
-        return this.getFleets(user.id_str)
-        .catch(error => {
-          logger.error(`unhandled error while fetching fleets for ${feed}: ${JSON.stringify(error)}`);
-          this.bot.sendTo(receiver, `获取 Fleets 时出现错误:${error}`);
+        .then((user: FullUser) => {
+          const feed = `https://twitter.com/${user.screen_name}`;
+          return this.getFleets(user.id_str)
+            .catch(error => {
+              logger.error(`unhandled error while fetching fleets for ${feed}: ${JSON.stringify(error)}`);
+              this.bot.sendTo(receiver, `获取 Fleets 时出现错误:${error}`);
+            })
+            .then((fleetFeed: IFleetFeed) => {
+              if (!fleetFeed || fleetFeed.fleet_threads.length === 0) {
+                this.bot.sendTo(receiver, `当前用户(@${user.screen_name})没有可用的 Fleets。`);
+                return;
+              }
+              this.workOnFleets(user, fleetFeed.fleet_threads[0].fleets, this.sendFleets(`thread ${feed}`, receiver));
+            });
         })
-        .then((fleetFeed: IFleetFeed) => {
-          if (!fleetFeed || fleetFeed.fleet_threads.length === 0) {
-            this.bot.sendTo(receiver, `当前用户(@${user.screen_name})没有可用的 Fleets。`);
-            return;
+        .catch((err: {code: number, message: string}[]) => {
+          if (err[0].code !== 50) {
+            logger.warn(`error looking up user: ${err[0].message}, unable to fetch fleets`);
           }
-          this.workOnFleets(user, fleetFeed.fleet_threads[0].fleets, this.sendFleets(`thread ${feed}`, receiver));
+          this.bot.sendTo(receiver, `找不到用户 ${username.replace(/^@?(.*)$/, '@$1')}。`);
         });
-      })
-      .catch((err: {code: number, message: string}[]) => {
-        if (err[0].code !== 50) {
-          logger.warn(`error looking up user: ${err[0].message}, unable to fetch fleets`);
-        }
-        this.bot.sendTo(receiver, `找不到用户 ${username.replace(/^@?(.*)$/, '@$1')}。`);
-      });
     };
   }
 
   public launch = () => {
     this.webshot = new Webshot(
+      this.wsUrl,
       this.mode,
       () => setTimeout(this.work, this.workInterval * 1000)
     );
-  }
+  };
 
   public queryUser = (username: string) =>
     this.client.get('users/show', {screen_name: username})
-    .then((user: FullUser) => {
-      ScreenNameNormalizer.savePermaFeedForUser(user);
-      return user.screen_name;
-    })
+      .then((user: FullUser) => {
+        ScreenNameNormalizer.savePermaFeedForUser(user);
+        return user.screen_name;
+      });
 
   private workOnFleets = (
     user: FullUser,
@@ -219,38 +223,37 @@ export default class {
       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 <= maxTrials) {
-          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());
-        }
-      });
+      return retryOnError(() => this.bot.uploadPic(message, timeout).then(() => message),
+        (_, count, terminate: (defaultValue: ReturnType<typeof Message.Plain>) => void) => {
+          if (count <= maxTrials) {
+            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());
+          }
+        });
     };
     return this.webshot(user, fleets, uploader, sendFleets, this.webshotDelay);
-  }
+  };
 
   private sendFleets = (source?: string, ...to: IChat[]) =>
-  (msg: MessageChain, text: string) => {
-    to.forEach(subscriber => {
-      logger.info(`pushing data${source ? ` of ${source}` : ''} to ${JSON.stringify(subscriber)}`);
-      retryOnError(
-        () => this.bot.sendTo(subscriber, msg),
-      (_, count, terminate: (doNothing: Promise<void>) => void) => {
-        if (count <= maxTrials) {
-          logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);
-        } else {
-          logger.warn(`${count - 1} consecutive failures while sending` +
+    (msg: MessageChain, text: string) => {
+      to.forEach(subscriber => {
+        logger.info(`pushing data${source ? ` of ${source}` : ''} to ${JSON.stringify(subscriber)}`);
+        retryOnError(
+          () => this.bot.sendTo(subscriber, msg),
+          (_, count, terminate: (doNothing: Promise<void>) => void) => {
+            if (count <= maxTrials) {
+              logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);
+            } else {
+              logger.warn(`${count - 1} consecutive failures while sending` +
             'message chain, trying plain text instead...');
-          terminate(this.bot.sendTo(subscriber, text));
-        }
+              terminate(this.bot.sendTo(subscriber, text));
+            }
+          });
       });
-    });
-  }
+    };
   
   private getFleets = (userID: string) => new Promise<IFleetFeed | void>((resolve, reject) => {
     const endpoint = `https://api.twitter.com/fleets/v1/user_fleets?user_id=${userID}`;
@@ -258,7 +261,7 @@ export default class {
       if (error) reject(error);
       else resolve(fleetFeed);
     });
-  })
+  });
 
   public work = () => {
     const lock = this.lock;
@@ -285,49 +288,49 @@ export default class {
     logger.debug(`pulling feed ${currentFeed}`);
 
     let user: FullUser;
-    let match = currentFeed.match(/https:\/\/twitter.com\/([^\/]+)/);
-    if (match) match = lock.threads[currentFeed].permaFeed.match(/https:\/\/twitter.com\/i\/user\/([^\/]+)/);
+    let match = /https:\/\/twitter.com\/([^\/]+)/.exec(currentFeed);
+    if (match) match = /https:\/\/twitter.com\/i\/user\/([^\/]+)/.exec(lock.threads[currentFeed].permaFeed);
     if (!match) {
       logger.error(`cannot get endpoint for feed ${currentFeed}`);
       return;
     }
 
     this.client.get('users/show', {user_id: match[1]})
-    .then((fullUser: FullUser) => { user = fullUser; return this.getFleets(match[1]); })
-    .catch(error => {
-      logger.error(`unhandled error on fetching fleets for ${currentFeed}: ${JSON.stringify(error)}`);
-    })
-    .then((fleetFeed: IFleetFeed) => {
-      logger.debug(`private api returned ${JSON.stringify(fleetFeed)} for feed ${currentFeed}`);
-      logger.debug(`api returned ${JSON.stringify(user)} for owner of feed ${currentFeed}`);
-      const currentThread = lock.threads[currentFeed];
+      .then((fullUser: FullUser) => { user = fullUser; return this.getFleets(match[1]); })
+      .catch(error => {
+        logger.error(`unhandled error on fetching fleets for ${currentFeed}: ${JSON.stringify(error)}`);
+      })
+      .then((fleetFeed: IFleetFeed) => {
+        logger.debug(`private api returned ${JSON.stringify(fleetFeed)} for feed ${currentFeed}`);
+        logger.debug(`api returned ${JSON.stringify(user)} for owner of feed ${currentFeed}`);
+        const currentThread = lock.threads[currentFeed];
 
-      const updateDate = () => currentThread.updatedAt = new Date().toString();
-      if (!fleetFeed || fleetFeed.fleet_threads.length === 0) { updateDate(); return; }
+        const updateDate = () => currentThread.updatedAt = new Date().toString();
+        if (!fleetFeed || fleetFeed.fleet_threads.length === 0) { updateDate(); return; }
 
-      let fleets = fleetFeed.fleet_threads[0].fleets;
-      const bottomOfFeed = fleets.slice(-1)[0].fleet_id.substring(3);
-      const updateOffset = () => currentThread.offset = bottomOfFeed;
+        let fleets = fleetFeed.fleet_threads[0].fleets;
+        const bottomOfFeed = fleets.slice(-1)[0].fleet_id.substring(3);
+        const updateOffset = () => currentThread.offset = bottomOfFeed;
 
-      if (currentThread.offset === '-1') { updateOffset(); return; }
-      if (currentThread.offset !== '0') {
-        const readCount = fleets.findIndex(fleet =>
-          Number(BigNumOps.plus(fleet.fleet_id.substring(3), `-${currentThread.offset}`)) > 0);
-        if (readCount === -1) return;
-        fleets = fleets.slice(readCount);
-      }
+        if (currentThread.offset === '-1') { updateOffset(); return; }
+        if (currentThread.offset !== '0') {
+          const readCount = fleets.findIndex(fleet =>
+            Number(BigNumOps.plus(fleet.fleet_id.substring(3), `-${currentThread.offset}`)) > 0);
+          if (readCount === -1) return;
+          fleets = fleets.slice(readCount);
+        }
 
-      return this.workOnFleets(user, fleets, this.sendFleets(`thread ${currentFeed}`, ...currentThread.subscribers))
-      .then(updateDate).then(updateOffset);
-    })
-    .then(() => {
-      lock.workon++;
-      let timeout = this.workInterval * 1000 / lock.feed.length;
-      if (timeout < 1000) timeout = 1000;
-      fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
-      setTimeout(() => {
-        this.work();
-      }, timeout);
-    });
-  }
+        return this.workOnFleets(user, fleets, this.sendFleets(`thread ${currentFeed}`, ...currentThread.subscribers))
+          .then(updateDate).then(updateOffset);
+      })
+      .then(() => {
+        lock.workon++;
+        let timeout = this.workInterval * 1000 / lock.feed.length;
+        if (timeout < 1000) timeout = 1000;
+        fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock));
+        setTimeout(() => {
+          this.work();
+        }, timeout);
+      });
+  };
 }

+ 8 - 11
src/utils.ts

@@ -2,11 +2,9 @@ export const chainPromises = <T>(
   promises: Promise<T>[],
   reducer = (p1: Promise<T>, p2: Promise<T>) => p1.then(() => p2),
   initialValue?: T
-) =>
-  promises.reduce(reducer, Promise.resolve(initialValue));
+) => promises.reduce(reducer, Promise.resolve(initialValue));
 
-const splitBigNumAt = (num: string, at: number) =>
-  num.replace(RegExp(String.raw`^([+-]?)(\d+)(\d{${at}})$`), '$1$2,$1$3')
+const splitBigNumAt = (num: string, at: number) => num.replace(RegExp(String.raw`^([+-]?)(\d+)(\d{${at}})$`), '$1$2,$1$3')
   .replace(/^([^,]*)$/, '0,$1').split(',')
   .map(Number);
 
@@ -24,11 +22,10 @@ const bigNumPlus = (num1: string, num2: string) => {
   return `${high}${Math.abs(low).toString().padStart(15, '0')}`;
 };
 
-const bigNumCompare = (num1: string, num2: string) =>
-  Math.sign(Number(bigNumPlus(
-    num1, 
-    num2.replace(/^([+-]?)(\d+)/, (_, $1, $2) => `${$1 === '-' ? '' : '-'}${$2}`)
-  )));
+const bigNumCompare = (num1: string, num2: string) => Math.sign(Number(bigNumPlus(
+  num1, 
+  num2.replace(/^([+-]?)(\d+)/, (_, $1, $2) => `${$1 === '-' ? '' : '-'}${$2}`)
+)));
 
 const bigNumMin = (...nums: string[]) => {
   if (!nums || !nums.length) return undefined;
@@ -43,10 +40,10 @@ const bigNumLShift = (num: string, by: number) => {
   if (by < 0) throw Error('cannot perform right shift');
   const at = Math.trunc((52 - by) / 10) * 3;
   const [high, low] = splitBigNumAt(num, at).map(n => n * 2 ** by);
-  return bigNumPlus(high + '0'.repeat(at), low.toString());
+  return bigNumPlus(`${high} ${'0'.repeat(at)}`, low.toString());
 };
 
-const parseBigNum = (str: string) => (str?.match(/^-?\d+$/) || [''])[0].replace(/^(-)?0*/, '$1');
+const parseBigNum = (str: string) => (/^-?\d+$/.exec(str) || [''])[0].replace(/^(-)?0*/, '$1');
 
 export const BigNumOps = {
   splitAt: splitBigNumAt,

+ 57 - 58
src/webshot.ts

@@ -5,7 +5,7 @@ import { XmlEntities } from 'html-entities';
 import gifski from './gifski';
 import { getLogger } from './loggers';
 import { Message, MessageChain } from './mirai';
-import { Fleets, FullUser } from './twitter';
+import { Fleets, FullUser, MediaEntity } from './twitter';
 
 const xmlEntities = new XmlEntities();
 
@@ -22,15 +22,13 @@ const typeInZH = {
 
 const logger = getLogger('webshot');
 
-class Webshot
-extends CallableInstance<
-  [FullUser, Fleets, (...args) => Promise<any>, (...args) => void, number],
-  Promise<void>
-> {
+class Webshot extends CallableInstance<[
+  FullUser, Fleets, (...args) => Promise<any>, (...args) => void, number
+], Promise<void>> {
 
   private mode: number;
 
-  constructor(mode: number, onready?: () => any) {
+  constructor(_wsUrl: string, mode: number, onready?: (...args) => void) {
     super('webshot');
     this.mode = mode;
     onready();
@@ -38,7 +36,7 @@ extends CallableInstance<
 
   private fetchMedia = (url: string): Promise<string> => {
     const gif = (data: ArrayBuffer) => {
-      const matchDims = url.match(/\/(\d+)x(\d+)\//);
+      const matchDims = /\/(\d+)x(\d+)\//.exec(url);
       if (matchDims) {
         const [ width, height ] = matchDims.slice(1).map(Number);
         const factor = width + height > 1600 ? 0.375 : 0.5;
@@ -56,40 +54,38 @@ extends CallableInstance<
         timeout: 150000,
       }).then(res => {
         if (res.status === 200) {
-            logger.info(`successfully fetched ${url}`);
-            resolve(res.data);
+          logger.info(`successfully fetched ${url}`);
+          resolve(res.data);
         } else {
           logger.error(`failed to fetch ${url}: ${res.status}`);
           reject();
         }
       }).catch (err => {
-        logger.error(`failed to fetch ${url}: ${err.message}`);
+        logger.error(`failed to fetch ${url}: ${err instanceof Error ? err.message : err}`);
         reject();
       });
-    }).then(data =>
-      (async ext => {
-        switch (ext) {
-          case 'jpg':
-            return {mimetype: 'image/jpeg', data};
-          case 'png':
-            return {mimetype: 'image/png', data};
-          case 'mp4':
-            try {
-              return {mimetype: 'image/gif', data: await gif(data)};
-            } catch (err) {
-              logger.error(err);
-              throw Error(err);
-            }
-        }
-      })((url.match(/\?format=([a-z]+)&/) ?? url.match(/.*\/.*\.([^?]+)/))[1])
+    }).then(data => (async ext => {
+      switch (ext) {
+        case 'jpg':
+          return {mimetype: 'image/jpeg', data};
+        case 'png':
+          return {mimetype: 'image/png', data};
+        case 'mp4':
+          try {
+            return {mimetype: 'image/gif', data: await gif(data)};
+          } catch (err) {
+            logger.error(err);
+            throw Error(err);
+          }
+      }
+    })(((/\?format=([a-z]+)&/.exec(url)) ?? (/.*\/.*\.([^?]+)/.exec(url)))[1])
       .catch(() => {
         logger.warn('unable to find MIME type of fetched media, failing this fetch');
         throw Error();
       })
-    ).then(typedData => 
-      `data:${typedData.mimetype};base64,${Buffer.from(typedData.data).toString('base64')}`
+    ).then(typedData => `data:${typedData.mimetype};base64,${Buffer.from(typedData.data).toString('base64')}`
     );
-  }
+  };
 
   public webshot(
     user: FullUser,
@@ -97,7 +93,7 @@ extends CallableInstance<
     uploader: (
       img: ReturnType<typeof Message.Image>,
       lastResort: (...args) => ReturnType<typeof Message.Plain>)
-      => Promise<ReturnType<typeof Message.Image | typeof Message.Plain>>,
+    => Promise<ReturnType<typeof Message.Image | typeof Message.Plain>>,
     callback: (msgs: MessageChain, text: string) => void,
     webshotDelay: number
   ): Promise<void> {
@@ -113,38 +109,41 @@ extends CallableInstance<
       // text processing
       const author = `${user.name} (@${user.screen_name}):\n`;
       const date = `${new Date(fleet.created_at)}\n`;
-      let text = author + date + fleet.media_bounding_boxes?.map(box => box.entity.value).join('\n') ?? '';
+      let text = author + date + fleet.media_bounding_boxes?.map(box => box.entity.value as string).join('\n') ?? '';
       messageChain.push(Message.Plain(author + date));
 
       // fetch extra entities
       // tslint:disable-next-line: curly
+      // eslint-disable-next-line curly
       if (1 - this.mode % 2) promise = promise.then(() => {
-          const media = fleet.media_entity;
-          let url: string;
-          if (fleet.media_key.media_category === 'TWEET_IMAGE') {
-            media.type = 'photo';
-            url = media.media_url_https.replace(/\.([a-z]+)$/, '?format=$1') + '&name=orig';
-          } else {
-            media.type = fleet.media_key.media_category === 'TWEET_VIDEO' ? 'video' : 'animated_gif';
-            media.video_info = (media as any).media_info.video_info;
-            text += `[${typeInZH[media.type].type}]`;
-            url = (media.video_info.variants as any) // bitrate -> bit_rate
-              .filter(variant => variant.bit_rate !== undefined)
-              .sort((var1, var2) => var2.bit_rate - var1.bit_rate)
-              .map(variant => variant.url)[0]; // largest video
-          }
-          const altMessage = Message.Plain(`\n[失败的${typeInZH[media.type].type}:${url}]`);
-          return this.fetchMedia(url)
-            .then(base64url =>
-              uploader(Message.Image('', base64url, media.type === 'photo' ? url : `${url} as gif`), () => altMessage)
-            )
-            .catch(error => {
-              logger.warn('unable to fetch media, sending plain text instead...');
-              return altMessage;
-            })
-            .then(msg => {
-              messageChain.push(msg);
-            });
+        const media: MediaEntity & {media_info?: MediaEntity} = fleet.media_entity;
+        let url: string;
+        if (fleet.media_key.media_category === 'TWEET_IMAGE') {
+          media.type = 'photo';
+          url = media.media_url_https.replace(/\.([a-z]+)$/, '?format=$1') + '&name=orig';
+        } else {
+          media.type = fleet.media_key.media_category === 'TWEET_VIDEO' ? 'video' : 'animated_gif';
+          media.video_info = media.media_info.video_info;
+          text += `[${typeInZH[media.type as keyof typeof typeInZH].type}]`;
+          url = (media.video_info.variants as (
+            typeof media.video_info.variants[0] & {bit_rate: number} // bitrate -> bit_rate
+          )[])
+            .filter(variant => variant.bit_rate !== undefined)
+            .sort((var1, var2) => var2.bit_rate - var1.bit_rate)
+            .map(variant => variant.url)[0]; // largest video
+        }
+        const altMessage = Message.Plain(`\n[失败的${typeInZH[media.type as keyof typeof typeInZH].type}:${url}]`);
+        return this.fetchMedia(url)
+          .then(base64url =>
+            uploader(Message.Image('', base64url, media.type === 'photo' ? url : `${url} as gif`), () => altMessage)
+          )
+          .catch(error => {
+            logger.warn('unable to fetch media, sending plain text instead...');
+            return altMessage;
+          })
+          .then(msg => {
+            messageChain.push(msg);
+          });
       });
       promise.then(() => {
         logger.info(`done working on ${user.screen_name}/${fleet.fleet_id}, message chain:`);

+ 5 - 2
tsconfig.json

@@ -1,6 +1,7 @@
 {
   "include": [
-    "src/**/*"
+    "src/**/*",
+    ".eslintrc.js"
   ],
   "exclude": [
     "node_modules",
@@ -12,6 +13,8 @@
     "outDir": "dist",
     "noUnusedLocals": true,
     "allowJs": true,
-    "allowSyntheticDefaultImports": true
+    "allowSyntheticDefaultImports": true,
+    "resolveJsonModule": true,
+    "removeComments": true
   }
 }