|
@@ -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; }
|