import * as emojiStrip from 'emoji-strip'; import * as fs from 'fs'; import * as path from 'path'; import * as Twitter from 'twitter'; import TwitterTypes from 'twitter-d'; import { XmlEntities } from 'html-entities'; import QQBot from './koishi'; import { getLogger } from './loggers'; import { BigNumOps, chainPromises } from './utils'; import Wiki from './wiki'; interface IWorkerOption { lock: ILock; lockfile: string; bot: QQBot, workInterval: number; wikiSessionCookie: string; consumerKey: string; consumerSecret: string; accessTokenKey: string; accessTokenSecret: string; } export interface ITimelineQueryConfig { username: string; count?: number; since?: string; until?: string; noreps?: boolean; norts?: boolean; } export const keywordMap = { 'ガチャ予告': '卡池', '一日一回無料ガチャ': '卡池', 'イベント開催決定': '活动', 'タワー.*追加': '新塔', '(生放送|配信)予告': '生放', 'メンテナンス予告': '维护', '(育成応援|お仕事).*開催': '工作', '新曲一部公開': '新曲', 'キャラクター紹介': '组合', '今後のアップデート情報': '计划', '(? { const urls = tweet.entities.urls ? tweet.entities.urls.map(url => url.expanded_url) : []; const [title, body] = emojiStrip(xmlEntities.decode(tweet.full_text)) .replace(/(?<=\n|^)(.*)(?:\: |:)(https?:\/\/.*?)(?=\s|$)/g, '[$2 $1]') .replace(/https?:\/\/.*?(?=\s|$)/g, () => urls.length ? urls.splice(0, 1)[0] : '') .replace(/(?<=\n|^)[\/]\n/g, '') .replace(/((?<=\s)#.*?\s+)+$/g, '') .trim() .match(/(.*?)\n\n(.*)/s).slice(1); const date = new Date(tweet.created_at); const formatedDate = [[date.getFullYear(), 4], [date.getMonth(), 2], [date.getDate(), 2]] .map(([value, padLength]) => value.toString().padStart(padLength, '0')) .join(''); const pageTitle = title.replace(/[【】\/]/g, '') + `${recurringKeywords.some( keyword => new RegExp(keyword).exec(title) ) ? `-${formatedDate}` : ''}`; return {title, body, pageTitle, date: formatedDate}; }; export const parseAction = (action: WikiEditResult) => { if (!action || !Object.keys(action)) return '(无)'; return `\n 标题:${action.title} 操作:${action.new ? '新建' : '更新'} 媒体:${action.mediafiles.map((fileName, index) => `\n ${index + 1}. ${fileName}`).join('') || '(无)'} 链接:${action.pageid ? `https://wiki.biligame.com/idolypride/index.php?curid=${action.pageid}` : '(无)'} `; } 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 default class { private client: Twitter; private bot: QQBot; private publisher: Wiki; private lock: ILock; private lockfile: string; private workInterval: number; private wikiSessionCookie: 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, }); this.bot = opt.bot; this.publisher = new Wiki(opt.lock); this.lockfile = opt.lockfile; this.lock = opt.lock; this.workInterval = opt.workInterval; this.wikiSessionCookie = opt.wikiSessionCookie; } public launch = () => { this.publisher.login(this.wikiSessionCookie).then(() => { setTimeout(this.work, this.workInterval * 1000) }); } public work = () => { const lock = this.lock; if (this.workInterval < 1) this.workInterval = 1; const currentFeed = 'https://twitter.com/idolypride'; logger.debug(`pulling feed ${currentFeed}`); const promise = new Promise(resolve => { let config: {[key: string]: any}; let endpoint: string; const 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.offset; if (offset as unknown as number > 0) config.since_id = offset; const getMore = (lastTweets: Tweets = []) => this.client.get( endpoint, config, (error: {[key: string]: any}[], tweets: Tweets ) => { 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.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(); } }); promise.then((tweets: Tweets) => { logger.debug(`api returned ${JSON.stringify(tweets)} for feed ${currentFeed}`); const updateDate = () => lock.updatedAt = new Date().toString(); if (tweets.length === 0) { updateDate(); return; } const topOfFeed = tweets[0].id_str; const updateOffset = () => lock.offset = topOfFeed; if (lock.offset === '-1') { updateOffset(); return; } if (lock.offset === '0') tweets.splice(1); return chainPromises(tweets.reverse().map(tweet => () => { const match = /(.*?)\n\n(.*)/s.exec(tweet.full_text); if (!match) return Promise.resolve({}); for (const keyword in keywordMap) { if (new RegExp(keyword).exec(match[1])) { const tweetUrl = `${currentFeed}/status/${tweet.id_str}`; logger.info(`working on ${tweetUrl}`); return this.publisher.post(tweet, keywordMap[keyword]) .then(action => { if (action.result === 'Success') { this.lock.lastActions.push(action); logger.info(`successfully posted content of ${tweetUrl} to bwiki, link:`); logger.info(`https://wiki.biligame.com/idolypride/index.php?curid=${action.pageid}`) const message = `已更新如下页面:${parseAction(action)}`; return Promise.all( this.lock.subscribers.map(subscriber => this.bot.sendTo(subscriber, message)) ); } }) .then(updateDate).then(updateOffset); } } })); }) .then(() => { let timeout = this.workInterval * 1000; if (timeout < 1000) timeout = 1000; fs.writeFileSync(path.resolve(this.lockfile), JSON.stringify(lock)); setTimeout(() => { this.work(); }, timeout); }); }; }