Browse Source

add querying via twitter

Mike L 3 years ago
parent
commit
b104bd284e
6 changed files with 218 additions and 33 deletions
  1. 4 0
      config.example.json
  2. 15 14
      dist/command.js
  3. 14 2
      dist/main.js
  4. 16 15
      src/command.ts
  5. 15 2
      src/main.ts
  6. 154 0
      src/twitter.ts

+ 4 - 0
config.example.json

@@ -3,5 +3,9 @@
   "cq_ws_host": "127.0.0.1",
   "cq_ws_port": 6700,
   "cq_bot_qq": 10000,
+  "twitter_consumer_key": "",
+  "twitter_consumer_secret": "",
+  "twitter_access_token_key": "",
+  "twitter_access_token_secret": "",
   "loglevel": "info"
 }

+ 15 - 14
dist/command.js

@@ -2,6 +2,7 @@
 Object.defineProperty(exports, "__esModule", { value: true });
 exports.view = exports.parseCmd = void 0;
 const koishi_1 = require("./koishi");
+const twitter_1 = require("./twitter");
 function parseCmd(message) {
     message = message.trim();
     message = message.replace('\\\\', '\\0x5c');
@@ -22,19 +23,19 @@ function parseCmd(message) {
     };
 }
 exports.parseCmd = parseCmd;
-function view(chat, args, reply) {
-    if (args.length === 0) {
-        return reply('找不到要查看的回数。');
-    }
-    const match = Number(args[0]);
-    if (match < 1 || match > 999) {
-        return reply('链接格式有误。');
-    }
-    try {
-        reply(koishi_1.Message.Image(`https://d2n19nac4w0gh6.cloudfront.net/resource/images/webview/comic/story/comic_${String(match).padStart(3, '0')}.jpg`));
-    }
-    catch (e) {
-        reply('机器人尚未加载完毕,请稍后重试。');
-    }
+function view(_chat, args, reply) {
+    twitter_1.queryByRegExp('t7s_staff', /今週の『それゆけ!ナナスタ☆通信』第(\d+)話はこちら!/, 3600 * 24 * 6)
+        .then(match => {
+        if (!match)
+            throw Error();
+        let query = Number(args[0] || Number(match[1]));
+        if (query < 0)
+            query += Number(match[1]);
+        if (args[0] === '0' || query < 0 || query > Number(match[1])) {
+            return reply(`查询取值范围有误。当前可用的取值范围:1~${Number(match[1])}`);
+        }
+        reply(`第 ${query} 话:\n` +
+            koishi_1.Message.Image(`https://d2n19nac4w0gh6.cloudfront.net/resource/images/webview/comic/story/comic_${String(query).padStart(3, '0')}.jpg`));
+    }).catch((err) => reply(`查询失败,请稍后重试。${err.message ? `原因:${err}` : ''}`));
 }
 exports.view = view;

+ 14 - 2
dist/main.js

@@ -7,6 +7,7 @@ const commandLineUsage = require("command-line-usage");
 const exampleConfig = require("../config.example.json");
 const loggers_1 = require("./loggers");
 const koishi_1 = require("./koishi");
+const twitter_1 = require("./twitter");
 const logger = loggers_1.getLogger();
 const sections = [
     {
@@ -45,18 +46,23 @@ catch (e) {
     process.exit(1);
 }
 const requiredFields = [
+    'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
     'cq_bot_qq',
 ];
 const warningFields = [
     'cq_ws_host', 'cq_ws_port', 'cq_access_token',
 ];
+const optionalFields = [
+    'loglevel',
+].concat(warningFields);
 if (requiredFields.some((value) => config[value] === undefined)) {
     console.log(`${requiredFields.join(', ')} are required`);
     process.exit(1);
 }
-warningFields.forEach(key => {
+optionalFields.forEach(key => {
     if (config[key] === undefined || typeof (config[key]) !== typeof (exampleConfig[key])) {
-        logger.warn(`${key} is undefined, use ${exampleConfig[key] || 'empty string'} as default`);
+        if (warningFields.includes(key))
+            logger.warn(`${key} is undefined, use ${exampleConfig[key] || 'empty string'} as default`);
         config[key] = exampleConfig[key];
     }
 });
@@ -67,4 +73,10 @@ const qq = new koishi_1.default({
     port: config.cq_ws_port,
     bot_id: config.cq_bot_qq,
 });
+new twitter_1.default({
+    consumerKey: config.twitter_consumer_key,
+    consumerSecret: config.twitter_consumer_secret,
+    accessTokenKey: config.twitter_access_token_key,
+    accessTokenSecret: config.twitter_access_token_secret,
+});
 qq.connect();

+ 16 - 15
src/command.ts

@@ -3,6 +3,7 @@
 /* eslint-disable prefer-arrow/prefer-arrow-functions */
 
 import { Message } from './koishi';
+import { queryByRegExp } from './twitter';
 
 function parseCmd(message: string): {
   cmd: string;
@@ -27,21 +28,21 @@ function parseCmd(message: string): {
   };
 }
 
-function view(chat: IChat, args: string[], reply: (msg: string) => any): void {
-  if (args.length === 0) {
-    return reply('找不到要查看的回数。');
-  }
-  const match = Number(args[0]);
-  if (match < 1 || match > 999) {
-    return reply('链接格式有误。');
-  }
-  try {
-    reply(Message.Image(`https://d2n19nac4w0gh6.cloudfront.net/resource/images/webview/comic/story/comic_${
-      String(match).padStart(3, '0')
-    }.jpg`));
-  } catch (e) {
-    reply('机器人尚未加载完毕,请稍后重试。');
-  }
+function view(_chat: IChat, args: string[], reply: (msg: string) => any): void {
+  queryByRegExp('t7s_staff', /今週の『それゆけ!ナナスタ☆通信』第(\d+)話はこちら!/, 3600 * 24 * 6)
+    .then(match => {
+      if (!match) throw Error();
+      let query = Number(args[0] || Number(match[1]));
+      if (query < 0) query += Number(match[1]);
+      if (args[0] === '0' || query < 0 || query > Number(match[1])) {
+        return reply(`查询取值范围有误。当前可用的取值范围:1~${Number(match[1])}`);
+      }
+      reply(`第 ${query} 话:\n` +
+        Message.Image(`https://d2n19nac4w0gh6.cloudfront.net/resource/images/webview/comic/story/comic_${
+          String(query).padStart(3, '0')
+        }.jpg`)
+      );
+    }).catch((err: Error) => reply(`查询失败,请稍后重试。${err.message ? `原因:${err}`: ''}`));
 }
 
 export { parseCmd, view };

+ 15 - 2
src/main.ts

@@ -8,6 +8,7 @@ import * as commandLineUsage from 'command-line-usage';
 import * as exampleConfig from '../config.example.json';
 import { getLogger, setLogLevels } from './loggers';
 import QQBot from './koishi';
+import Worker from './twitter';
 
 const logger = getLogger();
 
@@ -54,6 +55,7 @@ try {
 }
 
 const requiredFields = [
+  'twitter_consumer_key', 'twitter_consumer_secret', 'twitter_access_token_key', 'twitter_access_token_secret',
   'cq_bot_qq',
 ];
 
@@ -61,14 +63,18 @@ const warningFields = [
   'cq_ws_host', 'cq_ws_port', 'cq_access_token',
 ];
 
+const optionalFields = [
+  'loglevel',
+].concat(warningFields);
+
 if (requiredFields.some((value) => config[value] === undefined)) {
   console.log(`${requiredFields.join(', ')} are required`);
   process.exit(1);
 }
 
-warningFields.forEach(key => {
+optionalFields.forEach(key => {
   if (config[key] === undefined || typeof(config[key]) !== typeof (exampleConfig[key])) {
-    logger.warn(`${key} is undefined, use ${exampleConfig[key] || 'empty string'} as default`);
+    if (warningFields.includes(key)) logger.warn(`${key} is undefined, use ${exampleConfig[key] || 'empty string'} as default`);
     config[key] = exampleConfig[key as keyof Config];
   }
 });
@@ -82,4 +88,11 @@ const qq = new QQBot({
   bot_id: config.cq_bot_qq,
 });
 
+new Worker({
+  consumerKey: config.twitter_consumer_key,
+  consumerSecret: config.twitter_consumer_secret,
+  accessTokenKey: config.twitter_access_token_key,
+  accessTokenSecret: config.twitter_access_token_secret,
+});
+
 qq.connect();

+ 154 - 0
src/twitter.ts

@@ -0,0 +1,154 @@
+import * as Twitter from 'twitter';
+import TwitterTypes from 'twitter-d';
+
+import { getLogger } from './loggers';
+import { BigNumOps } from './utils';
+
+interface IWorkerOption {
+  consumerKey: string;
+  consumerSecret: string;
+  accessTokenKey: string;
+  accessTokenSecret: string;
+}
+
+export interface ITimelineQueryConfig {
+  username: string;
+  count?: number;
+  since?: string;
+  until?: string;
+  noreps?: boolean;
+  norts?: boolean;
+}
+
+const TWITTER_EPOCH = 1288834974657;
+export const snowflake = (epoch: number) => Number.isNaN(epoch) ? undefined :
+  BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22);
+
+const logger = getLogger('twitter');
+
+export type FullUser = TwitterTypes.FullUser;
+export type Entities = TwitterTypes.Entities;
+export type ExtendedEntities = TwitterTypes.ExtendedEntities;
+export type MediaEntity = TwitterTypes.MediaEntity;
+
+interface ITweet extends TwitterTypes.Status {
+  user: FullUser;
+  retweeted_status?: Tweet;
+}
+
+export type Tweet = ITweet;
+export type Tweets = ITweet[];
+
+export let queryByRegExp = (username: string, regexp: RegExp, cacheSeconds?: number, until?: string) =>
+  Promise.resolve<RegExpExecArray>(null);
+
+export default class {
+
+  private client: Twitter;
+  private lastQueries: {[key: string]: {match: RegExpExecArray, id: string}} = {};
+
+  constructor(opt: IWorkerOption) {
+    this.client = new Twitter({
+      consumer_key: opt.consumerKey,
+      consumer_secret: opt.consumerSecret,
+      access_token_key: opt.accessTokenKey,
+      access_token_secret: opt.accessTokenSecret,
+    });
+    queryByRegExp = (username, regexp, cacheSeconds?: number, until?: string) => {
+      logger.info(`searching timeline of @${username} for matches of ${regexp}...`);
+      const normalizedUsername = username.toLowerCase().replace(/^@/, '');
+      const queryKey = `${normalizedUsername}:${regexp.toString()}`;
+      const isOld = (then: string) => {
+        if (!then) return true;
+        return BigNumOps.compare(snowflake(Date.now() - cacheSeconds * 1000), then) >= 0;
+      };
+      if (queryKey in this.lastQueries && !isOld(this.lastQueries[queryKey].id)) {
+        const {match, id} = this.lastQueries[queryKey];
+        logger.info(`found match ${JSON.stringify(match)} from cached tweet of id ${id}`);
+        return Promise.resolve(match);
+      }
+      return this.queryTimeline({username, norts: true, until})
+        .then(tweets => {
+          const found = tweets.find(tweet => regexp.test(tweet.full_text));
+          if (found) {
+            const match = regexp.exec(found.full_text);
+            this.lastQueries[queryKey] = {match, id: found.id_str};
+            logger.info(`found match ${JSON.stringify(match)} in tweet of id ${found.id_str} from timeline`);
+            return match;
+          }
+          const last = tweets.slice(-1)[0].id_str;
+          if (isOld(last)) return null;
+          queryByRegExp(username, regexp, cacheSeconds, last);
+        });
+    };
+  }
+
+  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)));
+    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 ||
+          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,
+        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 || count === undefined || 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();
+  };
+}