command.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. /* eslint-disable @typescript-eslint/no-unsafe-return */
  2. /* eslint-disable @typescript-eslint/member-delimiter-style */
  3. /* eslint-disable prefer-arrow/prefer-arrow-functions */
  4. import * as fs from 'fs';
  5. import * as path from 'path';
  6. import { relativeDate } from './datetime';
  7. import { getLogger } from './loggers';
  8. import { linkBuilder, parseLink, sendTimeline, sendTweet, ScreenNameNormalizer as normalizer } from './twitter';
  9. import { BigNumOps } from './utils';
  10. const logger = getLogger('command');
  11. function parseCmd(message: string): {
  12. cmd: string;
  13. args: string[];
  14. } {
  15. message = message.trim();
  16. message = message.replace('\\\\', '\\0x5c');
  17. message = message.replace('\\\"', '\\0x22');
  18. message = message.replace('\\\'', '\\0x27');
  19. const strs = message.match(/'[\s\S]*?'|(?:\S+=)?"[\s\S]*?"|\S+/mg);
  20. const cmd = strs?.length ? strs[0].length ? strs[0].substring(0, 1) === '/' ? strs[0].substring(1) : '' : '' : '';
  21. const args = (strs ?? []).slice(1).map(arg => {
  22. arg = arg.replace(/^(\S+=)?["']+(?!.*=)|["']+$/g, '$1');
  23. arg = arg.replace('\\0x27', '\\\'');
  24. arg = arg.replace('\\0x22', '\\\"');
  25. arg = arg.replace('\\0x5c', '\\\\');
  26. return arg;
  27. });
  28. return {
  29. cmd,
  30. args,
  31. };
  32. }
  33. function linkFinder(checkedMatch: string[], chat: IChat, lock: ILock): [string, number] {
  34. const normalizedLink =
  35. linkBuilder(normalizer.normalize(checkedMatch[0]), checkedMatch[1]?.toLowerCase());
  36. const link = Object.keys(lock.threads).find(realLink =>
  37. normalizedLink === realLink.replace(/\/@/, '/').toLowerCase()
  38. );
  39. if (!link) return [null, -1];
  40. const index = lock.threads[link].subscribers.findIndex(({chatID, chatType}) =>
  41. chat.chatID.toString() === chatID.toString() && chat.chatType === chatType
  42. );
  43. return [link, index];
  44. }
  45. function sub(chat: IChat, args: string[], reply: (msg: string) => any,
  46. lock: ILock, lockfile: string
  47. ): void {
  48. if (chat.chatType === 'temp') {
  49. return reply('请先添加机器人为好友。');
  50. }
  51. if (args.length === 0) {
  52. return reply('找不到要订阅的链接。');
  53. }
  54. const match = parseLink(args[0]);
  55. if (!match) {
  56. return reply(`订阅链接格式错误:
  57. 示例:
  58. https://twitter.com/Saito_Shuka
  59. https://twitter.com/_satou_riko/lists/lovelive
  60. https://twitter.com/TomoyoKurosawa/status/1294613494860361729`);
  61. }
  62. let offset = '0';
  63. if (match[1]) {
  64. const matchStatus = /\/status\/(\d+)/.exec(match[1]);
  65. if (matchStatus) {
  66. offset = BigNumOps.plus(matchStatus[1], '-1');
  67. delete match[1];
  68. }
  69. }
  70. const subscribeTo = (link: string, config: {addNew?: boolean, msg?: string} = {}) => {
  71. const {addNew = false, msg = `已为此聊天订阅 ${link}`} = config;
  72. if (addNew) {
  73. lock.feed.push(link);
  74. lock.threads[link] = {
  75. offset,
  76. subscribers: [],
  77. updatedAt: '',
  78. };
  79. }
  80. lock.threads[link].subscribers.push(chat);
  81. logger.warn(`chat ${JSON.stringify(chat)} has subscribed ${link}`);
  82. fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
  83. reply(msg);
  84. };
  85. const [realLink, index] = linkFinder(match, chat, lock);
  86. if (index > -1) return reply('此聊天已订阅此链接。');
  87. if (realLink) return subscribeTo(realLink);
  88. const [rawUserName, more] = match;
  89. if (rawUserName.toLowerCase() === 'i' && /lists\/(\d+)/.exec(more)) {
  90. return subscribeTo(linkBuilder('i', more), {addNew: true});
  91. }
  92. normalizer.normalizeLive(rawUserName).then(userName => {
  93. if (!userName) return reply(`找不到用户 ${rawUserName.replace(/^@?(.*)$/, '@$1')}。`);
  94. const link = linkBuilder(userName, more);
  95. const msg = (offset === '0') ?
  96. undefined :
  97. `已为此聊天订阅 ${link} 并回溯到此动态 ID(含)之后的第一条动态。
  98. (参见:https://blog.twitter.com/engineering/en_us/a/2010/announcing-snowflake.html)`;
  99. subscribeTo(link, {addNew: true, msg});
  100. });
  101. }
  102. function unsubAll(chat: IChat, args: string[], reply: (msg: string) => any,
  103. lock: ILock, lockfile: string
  104. ): void {
  105. if (chat.chatType === 'temp') {
  106. return reply('请先添加机器人为好友。');
  107. }
  108. Object.entries(lock.threads).forEach(([link, {subscribers}]) => {
  109. const index = subscribers.findIndex(({chatID, chatType}) =>
  110. chat.chatID.toString() === chatID.toString() && chat.chatType === chatType
  111. );
  112. if (index === -1) return;
  113. subscribers.splice(index, 1);
  114. fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
  115. logger.warn(`chat ${JSON.stringify(chat)} has unsubscribed ${link}`);
  116. });
  117. return reply(`已为此聊天退订所有推特链接。`);
  118. }
  119. function unsub(chat: IChat, args: string[], reply: (msg: string) => any,
  120. lock: ILock, lockfile: string
  121. ): void {
  122. if (chat.chatType === 'temp') {
  123. return reply('请先添加机器人为好友。');
  124. }
  125. if (args.length === 0) {
  126. return reply('找不到要退订的链接。');
  127. }
  128. const match = parseLink(args[0]);
  129. if (!match) {
  130. return reply('链接格式有误。');
  131. }
  132. const [link, index] = linkFinder(match, chat, lock);
  133. if (index === -1) return list(chat, args, msg => reply('您没有订阅此链接。\n' + msg), lock);
  134. else {
  135. lock.threads[link].subscribers.splice(index, 1);
  136. fs.writeFileSync(path.resolve(lockfile), JSON.stringify(lock));
  137. logger.warn(`chat ${JSON.stringify(chat)} has unsubscribed ${link}`);
  138. return reply(`已为此聊天退订 ${link}`);
  139. }
  140. }
  141. function list(chat: IChat, _: string[], reply: (msg: string) => any, lock: ILock): void {
  142. if (chat.chatType === 'temp') {
  143. return reply('请先添加机器人为好友。');
  144. }
  145. const links = [];
  146. Object.keys(lock.threads).forEach(key => {
  147. if (lock.threads[key].subscribers.find(({chatID, chatType}) =>
  148. chat.chatID === chatID && chat.chatType === chatType
  149. )) links.push(`${key} ${relativeDate(lock.threads[key].updatedAt)}`);
  150. });
  151. return reply('此聊天中订阅的链接:\n' + links.join('\n'));
  152. }
  153. function view(chat: IChat, args: string[], reply: (msg: string) => any): void {
  154. if (args.length === 0 || !args[0]) {
  155. return reply('找不到要查看的链接或表达式。');
  156. }
  157. const match =
  158. /^(last(?:|-(\d+))@[^\/?#]+)$/.exec(args[0]) ||
  159. /^(?:.*twitter.com\/[^\/?#]+\/status\/)?(\d+)/.exec(args[0]);
  160. if (!match) {
  161. return reply(`链接或表达式格式有误。
  162. 示例:
  163. https://twitter.com/TomoyoKurosawa/status/1486136914864345092
  164. 1486136914864345092
  165. last@TomoyoKurosawa
  166. last-1@sunflower930316,noreps=off,norts=on
  167. (表达式筛选参数详见 /help twitter_query)`);
  168. }
  169. if (Math.abs(Number(match[2])) >= 50) {
  170. return reply('表达式中指定的回溯数量超出取值范围。');
  171. }
  172. let forceRefresh: boolean;
  173. for (const arg of args.slice(1)) {
  174. const optMatch = /^(force|refresh)=(.*)/.exec(arg);
  175. if (!optMatch) return reply(`未定义的查看参数:${arg}。`);
  176. forceRefresh = {on: true, off: false}[optMatch[2]];
  177. }
  178. try {
  179. sendTweet(match[1], chat, forceRefresh);
  180. } catch (e) {
  181. reply('推特机器人尚未加载完毕,请稍后重试。');
  182. }
  183. }
  184. function resendLast(chat: IChat, args: string[], reply: (msg: string) => any): void {
  185. view(chat, [(args[0] || '').replace(/^@?(.+)$/, 'last@$1'), 'refresh=on'], reply);
  186. }
  187. function query(chat: IChat, args: string[], reply: (msg: string) => any): void {
  188. if (args.length === 0 || !args[0]) {
  189. return reply('找不到要查询的用户。');
  190. }
  191. const match =
  192. /twitter.com\/([^\/?#]+)/.exec(args[0]) ||
  193. /^([^\/?#]+)$/.exec(args[0]);
  194. if (!match) {
  195. return reply('链接或用户名格式有误。');
  196. }
  197. const conf: {
  198. username: string,
  199. count?: string,
  200. since?: string,
  201. until?: string,
  202. noreps: string,
  203. norts: string,
  204. } = {username: match[1], noreps: 'on', norts: 'off'};
  205. const confZH: Record<Exclude<keyof typeof conf, 'username'>, string> = {
  206. count: '数量上限',
  207. since: '起始点',
  208. until: '结束点',
  209. noreps: '忽略回复推文(on/off)',
  210. norts: '忽略原生转推(on/off)',
  211. };
  212. for (const arg of args.slice(1)) {
  213. const optMatch = /^(count|since|until|noreps|norts)=(.*)/.exec(arg);
  214. if (!optMatch) return reply(`未定义的查询参数:${arg}。`);
  215. const optKey = optMatch[1] as keyof typeof confZH;
  216. if (optMatch.length === 1) return reply(`查询${confZH[optKey]}参数格式有误。`);
  217. conf[optKey] = optMatch[2];
  218. if (optMatch[2] === '') return reply(`查询${confZH[optKey]}参数值不可为空。`);
  219. }
  220. if (conf.count !== undefined && !Number(conf.count) || Math.abs(Number(conf.count)) > 50) {
  221. return reply('查询数量上限参数为零、非数值或超出取值范围。');
  222. }
  223. try {
  224. sendTimeline(conf, chat);
  225. } catch (e) {
  226. logger.error(`error querying timeline, error: ${e}`);
  227. reply('推特机器人尚未加载完毕,请稍后重试。');
  228. }
  229. }
  230. export { parseCmd, sub, list, unsub, unsubAll, view, resendLast, query };