Browse Source

normalize links, fix lists, add direct tweet viewing

Mike L 4 years ago
parent
commit
6345aeb336
8 changed files with 499 additions and 311 deletions
  1. 104 84
      dist/command.js
  2. 3 3
      dist/main.js
  3. 10 4
      dist/mirai.js
  4. 125 60
      dist/twitter.js
  5. 107 80
      src/command.ts
  6. 3 3
      src/main.ts
  7. 13 7
      src/mirai.ts
  8. 134 70
      src/twitter.ts

+ 104 - 84
dist/command.js

@@ -1,119 +1,139 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
-exports.unsub = exports.list = exports.sub = void 0;
+exports.view = exports.unsub = exports.list = exports.sub = 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 logger = loggers_1.getLogger('command');
 function parseLink(link) {
-    let match = link.match(/twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/);
-    if (match) {
-        link = `https://twitter.com/${match[1]}/lists/${match[2]}`;
-        return {
-            link,
-            match: [match[1], match[2]],
-        };
-    }
-    match = link.match(/twitter.com\/([^\/?#]+)/);
-    if (match) {
-        link = `https://twitter.com/${match[1]}`;
-        return {
-            link,
-            match: [match[1]],
-        };
-    }
-    match = link.match(/^([^\/?#]+)\/([^\/?#]+)$/);
-    if (match) {
-        link = `https://twitter.com/${match[1]}/lists/${match[2]}`;
-        return {
-            link,
-            match: [match[1], match[2]],
-        };
-    }
-    match = link.match(/^([^\/?#]+)$/);
-    if (match) {
-        link = `https://twitter.com/${match[1]}`;
-        return {
-            link,
-            match: [match[1]],
-        };
-    }
-    return undefined;
+    let match = link.match(/twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/) ||
+        link.match(/^([^\/?#]+)\/([^\/?#]+)$/);
+    if (match)
+        return [match[1], `/lists/${match[2]}`];
+    match =
+        link.match(/twitter.com\/([^\/?#]+)\/status\/(\d+)/);
+    if (match)
+        return [match[1], `/status/${match[2]}`];
+    match =
+        link.match(/twitter.com\/([^\/?#]+)/) ||
+            link.match(/^([^\/?#]+)$/);
+    if (match)
+        return [match[1]];
+    return;
+}
+function linkBuilder(userName, more = '') {
+    if (!userName)
+        return;
+    return `https://twitter.com/${userName}${more}`;
+}
+function linkFinder(checkedMatch, chat, lock) {
+    var _a;
+    const normalizedLink = linkBuilder(twitter_1.ScreenNameNormalizer.normalize(checkedMatch[0]), (_a = checkedMatch[1]) === null || _a === void 0 ? void 0 : _a.toLowerCase());
+    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 }) => chat.chatID === chatID && chat.chatType === chatType);
+    return [link, index];
 }
-function sub(chat, args, lock, lockfile) {
+function sub(chat, args, reply, lock, lockfile) {
     if (args.length === 0) {
-        return '找不到要订阅的链接。';
+        return reply('找不到要订阅的链接。');
     }
     const match = parseLink(args[0]);
     if (!match) {
-        return `订阅链接格式错误:
+        return reply(`订阅链接格式错误:
 示例:
 https://twitter.com/Saito_Shuka
-https://twitter.com/rikakomoe/lists/lovelive`;
+https://twitter.com/rikakomoe/lists/lovelive
+https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
     }
-    const link = match.link;
-    let flag = false;
-    lock.feed.forEach(fl => {
-        if (fl === link)
-            flag = true;
-    });
-    if (!flag)
-        lock.feed.push(link);
-    if (!lock.threads[link]) {
-        lock.threads[link] = {
-            offset: '0',
-            subscribers: [],
-            updatedAt: '',
-        };
+    let offset = '0';
+    if (match[1]) {
+        const matchStatus = match[1].match(/\/status\/(\d+)/);
+        if (matchStatus) {
+            offset = String(matchStatus[1] - 1);
+            delete match[1];
+        }
     }
-    flag = false;
-    lock.threads[link].subscribers.forEach(c => {
-        if (c.chatID === chat.chatID && c.chatType === chat.chatType)
-            flag = true;
-    });
-    if (!flag)
+    const subscribeTo = (link, config = {}) => {
+        const { addNew = false, msg = `已为此聊天订阅 ${link}` } = config;
+        if (addNew) {
+            lock.feed.push(link);
+            lock.threads[link] = {
+                offset,
+                subscribers: [],
+                updatedAt: '',
+            };
+        }
         lock.threads[link].subscribers.push(chat);
-    logger.warn(`chat ${JSON.stringify(chat)} has subscribed ${link}`);
-    fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
-    return `已为此聊天订阅 ${link}`;
+        logger.warn(`chat ${JSON.stringify(chat)} has subscribed ${link}`);
+        fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
+        reply(msg);
+    };
+    const [realLink, index] = linkFinder(match, chat, lock);
+    if (index > -1)
+        return reply('此聊天已订阅此链接。');
+    if (realLink)
+        return subscribeTo(realLink);
+    const [rawUserName, more] = match;
+    if (rawUserName.toLowerCase() === 'i' && more.match(/lists\/(\d+)/)) {
+        return subscribeTo(linkBuilder('i', more), { addNew: true });
+    }
+    twitter_1.ScreenNameNormalizer.normalizeLive(rawUserName).then(userName => {
+        if (!userName)
+            return reply(`找不到用户 @${rawUserName}。`);
+        const link = linkBuilder(userName, more);
+        const msg = (offset === '0') ?
+            undefined :
+            `已为此聊天订阅 ${link} 并回溯到此动态 ID(含)之后的第一条动态。
+(参见:https://blog.twitter.com/engineering/en_us/a/2010/announcing-snowflake.html)`;
+        subscribeTo(link, { addNew: true, msg });
+    });
 }
 exports.sub = sub;
-function unsub(chat, args, lock, lockfile) {
+function unsub(chat, args, reply, lock, lockfile) {
     if (args.length === 0) {
-        return '找不到要退订的链接。';
+        return reply('找不到要退订的链接。');
     }
     const match = parseLink(args[0]);
     if (!match) {
-        return '链接格式有误。';
-    }
-    const link = match.link;
-    if (!lock.threads[link]) {
-        return '您没有订阅此链接。\n' + list(chat, args, lock);
+        return reply('链接格式有误。');
     }
-    let flag = false;
-    lock.threads[link].subscribers.forEach((c, index) => {
-        if (c.chatID === chat.chatID && c.chatType === chat.chatType) {
-            flag = true;
-            lock.threads[link].subscribers.splice(index, 1);
-        }
-    });
-    if (flag) {
+    const [link, index] = linkFinder(match, chat, lock);
+    if (index === -1)
+        return list(chat, args, msg => reply('您没有订阅此链接。\n' + msg), lock);
+    else {
+        lock.threads[link].subscribers.splice(index, 1);
         fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
         logger.warn(`chat ${JSON.stringify(chat)} has unsubscribed ${link}`);
-        return `已为此聊天退订 ${link}`;
+        return reply(`已为此聊天退订 ${link}`);
     }
-    return '您没有订阅此链接。\n' + list(chat, args, lock);
 }
 exports.unsub = unsub;
-function list(chat, args, lock) {
+function list(chat, _, reply, lock) {
     const links = [];
     Object.keys(lock.threads).forEach(key => {
-        lock.threads[key].subscribers.forEach(c => {
-            if (c.chatID === chat.chatID && c.chatType === chat.chatType)
-                links.push(`${key} ${datetime_1.relativeDate(lock.threads[key].updatedAt)}`);
-        });
+        if (lock.threads[key].subscribers.find(({ chatID, chatType }) => chat.chatID === chatID && chat.chatType === chatType))
+            links.push(`${key} ${datetime_1.relativeDate(lock.threads[key].updatedAt)}`);
     });
-    return '此聊天中订阅的链接:\n' + links.join('\n');
+    return reply('此聊天中订阅的链接:\n' + links.join('\n'));
 }
 exports.list = list;
+function view(chat, args, reply) {
+    if (args.length === 0) {
+        return reply('找不到要查看的链接。');
+    }
+    const match = args[0].match(/^(?:.*twitter.com\/[^\/?#]+\/status\/)?(\d+)/);
+    if (!match) {
+        return reply('链接格式有误。');
+    }
+    try {
+        twitter_1.sendTweet(match[1], chat);
+    }
+    catch (e) {
+        reply('推特机器人尚未加载完毕,请稍后重试。');
+    }
+}
+exports.view = view;

+ 3 - 3
dist/main.js

@@ -127,9 +127,9 @@ const qq = new mirai_1.default({
     host: config.mirai_http_host,
     port: config.mirai_http_port,
     bot_id: config.mirai_bot_qq,
-    list: (c, a) => command_1.list(c, a, lock),
-    sub: (c, a) => command_1.sub(c, a, lock, config.lockfile),
-    unsub: (c, a) => command_1.unsub(c, a, lock, config.lockfile),
+    list: (c, a, cb) => command_1.list(c, a, cb, lock),
+    sub: (c, a, cb) => command_1.sub(c, a, cb, lock, config.lockfile),
+    unsub: (c, a, cb) => command_1.unsub(c, a, cb, lock, config.lockfile),
 });
 const worker = new twitter_1.default({
     consumer_key: config.twitter_consumer_key,

+ 10 - 4
dist/mirai.js

@@ -15,6 +15,7 @@ const fs_1 = require("fs");
 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');
@@ -110,23 +111,28 @@ class default_1 {
                 }
                 const cmdObj = helper_1.default(msg.plain);
                 switch (cmdObj.cmd) {
+                    case 'twitter_view':
+                    case 'twitter_get':
+                        command_1.view(chat, cmdObj.args, msg.reply);
+                        break;
                     case 'twitter_sub':
                     case 'twitter_subscribe':
-                        msg.reply(this.botInfo.sub(chat, cmdObj.args));
+                        this.botInfo.sub(chat, cmdObj.args, msg.reply);
                         break;
                     case 'twitter_unsub':
                     case 'twitter_unsubscribe':
-                        msg.reply(this.botInfo.unsub(chat, cmdObj.args));
+                        this.botInfo.unsub(chat, cmdObj.args, msg.reply);
                         break;
                     case 'ping':
                     case 'twitter':
-                        msg.reply(this.botInfo.list(chat, cmdObj.args));
+                        this.botInfo.list(chat, cmdObj.args, msg.reply);
                         break;
                     case 'help':
                         msg.reply(`推特搬运机器人:
 /twitter - 查询当前聊天中的订阅
 /twitter_subscribe [链接] - 订阅 Twitter 搬运
-/twitter_unsubscribe [链接] - 退订 Twitter 搬运`);
+/twitter_unsubscribe [链接] - 退订 Twitter 搬运
+/twitter_view [链接] - 查看推文`);
                 }
             });
         };

+ 125 - 60
dist/twitter.js

@@ -1,16 +1,116 @@
 "use strict";
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+    return new (P || (P = Promise))(function (resolve, reject) {
+        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+        step((generator = generator.apply(thisArg, _arguments || [])).next());
+    });
+};
 Object.defineProperty(exports, "__esModule", { value: true });
+exports.sendTweet = exports.ScreenNameNormalizer = void 0;
 const fs = require("fs");
 const path = require("path");
 const Twitter = require("twitter");
 const loggers_1 = require("./loggers");
 const webshot_1 = require("./webshot");
+class ScreenNameNormalizer {
+    static normalizeLive(username) {
+        return __awaiter(this, void 0, void 0, function* () {
+            if (this._queryUser) {
+                return yield this._queryUser(username)
+                    .catch((err) => {
+                    if (err[0].code !== 50) {
+                        logger.warn(`error looking up user: ${err[0].message}`);
+                        return username;
+                    }
+                    return null;
+                });
+            }
+            return this.normalize(username);
+        });
+    }
+}
+exports.ScreenNameNormalizer = ScreenNameNormalizer;
+ScreenNameNormalizer.normalize = (username) => username.toLowerCase().replace(/^@/, '');
+exports.sendTweet = (id, receiver) => {
+    throw Error();
+};
 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) {
+        case 1:
+            return `${n}st`;
+        case 2:
+            return `${n}nd`;
+        case 3:
+            return `${n}rd`;
+        default:
+            return `${n}th`;
+    }
+};
+const retryOnError = (doWork, onRetry) => new Promise(resolve => {
+    const retry = (reason, count) => {
+        setTimeout(() => {
+            let terminate = false;
+            onRetry(reason, count, defaultValue => { terminate = true; resolve(defaultValue); });
+            if (!terminate)
+                doWork().then(resolve).catch(error => retry(error, count + 1));
+        }, retryInterval);
+    };
+    doWork().then(resolve).catch(error => retry(error, 1));
+});
 class default_1 {
     constructor(opt) {
         this.launch = () => {
             this.webshot = new webshot_1.default(this.mode, () => setTimeout(this.work, this.workInterval * 1000));
         };
+        this.queryUser = (username) => this.client.get('users/show', { screen_name: username })
+            .then((user) => user.screen_name);
+        this.workOnTweets = (tweets, sendTweets) => {
+            const uploader = (message, lastResort) => {
+                let timeout = uploadTimeout;
+                return retryOnError(() => this.bot.uploadPic(message, timeout).then(() => message), (_, count, terminate) => {
+                    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);
+        };
+        this.getTweet = (id, sender) => {
+            const endpoint = 'statuses/show';
+            const config = {
+                id,
+                tweet_mode: 'extended',
+            };
+            return this.client.get(endpoint, config)
+                .then((tweet) => this.workOnTweets([tweet], sender));
+        };
+        this.sendTweets = (source, ...to) => (msg, text, author) => {
+            to.forEach(subscriber => {
+                logger.info(`pushing data${source ? ` of ${source}` : ''} to ${JSON.stringify(subscriber)}`);
+                retryOnError(() => this.bot.sendTo(subscriber, msg), (_, count, terminate) => {
+                    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));
+                    }
+                });
+            });
+        };
         this.work = () => {
             const lock = this.lock;
             if (this.workInterval < 1)
@@ -40,11 +140,19 @@ class default_1 {
                 let config;
                 let endpoint;
                 if (match) {
-                    config = {
-                        owner_screen_name: match[1],
-                        slug: match[2],
-                        tweet_mode: 'extended',
-                    };
+                    if (match[1] === 'i') {
+                        config = {
+                            list_id: match[2],
+                            tweet_mode: 'extended',
+                        };
+                    }
+                    else {
+                        config = {
+                            owner_screen_name: match[1],
+                            slug: match[2],
+                            tweet_mode: 'extended',
+                        };
+                    }
                     endpoint = 'lists/statuses';
                 }
                 else {
@@ -97,61 +205,7 @@ class default_1 {
                 }
                 if (currentThread.offset === '0')
                     tweets.splice(1);
-                const maxCount = 3;
-                const uploadTimeout = 10000;
-                const retryInterval = 1500;
-                const ordinal = (n) => {
-                    switch ((~~(n / 10) % 10 === 1) ? 0 : n % 10) {
-                        case 1:
-                            return `${n}st`;
-                        case 2:
-                            return `${n}nd`;
-                        case 3:
-                            return `${n}rd`;
-                        default:
-                            return `${n}th`;
-                    }
-                };
-                const retryOnError = (doWork, onRetry) => new Promise(resolve => {
-                    const retry = (reason, count) => {
-                        setTimeout(() => {
-                            let terminate = false;
-                            onRetry(reason, count, defaultValue => { terminate = true; resolve(defaultValue); });
-                            if (!terminate)
-                                doWork().then(resolve).catch(error => retry(error, count + 1));
-                        }, retryInterval);
-                    };
-                    doWork().then(resolve).catch(error => retry(error, 1));
-                });
-                const uploader = (message, lastResort) => {
-                    let timeout = uploadTimeout;
-                    return retryOnError(() => this.bot.uploadPic(message, timeout).then(() => message), (_, count, terminate) => {
-                        if (count <= maxCount) {
-                            timeout *= (count + 2) / (count + 1);
-                            logger.warn(`retry uploading for the ${ordinal(count)} time...`);
-                        }
-                        else {
-                            logger.warn(`${count - 1} consecutive failures while uploading, trying plain text instead...`);
-                            terminate(lastResort());
-                        }
-                    });
-                };
-                const sendTweets = (msg, text, author) => {
-                    currentThread.subscribers.forEach(subscriber => {
-                        logger.info(`pushing data of thread ${currentFeed} to ${JSON.stringify(subscriber)}`);
-                        retryOnError(() => this.bot.sendTo(subscriber, msg), (_, count, terminate) => {
-                            if (count <= maxCount) {
-                                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));
-                            }
-                        });
-                    });
-                };
-                return this.webshot(tweets, uploader, sendTweets, this.webshotDelay)
+                return this.workOnTweets(tweets, this.sendTweets(`thread ${currentFeed}`, ...currentThread.subscribers))
                     .then(updateDate).then(updateOffset);
             })
                 .then(() => {
@@ -177,6 +231,17 @@ class default_1 {
         this.bot = opt.bot;
         this.webshotDelay = opt.webshotDelay;
         this.mode = opt.mode;
+        ScreenNameNormalizer._queryUser = this.queryUser;
+        exports.sendTweet = (id, receiver) => {
+            this.getTweet(id, this.sendTweets(`tweet ${id}`, receiver))
+                .catch((err) => {
+                if (err[0].code !== 144) {
+                    logger.warn(`error retrieving tweet: ${err[0].message}`);
+                    this.bot.sendTo(receiver, `获取推文时出现错误:${err[0].message}`);
+                }
+                this.bot.sendTo(receiver, '找不到请求的推文,它可能已被删除。');
+            });
+        };
     }
 }
 exports.default = default_1;

+ 107 - 80
src/command.ts

@@ -3,114 +3,141 @@ import * as path from 'path';
 
 import { relativeDate } from './datetime';
 import { getLogger } from './loggers';
+import { sendTweet, ScreenNameNormalizer as normalizer } from './twitter';
 
 const logger = getLogger('command');
 
-function parseLink(link: string): { link: string, match: string[] } | undefined {
-  let match = link.match(/twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/);
-  if (match) {
-    link = `https://twitter.com/${match[1]}/lists/${match[2]}`;
-    return {
-      link,
-      match: [match[1], match[2]],
-    };
-  }
-  match = link.match(/twitter.com\/([^\/?#]+)/);
-  if (match) {
-    link = `https://twitter.com/${match[1]}`;
-    return {
-      link,
-      match: [match[1]],
-    };
-  }
-  match = link.match(/^([^\/?#]+)\/([^\/?#]+)$/);
-  if (match) {
-    link = `https://twitter.com/${match[1]}/lists/${match[2]}`;
-    return {
-      link,
-      match: [match[1], match[2]],
-    };
-  }
-  match = link.match(/^([^\/?#]+)$/);
-  if (match) {
-    link = `https://twitter.com/${match[1]}`;
-    return {
-      link,
-      match: [match[1]],
-    };
-  }
-  return undefined;
+function parseLink(link: string): string[] {
+  let match =
+    link.match(/twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/) ||
+    link.match(/^([^\/?#]+)\/([^\/?#]+)$/);
+  if (match) return [match[1], `/lists/${match[2]}`];
+  match =
+    link.match(/twitter.com\/([^\/?#]+)\/status\/(\d+)/);
+  if (match) return [match[1], `/status/${match[2]}`];
+  match =
+    link.match(/twitter.com\/([^\/?#]+)/) ||
+    link.match(/^([^\/?#]+)$/);
+  if (match) return [match[1]];
+  return;
+}
+
+function linkBuilder(userName: string, more = ''): string {
+  if (!userName) return;
+  return `https://twitter.com/${userName}${more}`;
 }
 
-function sub(chat: IChat, args: string[], lock: ILock, lockfile: string): 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 => 
+    normalizedLink === realLink.replace(/\/@/, '/').toLowerCase()
+  );
+  if (!link) return [null, -1];
+  const index = lock.threads[link].subscribers.findIndex(({chatID, chatType}) => 
+    chat.chatID === chatID && chat.chatType === chatType
+  );
+  return [link, index];
+}
+
+function sub(chat: IChat, args: string[], reply: (msg: string) => any,
+  lock: ILock, lockfile: string
+): void {
   if (args.length === 0) {
-    return '找不到要订阅的链接。';
+    return reply('找不到要订阅的链接。');
   }
   const match = parseLink(args[0]);
   if (!match) {
-    return `订阅链接格式错误:
+    return reply(`订阅链接格式错误:
 示例:
 https://twitter.com/Saito_Shuka
-https://twitter.com/rikakomoe/lists/lovelive`;
+https://twitter.com/rikakomoe/lists/lovelive
+https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
   }
-  const link = match.link;
-  let flag = false;
-  lock.feed.forEach(fl => {
-    if (fl === link) flag = true;
-  });
-  if (!flag) lock.feed.push(link);
-  if (!lock.threads[link]) {
-    lock.threads[link] = {
-      offset: '0',
-      subscribers: [],
-      updatedAt: '',
-    };
+  let offset = '0';
+  if (match[1]) {
+    const matchStatus = match[1].match(/\/status\/(\d+)/);
+    if (matchStatus) {
+      offset = String(matchStatus[1] as unknown as number - 1);
+      delete match[1];
+    }
   }
-  flag = false;
-  lock.threads[link].subscribers.forEach(c => {
-    if (c.chatID === chat.chatID && c.chatType === chat.chatType) flag = true;
+  const subscribeTo = (link: string, config: {addNew?: boolean, msg?: string} = {}) => {
+    const {addNew = false, msg = `已为此聊天订阅 ${link}`} = config;
+    if (addNew) {
+      lock.feed.push(link);
+      lock.threads[link] = {
+        offset,
+        subscribers: [],
+        updatedAt: '',
+      };
+    }
+    lock.threads[link].subscribers.push(chat);
+    logger.warn(`chat ${JSON.stringify(chat)} has subscribed ${link}`);
+    fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
+    reply(msg);
+  };
+  const [realLink, index] = linkFinder(match, chat, lock);
+  if (index > -1) return reply('此聊天已订阅此链接。');
+  if (realLink) return subscribeTo(realLink);
+  const [rawUserName, more] = match;
+  if (rawUserName.toLowerCase() === 'i' && more.match(/lists\/(\d+)/)) {
+    return subscribeTo(linkBuilder('i', more), {addNew: true});
+  }
+  normalizer.normalizeLive(rawUserName).then(userName => {
+    if (!userName) return reply(`找不到用户 @${rawUserName}。`);
+    const link = linkBuilder(userName, more);
+    const msg = (offset === '0') ?
+      undefined :
+        `已为此聊天订阅 ${link} 并回溯到此动态 ID(含)之后的第一条动态。
+(参见:https://blog.twitter.com/engineering/en_us/a/2010/announcing-snowflake.html)`;
+    subscribeTo(link, {addNew: true, msg});
   });
-  if (!flag) lock.threads[link].subscribers.push(chat);
-  logger.warn(`chat ${JSON.stringify(chat)} has subscribed ${link}`);
-  fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
-  return `已为此聊天订阅 ${link}`;
 }
 
-function unsub(chat: IChat, args: string[], lock: ILock, lockfile: string): string {
+function unsub(chat: IChat, args: string[], reply: (msg: string) => any,
+  lock: ILock, lockfile: string
+): void {
   if (args.length === 0) {
-    return '找不到要退订的链接。';
+    return reply('找不到要退订的链接。');
   }
   const match = parseLink(args[0]);
   if (!match) {
-    return '链接格式有误。';
-  }
-  const link = match.link;
-  if (!lock.threads[link]) {
-    return '您没有订阅此链接。\n' + list(chat, args, lock);
+    return reply('链接格式有误。');
   }
-  let flag = false;
-  lock.threads[link].subscribers.forEach((c, index) => {
-    if (c.chatID === chat.chatID && c.chatType === chat.chatType) {
-      flag = true;
-      lock.threads[link].subscribers.splice(index, 1);
-    }
-  });
-  if (flag) {
+  const [link, index] = linkFinder(match, chat, lock);
+  if (index === -1) return list(chat, args, msg => reply('您没有订阅此链接。\n' + msg), lock);
+  else {
+    lock.threads[link].subscribers.splice(index, 1);
     fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
     logger.warn(`chat ${JSON.stringify(chat)} has unsubscribed ${link}`);
-    return `已为此聊天退订 ${link}`;
+    return reply(`已为此聊天退订 ${link}`);
   }
-  return '您没有订阅此链接。\n' + list(chat, args, lock);
 }
 
-function list(chat: IChat, args: string[], lock: ILock): string {
+function list(chat: IChat, _: string[], reply: (msg: string) => any, lock: ILock): void {
   const links = [];
   Object.keys(lock.threads).forEach(key => {
-    lock.threads[key].subscribers.forEach(c => {
-      if (c.chatID === chat.chatID && c.chatType === chat.chatType) links.push(`${key} ${relativeDate(lock.threads[key].updatedAt)}`);
-    });
+    if (lock.threads[key].subscribers.find(({chatID, chatType}) => 
+      chat.chatID === chatID && chat.chatType === chatType
+    )) links.push(`${key} ${relativeDate(lock.threads[key].updatedAt)}`);
   });
-  return '此聊天中订阅的链接:\n' + links.join('\n');
+  return reply('此聊天中订阅的链接:\n' + links.join('\n'));
+}
+
+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+)/);
+  if (!match) {
+    return reply('链接格式有误。');
+  }
+  try {
+    sendTweet(match[1], chat);
+  } catch (e) {
+    reply('推特机器人尚未加载完毕,请稍后重试。');
+  }
 }
 
-export { sub, list, unsub };
+export { sub, list, unsub, view };

+ 3 - 3
src/main.ts

@@ -135,9 +135,9 @@ const qq = new QQBot({
   host: config.mirai_http_host,
   port: config.mirai_http_port,
   bot_id: config.mirai_bot_qq,
-  list: (c, a) => list(c, a, lock),
-  sub: (c, a) => sub(c, a, lock, config.lockfile),
-  unsub: (c, a) => unsub(c, a, lock, config.lockfile),
+  list: (c, a, cb) => list(c, a, cb, lock),
+  sub: (c, a, cb) => sub(c, a, cb, lock, config.lockfile),
+  unsub: (c, a, cb) => unsub(c, a, cb, lock, config.lockfile),
 });
 
 const worker = new Worker({

+ 13 - 7
src/mirai.ts

@@ -4,6 +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 { getLogger } from './loggers';
 
@@ -14,9 +15,9 @@ interface IQQProps {
   host: string;
   port: number;
   bot_id: number;
-  list(chat: IChat, args: string[]): string;
-  sub(chat: IChat, args: string[]): string;
-  unsub(chat: IChat, args: string[]): string;
+  list(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
+  sub(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
+  unsub(chat: IChat, args: string[], replyfn: (msg: string) => any): void;
 }
 
 const ChatTypeMap: Record<MessageType.ChatMessageType, ChatType> = {
@@ -119,23 +120,28 @@ export default class {
       }
       const cmdObj = command(msg.plain);
       switch (cmdObj.cmd) {
+        case 'twitter_view':
+        case 'twitter_get':
+          view(chat, cmdObj.args, msg.reply);
+          break;
         case 'twitter_sub':
         case 'twitter_subscribe':
-          msg.reply(this.botInfo.sub(chat, cmdObj.args));
+          this.botInfo.sub(chat, cmdObj.args, msg.reply);
           break;
         case 'twitter_unsub':
         case 'twitter_unsubscribe':
-          msg.reply(this.botInfo.unsub(chat, cmdObj.args));
+          this.botInfo.unsub(chat, cmdObj.args, msg.reply);
           break;
         case 'ping':
         case 'twitter':
-          msg.reply(this.botInfo.list(chat, cmdObj.args));
+          this.botInfo.list(chat, cmdObj.args, msg.reply);
           break;
         case 'help':
           msg.reply(`推特搬运机器人:
 /twitter - 查询当前聊天中的订阅
 /twitter_subscribe [链接] - 订阅 Twitter 搬运
-/twitter_unsubscribe [链接] - 退订 Twitter 搬运`);
+/twitter_unsubscribe [链接] - 退订 Twitter 搬运
+/twitter_view [链接] - 查看推文`);
       }
     });
 }

+ 134 - 70
src/twitter.ts

@@ -20,7 +20,61 @@ interface IWorkerOption {
   mode: number;
 }
 
+export class ScreenNameNormalizer {
+
+  // tslint:disable-next-line: variable-name
+  public static _queryUser: (username: string) => Promise<string>;
+
+  public static normalize = (username: string) => username.toLowerCase().replace(/^@/, '');
+
+  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;
+      });
+    }
+    return this.normalize(username);
+  }
+}
+
+export let sendTweet = (id: string, receiver: IChat): void => {
+  throw Error();
+};
+
 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) {
+    case 1:
+      return `${n}st`;
+    case 2:
+      return `${n}nd`;
+    case 3:
+      return `${n}rd`;
+    default:
+      return `${n}th`;
+  }
+};
+const retryOnError = <T, U>(
+  doWork: () => Promise<T>,
+  onRetry: (error, count: number, terminate: (defaultValue: U) => void) => void
+) => new Promise<T | U>(resolve => {
+  const retry = (reason, count: number) => {
+    setTimeout(() => {
+      let terminate = false;
+      onRetry(reason, count, defaultValue => { terminate = true; resolve(defaultValue); });
+      if (!terminate) doWork().then(resolve).catch(error => retry(error, count + 1));
+    }, retryInterval);
+  };
+  doWork().then(resolve).catch(error => retry(error, 1));
+});
 
 export type FullUser = TwitterTypes.FullUser;
 export type Entities = TwitterTypes.Entities;
@@ -63,6 +117,17 @@ export default class {
     this.bot = opt.bot;
     this.webshotDelay = opt.webshotDelay;
     this.mode = opt.mode;
+    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, '找不到请求的推文,它可能已被删除。');
+      });
+    };
   }
 
   public launch = () => {
@@ -72,6 +137,62 @@ export default class {
     );
   }
 
+  public queryUser = (username: string) =>
+    this.client.get('users/show', {screen_name: username})
+    .then((user: FullUser) => user.screen_name)
+
+  private workOnTweets = (
+    tweets: Tweets,
+    sendTweets: (msg: MessageChain, text: string, author: string) => void
+  ) => {
+    const uploader = (
+      message: ReturnType<typeof Message.Image>,
+      lastResort: (...args) => ReturnType<typeof Message.Plain>
+    ) => {
+      let timeout = uploadTimeout;
+      return retryOnError(() =>
+        this.bot.uploadPic(message, timeout).then(() => message),
+      (_, count, terminate: (defaultValue: ReturnType<typeof Message.Plain>) => void) => {
+        if (count <= 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';
+    const config = {
+      id,
+      tweet_mode: 'extended',
+    };
+    return this.client.get(endpoint, config)
+    .then((tweet: Tweet) => this.workOnTweets([tweet], sender));
+  }
+
+  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` +
+            'message chain, trying plain text instead...');
+          terminate(this.bot.sendTo(subscriber, author + text));
+        }
+      });
+    });
+  }
+
   public work = () => {
     const lock = this.lock;
     if (this.workInterval < 1) this.workInterval = 1;
@@ -101,11 +222,18 @@ export default class {
       let config: any;
       let endpoint: string;
       if (match) {
-        config = {
-          owner_screen_name: match[1],
-          slug: match[2],
-          tweet_mode: 'extended',
-        };
+        if (match[1] === 'i') {
+          config = {
+            list_id: match[2],
+            tweet_mode: 'extended',
+          };
+        } else {
+          config = {
+            owner_screen_name: match[1],
+            slug: match[2],
+            tweet_mode: 'extended',
+          };
+        }
         endpoint = 'lists/statuses';
       } else {
         match = currentFeed.match(/https:\/\/twitter.com\/([^\/]+)/);
@@ -152,71 +280,7 @@ export default class {
       if (currentThread.offset === '-1') { updateOffset(); return; }
       if (currentThread.offset === '0') tweets.splice(1);
 
-      const maxCount = 3;
-      const uploadTimeout = 10000;
-      const retryInterval = 1500;
-      const ordinal = (n: number) => {
-        switch ((~~(n / 10) % 10 === 1) ? 0 : n % 10) {
-          case 1:
-            return `${n}st`;
-          case 2:
-            return `${n}nd`;
-          case 3:
-            return `${n}rd`;
-          default:
-            return `${n}th`;
-        }
-      };
-
-      const retryOnError = <T, U>(
-        doWork: () => Promise<T>,
-        onRetry: (error, count: number, terminate: (defaultValue: U) => void) => void
-      ) => new Promise<T | U>(resolve => {
-        const retry = (reason, count: number) => {
-          setTimeout(() => {
-            let terminate = false;
-            onRetry(reason, count, defaultValue => { terminate = true; resolve(defaultValue); });
-            if (!terminate) doWork().then(resolve).catch(error => retry(error, count + 1));
-          }, retryInterval);
-        };
-        doWork().then(resolve).catch(error => retry(error, 1));
-      });
-
-      const uploader = (
-        message: ReturnType<typeof Message.Image>,
-        lastResort: (...args) => ReturnType<typeof Message.Plain>
-      ) => {
-        let timeout = uploadTimeout;
-        return retryOnError(() =>
-          this.bot.uploadPic(message, timeout).then(() => message),
-        (_, count, terminate: (defaultValue: ReturnType<typeof Message.Plain>) => void) => {
-          if (count <= maxCount) {
-            timeout *= (count + 2) / (count + 1);
-            logger.warn(`retry uploading for the ${ordinal(count)} time...`);
-          } else {
-            logger.warn(`${count - 1} consecutive failures while uploading, trying plain text instead...`);
-            terminate(lastResort());
-          }
-        });
-      };
-
-      const sendTweets = (msg: MessageChain, text: string, author: string) => {
-        currentThread.subscribers.forEach(subscriber => {
-          logger.info(`pushing data of thread ${currentFeed} to ${JSON.stringify(subscriber)}`);
-          retryOnError(
-            () => this.bot.sendTo(subscriber, msg),
-          (_, count, terminate: (doNothing: Promise<void>) => void) => {
-            if (count <= maxCount) {
-              logger.warn(`retry sending to ${subscriber.chatID} for the ${ordinal(count)} time...`);
-            } else {
-              logger.warn(`${count - 1} consecutive failures while sending` +
-                'message chain, trying plain text instead...');
-              terminate(this.bot.sendTo(subscriber, author + text));
-            }
-          });
-        });
-      };
-      return this.webshot(tweets, uploader, sendTweets, this.webshotDelay)
+      return this.workOnTweets(tweets, this.sendTweets(`thread ${currentFeed}`, ...currentThread.subscribers))
       .then(updateDate).then(updateOffset);
     })
       .then(() => {