|
@@ -1,9 +1,10 @@
|
|
|
import * as fs from 'fs';
|
|
|
import * as path from 'path';
|
|
|
import * as Twitter from 'twitter';
|
|
|
+import TwitterTypes from 'twitter-d';
|
|
|
|
|
|
import { getLogger } from './loggers';
|
|
|
-import QQBot, { MessageChain, MiraiMessage as Message } from './mirai';
|
|
|
+import QQBot, { Message, MessageChain } from './mirai';
|
|
|
import Webshot from './webshot';
|
|
|
|
|
|
interface IWorkerOption {
|
|
@@ -12,7 +13,6 @@ interface IWorkerOption {
|
|
|
bot: QQBot;
|
|
|
workInterval: number;
|
|
|
webshotDelay: number;
|
|
|
- webshotOutDir: string;
|
|
|
consumer_key: string;
|
|
|
consumer_secret: string;
|
|
|
access_token_key: string;
|
|
@@ -22,15 +22,31 @@ interface IWorkerOption {
|
|
|
|
|
|
const logger = getLogger('twitter');
|
|
|
|
|
|
+export type FullUser = TwitterTypes.FullUser;
|
|
|
+export type Entities = TwitterTypes.Entities;
|
|
|
+export type ExtendedEntities = TwitterTypes.ExtendedEntities;
|
|
|
+
|
|
|
+interface ITweet {
|
|
|
+ user: FullUser;
|
|
|
+ entities: Entities;
|
|
|
+ extended_entities: ExtendedEntities;
|
|
|
+ full_text: string;
|
|
|
+ display_text_range: [number, number];
|
|
|
+ id_str: string;
|
|
|
+ retweeted_status?: Tweet;
|
|
|
+}
|
|
|
+
|
|
|
+export type Tweet = ITweet;
|
|
|
+export type Tweets = ITweet[];
|
|
|
+
|
|
|
export default class {
|
|
|
|
|
|
- private client;
|
|
|
+ private client: Twitter;
|
|
|
private lock: ILock;
|
|
|
private lockfile: string;
|
|
|
private workInterval: number;
|
|
|
private bot: QQBot;
|
|
|
private webshotDelay: number;
|
|
|
- private webshotOutDir: string;
|
|
|
private webshot: Webshot;
|
|
|
private mode: number;
|
|
|
|
|
@@ -46,13 +62,11 @@ export default class {
|
|
|
this.workInterval = opt.workInterval;
|
|
|
this.bot = opt.bot;
|
|
|
this.webshotDelay = opt.webshotDelay;
|
|
|
- this.webshotOutDir = opt.webshotOutDir;
|
|
|
this.mode = opt.mode;
|
|
|
}
|
|
|
|
|
|
public launch = () => {
|
|
|
this.webshot = new Webshot(
|
|
|
- this.webshotOutDir,
|
|
|
this.mode,
|
|
|
() => setTimeout(this.work, this.workInterval * 1000)
|
|
|
);
|
|
@@ -79,10 +93,11 @@ export default class {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- logger.debug(`pulling feed ${lock.feed[lock.workon]}`);
|
|
|
+ const currentFeed = lock.feed[lock.workon];
|
|
|
+ logger.debug(`pulling feed ${currentFeed}`);
|
|
|
|
|
|
const promise = new Promise(resolve => {
|
|
|
- let match = lock.feed[lock.workon].match(/https:\/\/twitter.com\/([^\/]+)\/lists\/([^\/]+)/);
|
|
|
+ let match = currentFeed.match(/https:\/\/twitter.com\/([^\/]+)\/lists\/([^\/]+)/);
|
|
|
let config: any;
|
|
|
let endpoint: string;
|
|
|
if (match) {
|
|
@@ -93,7 +108,7 @@ export default class {
|
|
|
};
|
|
|
endpoint = 'lists/statuses';
|
|
|
} else {
|
|
|
- match = lock.feed[lock.workon].match(/https:\/\/twitter.com\/([^\/]+)/);
|
|
|
+ match = currentFeed.match(/https:\/\/twitter.com\/([^\/]+)/);
|
|
|
if (match) {
|
|
|
config = {
|
|
|
screen_name: match[1],
|
|
@@ -105,18 +120,18 @@ export default class {
|
|
|
}
|
|
|
|
|
|
if (endpoint) {
|
|
|
- const offset = lock.threads[lock.feed[lock.workon]].offset;
|
|
|
+ const offset = lock.threads[currentFeed].offset as unknown as number;
|
|
|
if (offset > 0) config.since_id = offset;
|
|
|
this.client.get(endpoint, config, (error, tweets, response) => {
|
|
|
if (error) {
|
|
|
if (error instanceof Array && error.length > 0 && error[0].code === 34) {
|
|
|
- logger.warn(`error on fetching tweets for ${lock.feed[lock.workon]}: ${JSON.stringify(error)}`);
|
|
|
- lock.threads[lock.feed[lock.workon]].subscribers.forEach(subscriber => {
|
|
|
- logger.info(`sending notfound message of ${lock.feed[lock.workon]} to ${JSON.stringify(subscriber)}`);
|
|
|
- this.bot.sendTo(subscriber, `链接 ${lock.feed[lock.workon]} 指向的用户或列表不存在,请退订。`).catch();
|
|
|
+ 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 ${lock.feed[lock.workon]}: ${JSON.stringify(error)}`);
|
|
|
+ logger.error(`unhandled error on fetching tweets for ${currentFeed}: ${JSON.stringify(error)}`);
|
|
|
}
|
|
|
resolve();
|
|
|
} else resolve(tweets);
|
|
@@ -124,21 +139,22 @@ export default class {
|
|
|
}
|
|
|
});
|
|
|
|
|
|
- promise.then((tweets: any) => {
|
|
|
- logger.debug(`api returned ${JSON.stringify(tweets)} for feed ${lock.feed[lock.workon]}`);
|
|
|
- if (!tweets || tweets.length === 0) {
|
|
|
- lock.threads[lock.feed[lock.workon]].updatedAt = new Date().toString();
|
|
|
- return;
|
|
|
- }
|
|
|
- if (lock.threads[lock.feed[lock.workon]].offset === -1) {
|
|
|
- lock.threads[lock.feed[lock.workon]].offset = tweets[0].id_str;
|
|
|
- return;
|
|
|
- }
|
|
|
- if (lock.threads[lock.feed[lock.workon]].offset === 0) tweets.splice(1);
|
|
|
+ promise.then((tweets: Tweets) => {
|
|
|
+ logger.debug(`api returned ${JSON.stringify(tweets)} for feed ${currentFeed}`);
|
|
|
+ const currentThread = lock.threads[currentFeed];
|
|
|
+
|
|
|
+ const updateDate = () => currentThread.updatedAt = new Date().toString();
|
|
|
+ if (!tweets || tweets.length === 0) { updateDate(); return; }
|
|
|
+
|
|
|
+ const topOfFeed = tweets[0].id_str;
|
|
|
+ const updateOffset = () => currentThread.offset = topOfFeed;
|
|
|
+
|
|
|
+ if (currentThread.offset === '-1') { updateOffset(); return; }
|
|
|
+ if (currentThread.offset === '0') tweets.splice(1);
|
|
|
|
|
|
const maxCount = 3;
|
|
|
- let sendTimeout = 10000;
|
|
|
- const retryTimeout = 1500;
|
|
|
+ const uploadTimeout = 10000;
|
|
|
+ const retryInterval = 1500;
|
|
|
const ordinal = (n: number) => {
|
|
|
switch ((~~(n / 10) % 10 === 1) ? 0 : n % 10) {
|
|
|
case 1:
|
|
@@ -151,34 +167,57 @@ export default class {
|
|
|
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) => {
|
|
|
- lock.threads[lock.feed[lock.workon]].subscribers.forEach(subscriber => {
|
|
|
- logger.info(`pushing data of thread ${lock.feed[lock.workon]} to ${JSON.stringify(subscriber)}`);
|
|
|
- const retry = (reason, count: number) => { // workaround for https://github.com/mamoe/mirai/issues/194
|
|
|
- if (count <= maxCount) sendTimeout *= (count + 2) / (count + 1);
|
|
|
- setTimeout(() => {
|
|
|
- (msg as MessageChain).forEach((message, pos) => {
|
|
|
- if (count > maxCount && message.type === 'Image') {
|
|
|
- if (pos === 0) {
|
|
|
- logger.warn(`${count - 1} consecutive failures sending webshot, trying plain text instead...`);
|
|
|
- msg[pos] = Message.Plain(author + text);
|
|
|
- } else {
|
|
|
- msg[pos] = Message.Plain(`[失败的图片:${message.path}]`);
|
|
|
- }
|
|
|
- }
|
|
|
- });
|
|
|
+ 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...`);
|
|
|
- this.bot.sendTo(subscriber, msg, sendTimeout).catch(error => retry(error, count + 1));
|
|
|
- }, retryTimeout);
|
|
|
- };
|
|
|
- this.bot.sendTo(subscriber, msg, sendTimeout).catch(error => retry(error, 1));
|
|
|
+ } 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 as any)(tweets, sendTweets, this.webshotDelay)
|
|
|
- .then(() => {
|
|
|
- lock.threads[lock.feed[lock.workon]].offset = tweets[0].id_str;
|
|
|
- lock.threads[lock.feed[lock.workon]].updatedAt = new Date().toString();
|
|
|
- });
|
|
|
+ return this.webshot(tweets, uploader, sendTweets, this.webshotDelay)
|
|
|
+ .then(updateDate).then(updateOffset);
|
|
|
})
|
|
|
.then(() => {
|
|
|
lock.workon++;
|
|
@@ -189,6 +228,5 @@ export default class {
|
|
|
this.work();
|
|
|
}, timeout);
|
|
|
});
|
|
|
-
|
|
|
}
|
|
|
}
|