"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.sendTimeline = exports.sendTweet = exports.ScreenNameNormalizer = void 0; const fs = require("fs"); const path = require("path"); const Twitter = require("twitter"); const loggers_1 = require("./loggers"); const redis_1 = require("./redis"); const utils_1 = require("./utils"); 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(/^@/, ''); let sendTweet = (id, receiver) => { throw Error(); }; exports.sendTweet = sendTweet; let sendTimeline = (conf, receiver) => { throw Error(); }; exports.sendTimeline = sendTimeline; const TWITTER_EPOCH = 1288834974657; const snowflake = (epoch) => Number.isNaN(epoch) ? undefined : utils_1.BigNumOps.lShift(String(epoch - 1 - TWITTER_EPOCH), 22); const logger = loggers_1.getLogger('twitter'); const maxTrials = 3; const retryInterval = 1500; const ordinal = (n) => { switch ((Math.trunc(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.wsUrl, 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.queryTimelineReverse = (conf) => { if (!conf.since) return this.queryTimeline(conf); const count = conf.count; const maxID = conf.until; conf.count = undefined; const until = () => utils_1.BigNumOps.min(maxID, utils_1.BigNumOps.plus(conf.since, String(7 * 24 * 3600 * 1000 * Math.pow(2, 22)))); conf.until = until(); const promise = (tweets) => this.queryTimeline(conf).then(newTweets => { tweets = newTweets.concat(tweets); conf.since = conf.until; conf.until = until(); if (tweets.length >= count || utils_1.BigNumOps.compare(conf.since, conf.until) >= 0) { return tweets.slice(-count); } return promise(tweets); }); return promise([]); }; this.queryTimeline = ({ username, count, since, until, noreps, norts }) => { username = username.replace(/^@?(.*)$/, '@$1'); logger.info(`querying timeline of ${username} with config: ${JSON.stringify(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, (count && { count })), (since && { since })), (until && { until })), (noreps && { noreps })), (norts && { norts })))}`); const fetchTimeline = (config = { screen_name: username.slice(1), trim_user: true, exclude_replies: noreps !== null && noreps !== void 0 ? noreps : true, include_rts: !(norts !== null && norts !== void 0 ? norts : false), since_id: since, max_id: until, tweet_mode: 'extended', }, tweets = []) => this.client.get('statuses/user_timeline', config) .then((newTweets) => { if (newTweets.length) { logger.debug(`fetched tweets: ${JSON.stringify(newTweets)}`); config.max_id = utils_1.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(); }; this.workOnTweets = (tweets, sendTweets) => Promise.all(tweets.map(tweet => (this.redis ? this.redis.getContent(`webshot/${tweet.id_str}`) : Promise.reject()) .then(content => { if (content === null) throw Error(); logger.info(`retrieved cached webshot of tweet ${tweet.id_str} from redis database`); const { msg, text, author } = JSON.parse(content); sendTweets(tweet.retweeted_status ? tweet.retweeted_status.id_str : tweet.id_str, msg, text, author); }).catch(() => this.webshot([tweet], (id, msg, text, author) => { Promise.resolve() .then(() => { if (!this.redis) return; logger.info(`caching webshot of tweet ${tweet.id_str} to redis database`); this.redis.cacheContent(`webshot/${tweet.id_str}`, JSON.stringify({ msg, text, author })); }) .then(() => sendTweets(id, msg, text, author)); }, this.webshotDelay)))); this.getTweet = (id, sender) => { const endpoint = 'statuses/show'; const config = { id, tweet_mode: 'extended', }; return this.client.get(endpoint, config) .then((tweet) => { logger.debug(`api returned tweet ${JSON.stringify(tweet)} for query id=${id}`); return this.workOnTweets([tweet], sender); }); }; this.sendTweets = (config = { reportOnSkip: false }, ...to) => (id, msg, text, author) => { to.forEach(subscriber => { const { sourceInfo: source, reportOnSkip } = config; const targetStr = JSON.stringify(subscriber); const send = () => 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, true)); } }).then(() => { if (this.redis) { logger.info(`caching push status of this tweet (or its origin in case of a retweet) for ${targetStr}...`); return this.redis.cacheForChat(id, subscriber); } }); (this.redis ? this.redis.isCachedForChat(id, subscriber) : Promise.resolve(false)) .then(isCached => { if (isCached) { logger.info(`skipped subscriber ${targetStr} as this tweet or the origin of this retweet has been sent already`); if (!reportOnSkip) return; text = `[最近发送过的推文:${id}]`; msg = author + text; } logger.info(`pushing data${source ? ` of ${source}` : ''} to ${targetStr}`); return send(); }); }); }; this.work = () => { const lock = this.lock; if (this.workInterval < 1) this.workInterval = 1; if (lock.feed.length === 0) { setTimeout(() => { this.work(); }, this.workInterval * 1000); return; } if (lock.workon >= lock.feed.length) lock.workon = 0; if (!lock.threads[lock.feed[lock.workon]] || !lock.threads[lock.feed[lock.workon]].subscribers || lock.threads[lock.feed[lock.workon]].subscribers.length === 0) { logger.warn(`nobody subscribes thread ${lock.feed[lock.workon]}, removing from feed`); delete lock.threads[lock.feed[lock.workon]]; lock.feed.splice(lock.workon, 1); fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock)); this.work(); return; } const currentFeed = lock.feed[lock.workon]; logger.debug(`pulling feed ${currentFeed}`); const promise = new Promise(resolve => { let match = /https:\/\/twitter.com\/([^\/]+)\/lists\/([^\/]+)/.exec(currentFeed); let config; let endpoint; if (match) { 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 = /https:\/\/twitter.com\/([^\/]+)/.exec(currentFeed); if (match) { config = { screen_name: match[1], exclude_replies: false, tweet_mode: 'extended', }; endpoint = 'statuses/user_timeline'; } } if (endpoint) { const offset = lock.threads[currentFeed].offset; 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 ${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)}`); } resolve([]); } else resolve(tweets); }); } }); promise.then((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); return this.workOnTweets(tweets, this.sendTweets({ sourceInfo: `thread ${currentFeed}` }, ...currentThread.subscribers)) .then(updateDate).then(updateOffset); }) .then(() => { lock.workon++; let timeout = this.workInterval * 1000 / lock.feed.length; if (timeout < 1000) timeout = 1000; fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock)); setTimeout(() => { this.work(); }, timeout); }); }; this.client = new Twitter({ consumer_key: opt.consumerKey, consumer_secret: opt.consumerSecret, access_token_key: opt.accessTokenKey, access_token_secret: opt.accessTokenSecret, }); this.lockfile = opt.lockfile; this.lock = opt.lock; this.workInterval = opt.workInterval; this.bot = opt.bot; this.webshotDelay = opt.webshotDelay; this.mode = opt.mode; this.wsUrl = opt.wsUrl; if (opt.redis) this.redis = new redis_1.default(opt.redis); ScreenNameNormalizer._queryUser = this.queryUser; exports.sendTweet = (id, receiver) => { this.getTweet(id, this.sendTweets({ sourceInfo: `tweet ${id}`, reportOnSkip: true }, 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.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: utils_1.BigNumOps.parse(since) || snowflake(new Date(since).getTime()), until: utils_1.BigNumOps.parse(until) || snowflake(new Date(until).getTime()), noreps: { on: true, off: false }[noreps], norts: { on: true, off: false }[norts], }) .then(tweets => utils_1.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')}`)) .concat(() => this.bot.sendTo(receiver, tweets.length ? '时间线查询完毕,使用 /twitter_view <编号> 查看推文详细内容。' : '时间线查询完毕,没有找到符合条件的推文。')))) .catch((err) => { var _a, _b, _c; if (((_a = err[0]) === null || _a === void 0 ? void 0 : _a.code) !== 34) { logger.warn(`error retrieving timeline: ${((_b = err[0]) === null || _b === void 0 ? void 0 : _b.message) || err}`); return this.bot.sendTo(receiver, `获取时间线时出现错误:${((_c = err[0]) === null || _c === void 0 ? void 0 : _c.message) || err}`); } this.bot.sendTo(receiver, `找不到用户 ${username.replace(/^@?(.*)$/, '@$1')}。`); }); }; } } exports.default = default_1; //# sourceMappingURL=twitter.js.map