announcements.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. import { suppressedThreads } from "../../config";
  2. import {
  3. Api,
  4. Block,
  5. Council,
  6. Member,
  7. ProposalDetail,
  8. Proposals,
  9. Send,
  10. } from "../types";
  11. import { BlockNumber } from "@polkadot/types/interfaces";
  12. import { Channel, ElectionStage } from "@joystream/types/augment";
  13. import { Category, Thread, Post } from "@joystream/types/forum";
  14. import { DiscussionPost } from "@joystream/types/proposals";
  15. import { domain } from "../../config";
  16. import { formatTime } from "./util";
  17. import {
  18. categoryById,
  19. memberHandle,
  20. memberHandleByAccount,
  21. proposalDetail,
  22. fetchTokenValue,
  23. fetchStorageSize,
  24. } from "./getters";
  25. import moment, { now } from "moment";
  26. const dateFormat = "DD-MM-YYYY HH:mm (UTC)";
  27. const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
  28. // query API repeatedly to ensure a result
  29. const query = async (test: string, cb: () => Promise<any>): Promise<any> => {
  30. let result = await cb();
  31. for (let i: number = 0; i < 10; i++) {
  32. if (result[test] !== "") return result;
  33. result = await cb();
  34. await sleep(5000);
  35. }
  36. };
  37. // announce latest channels
  38. export const channels = async (
  39. api: Api,
  40. channels: number[],
  41. sendMessage: Send,
  42. channel: any
  43. ): Promise<void> => {
  44. const [last, current] = channels;
  45. const messages: string[][] = [[], []];
  46. for (let id: number = +last + 1; id <= current; id++) {
  47. const channel: Channel = await query("title", () =>
  48. api.query.contentWorkingGroup.channelById(id)
  49. );
  50. const member: Member = { id: channel.owner.asMember, handle: "", url: "" };
  51. member.handle = await memberHandle(api, member.id);
  52. member.url = `${domain}/#/members/${member.handle}`;
  53. messages[0].push(
  54. `<b>Channel <a href="${domain}/#//media/channels/${id}">${id}</a> by <a href="${member.url}">${member.handle} (${member.id})</a></b>`
  55. );
  56. messages[1].push(
  57. `**Channel ${id}** by ${member.handle} (${member.id})\n${domain}/#//media/channels/${id}`
  58. );
  59. }
  60. sendMessage(
  61. {
  62. tg: messages[0].join("\r\n\r\n"),
  63. discord: messages[1].join(`\n\n`),
  64. tgParseMode: "HTML",
  65. },
  66. channel
  67. );
  68. };
  69. // announce council change
  70. export const council = async (
  71. api: Api,
  72. council: Council,
  73. currentBlock: number,
  74. sendMessage: Send,
  75. channel: any
  76. ): Promise<Council> => {
  77. const round: number = await api.query.councilElection.round();
  78. const stage: any = await api.query.councilElection.stage();
  79. const stageObj = JSON.parse(JSON.stringify(stage));
  80. let stageString = stageObj ? Object.keys(stageObj)[0] : "";
  81. let msg: string[] = ["", ""];
  82. if (!stage || stage.toJSON() === null) {
  83. stageString = "elected";
  84. const councilEnd: BlockNumber = await api.query.council.termEndsAt();
  85. const termDuration: BlockNumber =
  86. await api.query.councilElection.newTermDuration();
  87. const block = councilEnd.toNumber() - termDuration.toNumber();
  88. if (currentBlock - block < 2000) {
  89. const remainingBlocks = councilEnd.toNumber() - currentBlock;
  90. const m = moment().add(remainingBlocks * 6, "s");
  91. const endDate = formatTime(m, dateFormat);
  92. const handles: string[] = await Promise.all(
  93. (
  94. await api.query.council.activeCouncil()
  95. ).map(
  96. async (seat: { member: string }) =>
  97. await memberHandleByAccount(api, seat.member)
  98. )
  99. );
  100. const members = handles.join(", ");
  101. msg[0] = `Council election ended: ${members} have been elected for <a href="${domain}/#/council/members">council ${round}</a>. Congratulations!\nNext election starts on ${endDate}.`;
  102. msg[1] = `Council election ended: ${members} have been elected for council ${round}. Congratulations!\nNext election starts on ${endDate}.\n${domain}/#/council/members`;
  103. }
  104. } else {
  105. const remainingBlocks = stage.toJSON()[stageString] - currentBlock;
  106. const m = moment().add(remainingBlocks * 6, "second");
  107. const endDate = formatTime(m, dateFormat);
  108. const link = `${domain}/#/council/`;
  109. if (stageString === "announcing") {
  110. msg[0] = `Council election started. You can <b><a href="${link}applicants">announce your application</a></b> until ${endDate}`;
  111. msg[1] = `Council election started. You can **announce your application** until ${endDate} ${link}applicants`;
  112. } else if (stageString === "voting") {
  113. msg[0] = `Council election: <b><a href="${link}applicants">Vote</a></b> until ${endDate}`;
  114. msg[1] = `Council election: **Vote* until ${endDate} ${link}applicants`;
  115. } else if (stageString === "revealing") {
  116. msg[0] = `Council election: <b><a href="${link}votes">Reveal your votes</a></b> until ${endDate}`;
  117. msg[1] = `Council election: **Reveal your votes** until ${endDate} ${link}votes`;
  118. }
  119. }
  120. if (
  121. council.last !== "" &&
  122. round !== council.round &&
  123. stageString !== council.last
  124. ) {
  125. sendMessage({ tg: msg[0], discord: msg[1], tgParseMode: "HTML" }, channel);
  126. }
  127. return { round, last: stageString };
  128. };
  129. export const councilStatus = async (
  130. api: Api,
  131. block: Block,
  132. sendMessage: Send,
  133. channel: any
  134. ): Promise<void> => {
  135. const currentBlock = block.id;
  136. const councilTermEndBlock: number = (
  137. await api.query.council.termEndsAt()
  138. ).toJSON();
  139. const announcingPeriod: number = (
  140. await api.query.councilElection.announcingPeriod()
  141. ).toJSON();
  142. const votingPeriod: number = (
  143. await api.query.councilElection.votingPeriod()
  144. ).toJSON();
  145. const revealingPeriod: number = (
  146. await api.query.councilElection.revealingPeriod()
  147. ).toJSON();
  148. const stage: any = await api.query.councilElection.stage();
  149. const stageObj = JSON.parse(JSON.stringify(stage));
  150. let stageString = stageObj ? Object.keys(stageObj)[0] : "";
  151. let stageEndDate = moment();
  152. if (!stage || stage.toJSON() === null) {
  153. stageString = "elected";
  154. const councilEnd: BlockNumber = await api.query.council.termEndsAt();
  155. const termDuration: BlockNumber =
  156. await api.query.councilElection.newTermDuration();
  157. const block = councilEnd.toNumber() - termDuration.toNumber();
  158. if (currentBlock - block < 2000) {
  159. const remainingBlocks = councilEnd.toNumber() - currentBlock;
  160. stageEndDate = moment().add(remainingBlocks * 6, "s");
  161. }
  162. } else {
  163. const remainingBlocks = stage.toJSON()[stageString] - currentBlock;
  164. stageEndDate = moment().add(remainingBlocks * 6, "second");
  165. }
  166. const revealingEndsAt =
  167. councilTermEndBlock + announcingPeriod + votingPeriod + revealingPeriod;
  168. const termBlocksRemaining = revealingEndsAt - currentBlock;
  169. let councilEndDate = moment().add(termBlocksRemaining * 6, "seconds");
  170. let councilEndDateString = formatTime(councilEndDate, dateFormat);
  171. let councilDaysLeft = councilEndDate.diff(moment(), "d");
  172. let councilDurationSuffix = "day(s)";
  173. if (councilDaysLeft <= 0) {
  174. councilDaysLeft = councilEndDate.diff(moment(), "h");
  175. councilDurationSuffix = "hour(s)";
  176. }
  177. if (councilDaysLeft <= 0) {
  178. councilDaysLeft = councilEndDate.diff(moment(), "m");
  179. councilDurationSuffix = "minute(s)";
  180. }
  181. let stageEndDateString = formatTime(stageEndDate, dateFormat);
  182. let stageDaysLeft = stageEndDate.diff(moment(), "d");
  183. let stageDurationSuffix = "day(s)";
  184. if (stageDaysLeft <= 0) {
  185. stageDaysLeft = stageEndDate.diff(moment(), "h");
  186. stageDurationSuffix = "hour(s)";
  187. }
  188. if (stageDaysLeft <= 0) {
  189. stageDaysLeft = stageEndDate.diff(moment(), "m");
  190. stageDurationSuffix = "minute(s)";
  191. }
  192. const msgTg = `It is block number *#${currentBlock}* \nCouncil ends in *${councilDaysLeft} ${councilDurationSuffix}* on *${councilEndDateString}* \nCurrent stage *${stageString}* ends in *${stageDaysLeft} ${stageDurationSuffix}* on *${stageEndDateString}*.`;
  193. const msgDs = `It is block number **#${currentBlock}** \nCouncil ends in **${councilDaysLeft} ${councilDurationSuffix}** on *${councilEndDateString}* \nCurrent stage **${stageString}** ends in *${stageDaysLeft} ${stageDurationSuffix}* on *${stageEndDateString}*.`;
  194. sendMessage({ tg: msgTg, discord: msgDs, tgParseMode: "Markdown" }, channel);
  195. };
  196. // forum
  197. // announce latest categories
  198. export const categories = async (
  199. api: Api,
  200. category: number[],
  201. sendMessage: Send,
  202. channel: any
  203. ): Promise<number> => {
  204. if (category[0] === category[1]) return category[0];
  205. const messages: string[][] = [[], []];
  206. for (let id: number = +category[0] + 1; id <= category[1]; id++) {
  207. const cat: Category = await query("title", () => categoryById(api, id));
  208. messages[0].push(
  209. `Category ${id}: <b><a href="${domain}/#/forum/categories/${id}">${cat.title}</a></b>`
  210. );
  211. messages[1].push(
  212. `Category ${id}: **${cat.title}** ${domain}/#/forum/categories/${id}`
  213. );
  214. }
  215. sendMessage(
  216. {
  217. tg: messages[0].join("\r\n\r\n"),
  218. discord: messages[1].join(`\n\n`),
  219. tgParseMode: "HTML",
  220. },
  221. channel
  222. );
  223. return category[1];
  224. };
  225. // announce latest posts
  226. export const posts = async (
  227. api: Api,
  228. posts: number[],
  229. sendMessage: Send,
  230. channel: any
  231. ): Promise<number> => {
  232. const [last, current] = posts;
  233. if (current === last) return last;
  234. const messages: string[][] = [[], []];
  235. for (let id: number = +last + 1; id <= current; id++) {
  236. const post: Post = await query("current_text", () =>
  237. api.query.forum.postById(id)
  238. );
  239. const replyId: number = post.nr_in_thread.toNumber();
  240. const threadId: number = post.thread_id.toNumber();
  241. const thread: Thread = await query("title", () =>
  242. api.query.forum.threadById(threadId)
  243. );
  244. const categoryId = thread.category_id.toNumber();
  245. if (categoryId === 19 || categoryId === 38) continue; // hide: 19 Media, 38 Russian
  246. if (suppressedThreads.includes(threadId)) continue;
  247. const category: Category = await query("title", () =>
  248. categoryById(api, categoryId)
  249. );
  250. const handle = await memberHandleByAccount(api, post.author_id.toJSON());
  251. const s = {
  252. content: post.current_text.substring(0, 250),
  253. link: `${domain}/#/forum/threads/${threadId}?replyIdx=${replyId}`,
  254. };
  255. messages[0].push(
  256. `<u><a href="${domain}/#/forum/categories/${category.id}">${category.title}</a></u> <b><a href="${domain}/#/members/${handle}">${handle}</a></b> posted in <b><a href="${domain}/#/forum/threads/${threadId}?replyIdx=${replyId}">${thread.title}</a></b>:\n\r<i>${s.content}</i> <a href="${s.link}">more</a>`
  257. );
  258. messages[1].push(
  259. `**[${category.title}]** ${handle} posted in **${thread.title}**:\n*${s.content}*\nMore: ${s.link}`
  260. );
  261. }
  262. sendMessage(
  263. {
  264. tg: messages[0].join("\r\n\r\n"),
  265. discord: messages[1].join(`\n\n`),
  266. tgParseMode: "HTML",
  267. },
  268. channel
  269. );
  270. return current;
  271. };
  272. export const proposalCreated = (
  273. proposal: ProposalDetail,
  274. sendMessage: Send,
  275. channel: any
  276. ): void => {
  277. const { id, createdAt, finalizedAt, message, parameters, result } = proposal;
  278. if (!createdAt) return console.warn(`proposalCreated: wrong data`, proposal);
  279. const votingEndsAt = createdAt + parameters.votingPeriod.toNumber();
  280. const endTime = moment()
  281. .add(6 * (votingEndsAt - id), "second")
  282. .format("DD/MM/YYYY HH:mm");
  283. const link = `${domain}/#/proposals/${id}`;
  284. const tg = `<a href="${link}">Proposal ${id}</a> <b>created</b> at block ${createdAt}.\r\n${message.tg}\r\nYou can <a href="${link}">vote</a> until block ${votingEndsAt} (${endTime} UTC).`;
  285. const discord = `Proposal ${id} **created** at block ${createdAt}.\n${message.discord}\nVote until block ${votingEndsAt} (${endTime} UTC): ${link}\n`;
  286. sendMessage({ tg, discord, tgParseMode: "HTML" }, channel);
  287. };
  288. export const proposalUpdated = (
  289. proposal: ProposalDetail,
  290. blockId: number,
  291. sendMessage: Send,
  292. channel: any
  293. ): void => {
  294. const { id, finalizedAt, message, parameters, result, stage } = proposal;
  295. const link = `${domain}/#/proposals/${id}`;
  296. if (stage === "Finalized") {
  297. let label: string = result.toLowerCase();
  298. let grace = ``;
  299. if (result === "Approved") {
  300. const executesAt = parameters.gracePeriod.toNumber();
  301. label = executesAt ? "approved" : "executed";
  302. if (executesAt && blockId < executesAt)
  303. grace = `and executes at block ${executesAt}`;
  304. }
  305. // send announcement
  306. const tg = `<a href="${link}">Proposal ${id}</a> <b>${label}</b> at block ${finalizedAt}${grace}.\r\n${message.tg}`;
  307. const discord = `Proposal ${id} **${label}** at block ${finalizedAt}${grace}.\n${message.discord}\n${link}\n`;
  308. sendMessage({ tg, discord, tgParseMode: "HTML" }, channel);
  309. }
  310. };
  311. export const proposalPost = async (
  312. post: DiscussionPost,
  313. author: string,
  314. proposalId: number,
  315. sendMessage: Send,
  316. channel: any
  317. ) => {
  318. const { text, created_at, author_id, thread_id } = post;
  319. const txt = text.slice(0, 100);
  320. const link = `${domain}/#/proposals/${proposalId}`;
  321. const tg = `<b>${author}</b> commented on <b><a href="${link}">Proposal ${proposalId}</a></b>: ${txt}`;
  322. const discord = `**${author}** commented on **Proposal ${proposalId}**: ${txt} $link`;
  323. console.log(tg);
  324. sendMessage({ tg, discord, tgParseMode: "HTML" }, channel);
  325. };
  326. // heartbeat
  327. const getAverage = (array: number[]): number =>
  328. array.reduce((a: number, b: number) => a + b, 0) / array.length;
  329. export const heartbeat = async (
  330. api: Api,
  331. blocks: Block[],
  332. timePassed: string,
  333. proposals: Proposals,
  334. sendMessage: Send,
  335. channel: any
  336. ): Promise<void> => {
  337. const price = await fetchTokenValue();
  338. const storageSize = await fetchStorageSize();
  339. const durations = blocks.map((b) => b.duration);
  340. console.log(durations);
  341. const blocktime = getAverage(durations) / 1000;
  342. const stake = blocks.map((b) => b.stake);
  343. const avgStake = getAverage(stake) / 1000000;
  344. const issued = blocks.map((b) => b.issued);
  345. const avgIssued = getAverage(issued) / 1000000;
  346. const percent = ((100 * avgStake) / avgIssued).toFixed(2);
  347. const noms = blocks.map((b) => b.noms);
  348. const vals = blocks.map((b) => b.vals);
  349. const avgVals = getAverage(vals);
  350. const totalReward = blocks.map((b) => b.reward);
  351. const avgReward = getAverage(totalReward);
  352. const reward = (avgReward / avgVals).toFixed();
  353. const pending = proposals.active.length;
  354. const finalized = proposals.executing.length;
  355. const p = (n: number) => (n > 1 ? "proposals" : "proposal");
  356. let proposalString: string[] = pending
  357. ? [
  358. `<a href="${domain}/#/proposals">${pending} pending ${p(pending)}</a> `,
  359. `${pending} active ${p(pending)} ${domain}/#/proposals`,
  360. ]
  361. : ["", ""];
  362. if (finalized)
  363. proposalString = proposalString.map(
  364. (s) => (s += `${finalized} ${p(finalized)} in grace period.`)
  365. );
  366. const msg = ` ${blocks.length} blocks produced in ${timePassed}
  367. Blocktime: ${blocktime.toFixed(3)}s
  368. Price: ${price} / 1 M tJOY
  369. Stake: ${avgStake.toFixed(1)} / ${avgIssued.toFixed()} M tJOY (${percent}%)
  370. Validators: ${avgVals.toFixed()} (${reward} tJOY/h)
  371. Nominators: ${getAverage(noms).toFixed()}
  372. Volume: ${storageSize}\n`;
  373. const tg = msg + proposalString[0];
  374. const discord = msg + proposalString[1];
  375. sendMessage({ tg, discord, tgParseMode: "HTML" }, channel);
  376. };
  377. export const formatProposalMessage = (
  378. data: string[]
  379. ): { tg: string; discord: string } => {
  380. const [id, title, type, stage, result, handle] = data;
  381. const tg = `<b>Type</b>: ${type}\r\n<b>Proposer</b>: <a href="${domain}/#/members/${handle}">${handle}</a>\r\n<b>Title</b>: <a href="${domain}/#/proposals/${id}">${title}</a>\r\n<b>Stage</b>: ${stage}\r\n<b>Result</b>: ${result}`;
  382. const discord = `**Type**: ${type}\n**Proposer**: ${handle}\n**Title**: ${title}\n**Stage**: ${stage}\n**Result**: ${result}`;
  383. return { tg, discord };
  384. };