telegram/event-subscriber.js

  1. const { Bot, InlineKeyboard } = require('grammy');
  2. const logger = require('../logger').child({ module: 'telegram-channel-subscriber' });
  3. const { getHealth } = require('../services/health');
  4. const { getRedis } = require('../services/redis');
  5. const { icons, wideSpace } = require('../utils');
  6. const discord_event_map = {};
  7. const chat_event_map = {};
  8. const bot_config = {};
  9. if (process.env?.ENV === 'dev') {
  10. bot_config.client = {
  11. buildUrl: (_, token, method) => `https://api.telegram.org/bot${token}/test/${method}`
  12. }
  13. }
  14. /**
  15. * @property {Bot?}
  16. */
  17. const bot = process.env.TELEGRAM_TOKEN ? new Bot(process.env.TELEGRAM_TOKEN, bot_config) : null;
  18. class DiscordEvent {
  19. constructor(event_data, chat_id) {
  20. this.current_event_data = null;
  21. this.chat_id = chat_id;
  22. this.event_id = event_data.event_id;
  23. this.event_name = event_data.event_name;
  24. this.guild_id = event_data.guild_id;
  25. this.guild_name = event_data.guild_name;
  26. this.current_update_promise = null;
  27. }
  28. get current_message_id() {
  29. return this._current_message_id;
  30. }
  31. set current_message_id(value) {
  32. if (!chat_event_map[this.chat_id]) {
  33. chat_event_map[this.chat_id] = new Set();
  34. }
  35. if (value === null && this._current_message_id !== null) {
  36. chat_event_map[this.chat_id].delete(this._current_message_id);
  37. }
  38. else {
  39. chat_event_map[this.chat_id].add(value);
  40. }
  41. if (getHealth('redis') === 'ready') {
  42. const redis = getRedis();
  43. if (value) {
  44. redis.hset(`telegram:${this.chat_id}:event_subscriber:message_to_event`, { [value]: this.event_id });
  45. }
  46. else {
  47. redis.hdel(`telegram:${this.chat_id}:event_subscriber:message_to_event`, [this._current_message_id]);
  48. }
  49. }
  50. this._current_message_id = value;
  51. }
  52. isNotified() {
  53. return !!this.current_message_id;
  54. }
  55. update(event_data) {
  56. if (!event_data) {
  57. return;
  58. }
  59. this.current_event_data = event_data;
  60. }
  61. getRedirectUrl(discord_url) {
  62. if (!discord_url) {
  63. return;
  64. }
  65. return discord_url.replace('discord.com', 'dr.bldbr.club/a');
  66. }
  67. generateNotificationTextFrom(event_data) {
  68. if (!event_data) {
  69. return null;
  70. }
  71. let text = `${icons.event}${wideSpace}В Discord начался новый эвент\nНазвание: <a href="${this.getRedirectUrl(event_data.event_url)}">${event_data.event_name}</a>`;
  72. if (event_data.channel_url) text += `\nКанал: <a href="${this.getRedirectUrl(event_data.channel_url)}">${event_data.channel_name}</a>`;
  73. if (event_data.event_description) text += `\n${event_data.event_description}`;
  74. return text;
  75. }
  76. getNotificationText(event_data) {
  77. return event_data ? this.generateNotificationTextFrom(event_data) : this.generateNotificationTextFrom(this.current_event_data);
  78. }
  79. generateKeyboardFrom(event_data) {
  80. return new InlineKeyboard().url(
  81. 'Посетить',
  82. event_data?.channel_url ? this.getRedirectUrl(event_data.channel_url) : this.getRedirectUrl(event_data.event_url)
  83. );
  84. }
  85. getNotificationKeyboard(event_data) {
  86. return event_data ? this.generateKeyboardFrom(event_data) : this.generateKeyboardFrom(this.current_event_data);
  87. }
  88. getLogMeta() {
  89. let meta = {};
  90. meta['discord_event'] = this.event_name;
  91. meta['discord_event_id'] = this.event_id;
  92. meta['discord_guild'] = this.guild_name;
  93. meta['discord_guild_id'] = this.guild_id;
  94. meta['telegram_chat_id'] = this.chat_id;
  95. if (this.isNotified()) {
  96. meta['telegram_message_id'] = this.current_message_id;
  97. }
  98. return meta;
  99. }
  100. getImage(event_data) {
  101. return event_data ? event_data?.event_cover_url : this.current_event_data?.event_cover_url;
  102. }
  103. }
  104. async function restoreMessageID(chat_id, event_id) {
  105. if (getHealth('redis') !== 'ready') {
  106. return null;
  107. }
  108. const redis = getRedis();
  109. const message_to_event = await redis.hgetall(`telegram:${chat_id}:event_subscriber:message_to_event`);
  110. let current_message_id;
  111. for (const [message_id, event_id_] of Object.entries(message_to_event)) {
  112. if (event_id_ === event_id) {
  113. current_message_id = Number(message_id);
  114. break;
  115. }
  116. }
  117. if (isNaN(current_message_id) || !current_message_id) {
  118. return null;
  119. }
  120. return current_message_id;
  121. }
  122. function getDiscordEvent(event_data, chat_id) {
  123. if (event_data instanceof DiscordEvent) {
  124. return event_data;
  125. }
  126. if (!discord_event_map[`${chat_id}:${event_data.event_id}`]) {
  127. discord_event_map[`${chat_id}:${event_data.event_id}`] = new DiscordEvent(event_data, chat_id);
  128. }
  129. return discord_event_map[`${chat_id}:${event_data.event_id}`];
  130. }
  131. /**
  132. *
  133. * @param {DiscordEvent} discord_event
  134. * @param {*} event_data
  135. * @param {'edit' | 'new'} type
  136. * @returns {['editMessageText' | 'editMessageCaption' | 'sendMessage' | 'sendPhoto', []]}
  137. */
  138. function generateAPICall(discord_event, event_data, type = 'new') {
  139. let method_name = 'sendMessage';
  140. const args = [discord_event.chat_id];
  141. const other = {
  142. link_preview_options: { is_disabled: true },
  143. parse_mode: 'HTML',
  144. reply_markup: discord_event.getNotificationKeyboard(event_data)
  145. };
  146. if (discord_event.getImage()) {
  147. method_name = type === 'edit' ? 'editMessageCaption' : 'sendPhoto';
  148. args.push(type === 'edit' ? discord_event.current_message_id : event_data.event_cover_url);
  149. other.caption = discord_event.getNotificationText(event_data);
  150. }
  151. else {
  152. method_name = type === 'edit' ? 'editMessageText' : 'sendMessage';
  153. args.push(type === 'edit' ? discord_event.current_message_id : discord_event.getNotificationText(event_data));
  154. type === 'edit' && args.push(discord_event.getNotificationText(event_data));
  155. }
  156. args.push(other);
  157. return [method_name, args];
  158. }
  159. async function editMessage(discord_event, new_event_data) {
  160. if (!discord_event || !new_event_data) return;
  161. const [method_name, args] = generateAPICall(discord_event, new_event_data, 'edit');
  162. discord_event.current_update_promise = bot.api[method_name](...args).then(message => {
  163. discord_event.update(new_event_data);
  164. logger.debug(
  165. `Successful call to ${method_name} about [event: ${discord_event.event_id}] to [chat: ${discord_event.chat_id}], got [message: ${message.message_id}]`,
  166. { ...discord_event.getLogMeta() }
  167. );
  168. }).catch(err => {
  169. if (err.description.search('message to edit not found') !== -1) {
  170. logger.debug(`[message: ${discord_event.current_message_id}] doesn't exist, sending new message instead`);
  171. discord_event.current_message_id = null;
  172. return sendMessage(discord_event);
  173. }
  174. logger.error(
  175. `Error while calling ${method_name} about [event: ${discord_event.event_id}] to [chat: ${discord_event.chat_id}]`,
  176. { error: err.stack || err, ...discord_event.getLogMeta() }
  177. );
  178. });
  179. return discord_event.current_update_promise;
  180. }
  181. async function sendMessage(discord_event) {
  182. if (!discord_event) return;
  183. const [method_name, args] = generateAPICall(discord_event, discord_event.current_event_data, 'new');
  184. discord_event.current_update_promise = bot.api[method_name](...args).then(message => {
  185. discord_event.current_update_promise = null;
  186. discord_event.current_message_id = message.message_id;
  187. logger.debug(
  188. `Successful call to ${method_name} about [event: ${discord_event.event_id}] to [chat: ${discord_event.chat_id}], got [message: ${message.message_id}]`,
  189. { ...discord_event.getLogMeta() }
  190. );
  191. }).catch(err => {
  192. discord_event.current_update_promise = null;
  193. logger.error(
  194. `Error while calling ${method_name} about [event: ${discord_event.event_id}] to [chat: ${discord_event.chat_id}]`,
  195. { error: err.stack || err, ...discord_event.getLogMeta() }
  196. );
  197. discord_event.current_event_data = null;
  198. });
  199. return discord_event.current_update_promise;
  200. }
  201. async function deleteMessage(discord_event) {
  202. if (!discord_event?.isNotified()) {
  203. logger.warn(
  204. `No event notification to clear about [event: ${discord_event.event_id}] to [chat: ${discord_event.chat_id}]`,
  205. { ...discord_event.getLogMeta() }
  206. );
  207. return;
  208. }
  209. return bot.api.deleteMessage(
  210. discord_event.chat_id,
  211. discord_event.current_message_id
  212. ).then(() => {
  213. logger.debug(
  214. `Deleted event notification [message: ${discord_event.current_message_id}] about [event:${discord_event.event_id}] in [chat: ${discord_event.chat_id}]`,
  215. { ...discord_event.getLogMeta() }
  216. );
  217. discord_event.current_message_id = null;
  218. });
  219. }
  220. async function sendNotification(event_data, chat_id) {
  221. if (!event_data || !chat_id || !bot) return;
  222. const discord_event = getDiscordEvent(event_data, chat_id);
  223. if (!discord_event.isNotified()) {
  224. discord_event.current_message_id = await restoreMessageID(chat_id, event_data.event_id);
  225. }
  226. if (!event_data.event_active) {
  227. return deleteMessage(discord_event);
  228. }
  229. if (discord_event.isNotified()) {
  230. if (discord_event.getNotificationText(event_data) === discord_event.getNotificationText()) {
  231. logger.debug(
  232. `Skip event notification about [event: ${discord_event.event_id}] to [chat: ${discord_event.chat_id}] as equals to current`,
  233. { ...discord_event.getLogMeta() }
  234. );
  235. }
  236. if (discord_event.current_update_promise !== null) {
  237. return discord_event.current_update_promise.then(() => {
  238. logger.debug(
  239. `Scheduling event notification update about [event: ${discord_event.event_id}] to [chat: ${discord_event.chat_id}]`,
  240. { ...discord_event.getLogMeta() }
  241. );
  242. editMessage(discord_event, event_data);
  243. });
  244. }
  245. return editMessage(discord_event, event_data);
  246. }
  247. discord_event.update(event_data);
  248. return sendMessage(discord_event, event_data);
  249. }
  250. async function deleteNotification(chat_id, event_id) {
  251. if (!chat_id || !event_id || !bot) return;
  252. if (!discord_event_map[`${chat_id}:${event_id}`]) return;
  253. deleteMessage(discord_event_map[`${chat_id}:${event_id}`]);
  254. delete discord_event_map[`${chat_id}:${event_id}`];
  255. }
  256. function isNotificationMessage(chat_id, message_id) {
  257. if (!chat_id || !message_id) return false;
  258. return chat_event_map[chat_id] && chat_event_map[chat_id].has(message_id);
  259. }
  260. async function addToReverseMap(guild_id, chat_id) {
  261. if (!guild_id || !chat_id || !getRedis() || getHealth('redis') !== 'ready') return;
  262. const redis = getRedis();
  263. return redis.sadd(`telegram:${chat_id}:event_subscriber:guild_ids`, guild_id);
  264. }
  265. function removeFromReverseMap(guild_id, chat_id) {
  266. if (!guild_id || !chat_id || !getRedis() || getHealth('redis') !== 'ready') return;
  267. const redis = getRedis();
  268. return redis.srem(`telegram:${chat_id}:event_subscriber:guild_ids`, guild_id);
  269. }
  270. function getReverseMap(chat_id) {
  271. if (!chat_id || !getRedis() || getHealth('redis') !== 'ready') return;
  272. const redis = getRedis();
  273. return redis.smembers(`telegram:${chat_id}:event_subscriber:guild_ids`);
  274. }
  275. module.exports = {
  276. sendNotification,
  277. deleteNotification,
  278. isNotificationMessage,
  279. ReverseMap: {
  280. add: addToReverseMap,
  281. remove: removeFromReverseMap,
  282. get: getReverseMap
  283. }
  284. };