Переглянути джерело

support querying timeline history; some refactorings

Mike L 4 роки тому
батько
коміт
5d9385b020
14 змінених файлів з 581 додано та 109 видалено
  1. 63 2
      dist/command.js
  2. 0 22
      dist/helper.js
  3. 34 8
      dist/mirai.js
  4. 81 19
      dist/twitter.js
  5. 29 0
      dist/twitter_test.js
  6. 48 0
      dist/utils.js
  7. 2 2
      dist/webshot.js
  8. 71 3
      src/command.ts
  9. 0 24
      src/helper.ts
  10. 35 9
      src/mirai.ts
  11. 129 17
      src/twitter.ts
  12. 31 0
      src/twitter_test.js
  13. 57 0
      src/utils.ts
  14. 1 3
      src/webshot.ts

+ 63 - 2
dist/command.js

@@ -1,12 +1,33 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
-exports.view = exports.unsub = exports.list = exports.sub = void 0;
+exports.query = exports.view = exports.unsub = exports.list = exports.sub = exports.parseCmd = void 0;
 const fs = require("fs");
 const path = require("path");
 const datetime_1 = require("./datetime");
 const loggers_1 = require("./loggers");
 const twitter_1 = require("./twitter");
+const utils_1 = require("./utils");
 const logger = loggers_1.getLogger('command');
+function parseCmd(message) {
+    message = message.trim();
+    message = message.replace('\\\\', '\\0x5c');
+    message = message.replace('\\\"', '\\0x22');
+    message = message.replace('\\\'', '\\0x27');
+    const strs = message.match(/'[\s\S]*?'|(?:\S+=)?"[\s\S]*?"|\S+/mg);
+    const cmd = (strs === null || strs === void 0 ? void 0 : strs.length) ? strs[0].length ? strs[0].substring(0, 1) === '/' ? strs[0].substring(1) : '' : '' : '';
+    const args = (strs !== null && strs !== void 0 ? strs : []).slice(1).map(arg => {
+        arg = arg.replace(/^(\S+=)?["']+(?!.*=)|["']+$/g, '$1');
+        arg = arg.replace('\\0x27', '\\\'');
+        arg = arg.replace('\\0x22', '\\\"');
+        arg = arg.replace('\\0x5c', '\\\\');
+        return arg;
+    });
+    return {
+        cmd,
+        args,
+    };
+}
+exports.parseCmd = parseCmd;
 function parseLink(link) {
     let match = link.match(/twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/) ||
         link.match(/^([^\/?#]+)\/([^\/?#]+)$/);
@@ -56,7 +77,7 @@ https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
     if (match[1]) {
         const matchStatus = match[1].match(/\/status\/(\d+)/);
         if (matchStatus) {
-            offset = twitter_1.bigNumPlus(matchStatus[1], '-1');
+            offset = utils_1.BigNumOps.plus(matchStatus[1], '-1');
             delete match[1];
         }
     }
@@ -146,3 +167,43 @@ function view(chat, args, reply) {
     }
 }
 exports.view = view;
+function query(chat, args, reply) {
+    if (args.length === 0) {
+        return reply('找不到要查询的用户。');
+    }
+    const match = args[0].match(/twitter.com\/([^\/?#]+)/) ||
+        args[0].match(/^([^\/?#]+)$/);
+    if (!match) {
+        return reply('链接格式有误。');
+    }
+    const conf = { username: match[1], noreps: 'on', norts: 'off' };
+    const confZH = {
+        count: '数量上限',
+        since: '起始点',
+        until: '结束点',
+        noreps: '忽略回复推文(on/off)',
+        norts: '忽略原生转推(on/off)',
+    };
+    for (const arg of args.slice(1)) {
+        const optMatch = arg.match(/^(count|since|until|noreps|norts)=(.*)/);
+        if (!optMatch)
+            return reply(`未定义的查询参数:${arg}。`);
+        const optKey = optMatch[1];
+        if (optMatch.length === 1)
+            return reply(`查询${confZH[optKey]}参数格式有误。`);
+        conf[optKey] = optMatch[2];
+        if (optMatch[2] === '')
+            return reply(`查询${confZH[optKey]}参数值不可为空。`);
+    }
+    if (conf.count !== undefined && !Number(conf.count) || Math.abs(Number(conf.count)) > 50) {
+        return reply('查询数量上限参数为零、非数值或超出取值范围。');
+    }
+    try {
+        twitter_1.sendTimeline(conf, chat);
+    }
+    catch (e) {
+        logger.error(`error querying timeline, error: ${e}`);
+        reply('推特机器人尚未加载完毕,请稍后重试。');
+    }
+}
+exports.query = query;

+ 0 - 22
dist/helper.js

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

+ 34 - 8
dist/mirai.js

@@ -16,7 +16,6 @@ const mirai_ts_1 = require("mirai-ts");
 const message_1 = require("mirai-ts/dist/message");
 const temp = require("temp");
 const command_1 = require("./command");
-const helper_1 = require("./helper");
 const loggers_1 = require("./loggers");
 const logger = loggers_1.getLogger('qqbot');
 exports.Message = message_1.default;
@@ -152,12 +151,16 @@ class default_1 {
             });
             this.bot.on('message', (msg) => __awaiter(this, void 0, void 0, function* () {
                 const chat = yield this.getChat(msg);
-                const cmdObj = helper_1.default(msg.plain);
+                const cmdObj = command_1.parseCmd(msg.plain);
                 switch (cmdObj.cmd) {
                     case 'twitterpic_view':
                     case 'twitterpic_get':
                         command_1.view(chat, cmdObj.args, msg.reply);
                         break;
+                    case 'twitterpic_query':
+                    case 'twitterpic_gettimeline':
+                        command_1.query(chat, cmdObj.args, msg.reply);
+                        break;
                     case 'twitterpic_sub':
                     case 'twitterpic_subscribe':
                         this.botInfo.sub(chat, cmdObj.args, msg.reply);
@@ -171,13 +174,36 @@ class default_1 {
                         this.botInfo.list(chat, cmdObj.args, msg.reply);
                         break;
                     case 'help':
-                        msg.reply(`推特媒体推文搬运机器人:
+                        if (cmdObj.args.length === 0) {
+                            msg.reply(`推特媒体推文搬运机器人:
 /twitterpic - 查询当前聊天中的媒体推文订阅
-/twitterpic_subscribe [链接] - 订阅 Twitter 媒体推文搬运
-/twitterpic_unsubscribe [链接] - 退订 Twitter 媒体推文搬运
-/twitterpic_view [链接] - 查看推文(无关是否包含媒体)
-${chat.chatType === "temp" /* Temp */ &&
-                            '(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)'}`);
+/twitterpic_subscribe〈链接|用户名〉- 订阅 Twitter 媒体推文搬运
+/twitterpic_unsubscribe〈链接|用户名〉- 退订 Twitter 媒体推文搬运
+/twitterpic_view〈链接〉- 查看推文(无关是否包含媒体)
+/twitterpic_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitterpic_query)\
+${chat.chatType === "temp" /* Temp */ ?
+                                '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''}`);
+                        }
+                        else if (cmdObj.args[0] === 'twitterpic_query') {
+                            msg.reply(`查询时间线中的媒体推文:
+/twitterpic_query〈链接|用户名〉[〈参数 1〉=〈值 1〉〈参数 2〉=〈值 2〉...]
+
+参数列表(方框内全部为可选,留空则为默认):
+    count:查询数量上限(类型:非零整数,最大值正负 50)[默认值:10]
+    since:查询起始点(类型:正整数或日期)[默认值:(空,无限过去)]
+    until:查询结束点(类型:正整数或日期)[默认值:(空,当前时刻)]
+    noreps 忽略回复推文(类型:on/off)[默认值:on(是)]
+    norts:忽略原生转推(类型:on/off)[默认值:off(否)]`)
+                                .then(() => msg.reply(`\
+起始点和结束点为正整数时取推特推文编号作为比较基准,否则会尝试作为日期读取。
+推荐的日期格式:2012-12-22 12:22 UTC+2 (日期和时间均为可选,可分别添加)
+count 为正时,从新向旧查询;为负时,从旧向新查询
+count 与 since/until 并用时,取二者中实际查询结果较少者
+例子:/twitterpic_query RiccaTachibana count=5 since="2019-12-30\
+ UTC+9" until="2020-01-06 UTC+8" norts=on
+    从起始时间点(含)到结束时间点(不含)从新到旧获取最多 5 条媒体推文,\
+其中不包含原生转推(实际上用户只发了 1 条)`));
+                        }
                 }
             }));
         };

+ 81 - 19
dist/twitter.js

@@ -9,11 +9,12 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
     });
 };
 Object.defineProperty(exports, "__esModule", { value: true });
-exports.sendTweet = exports.bigNumPlus = exports.ScreenNameNormalizer = void 0;
+exports.sendTimeline = exports.sendTweet = exports.ScreenNameNormalizer = void 0;
 const fs = require("fs");
 const path = require("path");
 const Twitter = require("twitter");
 const loggers_1 = require("./loggers");
+const utils_1 = require("./utils");
 const webshot_1 = require("./webshot");
 class ScreenNameNormalizer {
     static normalizeLive(username) {
@@ -34,31 +35,21 @@ class ScreenNameNormalizer {
 }
 exports.ScreenNameNormalizer = ScreenNameNormalizer;
 ScreenNameNormalizer.normalize = (username) => username.toLowerCase().replace(/^@/, '');
-exports.bigNumPlus = (num1, num2) => {
-    const split = (num) => num.replace(/^(-?)(\d+)(\d{15})$/, '$1$2,$1$3')
-        .replace(/^([^,]*)$/, '0,$1').split(',')
-        .map(Number);
-    let [high, low] = [split(num1), split(num2)].reduce((a, b) => [a[0] + b[0], a[1] + b[1]]);
-    const [highSign, lowSign] = [high, low].map(Math.sign);
-    if (highSign === 0)
-        return low.toString();
-    if (highSign !== lowSign) {
-        [high, low] = [high - highSign, low - lowSign * Math.pow(10, 15)];
-    }
-    else {
-        [high, low] = [high + ~~(low / Math.pow(10, 15)), low % Math.pow(10, 15)];
-    }
-    return `${high}${Math.abs(low).toString().padStart(15, '0')}`;
-};
 exports.sendTweet = (id, receiver) => {
     throw Error();
 };
+exports.sendTimeline = (conf, receiver) => {
+    throw Error();
+};
+const TWITTER_EPOCH = 1288834974657;
+const snowflake = (epoch) => Number.isNaN(epoch) ? undefined :
+    utils_1.BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22);
 const logger = loggers_1.getLogger('twitter');
 const maxTrials = 3;
 const uploadTimeout = 10000;
 const retryInterval = 1500;
 const ordinal = (n) => {
-    switch ((~~(n / 10) % 10 === 1) ? 0 : n % 10) {
+    switch ((Math.trunc(n / 10) % 10 === 1) ? 0 : n % 10) {
         case 1:
             return `${n}st`;
         case 2:
@@ -87,6 +78,51 @@ class default_1 {
         };
         this.queryUser = (username) => this.client.get('users/show', { screen_name: username })
             .then((user) => user.screen_name);
+        this.queryTimelineReverse = (conf) => {
+            if (!conf.since)
+                return this.queryTimeline(conf);
+            const count = conf.count;
+            const maxID = conf.until;
+            conf.count = undefined;
+            const until = () => utils_1.BigNumOps.min(maxID, utils_1.BigNumOps.plus(conf.since, String(7 * 24 * 3600 * 1000 * Math.pow(2, 22))));
+            conf.until = until();
+            const promise = (tweets) => this.queryTimeline(conf).then(newTweets => {
+                tweets = newTweets.concat(tweets);
+                conf.since = conf.until;
+                conf.until = until();
+                if (tweets.length >= count ||
+                    utils_1.BigNumOps.compare(conf.since, conf.until) >= 0) {
+                    return tweets.slice(-count);
+                }
+                return promise(tweets);
+            });
+            return promise([]);
+        };
+        this.queryTimeline = ({ username, count, since, until, noreps, norts }) => {
+            username = username.replace(/^@?(.*)$/, '@$1');
+            logger.info(`querying timeline of ${username} with config: ${JSON.stringify(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, (count && { count })), (since && { since })), (until && { until })), (noreps && { noreps })), (norts && { norts })))}`);
+            const fetchTimeline = (config = {
+                screen_name: username.slice(1),
+                trim_user: true,
+                exclude_replies: noreps !== null && noreps !== void 0 ? noreps : true,
+                include_rts: !(norts !== null && norts !== void 0 ? norts : false),
+                since_id: since,
+                max_id: until,
+            }, tweets = []) => this.client.get('statuses/user_timeline', config)
+                .then((newTweets) => {
+                if (newTweets.length) {
+                    config.max_id = utils_1.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.filter(tweet => tweet.extended_entities));
+                }
+                if (!newTweets.length || tweets.length >= count) {
+                    logger.info(`timeline query of ${username} finished successfully, ${tweets.length} tweets with extended entities have been fetched`);
+                    return tweets.slice(0, count);
+                }
+                return fetchTimeline(config, tweets);
+            });
+            return fetchTimeline();
+        };
         this.workOnTweets = (tweets, sendTweets) => {
             const uploader = (message, lastResort) => {
                 let timeout = uploadTimeout;
@@ -231,7 +267,7 @@ class default_1 {
                 }
                 if (currentThread.offset <= 0) {
                     if (tweets.length === 0) {
-                        setOffset('-' + bottomOfFeed);
+                        setOffset(utils_1.BigNumOps.plus('1', '-' + bottomOfFeed));
                         lock.workon--;
                         return;
                     }
@@ -279,6 +315,32 @@ class default_1 {
                 this.bot.sendTo(receiver, '找不到请求的推文,它可能已被删除。');
             });
         };
+        exports.sendTimeline = ({ username, count, since, until, noreps, norts }, receiver) => {
+            const countNum = Number(count) || 10;
+            (countNum > 0 ? this.queryTimeline : this.queryTimelineReverse)({
+                username,
+                count: Math.abs(countNum),
+                since: utils_1.BigNumOps.parse(since) || snowflake(new Date(since).getTime()),
+                until: utils_1.BigNumOps.parse(until) || snowflake(new Date(until).getTime()),
+                noreps: { on: true, off: false }[noreps],
+                norts: { on: true, off: false }[norts],
+            })
+                .then(tweets => utils_1.chainPromises(tweets.map(tweet => this.bot.sendTo(receiver, `\
+编号:${tweet.id_str}
+时间:${tweet.created_at}
+媒体:${tweet.extended_entities ? '有' : '无'}
+正文:\n${tweet.text}`))
+                .concat(this.bot.sendTo(receiver, tweets.length ?
+                '时间线查询完毕,使用 /twitterpic_view <编号> 查看媒体推文详细内容。' :
+                '时间线查询完毕,没有找到符合条件的媒体推文。'))))
+                .catch((err) => {
+                if (err[0].code !== 34) {
+                    logger.warn(`error retrieving timeline: ${err[0].message}`);
+                    return this.bot.sendTo(receiver, `获取时间线时出现错误:${err[0].message}`);
+                }
+                this.bot.sendTo(receiver, `找不到用户 ${username.replace(/^@?(.*)$/, '@$1')}。`);
+            });
+        };
     }
 }
 exports.default = default_1;

+ 29 - 0
dist/twitter_test.js

@@ -0,0 +1,29 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+const path = require("path");
+const twitter_1 = require("./twitter");
+const webshot_1 = require("./webshot");
+const configPath = './config.json';
+let worker;
+try {
+    const config = require(path.resolve(configPath));
+    worker = new twitter_1.default(Object.fromEntries(Object.entries(config).map(([k, v]) => [k.replace('twitter_', ''), v])));
+}
+catch (e) {
+    console.log('Failed to parse config file: ', configPath);
+    process.exit(1);
+}
+const webshot = new webshot_1.default(worker.mode, () => {
+    worker.webshot = webshot;
+    worker.getTweet('1296935552848035840', (msg, text, author) => {
+        console.log(author + text);
+        console.log(JSON.stringify(msg));
+    }).catch(console.log);
+    worker.getTweet('1296935552848035841', (msg, text, author) => {
+        console.log(author + text);
+        console.log(JSON.stringify(msg));
+    }).catch(console.log);
+});
+worker.queryUser('tomoyokurosawa').then(console.log).catch(console.log);
+worker.queryUser('tomoyourosawa').then(console.log).catch(console.log);
+worker.queryUser('@tomoyokurosawa').then(console.log).catch(console.log);

+ 48 - 0
dist/utils.js

@@ -0,0 +1,48 @@
+"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 splitBigNumAt = (num, at) => num.replace(RegExp(String.raw `^([+-]?)(\d+)(\d{${at}})$`), '$1$2,$1$3')
+    .replace(/^([^,]*)$/, '0,$1').split(',')
+    .map(Number);
+const bigNumPlus = (num1, num2) => {
+    let [high, low] = [splitBigNumAt(num1, 15), splitBigNumAt(num2, 15)]
+        .reduce((a, b) => [a[0] + b[0], a[1] + b[1]]);
+    const [highSign, lowSign] = [high, low].map(Math.sign);
+    if (highSign === 0)
+        return low.toString();
+    if (highSign !== lowSign) {
+        [high, low] = [high - highSign, low - lowSign * Math.pow(10, 15)];
+    }
+    else {
+        [high, low] = [high + Math.trunc(low / Math.pow(10, 15)), low % Math.pow(10, 15)];
+    }
+    return `${high}${Math.abs(low).toString().padStart(15, '0')}`;
+};
+const bigNumCompare = (num1, num2) => Math.sign(Number(bigNumPlus(num1, num2.replace(/^([+-]?)(\d+)/, (_, $1, $2) => `${$1 === '-' ? '' : '-'}${$2}`))));
+const bigNumMin = (...nums) => {
+    if (!nums || !nums.length)
+        return undefined;
+    let min = nums[0];
+    for (let i = 1; i < nums.length; i++) {
+        if (bigNumCompare(nums[i], min) < 0)
+            min = nums[i];
+    }
+    return min;
+};
+const bigNumLShift = (num, by) => {
+    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 * Math.pow(2, by));
+    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');
+exports.BigNumOps = {
+    splitAt: splitBigNumAt,
+    plus: bigNumPlus,
+    compare: bigNumCompare,
+    min: bigNumMin,
+    lShift: bigNumLShift,
+    parse: parseBigNum,
+};

+ 2 - 2
dist/webshot.js

@@ -19,8 +19,8 @@ const util_1 = require("util");
 const gifski_1 = require("./gifski");
 const loggers_1 = require("./loggers");
 const mirai_1 = require("./mirai");
+const utils_1 = require("./utils");
 const xmlEntities = new html_entities_1.XmlEntities();
-const chainPromises = (promises) => promises.reduce((p1, p2) => p1.then(() => p2), Promise.resolve());
 const ZHType = (type) => new class extends String {
     constructor() {
         super(...arguments);
@@ -345,7 +345,7 @@ class Webshot extends CallableInstance {
             if (1 - this.mode % 2)
                 promise = promise.then(() => {
                     if (originTwi.extended_entities) {
-                        return chainPromises(originTwi.extended_entities.media.map(media => {
+                        return utils_1.chainPromises(originTwi.extended_entities.media.map(media => {
                             let url;
                             if (media.type === 'photo') {
                                 url = media.media_url_https.replace(/\.([a-z]+)$/, '?format=$1') + '&name=orig';

+ 71 - 3
src/command.ts

@@ -3,10 +3,34 @@ import * as path from 'path';
 
 import { relativeDate } from './datetime';
 import { getLogger } from './loggers';
-import { bigNumPlus, sendTweet, ScreenNameNormalizer as normalizer } from './twitter';
+import { sendTimeline, sendTweet, ScreenNameNormalizer as normalizer } from './twitter';
+import { BigNumOps } from './utils';
 
 const logger = getLogger('command');
 
+function parseCmd(message: string): {
+  cmd: string;
+  args: string[];
+} {
+  message = message.trim();
+  message = message.replace('\\\\', '\\0x5c');
+  message = message.replace('\\\"', '\\0x22');
+  message = message.replace('\\\'', '\\0x27');
+  const strs = message.match(/'[\s\S]*?'|(?:\S+=)?"[\s\S]*?"|\S+/mg);
+  const cmd = strs?.length ? strs[0].length ? strs[0].substring(0, 1) === '/' ? strs[0].substring(1) : '' : '' : '';
+  const args = (strs ?? []).slice(1).map(arg => {
+    arg = arg.replace(/^(\S+=)?["']+(?!.*=)|["']+$/g, '$1');
+    arg = arg.replace('\\0x27', '\\\'');
+    arg = arg.replace('\\0x22', '\\\"');
+    arg = arg.replace('\\0x5c', '\\\\');
+    return arg;
+  });
+  return {
+    cmd,
+    args,
+  };
+}
+
 function parseLink(link: string): string[] {
   let match =
     link.match(/twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/) ||
@@ -61,7 +85,7 @@ https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
   if (match[1]) {
     const matchStatus = match[1].match(/\/status\/(\d+)/);
     if (matchStatus) {
-      offset = bigNumPlus(matchStatus[1], '-1');
+      offset = BigNumOps.plus(matchStatus[1], '-1');
       delete match[1];
     }
   }
@@ -149,4 +173,48 @@ function view(chat: IChat, args: string[], reply: (msg: string) => any): void {
   }
 }
 
-export { sub, list, unsub, view };
+function query(chat: IChat, args: string[], reply: (msg: string) => any): void {
+  if (args.length === 0) {
+    return reply('找不到要查询的用户。');
+  }
+  const match = 
+    args[0].match(/twitter.com\/([^\/?#]+)/) ||
+    args[0].match(/^([^\/?#]+)$/);
+  if (!match) {
+    return reply('链接格式有误。');
+  }
+  const conf: {
+    username: string,
+    count?: string,
+    since?: string,
+    until?: string,
+    noreps: string,
+    norts: string,
+  } = {username: match[1], noreps: 'on', norts: 'off'};
+  const confZH: Record<Exclude<keyof typeof conf, 'username'>, string> = {
+    count: '数量上限',
+    since: '起始点',
+    until: '结束点',
+    noreps: '忽略回复推文(on/off)',
+    norts: '忽略原生转推(on/off)',
+  };
+  for (const arg of args.slice(1)) {
+    const optMatch = arg.match(/^(count|since|until|noreps|norts)=(.*)/);
+    if (!optMatch) return reply(`未定义的查询参数:${arg}。`);
+    const optKey = optMatch[1] as keyof typeof confZH;
+    if (optMatch.length === 1) return reply(`查询${confZH[optKey]}参数格式有误。`);
+    conf[optKey] = optMatch[2];
+    if (optMatch[2] === '') return reply(`查询${confZH[optKey]}参数值不可为空。`);
+  }
+  if (conf.count !== undefined && !Number(conf.count) || Math.abs(Number(conf.count)) > 50) {
+    return reply('查询数量上限参数为零、非数值或超出取值范围。');
+  }
+  try {
+    sendTimeline(conf, chat);
+  } catch (e) {
+    logger.error(`error querying timeline, error: ${e}`);
+    reply('推特机器人尚未加载完毕,请稍后重试。');
+  }
+}
+
+export { parseCmd, sub, list, unsub, view, query };

+ 0 - 24
src/helper.ts

@@ -1,24 +0,0 @@
-interface ICommand {
-  cmd: string;
-  args: string[];
-}
-
-export default function (message: string): ICommand {
-  message = message.trim();
-  message = message.replace('\\\\', '\\0x5c');
-  message = message.replace('\\\"', '\\0x22');
-  message = message.replace('\\\'', '\\0x27');
-  const strs = message.match(/'[\s\S]*?'|"[\s\S]*?"|\S*\[CQ:[\s\S]*?\]\S*|\S+/mg);
-  const cmd = strs?.length ? strs[0].length ? strs[0].substring(0, 1) === '/' ? strs[0].substring(1) : '' : '' : '';
-  const args = strs?.slice(1).map(arg => {
-    arg = arg.replace(/^["']+|["']+$/g, '');
-    arg = arg.replace('\\0x27', '\\\'');
-    arg = arg.replace('\\0x22', '\\\"');
-    arg = arg.replace('\\0x5c', '\\\\');
-    return arg;
-  });
-  return {
-    cmd,
-    args,
-  };
-}

+ 35 - 9
src/mirai.ts

@@ -4,8 +4,7 @@ import Mirai, { MessageType } from 'mirai-ts';
 import MiraiMessage from 'mirai-ts/dist/message';
 import * as temp from 'temp';
 
-import { view } from './command';
-import command from './helper';
+import { parseCmd, query, view } from './command';
 import { getLogger } from './loggers';
 
 const logger = getLogger('qqbot');
@@ -176,12 +175,16 @@ export default class {
 
     this.bot.on('message', async msg => {
       const chat = await this.getChat(msg);
-      const cmdObj = command(msg.plain);
+      const cmdObj = parseCmd(msg.plain);
       switch (cmdObj.cmd) {
         case 'twitterpic_view':
         case 'twitterpic_get':
           view(chat, cmdObj.args, msg.reply);
           break;
+        case 'twitterpic_query':
+        case 'twitterpic_gettimeline':
+          query(chat, cmdObj.args, msg.reply);
+          break;
         case 'twitterpic_sub':
         case 'twitterpic_subscribe':
           this.botInfo.sub(chat, cmdObj.args, msg.reply);
@@ -195,14 +198,37 @@ export default class {
           this.botInfo.list(chat, cmdObj.args, msg.reply);
           break;
         case 'help':
-          msg.reply(`推特媒体推文搬运机器人:
+          if (cmdObj.args.length === 0) {
+            msg.reply(`推特媒体推文搬运机器人:
 /twitterpic - 查询当前聊天中的媒体推文订阅
-/twitterpic_subscribe [链接] - 订阅 Twitter 媒体推文搬运
-/twitterpic_unsubscribe [链接] - 退订 Twitter 媒体推文搬运
-/twitterpic_view [链接] - 查看推文(无关是否包含媒体)
-${chat.chatType === ChatType.Temp &&
-  '(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)'
+/twitterpic_subscribe〈链接|用户名〉- 订阅 Twitter 媒体推文搬运
+/twitterpic_unsubscribe〈链接|用户名〉- 退订 Twitter 媒体推文搬运
+/twitterpic_view〈链接〉- 查看推文(无关是否包含媒体)
+/twitterpic_query〈链接|用户名〉[参数列表...] - 查询时间线(详见 /help twitterpic_query)\
+${chat.chatType === ChatType.Temp ?
+  '\n(当前游客模式下无法使用订阅功能,请先添加本账号为好友。)' : ''
 }`);
+          } else if (cmdObj.args[0] === 'twitterpic_query') {
+            msg.reply(`查询时间线中的媒体推文:
+/twitterpic_query〈链接|用户名〉[〈参数 1〉=〈值 1〉〈参数 2〉=〈值 2〉...]
+
+参数列表(方框内全部为可选,留空则为默认):
+    count:查询数量上限(类型:非零整数,最大值正负 50)[默认值:10]
+    since:查询起始点(类型:正整数或日期)[默认值:(空,无限过去)]
+    until:查询结束点(类型:正整数或日期)[默认值:(空,当前时刻)]
+    noreps 忽略回复推文(类型:on/off)[默认值:on(是)]
+    norts:忽略原生转推(类型:on/off)[默认值:off(否)]`)
+              .then(() => msg.reply(`\
+起始点和结束点为正整数时取推特推文编号作为比较基准,否则会尝试作为日期读取。
+推荐的日期格式:2012-12-22 12:22 UTC+2 (日期和时间均为可选,可分别添加)
+count 为正时,从新向旧查询;为负时,从旧向新查询
+count 与 since/until 并用时,取二者中实际查询结果较少者
+例子:/twitterpic_query RiccaTachibana count=5 since="2019-12-30\
+ UTC+9" until="2020-01-06 UTC+8" norts=on
+    从起始时间点(含)到结束时间点(不含)从新到旧获取最多 5 条媒体推文,\
+其中不包含原生转推(实际上用户只发了 1 条)`)
+            );
+          }
       }
     });
 }

+ 129 - 17
src/twitter.ts

@@ -5,6 +5,7 @@ import TwitterTypes from 'twitter-d';
 
 import { getLogger } from './loggers';
 import QQBot, { Message, MessageChain } from './mirai';
+import { chainPromises, BigNumOps } from './utils';
 import Webshot from './webshot';
 
 interface IWorkerOption {
@@ -42,32 +43,37 @@ export class ScreenNameNormalizer {
   }
 }
 
-export const bigNumPlus = (num1: string, num2: string) => {
-  const split = (num: string) =>
-    num.replace(/^(-?)(\d+)(\d{15})$/, '$1$2,$1$3')
-    .replace(/^([^,]*)$/, '0,$1').split(',')
-    .map(Number);
-  let [high, low] = [split(num1), split(num2)].reduce((a, b) => [a[0] + b[0], a[1] + b[1]]);
-  const [highSign, lowSign] = [high, low].map(Math.sign);
-  if (highSign === 0) return low.toString();
-  if (highSign !== lowSign) {
-    [high, low] = [high - highSign, low - lowSign * 10 ** 15];
-  } else {
-    [high, low] = [high + ~~(low / 10 ** 15), low % 10 ** 15];
-  }
-  return `${high}${Math.abs(low).toString().padStart(15, '0')}`;
+export let sendTweet = (id: string, receiver: IChat): void => {
+  throw Error();
 };
 
-export let sendTweet = (id: string, receiver: IChat): void => {
+export interface ITimelineQueryConfig {
+  username: string;
+  count?: number;
+  since?: string;
+  until?: string;
+  noreps?: boolean;
+  norts?: boolean;
+}
+
+export let sendTimeline = (
+  conf: {[key in keyof ITimelineQueryConfig]: string},
+  receiver: IChat
+): void => {
   throw Error();
 };
 
+const TWITTER_EPOCH = 1288834974657;
+const snowflake = (epoch: number) =>
+  Number.isNaN(epoch) ? undefined :
+    BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22);
+
 const logger = getLogger('twitter');
 const maxTrials = 3;
 const uploadTimeout = 10000;
 const retryInterval = 1500;
 const ordinal = (n: number) => {
-  switch ((~~(n / 10) % 10 === 1) ? 0 : n % 10) {
+  switch ((Math.trunc(n / 10) % 10 === 1) ? 0 : n % 10) {
     case 1:
       return `${n}st`;
     case 2:
@@ -102,6 +108,11 @@ interface ITweet extends TwitterTypes.Status {
   retweeted_status?: Tweet;
 }
 
+interface IFoldedTweet extends TwitterTypes.Status {
+  text: string;
+  full_text: undefined;
+}
+
 export type Tweet = ITweet;
 export type Tweets = ITweet[];
 
@@ -140,6 +151,36 @@ export default class {
         this.bot.sendTo(receiver, '找不到请求的推文,它可能已被删除。');
       });
     };
+    sendTimeline = ({username, count, since, until, noreps, norts}, receiver) => {
+      const countNum = Number(count) || 10;
+      (countNum > 0 ? this.queryTimeline : this.queryTimelineReverse)({
+        username,
+        count: Math.abs(countNum),
+        since: BigNumOps.parse(since) || snowflake(new Date(since).getTime()),
+        until: BigNumOps.parse(until) || snowflake(new Date(until).getTime()),
+        noreps: {on: true, off: false}[noreps],
+        norts: {on: true, off: false}[norts],
+      })
+      .then(tweets => chainPromises(
+        tweets.map(tweet => this.bot.sendTo(receiver, `\
+编号:${tweet.id_str}
+时间:${tweet.created_at}
+媒体:${tweet.extended_entities ? '有' : '无'}
+正文:\n${tweet.text}`
+        ))
+        .concat(this.bot.sendTo(receiver, tweets.length ?
+          '时间线查询完毕,使用 /twitterpic_view <编号> 查看媒体推文详细内容。' :
+            '时间线查询完毕,没有找到符合条件的媒体推文。'
+        ))
+      ))
+      .catch((err: {code: number, message: string}[]) => {
+        if (err[0].code !== 34) {
+          logger.warn(`error retrieving timeline: ${err[0].message}`);
+          return this.bot.sendTo(receiver, `获取时间线时出现错误:${err[0].message}`);
+        }
+        this.bot.sendTo(receiver, `找不到用户 ${username.replace(/^@?(.*)$/, '@$1')}。`);
+      });
+    };
   }
 
   public launch = () => {
@@ -153,6 +194,73 @@ export default class {
     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)));
+    conf.until = until();
+    const promise = (tweets: IFoldedTweet[]): Promise<IFoldedTweet[]> =>
+      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 promise([]);
+  }
+
+  public queryTimeline = (
+    { username, count, since, until, noreps, norts }: ITimelineQueryConfig
+  ) => {
+    username = username.replace(/^@?(.*)$/, '@$1');
+    logger.info(`querying timeline of ${username} with config: ${
+      JSON.stringify({
+        ...(count && {count}),
+        ...(since && {since}),
+        ...(until && {until}),
+        ...(noreps && {noreps}),
+        ...(norts && {norts}),
+    })}`);
+    const fetchTimeline = (
+      config = {
+        screen_name: username.slice(1),
+        trim_user: true,
+        exclude_replies: noreps ?? true,
+        include_rts: !(norts ?? false),
+        since_id: since,
+        max_id: until,
+      },
+      tweets: IFoldedTweet[] = []
+    ): Promise<IFoldedTweet[]> =>
+      this.client.get('statuses/user_timeline', config)
+        .then((newTweets: IFoldedTweet[]) => {
+          if (newTweets.length) {
+            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.filter(tweet => tweet.extended_entities));
+          }
+          if (!newTweets.length || tweets.length >= count) {
+            logger.info(`timeline query of ${username} finished successfully, ${
+              tweets.length
+            } tweets with extended entities have been fetched`);
+            return tweets.slice(0, count);
+          }
+          return fetchTimeline(config, tweets);
+        });
+    return fetchTimeline();
+  }
+
   private workOnTweets = (
     tweets: Tweets,
     sendTweets: (msg: MessageChain, text: string, author: string) => void
@@ -299,7 +407,11 @@ export default class {
       logger.info(`found ${tweets.length} tweets with extended entities`);
       if (currentThread.offset === '-1') { updateOffset(); return; }
       if (currentThread.offset as unknown as number <= 0) {
-        if (tweets.length === 0) { setOffset('-' + bottomOfFeed); lock.workon--; return; }
+        if (tweets.length === 0) {
+          setOffset(BigNumOps.plus('1', '-' + bottomOfFeed));
+          lock.workon--;
+          return;
+        }
         tweets.splice(1);
       }
       if (tweets.length === 0) { updateDate(); updateOffset(); return; }

+ 31 - 0
src/twitter_test.js

@@ -0,0 +1,31 @@
+import * as path from 'path';
+
+import Worker from './twitter'
+import Webshot from './webshot';
+
+const configPath = './config.json';
+
+let worker;
+try {
+  const config = require(path.resolve(configPath));
+  worker = new Worker(
+    Object.fromEntries(Object.entries(config).map(([k, v]) => [k.replace('twitter_', ''), v]))
+  );
+} catch (e) {
+  console.log('Failed to parse config file: ', configPath);
+  process.exit(1);
+}
+const webshot = new Webshot(worker.mode, () => {
+  worker.webshot = webshot;
+  worker.getTweet('1296935552848035840', (msg, text, author) => {
+    console.log(author + text);
+    console.log(JSON.stringify(msg));
+  }).catch(console.log);
+  worker.getTweet('1296935552848035841', (msg, text, author) => {
+    console.log(author + text);
+    console.log(JSON.stringify(msg));
+  }).catch(console.log);
+});
+worker.queryUser('tomoyokurosawa').then(console.log).catch(console.log);
+worker.queryUser('tomoyourosawa').then(console.log).catch(console.log);
+worker.queryUser('@tomoyokurosawa').then(console.log).catch(console.log);

+ 57 - 0
src/utils.ts

@@ -0,0 +1,57 @@
+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));
+
+const splitBigNumAt = (num: string, at: number) =>
+  num.replace(RegExp(String.raw`^([+-]?)(\d+)(\d{${at}})$`), '$1$2,$1$3')
+  .replace(/^([^,]*)$/, '0,$1').split(',')
+  .map(Number);
+
+const bigNumPlus = (num1: string, num2: string) => {
+  let [high, low] = [splitBigNumAt(num1, 15), splitBigNumAt(num2, 15)]
+    .reduce((a, b) => [a[0] + b[0], a[1] + b[1]]);
+  const [highSign, lowSign] = [high, low].map(Math.sign);
+  if (highSign === 0) return low.toString();
+  if (highSign !== lowSign) {
+    [high, low] = [high - highSign, low - lowSign * 10 ** 15];
+  } else {
+    [high, low] = [high + Math.trunc(low / 10 ** 15), low % 10 ** 15];
+  }
+  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 bigNumMin = (...nums: string[]) => {
+  if (!nums || !nums.length) return undefined;
+  let min = nums[0];
+  for (let i = 1; i < nums.length; i++) {
+    if (bigNumCompare(nums[i], min) < 0) min = nums[i];
+  }
+  return min;
+};
+
+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());
+};
+
+const parseBigNum = (str: string) => (str?.match(/^-?\d+$/) || [''])[0].replace(/^(-)?0*/, '$1');
+
+export const BigNumOps = {
+  splitAt: splitBigNumAt,
+  plus: bigNumPlus,
+  compare: bigNumCompare,
+  min: bigNumMin,
+  lShift: bigNumLShift,
+  parse: parseBigNum,
+};

+ 1 - 3
src/webshot.ts

@@ -12,12 +12,10 @@ import gifski from './gifski';
 import { getLogger } from './loggers';
 import { Message, MessageChain } from './mirai';
 import { MediaEntity, Tweets } from './twitter';
+import { chainPromises } from './utils';
 
 const xmlEntities = new XmlEntities();
 
-const chainPromises = <T>(promises: Promise<T>[]) =>
-  promises.reduce((p1, p2) => p1.then(() => p2), Promise.resolve());
-
 const ZHType = (type: string) => new class extends String {
   public type = super.toString();
   public toString = () => `[${super.toString()}]`;