|  | @@ -1,7 +1,6 @@
 | 
	
		
			
				|  |  |  import * as fs from 'fs';
 | 
	
		
			
				|  |  |  import * as path from 'path';
 | 
	
		
			
				|  |  | -import * as Twitter from 'twitter';
 | 
	
		
			
				|  |  | -import TwitterTypes from 'twitter-d';
 | 
	
		
			
				|  |  | +import * as Twitter from 'twitter-api-v2';
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  import { getLogger } from './loggers';
 | 
	
		
			
				|  |  |  import QQBot from './koishi';
 | 
	
	
		
			
				|  | @@ -17,13 +16,33 @@ interface IWorkerOption {
 | 
	
		
			
				|  |  |    webshotDelay: number;
 | 
	
		
			
				|  |  |    consumerKey: string;
 | 
	
		
			
				|  |  |    consumerSecret: string;
 | 
	
		
			
				|  |  | -  accessTokenKey: string;
 | 
	
		
			
				|  |  | -  accessTokenSecret: string;
 | 
	
		
			
				|  |  | +  accessTokenKey?: string;
 | 
	
		
			
				|  |  | +  accessTokenSecret?: string;
 | 
	
		
			
				|  |  |    mode: number;
 | 
	
		
			
				|  |  |    wsUrl: string;
 | 
	
		
			
				|  |  |    redis?: IRedisConfig;
 | 
	
		
			
				|  |  |  }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | +export const parseLink = (link: string): string[] => {
 | 
	
		
			
				|  |  | +  let match =
 | 
	
		
			
				|  |  | +    /twitter.com\/([^\/?#]+)\/lists\/([^\/?#]+)/.exec(link) ||
 | 
	
		
			
				|  |  | +    /^([^\/?#]+)\/([^\/?#]+)$/.exec(link);
 | 
	
		
			
				|  |  | +  if (match) return [match[1], `/lists/${match[2]}`];
 | 
	
		
			
				|  |  | +  match =
 | 
	
		
			
				|  |  | +    /twitter.com\/([^\/?#]+)\/status\/(\d+)/.exec(link);
 | 
	
		
			
				|  |  | +  if (match) return [match[1], `/status/${match[2]}`];
 | 
	
		
			
				|  |  | +  match =
 | 
	
		
			
				|  |  | +    /twitter.com\/([^\/?#]+)/.exec(link) ||
 | 
	
		
			
				|  |  | +    /^([^\/?#]+)$/.exec(link);
 | 
	
		
			
				|  |  | +  if (match) return [match[1]];
 | 
	
		
			
				|  |  | +  return;
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +export const linkBuilder = (userName: string, more = ''): string => {
 | 
	
		
			
				|  |  | +  if (!userName) return;
 | 
	
		
			
				|  |  | +  return `https://twitter.com/${userName}${more}`;
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  |  export class ScreenNameNormalizer {
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |    // tslint:disable-next-line: variable-name
 | 
	
	
		
			
				|  | @@ -34,9 +53,10 @@ 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}`);
 | 
	
		
			
				|  |  | +        .then(userNameId => userNameId.split(':')[0])
 | 
	
		
			
				|  |  | +        .catch((err: Twitter.InlineErrorV2) => {
 | 
	
		
			
				|  |  | +          if (err.title === 'Not Found Error') {
 | 
	
		
			
				|  |  | +            logger.warn(`error looking up user: ${showApiError(err)}`);
 | 
	
		
			
				|  |  |              return username;
 | 
	
		
			
				|  |  |            }
 | 
	
		
			
				|  |  |            return null;
 | 
	
	
		
			
				|  | @@ -99,19 +119,53 @@ const retryOnError = <T, U>(
 | 
	
		
			
				|  |  |    doWork().then(resolve).catch(error => retry(error, 1));
 | 
	
		
			
				|  |  |  });
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -export type FullUser = TwitterTypes.FullUser;
 | 
	
		
			
				|  |  | -export type Entities = TwitterTypes.Entities;
 | 
	
		
			
				|  |  | -export type ExtendedEntities = TwitterTypes.ExtendedEntities;
 | 
	
		
			
				|  |  | -export type MediaEntity = TwitterTypes.MediaEntity;
 | 
	
		
			
				|  |  | +const showApiError = (err: Partial<Twitter.InlineErrorV2 & Twitter.ErrorV2 & Error>) =>
 | 
	
		
			
				|  |  | +  err.errors && err.errors[0].message || err.detail || err.stack || JSON.stringify(err);
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -export interface Tweet extends TwitterTypes.Status {
 | 
	
		
			
				|  |  | -  user: FullUser;
 | 
	
		
			
				|  |  | -  retweeted_status?: Tweet;
 | 
	
		
			
				|  |  | -}
 | 
	
		
			
				|  |  | +const toMutableConst = <T>(o: T) => {
 | 
	
		
			
				|  |  | +  // credits: https://stackoverflow.com/a/60493166
 | 
	
		
			
				|  |  | +  type DeepMutableArrays<T> =
 | 
	
		
			
				|  |  | +    (T extends object ? { [K in keyof T]: DeepMutableArrays<T[K]> } : T) extends infer O ?
 | 
	
		
			
				|  |  | +    O extends ReadonlyArray<any> ? { -readonly [K in keyof O]: O[K] } : O : never;
 | 
	
		
			
				|  |  | +  return o as DeepMutableArrays<T>
 | 
	
		
			
				|  |  | +};
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +const v2SingleParams = toMutableConst({
 | 
	
		
			
				|  |  | +  expansions: ['attachments.media_keys', 'author_id', 'referenced_tweets.id'],
 | 
	
		
			
				|  |  | +  'tweet.fields': ['created_at', 'entities'],
 | 
	
		
			
				|  |  | +  'media.fields': ['url', 'variants', 'alt_text'],
 | 
	
		
			
				|  |  | +  'user.fields': ['id', 'name', 'username']
 | 
	
		
			
				|  |  | +} as const);
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +type PickRequired<T, K extends keyof T> = Pick<Required<T>, K> & T;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +export type TweetObject = PickRequired<
 | 
	
		
			
				|  |  | +  Twitter.TweetV2SingleResult['data'],
 | 
	
		
			
				|  |  | +  typeof v2SingleParams['tweet.fields'][number]
 | 
	
		
			
				|  |  | +>;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +export type UserObject = PickRequired<
 | 
	
		
			
				|  |  | +  Twitter.TweetV2SingleResult['includes']['users'][number],
 | 
	
		
			
				|  |  | +  typeof v2SingleParams['user.fields'][number]
 | 
	
		
			
				|  |  | +>;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +export type MediaObject =
 | 
	
		
			
				|  |  | +  Twitter.TweetV2SingleResult['includes']['media'][number] & (
 | 
	
		
			
				|  |  | +    {type: 'video' | 'animated_gif', variants: Twitter.MediaVariantsV2[]} |
 | 
	
		
			
				|  |  | +    {type: 'photo', url: string}
 | 
	
		
			
				|  |  | +  );
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +export interface Tweet extends Twitter.TweetV2SingleResult {
 | 
	
		
			
				|  |  | +  data: TweetObject,
 | 
	
		
			
				|  |  | +  includes: {
 | 
	
		
			
				|  |  | +    media: MediaObject[],
 | 
	
		
			
				|  |  | +    users: UserObject[],
 | 
	
		
			
				|  |  | +  }
 | 
	
		
			
				|  |  | +};
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  export default class {
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -  private client: Twitter;
 | 
	
		
			
				|  |  | +  private client: Twitter.TwitterApiReadOnly;
 | 
	
		
			
				|  |  |    private lock: ILock;
 | 
	
		
			
				|  |  |    private lockfile: string;
 | 
	
		
			
				|  |  |    private workInterval: number;
 | 
	
	
		
			
				|  | @@ -123,12 +177,10 @@ export default class {
 | 
	
		
			
				|  |  |    private redis: RedisSvc;
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |    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,
 | 
	
		
			
				|  |  | -    });
 | 
	
		
			
				|  |  | +    this.client = new Twitter.TwitterApi({
 | 
	
		
			
				|  |  | +      appKey: opt.consumerKey,
 | 
	
		
			
				|  |  | +      appSecret: opt.consumerSecret,
 | 
	
		
			
				|  |  | +    }).readOnly;
 | 
	
		
			
				|  |  |      this.lockfile = opt.lockfile;
 | 
	
		
			
				|  |  |      this.lock = opt.lock;
 | 
	
		
			
				|  |  |      this.workInterval = opt.workInterval;
 | 
	
	
		
			
				|  | @@ -145,19 +197,20 @@ export default class {
 | 
	
		
			
				|  |  |            count: 1 - Number(match[1]),
 | 
	
		
			
				|  |  |            noreps: {on: true, off: false}[match[3].replace(/.*,noreps=([^,]*).*/, '$1')],
 | 
	
		
			
				|  |  |            norts: {on: true, off: false}[match[3].replace(/.*,norts=([^,]*).*/, '$1')],
 | 
	
		
			
				|  |  | -        }).then(tweets => tweets.slice(-1)[0].id_str);
 | 
	
		
			
				|  |  | +        }).then(tweets => tweets.slice(-1)[0].data.id);
 | 
	
		
			
				|  |  |        (match ? query() : Promise.resolve(idOrQuery))
 | 
	
		
			
				|  |  |          .then((id: string) => this.getTweet(
 | 
	
		
			
				|  |  |            id,
 | 
	
		
			
				|  |  |            this.sendTweets({sourceInfo: `tweet ${id}`, reportOnSkip: true, force: forceRefresh}, receiver),
 | 
	
		
			
				|  |  |            forceRefresh
 | 
	
		
			
				|  |  |          ))
 | 
	
		
			
				|  |  | -        .catch((err: {code: number, message: string}[]) => {
 | 
	
		
			
				|  |  | -          if (err[0]?.code === 34)
 | 
	
		
			
				|  |  | +        .catch((err: Twitter.InlineErrorV2) => {
 | 
	
		
			
				|  |  | +          if (err.title !== 'Not Found Error') {
 | 
	
		
			
				|  |  | +            logger.warn(`error retrieving tweet: ${showApiError(err)}`);
 | 
	
		
			
				|  |  | +            this.bot.sendTo(receiver, `获取推文时出现错误:${showApiError(err)}`);
 | 
	
		
			
				|  |  | +          }
 | 
	
		
			
				|  |  | +          if (err.resource_type === 'user') {
 | 
	
		
			
				|  |  |              return this.bot.sendTo(receiver, `找不到用户 ${match[2].replace(/^@?(.*)$/, '@$1')}。`);
 | 
	
		
			
				|  |  | -          if (err[0].code !== 144) {
 | 
	
		
			
				|  |  | -            logger.warn(`error retrieving tweet: ${err[0].message}`);
 | 
	
		
			
				|  |  | -            this.bot.sendTo(receiver, `获取推文时出现错误:${err[0].message}`);
 | 
	
		
			
				|  |  |            }
 | 
	
		
			
				|  |  |            this.bot.sendTo(receiver, '找不到请求的推文,它可能已被删除。');
 | 
	
		
			
				|  |  |          });
 | 
	
	
		
			
				|  | @@ -173,21 +226,21 @@ export default class {
 | 
	
		
			
				|  |  |          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.full_text.replace(/^([\s\S\n]{50})[\s\S\n]+?( https:\/\/t.co\/.*)?$/, '$1…$2')}`
 | 
	
		
			
				|  |  | +          tweets.map(({data}) => () => this.bot.sendTo(receiver, `\
 | 
	
		
			
				|  |  | +编号:${data.id}
 | 
	
		
			
				|  |  | +时间:${data.created_at}
 | 
	
		
			
				|  |  | +媒体:${(data.attachments || {}).media_keys ? '有' : '无'}
 | 
	
		
			
				|  |  | +正文:\n${data.text.replace(/^([\s\S\n]{50})[\s\S\n]+?( https:\/\/t.co\/.*)?$/, '$1…$2')}`
 | 
	
		
			
				|  |  |            ))
 | 
	
		
			
				|  |  |              .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}`);
 | 
	
		
			
				|  |  | +        .catch((err: Twitter.InlineErrorV2) => {
 | 
	
		
			
				|  |  | +          if (err.title !== 'Not Found Error') {
 | 
	
		
			
				|  |  | +            logger.warn(`error retrieving timeline: ${showApiError(err)}`);
 | 
	
		
			
				|  |  | +            return this.bot.sendTo(receiver, `获取时间线时出现错误:${showApiError(err)}`);
 | 
	
		
			
				|  |  |            }
 | 
	
		
			
				|  |  |            this.bot.sendTo(receiver, `找不到用户 ${username.replace(/^@?(.*)$/, '@$1')}。`);
 | 
	
		
			
				|  |  |          });
 | 
	
	
		
			
				|  | @@ -195,15 +248,25 @@ export default class {
 | 
	
		
			
				|  |  |    }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |    public launch = () => {
 | 
	
		
			
				|  |  | -    this.webshot = new Webshot(
 | 
	
		
			
				|  |  | -      this.wsUrl,
 | 
	
		
			
				|  |  | -      this.mode,
 | 
	
		
			
				|  |  | -      () => setTimeout(this.work, this.workInterval * 1000)
 | 
	
		
			
				|  |  | -    );
 | 
	
		
			
				|  |  | +    this.client.appLogin().then(client => {
 | 
	
		
			
				|  |  | +      this.client = client.readOnly;
 | 
	
		
			
				|  |  | +      this.webshot = new Webshot(
 | 
	
		
			
				|  |  | +        this.wsUrl,
 | 
	
		
			
				|  |  | +        this.mode,
 | 
	
		
			
				|  |  | +        () => setTimeout(this.work, this.workInterval * 1000)
 | 
	
		
			
				|  |  | +      );
 | 
	
		
			
				|  |  | +    });
 | 
	
		
			
				|  |  |    };
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -  public queryUser = (username: string) => this.client.get('users/show', {screen_name: username})
 | 
	
		
			
				|  |  | -    .then((user: FullUser) => user.screen_name);
 | 
	
		
			
				|  |  | +  public queryUser = (username: string) => {
 | 
	
		
			
				|  |  | +    const thread = this.lock.threads[linkBuilder(username)];
 | 
	
		
			
				|  |  | +    if (thread && thread.id) return Promise.resolve(`${username}:${thread.id}`);
 | 
	
		
			
				|  |  | +    return this.client.v2.userByUsername(username).then(({data: {username, id}, errors}) => {
 | 
	
		
			
				|  |  | +      if (errors && errors.length > 0) throw errors[0];
 | 
	
		
			
				|  |  | +      if (thread) thread.id = id;
 | 
	
		
			
				|  |  | +      return `${username}:${id}`;
 | 
	
		
			
				|  |  | +    })
 | 
	
		
			
				|  |  | +  }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |    public queryTimelineReverse = (conf: ITimelineQueryConfig) => {
 | 
	
		
			
				|  |  |      if (!conf.since) return this.queryTimeline(conf);
 | 
	
	
		
			
				|  | @@ -230,7 +293,7 @@ export default class {
 | 
	
		
			
				|  |  |    };
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |    public queryTimeline = (
 | 
	
		
			
				|  |  | -    { username, count, since, until, noreps, norts }: ITimelineQueryConfig
 | 
	
		
			
				|  |  | +    {username, count, since, until, noreps, norts}: ITimelineQueryConfig
 | 
	
		
			
				|  |  |    ) => {
 | 
	
		
			
				|  |  |      username = username.replace(/^@?(.*)$/, '@$1');
 | 
	
		
			
				|  |  |      logger.info(`querying timeline of ${username} with config: ${
 | 
	
	
		
			
				|  | @@ -241,59 +304,43 @@ export default class {
 | 
	
		
			
				|  |  |          ...(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: Tweet[] = []
 | 
	
		
			
				|  |  | -    ): Promise<Tweet[]> => this.client.get('statuses/user_timeline', config)
 | 
	
		
			
				|  |  | -      .then((newTweets: Tweet[]) => {
 | 
	
		
			
				|  |  | -        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();
 | 
	
		
			
				|  |  | +    return this.queryUser(username.slice(1)).then(userNameId =>
 | 
	
		
			
				|  |  | +      this.get('userTimeline', userNameId.split(':')[1], {
 | 
	
		
			
				|  |  | +        expansions: ['attachments.media_keys', 'author_id'],
 | 
	
		
			
				|  |  | +        'tweet.fields': ['created_at'],
 | 
	
		
			
				|  |  | +        exclude: [
 | 
	
		
			
				|  |  | +          ...(noreps ?? true) ? ['replies' as const] : [],
 | 
	
		
			
				|  |  | +          ...(norts ?? false) ? ['retweets' as const] : [],
 | 
	
		
			
				|  |  | +        ],
 | 
	
		
			
				|  |  | +        ...(count && {max_results: Math.min(Math.max(count, 5), 100)}),
 | 
	
		
			
				|  |  | +        ...(since && {since_id: since}),
 | 
	
		
			
				|  |  | +        ...(until && {until_id: until}),
 | 
	
		
			
				|  |  | +      }).then(tweets => tweets.slice(0, count))
 | 
	
		
			
				|  |  | +    );
 | 
	
		
			
				|  |  |    };
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |    private workOnTweets = (
 | 
	
		
			
				|  |  |      tweets: Tweet[],
 | 
	
		
			
				|  |  |      sendTweets: (cacheId: string, msg: string, text: string, author: string) => void,
 | 
	
		
			
				|  |  |      refresh = false
 | 
	
		
			
				|  |  | -  ) => Promise.all(tweets.map(tweet =>
 | 
	
		
			
				|  |  | +  ) => Promise.all(tweets.map(({data, includes}) =>
 | 
	
		
			
				|  |  |      ((this.redis && !refresh) ?
 | 
	
		
			
				|  |  | -      this.redis.waitForProcess(`webshot/${tweet.id_str}`, this.webshotDelay * 4)
 | 
	
		
			
				|  |  | -        .then(() => this.redis.getContent(`webshot/${tweet.id_str}`)) :
 | 
	
		
			
				|  |  | +      this.redis.waitForProcess(`webshot/${data.id}`, this.webshotDelay * 4)
 | 
	
		
			
				|  |  | +        .then(() => this.redis.getContent(`webshot/${data.id}`)) :
 | 
	
		
			
				|  |  |        Promise.reject())
 | 
	
		
			
				|  |  |        .then(content => {
 | 
	
		
			
				|  |  |          if (content === null) throw Error();
 | 
	
		
			
				|  |  | -        logger.info(`retrieved cached webshot of tweet ${tweet.id_str} from redis database`);
 | 
	
		
			
				|  |  | +        logger.info(`retrieved cached webshot of tweet ${data.id} from redis database`);
 | 
	
		
			
				|  |  |          const {msg, text, author} = JSON.parse(content) as {[key: string]: string};
 | 
	
		
			
				|  |  | -        let cacheId = tweet.id_str;
 | 
	
		
			
				|  |  | -        if (tweet.retweeted_status) cacheId += `,rt:${tweet.retweeted_status.id_str}`;
 | 
	
		
			
				|  |  | +        let cacheId = data.id;
 | 
	
		
			
				|  |  | +        const retweetRef = (data.referenced_tweets || []).find(ref => ref.type === 'retweeted');
 | 
	
		
			
				|  |  | +        if (retweetRef) cacheId += `,rt:${retweetRef.id}`;
 | 
	
		
			
				|  |  |          sendTweets(cacheId, msg, text, author);
 | 
	
		
			
				|  |  |          return null as Tweet;
 | 
	
		
			
				|  |  |        })
 | 
	
		
			
				|  |  |        .catch(() => {
 | 
	
		
			
				|  |  | -        this.redis.startProcess(`webshot/${tweet.id_str}`);
 | 
	
		
			
				|  |  | -        return tweet;
 | 
	
		
			
				|  |  | +        this.redis.startProcess(`webshot/${data.id}`);
 | 
	
		
			
				|  |  | +        return {data, includes} as Tweet;
 | 
	
		
			
				|  |  |        })
 | 
	
		
			
				|  |  |    )).then(tweets =>
 | 
	
		
			
				|  |  |      this.webshot(
 | 
	
	
		
			
				|  | @@ -317,31 +364,26 @@ export default class {
 | 
	
		
			
				|  |  |      id: string,
 | 
	
		
			
				|  |  |      sender: (cacheId: string, msg: string, text: string, author: string) => void,
 | 
	
		
			
				|  |  |      refresh = false
 | 
	
		
			
				|  |  | -  ) => {
 | 
	
		
			
				|  |  | -    const endpoint = 'statuses/show';
 | 
	
		
			
				|  |  | -    const config = {
 | 
	
		
			
				|  |  | -      id,
 | 
	
		
			
				|  |  | -      tweet_mode: 'extended',
 | 
	
		
			
				|  |  | -    };
 | 
	
		
			
				|  |  | -    return ((this.redis && !refresh) ?
 | 
	
		
			
				|  |  | +  ) =>
 | 
	
		
			
				|  |  | +    ((this.redis && !refresh) ?
 | 
	
		
			
				|  |  |        this.redis.waitForProcess(`webshot/${id}`, this.webshotDelay * 4)
 | 
	
		
			
				|  |  |          .then(() => this.redis.getContent(`webshot/${id}`))
 | 
	
		
			
				|  |  |          .then(content => {
 | 
	
		
			
				|  |  |            if (content === null) throw Error();
 | 
	
		
			
				|  |  |            const {rtid} = JSON.parse(content);
 | 
	
		
			
				|  |  | -          return {id_str: id, retweeted_status: rtid ? {id_str: rtid} : undefined} as Tweet;
 | 
	
		
			
				|  |  | +          return {data: {id, ...rtid && {referenced_tweets: [{type: 'retweeted', id: rtid}]}}} as Tweet;
 | 
	
		
			
				|  |  |          }) :
 | 
	
		
			
				|  |  | -      Promise.reject())
 | 
	
		
			
				|  |  | -      .catch(() => this.client.get(endpoint, config))
 | 
	
		
			
				|  |  | +      Promise.reject()
 | 
	
		
			
				|  |  | +    )
 | 
	
		
			
				|  |  | +      .catch(() => this.client.v2.singleTweet(id, v2SingleParams))
 | 
	
		
			
				|  |  |        .then((tweet: Tweet) => {
 | 
	
		
			
				|  |  | -        if (tweet.id) {
 | 
	
		
			
				|  |  | +        if (tweet.data.text) {
 | 
	
		
			
				|  |  |            logger.debug(`api returned tweet ${JSON.stringify(tweet)} for query id=${id}`);
 | 
	
		
			
				|  |  |          } else {
 | 
	
		
			
				|  |  |            logger.debug(`skipped querying api as this tweet has been cached`)
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |          return this.workOnTweets([tweet], sender, refresh);
 | 
	
		
			
				|  |  |        });
 | 
	
		
			
				|  |  | -  };
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |    private sendTweets = (
 | 
	
		
			
				|  |  |      config: {sourceInfo?: string, reportOnSkip?: boolean, force?: boolean}
 | 
	
	
		
			
				|  | @@ -382,6 +424,26 @@ export default class {
 | 
	
		
			
				|  |  |      });
 | 
	
		
			
				|  |  |    };
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | +  private get = <T extends 'userTimeline' | 'listTweets'>(
 | 
	
		
			
				|  |  | +    type: T, targetId: string, params: Parameters<typeof this.client.v2[T]>[1]
 | 
	
		
			
				|  |  | +  ) => {
 | 
	
		
			
				|  |  | +    const getMore = (res: Twitter.TweetUserTimelineV2Paginator | Twitter.TweetV2ListTweetsPaginator) => {
 | 
	
		
			
				|  |  | +      if (res.errors && res.errors.length > 0) throw res.errors[0];
 | 
	
		
			
				|  |  | +      if (!res.meta.next_token || res.meta.result_count >= params.max_results) return res;
 | 
	
		
			
				|  |  | +      return res.fetchNext().then<typeof res>(getMore);
 | 
	
		
			
				|  |  | +    };
 | 
	
		
			
				|  |  | +    return this.client.v2[type](targetId, params).then(getMore)
 | 
	
		
			
				|  |  | +      .then(({includes, tweets}) => tweets.map((tweet): Tweet =>
 | 
	
		
			
				|  |  | +        ({
 | 
	
		
			
				|  |  | +          data: tweet as TweetObject,
 | 
	
		
			
				|  |  | +          includes: {
 | 
	
		
			
				|  |  | +            media: includes.medias(tweet) as MediaObject[],
 | 
	
		
			
				|  |  | +            users: [includes.author(tweet)]
 | 
	
		
			
				|  |  | +          }
 | 
	
		
			
				|  |  | +        })
 | 
	
		
			
				|  |  | +      ));
 | 
	
		
			
				|  |  | +  };
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  |    public work = () => {
 | 
	
		
			
				|  |  |      const lock = this.lock;
 | 
	
		
			
				|  |  |      if (this.workInterval < 1) this.workInterval = 1;
 | 
	
	
		
			
				|  | @@ -406,60 +468,56 @@ export default class {
 | 
	
		
			
				|  |  |      const currentFeed = lock.feed[lock.workon];
 | 
	
		
			
				|  |  |      logger.debug(`pulling feed ${currentFeed}`);
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    const promise = new Promise(resolve => {
 | 
	
		
			
				|  |  | +    const promise = new Promise<Tweet[]>(resolve => {
 | 
	
		
			
				|  |  | +      let job = Promise.resolve();
 | 
	
		
			
				|  |  |        let match = /https:\/\/twitter.com\/([^\/]+)\/lists\/([^\/]+)/.exec(currentFeed);
 | 
	
		
			
				|  |  | -      let config: {[key: string]: any};
 | 
	
		
			
				|  |  | -      let endpoint: string;
 | 
	
		
			
				|  |  | +      let id: string;
 | 
	
		
			
				|  |  | +      let endpoint: Parameters<typeof this.get>[0];
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  |        if (match) {
 | 
	
		
			
				|  |  | +        endpoint = 'listTweets';
 | 
	
		
			
				|  |  |          if (match[1] === 'i') {
 | 
	
		
			
				|  |  | -          config = {
 | 
	
		
			
				|  |  | -            list_id: match[2],
 | 
	
		
			
				|  |  | -            tweet_mode: 'extended',
 | 
	
		
			
				|  |  | -          };
 | 
	
		
			
				|  |  | +          id = match[2];
 | 
	
		
			
				|  |  |          } else {
 | 
	
		
			
				|  |  | -          config = {
 | 
	
		
			
				|  |  | +          job = job.then(() => this.client.v1.list({
 | 
	
		
			
				|  |  |              owner_screen_name: match[1],
 | 
	
		
			
				|  |  |              slug: match[2],
 | 
	
		
			
				|  |  | -            tweet_mode: 'extended',
 | 
	
		
			
				|  |  | -          };
 | 
	
		
			
				|  |  | +          })).then(({id_str}) => {
 | 
	
		
			
				|  |  | +            id = id_str;
 | 
	
		
			
				|  |  | +          });
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  | -        endpoint = 'lists/statuses';
 | 
	
		
			
				|  |  |        } else {
 | 
	
		
			
				|  |  |          match = /https:\/\/twitter.com\/([^\/]+)/.exec(currentFeed);
 | 
	
		
			
				|  |  |          if (match) {
 | 
	
		
			
				|  |  | -          config = {
 | 
	
		
			
				|  |  | -            screen_name: match[1],
 | 
	
		
			
				|  |  | -            exclude_replies: false,
 | 
	
		
			
				|  |  | -            tweet_mode: 'extended',
 | 
	
		
			
				|  |  | -          };
 | 
	
		
			
				|  |  | -          endpoint = 'statuses/user_timeline';
 | 
	
		
			
				|  |  | +          endpoint = 'userTimeline';
 | 
	
		
			
				|  |  | +          id = lock.threads[currentFeed].id;
 | 
	
		
			
				|  |  | +          if (id === undefined) {
 | 
	
		
			
				|  |  | +            job = job.then(() => this.queryUser(
 | 
	
		
			
				|  |  | +              match[1]
 | 
	
		
			
				|  |  | +            )).then(userNameId => {
 | 
	
		
			
				|  |  | +              lock.threads[currentFeed].id = id = userNameId.split(':')[1];
 | 
	
		
			
				|  |  | +            });
 | 
	
		
			
				|  |  | +          }
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |        }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -      if (endpoint) {
 | 
	
		
			
				|  |  | -        const offset = lock.threads[currentFeed].offset;
 | 
	
		
			
				|  |  | -        if (offset as unknown as number > 0) config.since_id = offset;
 | 
	
		
			
				|  |  | -        const getMore = (lastTweets: Tweet[] = []) => this.client.get(
 | 
	
		
			
				|  |  | -          endpoint, config, (error: {[key: string]: any}[], tweets: Tweet[]
 | 
	
		
			
				|  |  | -        ) => {
 | 
	
		
			
				|  |  | -          if (error) {
 | 
	
		
			
				|  |  | -            if (error instanceof Array && error.length > 0 && error[0].code === 34) {
 | 
	
		
			
				|  |  | -              logger.warn(`error on fetching tweets for ${currentFeed}: ${JSON.stringify(error)}`);
 | 
	
		
			
				|  |  | -              lock.threads[currentFeed].subscribers.forEach(subscriber => {
 | 
	
		
			
				|  |  | -                logger.info(`sending notfound message of ${currentFeed} to ${JSON.stringify(subscriber)}`);
 | 
	
		
			
				|  |  | -                this.bot.sendTo(subscriber, `链接 ${currentFeed} 指向的用户或列表不存在,请退订。`).catch();
 | 
	
		
			
				|  |  | -              });
 | 
	
		
			
				|  |  | -            } else {
 | 
	
		
			
				|  |  | -              logger.error(`unhandled error on fetching tweets for ${currentFeed}: ${JSON.stringify(error)}`);
 | 
	
		
			
				|  |  | -            }
 | 
	
		
			
				|  |  | -          }
 | 
	
		
			
				|  |  | -          if (!(tweets instanceof Array) || tweets.length === 0) return resolve(lastTweets);
 | 
	
		
			
				|  |  | -          if (offset as unknown as number <= 0) return resolve(lastTweets.concat(tweets));
 | 
	
		
			
				|  |  | -          config.max_id = BigNumOps.plus(tweets.slice(-1)[0].id_str, '-1');
 | 
	
		
			
				|  |  | -          getMore(lastTweets.concat(tweets));
 | 
	
		
			
				|  |  | -        });
 | 
	
		
			
				|  |  | -        getMore();
 | 
	
		
			
				|  |  | -      }
 | 
	
		
			
				|  |  | +      const offset = lock.threads[currentFeed].offset;
 | 
	
		
			
				|  |  | +      job.then(() => this.get(endpoint, id, {
 | 
	
		
			
				|  |  | +        ...v2SingleParams,
 | 
	
		
			
				|  |  | +        max_results: 20,
 | 
	
		
			
				|  |  | +        ...(offset as unknown as number > 0) && {since_id: offset},
 | 
	
		
			
				|  |  | +      })).catch((err: Twitter.InlineErrorV2) => {
 | 
	
		
			
				|  |  | +        if (err.title === 'Not Found Error') {
 | 
	
		
			
				|  |  | +          logger.warn(`error on fetching tweets for ${currentFeed}: ${showApiError(err)}`);
 | 
	
		
			
				|  |  | +          lock.threads[currentFeed].subscribers.forEach(subscriber => {
 | 
	
		
			
				|  |  | +            logger.info(`sending notfound message of ${currentFeed} to ${JSON.stringify(subscriber)}`);
 | 
	
		
			
				|  |  | +            this.bot.sendTo(subscriber, `链接 ${currentFeed} 指向的用户或列表不存在,请退订。`).catch();
 | 
	
		
			
				|  |  | +          });
 | 
	
		
			
				|  |  | +        } else {
 | 
	
		
			
				|  |  | +          logger.error(`unhandled error on fetching tweets for ${currentFeed}: ${showApiError(err)}`);
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +        return [] as Tweet[];
 | 
	
		
			
				|  |  | +      }).then(resolve);
 | 
	
		
			
				|  |  |      });
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |      promise.then((tweets: Tweet[]) => {
 | 
	
	
		
			
				|  | @@ -469,7 +527,7 @@ export default class {
 | 
	
		
			
				|  |  |        const updateDate = () => currentThread.updatedAt = new Date().toString();
 | 
	
		
			
				|  |  |        if (tweets.length === 0) { updateDate(); return; }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -      const topOfFeed = tweets[0].id_str;
 | 
	
		
			
				|  |  | +      const topOfFeed = tweets[0].data.id;
 | 
	
		
			
				|  |  |        const updateOffset = () => currentThread.offset = topOfFeed;
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |        if (currentThread.offset === '-1') { updateOffset(); return; }
 |