Browse Source

fix gifski bug; update deps; migrate to eslint

Mike L 3 years ago
parent
commit
f34c877315
20 changed files with 785 additions and 582 deletions
  1. 248 0
      .eslintrc.js
  2. 1 1
      .gitignore
  3. 14 14
      dist/command.js
  4. 52 56
      dist/gifski.js
  5. 25 41
      dist/main.js
  6. 17 17
      dist/mirai.js
  7. 11 9
      dist/twitter.js
  8. 4 3
      dist/utils.js
  9. 15 38
      dist/webshot.js
  10. 14 7
      package.json
  11. 17 14
      src/command.ts
  12. 48 48
      src/gifski.ts
  13. 6 5
      src/loggers.ts
  14. 33 44
      src/main.ts
  15. 82 79
      src/mirai.ts
  16. 14 14
      src/model.d.ts
  17. 107 114
      src/twitter.ts
  18. 8 11
      src/utils.ts
  19. 64 65
      src/webshot.ts
  20. 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

+ 14 - 14
dist/command.js

@@ -29,17 +29,17 @@ function parseCmd(message) {
 }
 exports.parseCmd = parseCmd;
 function parseLink(link) {
-    let match = link.match(/twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/) ||
-        link.match(/^([^\/?#]+)\/([^\/?#]+)$/);
+    let match = /twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/.exec(link) ||
+        /^([^\/?#]+)\/([^\/?#]+)$/.exec(link);
     if (match)
         return [match[1], `/lists/${match[2]}`];
     match =
-        link.match(/twitter.com\/([^\/?#]+)\/status\/(\d+)/);
+        /twitter.com\/([^\/?#]+)\/status\/(\d+)/.exec(link);
     if (match)
         return [match[1], `/status/${match[2]}`];
     match =
-        link.match(/twitter.com\/([^\/?#]+)/) ||
-            link.match(/^([^\/?#]+)$/);
+        /twitter.com\/([^\/?#]+)/.exec(link) ||
+            /^([^\/?#]+)$/.exec(link);
     if (match)
         return [match[1]];
     return;
@@ -59,7 +59,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) {
@@ -75,7 +75,7 @@ https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
     }
     let offset = '0';
     if (match[1]) {
-        const matchStatus = match[1].match(/\/status\/(\d+)/);
+        const matchStatus = /\/status\/(\d+)/.exec(match[1]);
         if (matchStatus) {
             offset = utils_1.BigNumOps.plus(matchStatus[1], '-1');
             delete match[1];
@@ -102,7 +102,7 @@ https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
     if (realLink)
         return subscribeTo(realLink);
     const [rawUserName, more] = match;
-    if (rawUserName.toLowerCase() === 'i' && (more === null || more === void 0 ? void 0 : more.match(/lists\/(\d+)/))) {
+    if (rawUserName.toLowerCase() === 'i' && /lists\/(\d+)/.exec(more)) {
         return subscribeTo(linkBuilder('i', more), { addNew: true });
     }
     twitter_1.ScreenNameNormalizer.normalizeLive(rawUserName).then(userName => {
@@ -118,7 +118,7 @@ https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
 }
 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) {
@@ -140,7 +140,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 = [];
@@ -155,7 +155,7 @@ function view(chat, args, reply) {
     if (args.length === 0) {
         return reply('找不到要查看的链接。');
     }
-    const match = args[0].match(/^(?:.*twitter.com\/[^\/?#]+\/status\/)?(\d+)/);
+    const match = /^(?:.*twitter.com\/[^\/?#]+\/status\/)?(\d+)/.exec(args[0]);
     if (!match) {
         return reply('链接格式有误。');
     }
@@ -171,8 +171,8 @@ function query(chat, args, reply) {
     if (args.length === 0) {
         return reply('找不到要查询的用户。');
     }
-    const match = args[0].match(/twitter.com\/([^\/?#]+)/) ||
-        args[0].match(/^([^\/?#]+)$/);
+    const match = /twitter.com\/([^\/?#]+)/.exec(args[0]) ||
+        /^([^\/?#]+)$/.exec(args[0]);
     if (!match) {
         return reply('链接格式有误。');
     }
@@ -185,7 +185,7 @@ function query(chat, args, reply) {
         norts: '忽略原生转推(on/off)',
     };
     for (const arg of args.slice(1)) {
-        const optMatch = arg.match(/^(count|since|until|noreps|norts)=(.*)/);
+        const optMatch = /^(count|since|until|noreps|norts)=(.*)/.exec(arg);
         if (!optMatch)
             return reply(`未定义的查询参数:${arg}。`);
         const optKey = optMatch[1];

+ 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();
+    }
+});

+ 25 - 41
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 = [
+    'mirai_http_host', 'mirai_http_port', 'mirai_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))) {
@@ -132,10 +116,10 @@ const qq = new mirai_1.default({
     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,
+    consumerKey: config.twitter_consumer_key,
+    consumerSecret: config.twitter_consumer_secret,
+    accessTokenKey: config.twitter_access_token_key,
+    accessTokenSecret: config.twitter_access_token_secret,
     lock,
     lockfile: config.lockfile,
     workInterval: config.work_interval,

+ 17 - 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('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('allow');
+                        evt.respond(0);
                         return logger.info(`accepted group invitation from ${evt.fromId} (friend)`);
                     }
                     logger.warn(`received group invitation from ${evt.fromId} (unknown)`);
@@ -181,7 +182,7 @@ class default_1 {
 /twitter_unsubscribe〈链接|用户名〉- 退订 Twitter 推文搬运
 /twitter_view〈链接〉- 查看推文
 /twitter_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitter_query)\
-${chat.chatType === "temp" /* Temp */ ?
+${chat.chatType === "temp" ?
                                 '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''}`);
                         }
                         else if (cmdObj.args[0] === 'twitter_query') {
@@ -207,7 +208,6 @@ count 与 since/until 并用时,取二者中实际查询结果较少者
                 }
             }));
         };
-        // TODO doesn't work if connection is dropped after connection
         this.listen = (logMsg) => {
             if (logMsg !== '') {
                 logger.warn(logMsg !== null && logMsg !== void 0 ? logMsg : 'Listening...');
@@ -231,7 +231,7 @@ count 与 since/until 并用时,取二者中实际查询结果较少者
                 .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 = () => {

+ 11 - 9
dist/twitter.js

@@ -35,12 +35,14 @@ class ScreenNameNormalizer {
 }
 exports.ScreenNameNormalizer = ScreenNameNormalizer;
 ScreenNameNormalizer.normalize = (username) => username.toLowerCase().replace(/^@/, '');
-exports.sendTweet = (id, receiver) => {
+let sendTweet = (id, receiver) => {
     throw Error();
 };
-exports.sendTimeline = (conf, receiver) => {
+exports.sendTweet = sendTweet;
+let sendTimeline = (conf, receiver) => {
     throw Error();
 };
+exports.sendTimeline = sendTimeline;
 const TWITTER_EPOCH = 1288834974657;
 const snowflake = (epoch) => Number.isNaN(epoch) ? undefined :
     utils_1.BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22);
@@ -193,7 +195,7 @@ class default_1 {
             const currentFeed = lock.feed[lock.workon];
             logger.debug(`pulling feed ${currentFeed}`);
             const promise = new Promise(resolve => {
-                let match = currentFeed.match(/https:\/\/twitter.com\/([^\/]+)\/lists\/([^\/]+)/);
+                let match = /https:\/\/twitter.com\/([^\/]+)\/lists\/([^\/]+)/.exec(currentFeed);
                 let config;
                 let endpoint;
                 if (match) {
@@ -213,7 +215,7 @@ class default_1 {
                     endpoint = 'lists/statuses';
                 }
                 else {
-                    match = currentFeed.match(/https:\/\/twitter.com\/([^\/]+)/);
+                    match = /https:\/\/twitter.com\/([^\/]+)/.exec(currentFeed);
                     if (match) {
                         config = {
                             screen_name: match[1],
@@ -239,7 +241,7 @@ class default_1 {
                             else {
                                 logger.error(`unhandled error on fetching tweets for ${currentFeed}: ${JSON.stringify(error)}`);
                             }
-                            resolve();
+                            resolve([]);
                         }
                         else
                             resolve(tweets);
@@ -277,10 +279,10 @@ 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.lockfile = opt.lockfile;
         this.lock = opt.lock;

+ 4 - 3
dist/utils.js

@@ -1,7 +1,8 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
 exports.BigNumOps = exports.chainPromises = void 0;
-exports.chainPromises = (promises, reducer = (p1, p2) => p1.then(() => p2), initialValue) => promises.reduce(reducer, Promise.resolve(initialValue));
+const chainPromises = (promises, reducer = (p1, p2) => p1.then(() => p2), initialValue) => promises.reduce(reducer, Promise.resolve(initialValue));
+exports.chainPromises = chainPromises;
 const splitBigNumAt = (num, at) => num.replace(RegExp(String.raw `^([+-]?)(\d+)(\d{${at}})$`), '$1$2,$1$3')
     .replace(/^([^,]*)$/, '0,$1').split(',')
     .map(Number);
@@ -37,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,

+ 15 - 38
dist/webshot.js

@@ -9,13 +9,13 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
     });
 };
 Object.defineProperty(exports, "__esModule", { value: true });
+const util_1 = require("util");
 const axios_1 = require("axios");
 const CallableInstance = require("callable-instance");
 const html_entities_1 = require("html-entities");
 const pngjs_1 = require("pngjs");
 const puppeteer = require("puppeteer");
 const sharp = require("sharp");
-const util_1 = require("util");
 const gifski_1 = require("./gifski");
 const loggers_1 = require("./loggers");
 const mirai_1 = require("./mirai");
@@ -37,7 +37,6 @@ const logger = loggers_1.getLogger('webshot');
 class Webshot extends CallableInstance {
     constructor(mode, onready) {
         super('webshot');
-        // use local Chromium
         this.connect = (onready) => puppeteer.connect({ browserURL: 'http://127.0.0.1:9222' })
             .then(browser => this.browser = browser)
             .then(() => {
@@ -78,11 +77,9 @@ class Webshot extends CallableInstance {
                     }))
                         .then(() => page.setBypassCSP(true))
                         .then(() => page.goto(url, { waitUntil: 'load', timeout: getTimeout() }))
-                        // 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;}',
                     }))
-                        // remove listeners
                         .then(() => page.evaluate(() => {
                         const poll = setInterval(() => {
                             document.querySelectorAll('div[data-testid="placementTracking"]').forEach(container => {
@@ -107,9 +104,8 @@ class Webshot extends CallableInstance {
                         .then(() => page.evaluate(() => {
                         const cardImg = document.querySelector('div[data-testid^="card.layout"][data-testid$=".media"] img');
                         if (typeof (cardImg === null || cardImg === void 0 ? void 0 : cardImg.getAttribute('src')) === 'string') {
-                            const match = cardImg === null || cardImg === void 0 ? void 0 : cardImg.getAttribute('src').match(/^(.*\/card_img\/(\d+)\/.+\?format=.*)&name=/);
+                            const match = /^(.*\/card_img\/(\d+)\/.+\?format=.*)&name=/.exec(cardImg === null || cardImg === void 0 ? void 0 : cardImg.getAttribute('src'));
                             if (match) {
-                                // tslint:disable-next-line: variable-name
                                 const [media_url_https, id_str] = match.slice(1);
                                 return {
                                     media_url: media_url_https.replace(/^https/, 'http'),
@@ -125,8 +121,10 @@ class Webshot extends CallableInstance {
                             }
                         }
                     }))
-                        .then(cardImg => { if (cardImg)
-                        this.extendEntity(cardImg); })
+                        .then(cardImg => {
+                        if (cardImg)
+                            this.extendEntity(cardImg);
+                    })
                         .then(() => page.addScriptTag({
                         content: 'document.documentElement.scrollTop=0;',
                     }))
@@ -137,8 +135,6 @@ class Webshot extends CallableInstance {
                             filterType: 4,
                             deflateLevel: 0,
                         }).on('parsed', function () {
-                            // remove comment area
-                            // tslint:disable-next-line: no-shadowed-variable
                             const idx = (x, y) => (this.width * y + x) << 2;
                             let boundary = null;
                             let x = zoomFactor * 2;
@@ -146,7 +142,6 @@ class Webshot extends CallableInstance {
                                 if (this.data[idx(x, y)] !== 255 &&
                                     this.data[idx(x, y)] === this.data[idx(x + zoomFactor * 10, y)]) {
                                     if (this.data[idx(x, y + 18 * zoomFactor)] !== 255) {
-                                        // footer kicks in
                                         boundary = null;
                                     }
                                     else {
@@ -170,27 +165,18 @@ class Webshot extends CallableInstance {
                                     }
                                     else
                                         continue;
-                                    // line above the "comment", "retweet", "like", "share" button row
                                     if (cnt === 2) {
                                         boundary = y + 1;
                                     }
-                                    // if there are a "retweet" count and "like" count row, this will be the line above it
                                     if (cnt === 4) {
                                         const b = y + 1;
                                         if (this.height - boundary - (boundary - b) <= 1) {
                                             boundary = b;
-                                            //   }
-                                            // }
-                                            // // if "retweet" count and "like" count are two rows, this will be the line above the first
-                                            // if (cnt === 6) {
-                                            //   const c = y + 1;
-                                            //   if (this.height - boundary - 2 * (boundary - c) <= 2) {
-                                            //     boundary = c;
                                             break;
                                         }
                                     }
                                 }
-                                if (boundary != null) {
+                                if (boundary !== null) {
                                     logger.info(`found boundary at ${boundary}, trimming image`);
                                     this.data = this.data.slice(0, idx(this.width, boundary));
                                     this.height = boundary;
@@ -213,12 +199,12 @@ class Webshot extends CallableInstance {
                         }).parse(screenshot);
                     })
                         .catch(err => {
-                        if (err.name !== 'TimeoutError')
+                        if (err instanceof Error && err.name !== 'TimeoutError')
                             throw err;
                         logger.error(`error shooting webshot for ${url}, could not load web page of tweet`);
                         resolve({ base64: '', boundary: 0 });
                     })
-                        .finally(() => page.close());
+                        .finally(() => { page.close(); });
                 })
                     .catch(reject);
             });
@@ -227,12 +213,12 @@ class Webshot extends CallableInstance {
                     return this.renderWebshot(url, height + 1920, webshotDelay);
                 else
                     return data.base64;
-            }).catch(error => new Promise(resolve => this.reconnect(error, resolve))
+            }).catch(error => this.reconnect(error)
                 .then(() => this.renderWebshot(url, height, webshotDelay)));
         };
         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;
@@ -257,7 +243,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 => {
@@ -277,14 +263,13 @@ 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();
                 });
             }).then(typedData => `data:${typedData.mimetype};base64,${Buffer.from(typedData.data).toString('base64')}`);
         };
-        // tslint:disable-next-line: no-conditional-assignment
         if (this.mode = mode) {
             onready();
         }
@@ -302,7 +287,6 @@ class Webshot extends CallableInstance {
             });
             const originTwi = twi.retweeted_status || twi;
             const messageChain = [];
-            // text processing
             let author = `${twi.user.name} (@${twi.user.screen_name}):\n`;
             if (twi.retweeted_status)
                 author += `RT @${twi.retweeted_status.user.screen_name}: `;
@@ -321,7 +305,6 @@ class Webshot extends CallableInstance {
                 if (this.mode > 0)
                     messageChain.push(mirai_1.Message.Plain(author + xmlEntities.decode(text)));
             });
-            // invoke webshot
             if (this.mode === 0) {
                 const url = `https://mobile.twitter.com/${twi.user.screen_name}/status/${twi.id_str}`;
                 this.extendEntity = (cardImg) => {
@@ -342,8 +325,6 @@ class Webshot extends CallableInstance {
                         messageChain.push(msg);
                 });
             }
-            // fetch extra entities
-            // tslint:disable-next-line: curly
             if (1 - this.mode % 2)
                 promise = promise.then(() => {
                     if (originTwi.extended_entities) {
@@ -356,7 +337,7 @@ class Webshot extends CallableInstance {
                                 url = media.video_info.variants
                                     .filter(variant => variant.bitrate !== undefined)
                                     .sort((var1, var2) => var2.bitrate - var1.bitrate)
-                                    .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)
@@ -365,13 +346,10 @@ class Webshot extends CallableInstance {
                                 logger.warn('unable to fetch media, sending plain text instead...');
                                 return altMessage;
                             })
-                                .then(msg => {
-                                messageChain.push(msg);
-                            });
+                                .then(msg => { messageChain.push(msg); });
                         }));
                     }
                 });
-            // append URLs, if any
             if (this.mode === 0) {
                 if (originTwi.entities && originTwi.entities.urls && originTwi.entities.urls.length) {
                     promise = promise.then(() => {
@@ -384,7 +362,6 @@ class Webshot extends CallableInstance {
                     });
                 }
             }
-            // refer to quoted tweet, if any
             if (originTwi.is_quote_status) {
                 promise = promise.then(() => {
                     messageChain.push(mirai_1.Message.Plain(`\n回复此命令查看引用的推文:\n/twitter_view ${originTwi.quoted_status_permalink.expanded}`));

+ 14 - 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,15 +26,15 @@
   },
   "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": {
     "callable-instance": "^2.0.0",
     "command-line-usage": "^5.0.5",
     "html-entities": "^1.3.1",
     "log4js": "^6.3.0",
-    "mirai-ts": "github:CL-Jeremy/mirai-ts#built",
+    "mirai-ts": "github:CL-Jeremy/mirai-ts#upload-file-built",
     "pngjs": "^5.0.0",
     "puppeteer": "^2.1.0",
     "read-all-stream": "^3.1.0",
@@ -42,17 +42,24 @@
     "sharp": "^0.25.4",
     "temp": "^0.9.1",
     "twitter": "^1.7.1",
-    "typescript": "^4.0.2"
+    "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"
   }

+ 17 - 14
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';
 
@@ -33,15 +36,15 @@ function parseCmd(message: string): {
 
 function parseLink(link: string): string[] {
   let match =
-    link.match(/twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/) ||
-    link.match(/^([^\/?#]+)\/([^\/?#]+)$/);
+    /twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/.exec(link) ||
+    /^([^\/?#]+)\/([^\/?#]+)$/.exec(link);
   if (match) return [match[1], `/lists/${match[2]}`];
   match =
-    link.match(/twitter.com\/([^\/?#]+)\/status\/(\d+)/);
+    /twitter.com\/([^\/?#]+)\/status\/(\d+)/.exec(link);
   if (match) return [match[1], `/status/${match[2]}`];
   match =
-    link.match(/twitter.com\/([^\/?#]+)/) ||
-    link.match(/^([^\/?#]+)$/);
+    /twitter.com\/([^\/?#]+)/.exec(link) ||
+    /^([^\/?#]+)$/.exec(link);
   if (match) return [match[1]];
   return;
 }
@@ -54,11 +57,11 @@ function linkBuilder(userName: string, more = ''): string {
 function linkFinder(checkedMatch: string[], chat: IChat, lock: ILock): [string, number] {
   const normalizedLink =
     linkBuilder(normalizer.normalize(checkedMatch[0]), checkedMatch[1]?.toLowerCase());
-  const link = Object.keys(lock.threads).find(realLink => 
+  const link = Object.keys(lock.threads).find(realLink =>
     normalizedLink === realLink.replace(/\/@/, '/').toLowerCase()
   );
   if (!link) return [null, -1];
-  const index = lock.threads[link].subscribers.findIndex(({chatID, chatType}) => 
+  const index = lock.threads[link].subscribers.findIndex(({chatID, chatType}) =>
     chat.chatID === chatID && chat.chatType === chatType
   );
   return [link, index];
@@ -83,7 +86,7 @@ https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
   }
   let offset = '0';
   if (match[1]) {
-    const matchStatus = match[1].match(/\/status\/(\d+)/);
+    const matchStatus = /\/status\/(\d+)/.exec(match[1]);
     if (matchStatus) {
       offset = BigNumOps.plus(matchStatus[1], '-1');
       delete match[1];
@@ -108,7 +111,7 @@ https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
   if (index > -1) return reply('此聊天已订阅此链接。');
   if (realLink) return subscribeTo(realLink);
   const [rawUserName, more] = match;
-  if (rawUserName.toLowerCase() === 'i' && more?.match(/lists\/(\d+)/)) {
+  if (rawUserName.toLowerCase() === 'i' && /lists\/(\d+)/.exec(more)) {
     return subscribeTo(linkBuilder('i', more), {addNew: true});
   }
   normalizer.normalizeLive(rawUserName).then(userName => {
@@ -116,7 +119,7 @@ https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
     const link = linkBuilder(userName, more);
     const msg = (offset === '0') ?
       undefined :
-        `已为此聊天订阅 ${link} 并回溯到此动态 ID(含)之后的第一条动态。
+      `已为此聊天订阅 ${link} 并回溯到此动态 ID(含)之后的第一条动态。
 (参见:https://blog.twitter.com/engineering/en_us/a/2010/announcing-snowflake.html)`;
     subscribeTo(link, {addNew: true, msg});
   });
@@ -162,7 +165,7 @@ function view(chat: IChat, args: string[], reply: (msg: string) => any): void {
   if (args.length === 0) {
     return reply('找不到要查看的链接。');
   }
-  const match = args[0].match(/^(?:.*twitter.com\/[^\/?#]+\/status\/)?(\d+)/);
+  const match = /^(?:.*twitter.com\/[^\/?#]+\/status\/)?(\d+)/.exec(args[0]);
   if (!match) {
     return reply('链接格式有误。');
   }
@@ -178,8 +181,8 @@ function query(chat: IChat, args: string[], reply: (msg: string) => any): void {
     return reply('找不到要查询的用户。');
   }
   const match = 
-    args[0].match(/twitter.com\/([^\/?#]+)/) ||
-    args[0].match(/^([^\/?#]+)$/);
+    /twitter.com\/([^\/?#]+)/.exec(args[0]) ||
+    /^([^\/?#]+)$/.exec(args[0]);
   if (!match) {
     return reply('链接格式有误。');
   }
@@ -199,7 +202,7 @@ function query(chat: IChat, args: string[], reply: (msg: string) => any): void {
     norts: '忽略原生转推(on/off)',
   };
   for (const arg of args.slice(1)) {
-    const optMatch = arg.match(/^(count|since|until|noreps|norts)=(.*)/);
+    const optMatch = /^(count|since|until|noreps|norts)=(.*)/.exec(arg);
     if (!optMatch) return reply(`未定义的查询参数:${arg}。`);
     const optKey = optMatch[1] as keyof typeof confZH;
     if (optMatch.length === 1) return reply(`查询${confZH[optKey]}参数格式有误。`);

+ 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');
 }

+ 33 - 44
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 = [
+  'mirai_http_host', 'mirai_http_port', 'mirai_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 = {
@@ -141,10 +130,10 @@ const qq = new QQBot({
 });
 
 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,
+  consumerKey: config.twitter_consumer_key,
+  consumerSecret: config.twitter_consumer_secret,
+  accessTokenKey: config.twitter_access_token_key,
+  accessTokenSecret: config.twitter_access_token_secret,
   lock,
   lockfile: config.lockfile,
   workInterval: config.work_interval,

+ 82 - 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('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('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 => {
@@ -206,7 +207,7 @@ export default class {
 /twitter_view〈链接〉- 查看推文
 /twitter_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitter_query)\
 ${chat.chatType === ChatType.Temp ?
-  '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''
+    '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''
 }`);
           } else if (cmdObj.args[0] === 'twitter_query') {
             msg.reply(`查询时间线中的推文:
@@ -227,45 +228,47 @@ count 与 since/until 并用时,取二者中实际查询结果较少者
  UTC+9" until="2020-01-06 UTC+8" norts=on
     从起始时间点(含)到结束时间点(不含)从新到旧获取最多 5 条推文,\
 其中不包含原生转推(实际上用户只发了 1 条)`)
-            );
+              );
           }
       }
     });
-}
+  };
 
-  // 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}`);

+ 14 - 14
src/model.d.ts

@@ -5,31 +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]:
-      {
-        offset: string,
-        updatedAt: string,
-        subscribers: IChat[],
-      }
-  }
+    {
+      offset: string,
+      updatedAt: string,
+      subscribers: IChat[],
+    },
+  };
 }

+ 107 - 114
src/twitter.ts

@@ -14,10 +14,10 @@ interface IWorkerOption {
   bot: QQBot;
   workInterval: number;
   webshotDelay: number;
-  consumer_key: string;
-  consumer_secret: string;
-  access_token_key: string;
-  access_token_secret: string;
+  consumerKey: string;
+  consumerSecret: string;
+  accessTokenKey: string;
+  accessTokenSecret: string;
   mode: number;
 }
 
@@ -31,13 +31,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);
   }
@@ -64,9 +64,8 @@ export let sendTimeline = (
 };
 
 const TWITTER_EPOCH = 1288834974657;
-const snowflake = (epoch: number) =>
-  Number.isNaN(epoch) ? undefined :
-    BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22);
+const snowflake = (epoch: number) => Number.isNaN(epoch) ? undefined :
+  BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22);
 
 const logger = getLogger('twitter');
 const maxTrials = 3;
@@ -124,10 +123,10 @@ export default class {
 
   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.lockfile = opt.lockfile;
     this.lock = opt.lock;
@@ -138,13 +137,13 @@ export default class {
     ScreenNameNormalizer._queryUser = this.queryUser;
     sendTweet = (id, receiver) => {
       this.getTweet(id, this.sendTweets(`tweet ${id}`, receiver))
-      .catch((err: {code: number, message: string}[]) => {
-        if (err[0].code !== 144) {
-          logger.warn(`error retrieving tweet: ${err[0].message}`);
-          this.bot.sendTo(receiver, `获取推文时出现错误:${err[0].message}`);
-        }
-        this.bot.sendTo(receiver, '找不到请求的推文,它可能已被删除。');
-      });
+        .catch((err: {code: number, message: string}[]) => {
+          if (err[0].code !== 144) {
+            logger.warn(`error retrieving tweet: ${err[0].message}`);
+            this.bot.sendTo(receiver, `获取推文时出现错误:${err[0].message}`);
+          }
+          this.bot.sendTo(receiver, '找不到请求的推文,它可能已被删除。');
+        });
     };
     sendTimeline = ({username, count, since, until, noreps, norts}, receiver) => {
       const countNum = Number(count) || 10;
@@ -156,25 +155,25 @@ export default class {
         noreps: {on: true, off: false}[noreps],
         norts: {on: true, off: false}[norts],
       })
-      .then(tweets => chainPromises(
-        tweets.map(tweet => this.bot.sendTo(receiver, `\
+        .then(tweets => chainPromises(
+          tweets.map(tweet => this.bot.sendTo(receiver, `\
 编号:${tweet.id_str}
 时间:${tweet.created_at}
 媒体:${tweet.extended_entities ? '有' : '无'}
 正文:\n${tweet.full_text.replace(/^([\s\S\n]{50})[\s\S\n]+?( https:\/\/t.co\/.*)?$/, '$1…$2')}`
+          ))
+            .concat(this.bot.sendTo(receiver, tweets.length ?
+              '时间线查询完毕,使用 /twitter_view <编号> 查看推文详细内容。' :
+              '时间线查询完毕,没有找到符合条件的推文。'
+            ))
         ))
-        .concat(this.bot.sendTo(receiver, tweets.length ?
-          '时间线查询完毕,使用 /twitter_view <编号> 查看推文详细内容。' :
-            '时间线查询完毕,没有找到符合条件的推文。'
-        ))
-      ))
-      .catch((err: {code: number, message: string}[]) => {
-        if (err[0]?.code !== 34) {
-          logger.warn(`error retrieving timeline: ${err[0]?.message || err}`);
-          return this.bot.sendTo(receiver, `获取时间线时出现错误:${err[0]?.message || err}`);
-        }
-        this.bot.sendTo(receiver, `找不到用户 ${username.replace(/^@?(.*)$/, '@$1')}。`);
-      });
+        .catch((err: {code: number, message: string}[]) => {
+          if (err[0]?.code !== 34) {
+            logger.warn(`error retrieving timeline: ${err[0]?.message || err}`);
+            return this.bot.sendTo(receiver, `获取时间线时出现错误:${err[0]?.message || err}`);
+          }
+          this.bot.sendTo(receiver, `找不到用户 ${username.replace(/^@?(.*)$/, '@$1')}。`);
+        });
     };
   }
 
@@ -183,35 +182,32 @@ export default class {
       this.mode,
       () => setTimeout(this.work, this.workInterval * 1000)
     );
-  }
+  };
 
-  public queryUser = (username: string) =>
-    this.client.get('users/show', {screen_name: username})
-    .then((user: FullUser) => user.screen_name)
+  public queryUser = (username: string) => this.client.get('users/show', {screen_name: username})
+    .then((user: FullUser) => user.screen_name);
 
   public queryTimelineReverse = (conf: ITimelineQueryConfig) => {
     if (!conf.since) return this.queryTimeline(conf);
     const count = conf.count;
     const maxID = conf.until;
     conf.count = undefined;
-    const until = () =>
-      BigNumOps.min(maxID, BigNumOps.plus(conf.since, String(7 * 24 * 3600 * 1000 * 2 ** 22)));
+    const until = () => BigNumOps.min(maxID, BigNumOps.plus(conf.since, String(7 * 24 * 3600 * 1000 * 2 ** 22)));
     conf.until = until();
-    const promise = (tweets: ITweet[]): Promise<ITweet[]> =>
-      this.queryTimeline(conf).then(newTweets => {
-        tweets = newTweets.concat(tweets);
-        conf.since = conf.until;
-        conf.until = until();
-        if (
-          tweets.length >= count ||
+    const promise = (tweets: ITweet[]): Promise<ITweet[]> =>this.queryTimeline(conf).then(newTweets => {
+      tweets = newTweets.concat(tweets);
+      conf.since = conf.until;
+      conf.until = until();
+      if (
+        tweets.length >= count ||
           BigNumOps.compare(conf.since, conf.until) >= 0
-        ) {
-          return tweets.slice(-count);
-        }
-        return promise(tweets);
-      });
+      ) {
+        return tweets.slice(-count);
+      }
+      return promise(tweets);
+    });
     return promise([]);
-  }
+  };
 
   public queryTimeline = (
     { username, count, since, until, noreps, norts }: ITimelineQueryConfig
@@ -224,7 +220,7 @@ export default class {
         ...(until && {until}),
         ...(noreps && {noreps}),
         ...(norts && {norts}),
-    })}`);
+      })}`);
     const fetchTimeline = (
       config = {
         screen_name: username.slice(1),
@@ -236,27 +232,26 @@ export default class {
         tweet_mode: 'extended',
       },
       tweets: ITweet[] = []
-    ): Promise<ITweet[]> =>
-      this.client.get('statuses/user_timeline', config)
-        .then((newTweets: ITweet[]) => {
-          if (newTweets.length) {
-            logger.debug(`fetched tweets: ${JSON.stringify(newTweets)}`);
-            config.max_id = BigNumOps.plus('-1', newTweets[newTweets.length - 1].id_str);
-            logger.info(`timeline query of ${username} yielded ${
-              newTweets.length
-            } new tweets, next query will start at offset ${config.max_id}`);
-            tweets.push(...newTweets);
-          }
-          if (!newTweets.length || tweets.length >= count) {
-            logger.info(`timeline query of ${username} finished successfully, ${
-              tweets.length
-            } tweets have been fetched`);
-            return tweets.slice(0, count);
-          }
-          return fetchTimeline(config, tweets);
-        });
+    ): Promise<ITweet[]> => this.client.get('statuses/user_timeline', config)
+      .then((newTweets: ITweet[]) => {
+        if (newTweets.length) {
+          logger.debug(`fetched tweets: ${JSON.stringify(newTweets)}`);
+          config.max_id = BigNumOps.plus('-1', newTweets[newTweets.length - 1].id_str);
+          logger.info(`timeline query of ${username} yielded ${
+            newTweets.length
+          } new tweets, next query will start at offset ${config.max_id}`);
+          tweets.push(...newTweets);
+        }
+        if (!newTweets.length || tweets.length >= count) {
+          logger.info(`timeline query of ${username} finished successfully, ${
+            tweets.length
+          } tweets have been fetched`);
+          return tweets.slice(0, count);
+        }
+        return fetchTimeline(config, tweets);
+      });
     return fetchTimeline();
-  }
+  };
 
   private workOnTweets = (
     tweets: Tweets,
@@ -267,20 +262,19 @@ 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(tweets, uploader, sendTweets, this.webshotDelay);
-  }
+  };
 
   public getTweet = (id: string, sender: (msg: MessageChain, text: string, author: string) => void) => {
     const endpoint = 'statuses/show';
@@ -289,29 +283,28 @@ export default class {
       tweet_mode: 'extended',
     };
     return this.client.get(endpoint, config)
-    .then((tweet: Tweet) => {
-      logger.debug(`api returned tweet ${JSON.stringify(tweet)} for query id=${id}`);
-      return this.workOnTweets([tweet], sender);
-    });
-  }
+      .then((tweet: Tweet) => {
+        logger.debug(`api returned tweet ${JSON.stringify(tweet)} for query id=${id}`);
+        return this.workOnTweets([tweet], sender);
+      });
+  };
 
-  private sendTweets = (source?: string, ...to: IChat[]) =>
-  (msg: MessageChain, text: string, author: string) => {
+  private sendTweets = (source?: string, ...to: IChat[]) => (msg: MessageChain, text: string, author: 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` +
+        (_, 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, author + text));
-        }
-      });
+            terminate(this.bot.sendTo(subscriber, author + text));
+          }
+        });
     });
-  }
+  };
 
   public work = () => {
     const lock = this.lock;
@@ -338,8 +331,8 @@ export default class {
     logger.debug(`pulling feed ${currentFeed}`);
 
     const promise = new Promise(resolve => {
-      let match = currentFeed.match(/https:\/\/twitter.com\/([^\/]+)\/lists\/([^\/]+)/);
-      let config: any;
+      let match = /https:\/\/twitter.com\/([^\/]+)\/lists\/([^\/]+)/.exec(currentFeed);
+      let config: {[key: string]: any};
       let endpoint: string;
       if (match) {
         if (match[1] === 'i') {
@@ -356,7 +349,7 @@ export default class {
         }
         endpoint = 'lists/statuses';
       } else {
-        match = currentFeed.match(/https:\/\/twitter.com\/([^\/]+)/);
+        match = /https:\/\/twitter.com\/([^\/]+)/.exec(currentFeed);
         if (match) {
           config = {
             screen_name: match[1],
@@ -370,7 +363,7 @@ export default class {
       if (endpoint) {
         const offset = lock.threads[currentFeed].offset as unknown as number;
         if (offset > 0) config.since_id = offset;
-        this.client.get(endpoint, config, (error, tweets, response) => {
+        this.client.get(endpoint, config, (error: {[key: string]: any}[], tweets, response) => {
           if (error) {
             if (error instanceof Array && error.length > 0 && error[0].code === 34) {
               logger.warn(`error on fetching tweets for ${currentFeed}: ${JSON.stringify(error)}`);
@@ -381,7 +374,7 @@ export default class {
             } else {
               logger.error(`unhandled error on fetching tweets for ${currentFeed}: ${JSON.stringify(error)}`);
             }
-            resolve();
+            resolve([]);
           } else resolve(tweets);
         });
       }
@@ -401,7 +394,7 @@ export default class {
       if (currentThread.offset === '0') tweets.splice(1);
 
       return this.workOnTweets(tweets, this.sendTweets(`thread ${currentFeed}`, ...currentThread.subscribers))
-      .then(updateDate).then(updateOffset);
+        .then(updateDate).then(updateOffset);
     })
       .then(() => {
         lock.workon++;
@@ -412,5 +405,5 @@ export default class {
           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,

+ 64 - 65
src/webshot.ts

@@ -1,3 +1,6 @@
+import { Readable } from 'stream';
+import { promisify } from 'util';
+
 import axios from 'axios';
 import * as CallableInstance from 'callable-instance';
 import { XmlEntities } from 'html-entities';
@@ -5,8 +8,6 @@ import { PNG } from 'pngjs';
 import * as puppeteer from 'puppeteer';
 import { Browser } from 'puppeteer';
 import * as sharp from 'sharp';
-import { Readable } from 'stream';
-import { promisify } from 'util';
 
 import gifski from './gifski';
 import { getLogger } from './loggers';
@@ -29,18 +30,17 @@ const typeInZH = {
 
 const logger = getLogger('webshot');
 
-class Webshot
-extends CallableInstance<
-  [Tweets, (...args) => Promise<any>, (...args) => void, number],
-  Promise<void>
-> {
+class Webshot extends CallableInstance<[
+  Tweets, (...args) => Promise<any>, (...args) => void, number
+], Promise<void>> {
 
   private browser: Browser;
   private mode: number;
 
-  constructor(mode: number, onready?: () => any) {
+  constructor(mode: number, onready?: (...args) => void) {
     super('webshot');
     // tslint:disable-next-line: no-conditional-assignment
+    // eslint-disable-next-line no-cond-assign
     if (this.mode = mode) {
       onready();
     } else {
@@ -49,24 +49,25 @@ extends CallableInstance<
   }
 
   // use local Chromium
-  private connect = (onready) => puppeteer.connect({browserURL: 'http://127.0.0.1:9222'})
-  .then(browser => this.browser = browser)
-  .then(() => {
-    logger.info('launched puppeteer browser');
-    if (onready) return onready();
-  })
-  .catch(error => this.reconnect(error, onready))
+  private connect = (onready: (...args) => void): Promise<void> =>
+    puppeteer.connect({browserURL: 'http://127.0.0.1:9222'})
+      .then(browser => this.browser = browser)
+      .then(() => {
+        logger.info('launched puppeteer browser');
+        if (onready) return onready();
+      })
+      .catch(error => this.reconnect(error, onready));
 
-  private reconnect = (error, onready?) => {
+  private reconnect = (error, onready?: (...args) => void) => {
     logger.error(`connection error, reason: ${error}`);
     logger.warn('trying to reconnect in 2.5s...');
     return promisify(setTimeout)(2500)
-    .then(() => this.connect(onready));
-  }
+      .then(() => this.connect(onready));
+  };
 
   private extendEntity = (media: MediaEntity) => {
     logger.info('not working on a tweet');
-  }
+  };
 
   private renderWebshot = (url: string, height: number, webshotDelay: number): Promise<string> => {
     const jpeg = (data: Readable) => data.pipe(sharp()).jpeg({quality: 90, trellisQuantisation: true});
@@ -118,8 +119,7 @@ extends CallableInstance<
             .then(() => page.evaluate(() => {
               const cardImg = document.querySelector('div[data-testid^="card.layout"][data-testid$=".media"] img');
               if (typeof cardImg?.getAttribute('src') === 'string') {
-                const match = cardImg?.getAttribute('src')
-                  .match(/^(.*\/card_img\/(\d+)\/.+\?format=.*)&name=/);
+                const match = /^(.*\/card_img\/(\d+)\/.+\?format=.*)&name=/.exec(cardImg?.getAttribute('src'));
                 if (match) {
                   // tslint:disable-next-line: variable-name
                   const [media_url_https, id_str] = match.slice(1);
@@ -137,7 +137,9 @@ extends CallableInstance<
                 }
               }
             }))
-            .then(cardImg => { if (cardImg) this.extendEntity(cardImg); })
+            .then(cardImg => {
+              if (cardImg) this.extendEntity(cardImg); 
+            })
             .then(() => page.addScriptTag({
               content: 'document.documentElement.scrollTop=0;',
             }))
@@ -150,8 +152,9 @@ extends CallableInstance<
               }).on('parsed', function () {
                 // remove comment area
                 // tslint:disable-next-line: no-shadowed-variable
+                // eslint-disable-next-line @typescript-eslint/no-shadow
                 const idx = (x: number, y: number) => (this.width * y + x) << 2;
-                let boundary = null;
+                let boundary: number = null;
                 let x = zoomFactor * 2;
                 for (let y = 0; y < this.height; y++) {
                   if (
@@ -192,19 +195,19 @@ extends CallableInstance<
                       const b = y + 1;
                       if (this.height - boundary - (boundary - b) <= 1) {
                         boundary = b;
-                    //   }
-                    // }
+                        //   }
+                        // }
 
-                    // // if "retweet" count and "like" count are two rows, this will be the line above the first
-                    // if (cnt === 6) {
-                    //   const c = y + 1;
-                    //   if (this.height - boundary - 2 * (boundary - c) <= 2) {
-                    //     boundary = c;
+                        // // if "retweet" count and "like" count are two rows, this will be the line above the first
+                        // if (cnt === 6) {
+                        //   const c = y + 1;
+                        //   if (this.height - boundary - 2 * (boundary - c) <= 2) {
+                        //     boundary = c;
                         break;
                       }
                     }
                   }
-                  if (boundary != null) {
+                  if (boundary !== null) {
                     logger.info(`found boundary at ${boundary}, trimming image`);
                     this.data = this.data.slice(0, idx(this.width, boundary));
                     this.height = boundary;
@@ -226,26 +229,25 @@ extends CallableInstance<
               }).parse(screenshot);
             })
             .catch(err => {
-              if (err.name !== 'TimeoutError') throw err;
+              if (err instanceof Error && err.name !== 'TimeoutError') throw err;
               logger.error(`error shooting webshot for ${url}, could not load web page of tweet`);
               resolve({base64: '', boundary: 0});
             })
-            .finally(() => page.close());
+            .finally(() => { page.close(); });
         })
         .catch(reject);
     });
     return promise.then(data => {
       if (data.boundary === null) return this.renderWebshot(url, height + 1920, webshotDelay);
       else return data.base64;
-    }).catch(error =>
-      new Promise(resolve => this.reconnect(error, resolve))
+    }).catch(error => this.reconnect(error)
       .then(() => this.renderWebshot(url, height, webshotDelay))
     );
-  }
+  };
 
   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;
@@ -263,47 +265,45 @@ 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(
     tweets: Tweets,
     uploader: (
       img: ReturnType<typeof Message.Image>,
-      lastResort: (...args) => ReturnType<typeof Message.Plain>)
-      => Promise<ReturnType<typeof Message.Image | typeof Message.Plain>>,
+      lastResort: (...args) => ReturnType<typeof Message.Plain>
+    ) => Promise<ReturnType<typeof Message.Image | typeof Message.Plain>>,
     callback: (msgs: MessageChain, text: string, author: string) => void,
     webshotDelay: number
   ): Promise<void> {
@@ -360,6 +360,7 @@ extends CallableInstance<
       }
       // fetch extra entities
       // tslint:disable-next-line: curly
+      // eslint-disable-next-line curly
       if (1 - this.mode % 2) promise = promise.then(() => {
         if (originTwi.extended_entities) {
           return chainPromises(originTwi.extended_entities.media.map(media => {
@@ -372,7 +373,7 @@ extends CallableInstance<
                 .sort((var1, var2) => var2.bitrate - var1.bitrate)
                 .map(variant => variant.url)[0]; // largest video
             }
-            const altMessage = Message.Plain(`\n[失败的${typeInZH[media.type].type}:${url}]`);
+            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)
@@ -381,9 +382,7 @@ extends CallableInstance<
                 logger.warn('unable to fetch media, sending plain text instead...');
                 return altMessage;
               })
-              .then(msg => {
-                messageChain.push(msg);
-              });
+              .then(msg => { messageChain.push(msg); });
           }));
         }
       });

+ 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
   }
 }