Quellcode durchsuchen

Merge pull request #78 from traumschule/bounty4

Community Bounty #4: Improved Telegram Bot
Martin vor 3 Jahren
Ursprung
Commit
3850347e6c

+ 13 - 6
community-contributions/joystreamtelegrambot/README.md

@@ -2,7 +2,7 @@
 
 This bot notifies a Telegram chat about events on the Joystream chain.
 
-Demo: https://t.me/jsforumnotification
+Demo: https://t.me/JoyStreamOfficial
 
 Many thanks to [imOnlineMonitor](https://github.com/fkbenjamin/imOnlineMonitor) for providing example with polkadot chain (Kusama).
 
@@ -12,16 +12,23 @@ Many thanks to [imOnlineMonitor](https://github.com/fkbenjamin/imOnlineMonitor)
 [npm/Nodejs](https://github.com/Joystream/helpdesk/tree/master/roles/storage-providers#install-yarn-and-node-on-linux)
 
 ```
-git clone https://github.com/bitoven-dev/joystreamtelegrambot
-cd joystreamtelegrambot
-npm install
+git clone https://github.com/joystream/community-repo
+cd community-repo/community-contributions/joystreamtelegrambot
+yarn
 ```
 
 ## Configuration
 
-Open `config.ts` and set `token` and `chatid`. To get a bot token talk to @botfather on Telegram.
+Open `config.ts` and fill in the variables:
 
-Run `npm run build` to apply changes. After building \*.js files are available in `dist/` and you can run for example `node dist/bot.js --verbose --channel --council --forum --proposals`. For other options see below.
+- `token`: To get a bot token talk to @botfather on Telegram.
+- `chatid`: See below to find id of your group.
+
+Then run `npm run start` or `yarn run start`.
+Alternatively you can manually build with `npm run build` and run for example `node dist/src/bot.js --verbose --channel --council --forum --proposals`.
+For other options see below.
+
+To test status of storage providers, add their domains to to `storageProviders.ts`.
 
 ### Get chatid
 

+ 9 - 3
community-contributions/joystreamtelegrambot/config.ts

@@ -1,11 +1,17 @@
 // website
 export const domain = "https://testnet.joystream.org";
 
-export const wsLocation = "ws://rome-rpc-endpoint.joystream.org:9944/";
 // websocket location
+export const wsLocation = "wss://rome-rpc-endpoint.joystream.org:9944/";
 
 // telegram bot token
-export const token: string = "";
+export const token: string = "1168139699:AAHUWqHWqteAy55yM2yhF1XMvV1BgmYvfog";
 
 // telegram chat ID
-export const chatid: string = "";
+export const chatid: string = "-1001438587296";
+
+// time between heartbeat announcement in milliseconds
+export const heartbeat = 60000 * 60 * 6;
+
+// minutes between checking for proposal updates
+export const proposalDelay = 15

+ 4 - 7
community-contributions/joystreamtelegrambot/package.json

@@ -23,16 +23,13 @@
     "tests": "ts-node src/tests.ts"
   },
   "dependencies": {
-    "@joystream/types": "^0.13.1",
-    "@polkadot/api": "1.26.1",
-    "@polkadot/keyring": "^3.0.1",
-    "@polkadot/util": "^3.0.1",
-    "@types/node-telegram-bot-api": "^0.50.0",
     "moment": "^2.29.1",
-    "node-telegram-bot-api": "^0.50.0"
+    "node-telegram-bot-api": "^0.51.0",
+    "ws": "^7.4.1"
   },
   "devDependencies": {
-    "@polkadot/ts": "^0.3.49",
+    "@joystream/types": "^0.15.0",
+    "@types/node-telegram-bot-api": "^0.51.0",
     "ts-node": "^9.0.0",
     "ts-node-dev": "^1.0.0-pre.63",
     "typescript": "^4.0.3"

+ 106 - 45
community-contributions/joystreamtelegrambot/src/bot.ts

@@ -1,27 +1,32 @@
 import TelegramBot from "node-telegram-bot-api";
-import { token, chatid, wsLocation } from "../config";
+import { token, chatid, heartbeat, proposalDelay, wsLocation } from "../config";
 
 // types
-import { Options, Proposals } from "./types";
+import { Block, Council, Options, Proposals } from "./types";
 import { types } from "@joystream/types";
 import { ApiPromise, WsProvider } from "@polkadot/api";
-import { Header } from "@polkadot/types/interfaces";
+import { AccountId, Header } from "@polkadot/types/interfaces";
 
 // functions
 import * as announce from "./lib/announcements";
 import * as get from "./lib/getters";
-import { parseArgs, printStatus, exit } from "./lib/util";
+import { parseArgs, printStatus, passedTime, exit } from "./lib/util";
+import moment from "moment";
 
 const opts: Options = parseArgs(process.argv.slice(2));
 const log = (msg: string): void | number => opts.verbose && console.log(msg);
 process.env.NTBA_FIX_319 ||
   log("TL;DR: Set NTBA_FIX_319 to hide this warning.");
 
-const bot = new TelegramBot(token, { polling: true });
+const bot = token ? new TelegramBot(token, { polling: true }) : null;
+
+let lastHeartbeat: number = moment().valueOf();
 
 const sendMessage = (msg: string) => {
+  if (msg === "") return;
   try {
-    //bot.sendMessage(chatid, msg, { parse_mode: "HTML" });
+    if (bot) bot.sendMessage(chatid, msg, { parse_mode: "HTML" });
+    else console.log(msg);
   } catch (e) {
     console.log(`Failed to send message: ${e}`);
   }
@@ -32,41 +37,107 @@ const main = async () => {
   const api = await ApiPromise.create({ provider, types });
   await api.isReady;
 
-  log(`Publishing to ${chatid} with token ${token}`);
-
   const [chain, node, version] = await Promise.all([
-    api.rpc.system.chain(),
+    String(await api.rpc.system.chain()),
     api.rpc.system.name(),
-    api.rpc.system.version()
+    api.rpc.system.version(),
   ]);
 
-  let lastBlock = 0;
-  const cats: number[] = [0, 0];
+  let council: Council = { round: 0, last: "" };
+  let blocks: Block[] = [];
+  let lastEra = 0;
+  let lastBlock: Block = {
+    id: 0,
+    duration: 6000,
+    timestamp: lastHeartbeat,
+    stake: 0,
+    noms: 0,
+    vals: 0,
+    issued: 0,
+    reward: 0,
+  };
+  let issued = 0;
+  let reward = 0;
+  let stake = 0;
+  let vals = 0;
+  let noms = 0;
+
   const channels: number[] = [0, 0];
   const posts: number[] = [0, 0];
   const threads: number[] = [0, 0];
-  let proposals: Proposals = { last: 0, current: 0, active: [], pending: [] };
+  let proposals: Proposals = { last: 0, current: 0, active: [], executing: [] };
+  let lastProposalUpdate = 0;
 
   if (opts.channel) channels[0] = await get.currentChannelId(api);
 
   if (opts.forum) {
-    posts[0] = (await get.currentPostId(api)) - 1;
-    cats[0] = (await get.currentCategoryId(api)) - 1;
-    threads[0] = (await get.currentThreadId(api)) - 1;
+    posts[0] = await get.currentPostId(api);
+    threads[0] = await get.currentThreadId(api);
   }
 
   if (opts.proposals) {
-    proposals.last = (await get.proposalCount(api)) - 1;
-    proposals.active = await get.activeProposals(api);
-    proposals.pending = await get.pendingProposals(api);
+    proposals.last = await get.proposalCount(api);
+    proposals.active = await get.activeProposals(api, proposals.last);
   }
 
+  const getReward = async (era: number) =>
+    Number(await api.query.staking.erasValidatorReward(era));
+
   log(`Subscribed to ${chain} on ${node} v${version}`);
-  const unsubscribe = await api.rpc.chain.subscribeNewHeads(
-    async (block: Header): Promise<void> => {
-      const currentBlock = block.number.toNumber();
-      if (opts.council && currentBlock > lastBlock)
-        lastBlock = await announce.councils(api, currentBlock, sendMessage);
+  api.rpc.chain.subscribeNewHeads(
+    async (header: Header): Promise<void> => {
+      // current block
+      const id = header.number.toNumber();
+      if (lastBlock.id === id) return;
+      const timestamp = (await api.query.timestamp.now()).toNumber();
+      const duration = timestamp - lastBlock.timestamp;
+
+      // update validators and nominators every era
+      const era = Number(await api.query.staking.currentEra());
+
+      if (era > lastEra) {
+        vals = (await api.query.session.validators()).length;
+        stake = Number(await api.query.staking.erasTotalStake(era));
+        issued = Number(await api.query.balances.totalIssuance());
+        reward = (await getReward(era - 1)) || (await getReward(era - 2));
+
+        // nominator count
+        noms = 0;
+        const nominators: { [key: string]: number } = {};
+        const stashes = (await api.derive.staking.stashes())
+          .map((s) => String(s))
+          .map(async (v) => {
+            const stakers = await api.query.staking.erasStakers(era, v);
+            stakers.others.forEach(
+              (n: { who: AccountId }) => nominators[String(n.who)]++
+            );
+            noms = Object.keys(nominators).length;
+          });
+        lastEra = era;
+      }
+
+      const block: Block = {
+        id,
+        timestamp,
+        duration,
+        stake,
+        noms,
+        vals,
+        reward,
+        issued,
+      };
+      blocks = blocks.concat(block);
+
+      // heartbeat
+      if (timestamp > lastHeartbeat + heartbeat) {
+        const time = passedTime(lastHeartbeat, timestamp);
+        blocks = announce.heartbeat(api, blocks, time, proposals, sendMessage);
+        lastHeartbeat = block.timestamp;
+      }
+
+      // announcements
+      if (opts.council && block.id > lastBlock.id)
+        council = await announce.council(api, council, block.id, sendMessage);
 
       if (opts.channel) {
         channels[1] = await get.currentChannelId(api);
@@ -76,33 +147,23 @@ const main = async () => {
 
       if (opts.proposals) {
         proposals.current = await get.proposalCount(api);
-        if (proposals.current > proposals.last)
-          proposals = await announce.proposals(api, proposals, sendMessage);
+        if (
+          proposals.current > proposals.last ||
+          (timestamp > lastProposalUpdate + 60000 * proposalDelay &&
+            (proposals.active || proposals.executing))
+        ) {
+          proposals = await announce.proposals(api, proposals, id, sendMessage);
+          lastProposalUpdate = timestamp;
+        }
       }
 
       if (opts.forum) {
-        cats[1] = await get.currentCategoryId(api);
         posts[1] = await get.currentPostId(api);
-        threads[1] = await get.currentThreadId(api);
-
-        if (cats[1] > cats[0])
-          cats[0] = await announce.categories(api, cats, sendMessage);
-
-        if (posts[1] > posts[0])
-          posts[0] = await announce.posts(api, posts, sendMessage);
-
-        if (threads[1] > threads[0])
-          threads[0] = await announce.threads(api, threads, sendMessage);
+        posts[0] = await announce.posts(api, posts, sendMessage);
       }
 
-      printStatus(opts, {
-        block: currentBlock,
-        cats,
-        chain: String(chain),
-        posts,
-        proposals,
-        threads
-      });
+      printStatus(opts, { block: id, chain, posts, proposals });
+      lastBlock = block
     }
   );
 };

+ 188 - 146
community-contributions/joystreamtelegrambot/src/lib/announcements.ts

@@ -1,54 +1,37 @@
-import { Api, Member, ProposalDetail, Proposals } from "../types";
+import {
+  Api,
+  Block,
+  Council,
+  Member,
+  ProposalDetail,
+  Proposals,
+} from "../types";
 import { BlockNumber } from "@polkadot/types/interfaces";
 import { Channel, ElectionStage } from "@joystream/types/augment";
-//import { Channel } from "@joystream/types/channel";
 import { Category, Thread, Post } from "@joystream/types/forum";
 import { domain } from "../../config";
+import { formatTime } from "./util";
 import {
   categoryById,
   memberHandle,
   memberHandleByAccount,
-  proposalDetail
+  proposalDetail,
 } from "./getters";
+import moment from "moment";
 
-const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
 
-const query = async (
-  test: string,
-  callback: () => Promise<any>
-): Promise<any> => {
-  let result: any = await callback();
+// query API repeatedly to ensure a result
+const query = async (test: string, cb: () => Promise<any>): Promise<any> => {
+  let result = await cb();
   for (let i: number = 0; i < 10; i++) {
-    if (result[test] === "") {
-      console.debug(`refetching ${callback} (${i})`);
-      result = await callback();
-      await sleep(5000);
-    }
+    if (result[test] !== "") return result;
+    result = await cb();
+    await sleep(5000);
   }
-  return result;
-};
-
-// forum
-
-export const categories = async (
-  api: Api,
-  category: number[],
-  sendMessage: (msg: string) => void
-): Promise<number> => {
-  const messages: string[] = [];
-  let id: number = category[0] + 1;
-  for (id; id <= category[1]; id++) {
-    const category: Category = await query("title", () =>
-      categoryById(api, id)
-    );
-    messages.push(
-      `Category ${id}: <b><a href="${domain}/#/forum/categories/${id}">${category.title}</a></b>`
-    );
-  }
-  sendMessage(messages.join("\r\n\r\n"));
-  return category[1];
 };
 
+// announce latest channels
 export const channels = async (
   api: Api,
   channels: number[],
@@ -57,7 +40,7 @@ export const channels = async (
   const [last, current] = channels;
   const messages: string[] = [];
 
-  for (let id: number = last + 1; id <= current; id++) {
+  for (let id: number = +last + 1; id <= current; id++) {
     const channel: Channel = await query("title", () =>
       api.query.contentWorkingGroup.channelById(id)
     );
@@ -72,166 +55,225 @@ export const channels = async (
   return current;
 };
 
-export const councils = async (
+// announce council change
+
+export const council = async (
   api: Api,
-  block: number,
+  council: Council,
+  currentBlock: number,
   sendMessage: (msg: string) => void
-): Promise<number> => {
-  let current: number = block;
+): Promise<Council> => {
   const round: number = await api.query.councilElection.round();
-  const stage: ElectionStage | null = await await api.query.councilElection.stage();
-  if (!stage) {
+  const stage: any = await api.query.councilElection.stage();
+  const stageObj = JSON.parse(JSON.stringify(stage));
+  let stageString = stageObj ? Object.keys(stageObj)[0] : "";
+  let msg = "";
+
+  if (!stage || stage.toJSON() === null) {
+    stageString = "elected";
     const councilEnd: BlockNumber = await api.query.council.termEndsAt();
-    current = councilEnd.toNumber();
     const termDuration: BlockNumber = await api.query.councilElection.newTermDuration();
-    const block = current - termDuration.toNumber();
-    sendMessage(
-      `<a href="${domain}/#/council/members">Council for round ${round}</a> has been elected at block ${block} until block ${councilEnd}.`
-    );
-  } else {
-    if (stage.isAnnouncing) {
-      current = stage.asAnnouncing.toNumber();
-      const announcingPeriod: BlockNumber = await api.query.councilElection.announcingPeriod();
-      const block = current - announcingPeriod.toNumber();
-      sendMessage(
-        `Announcing election for round ${round} at ${block}.<a href="${domain}/#/council/applicants">Apply now!</a>`
-      );
-    }
+    const block = councilEnd.toNumber() - termDuration.toNumber();
+    if (currentBlock - block < 2000) {
+      const remainingBlocks = councilEnd.toNumber() - currentBlock;
+      const m = moment().add(remainingBlocks * 6, "s");
+      const endDate = formatTime(m, "DD/MM/YYYY");
 
-    if (stage.isVoting) {
-      current = stage.asVoting.toNumber();
-      const votingPeriod: BlockNumber = await api.query.councilElection.votingPeriod();
-      const block = current - votingPeriod.toNumber();
-      sendMessage(
-        `Voting stage for council election started at block ${block}. <a href="${domain}/#/council/applicants">Vote now!</a>`
+      const handles: string[] = await Promise.all(
+        (await api.query.council.activeCouncil()).map(
+          async (seat: { member: string }) =>
+            await memberHandleByAccount(api, seat.member)
+        )
       );
-    }
+      const members = handles.join(", ");
 
-    if (stage.isRevealing) {
-      current = stage.asRevealing.toNumber();
-      const revealingPeriod: BlockNumber = await api.query.councilElection.revealingPeriod();
-      const block = current - revealingPeriod.toNumber();
-      sendMessage(
-        `Revealing stage for council election started at block ${block}. <a href="${domain}/#/council/votes">Don't forget to reveal your vote!</a>`
-      );
+      msg = `Council election ended: ${members} have been elected for <a href="${domain}/#/council/members">council ${round}</a>. Congratulations!\nNext election starts on ${endDate}.`;
     }
+  } else {
+    const remainingBlocks = stage.toJSON()[stageString] - currentBlock;
+    const m = moment().add(remainingBlocks * 6, "second");
+    const endDate = formatTime(m, "DD-MM-YYYY HH:mm (UTC)");
+
+    if (stageString === "Announcing")
+      msg = `Council election started. You can <b><a href="${domain}/#/council/applicants">announce your application</a></b> until ${endDate}`;
+    else if (stageString === "Voting")
+      msg = `Council election: <b><a href="${domain}/#/council/applicants">Vote</a></b> until ${endDate}`;
+    else if (stageString === "Revealing")
+      msg = `Council election: <b><a href="${domain}/#/council/votes">Reveal your votes</a></b> until ${endDate}`;
   }
-  return current;
+
+  if (
+    council.last !== "" &&
+    round !== council.round &&
+    stageString !== council.last
+  )
+    sendMessage(msg);
+  return { round, last: stageString };
+};
+
+// forum
+// announce latest categories
+export const categories = async (
+  api: Api,
+  category: number[],
+  sendMessage: (msg: string) => void
+): Promise<number> => {
+  if (category[0] === category[1]) return category[0];
+  const messages: string[] = [];
+
+  for (let id: number = +category[0] + 1; id <= category[1]; id++) {
+    const cat: Category = await query("title", () => categoryById(api, id));
+    const msg = `Category ${id}: <b><a href="${domain}/#/forum/categories/${id}">${cat.title}</a></b>`;
+    messages.push(msg);
+  }
+
+  sendMessage(messages.join("\r\n\r\n"));
+  return category[1];
 };
 
+// announce latest posts
 export const posts = async (
   api: Api,
   posts: number[],
   sendMessage: (msg: string) => void
 ): Promise<number> => {
   const [last, current] = posts;
+  if (current === last) return last;
   const messages: string[] = [];
-  let id: number = last + 1;
-  for (id; id <= current; id++) {
+
+  for (let id: number = +last + 1; id <= current; id++) {
     const post: Post = await query("current_text", () =>
       api.query.forum.postById(id)
     );
     const replyId: number = post.nr_in_thread.toNumber();
-    const message: string = post.current_text;
-    const excerpt: string = message.substring(0, 100);
     const threadId: number = post.thread_id.toNumber();
     const thread: Thread = await query("title", () =>
       api.query.forum.threadById(threadId)
     );
-    const threadTitle: string = thread.title;
+    const categoryId = thread.category_id.toNumber();
+
     const category: Category = await query("title", () =>
-      categoryById(api, thread.category_id.toNumber())
+      categoryById(api, categoryId)
     );
     const handle = await memberHandleByAccount(api, post.author_id.toJSON());
+
+    const s = {
+      author: `<a href="${domain}/#/members/${handle}">${handle}</a>`,
+      thread: `<a href="${domain}/#/forum/threads/${threadId}?replyIdx=${replyId}">${thread.title}</a>`,
+      category: `<a href="${domain}/#/forum/categories/${category.id}">${category.title}</a>`,
+      content: `<i>${post.current_text.substring(0, 150)}</i> `,
+      link: `<a href="${domain}/#/forum/threads/${threadId}?replyIdx=${replyId}">more</a>`,
+    };
+
     messages.push(
-      `<b><a href="${domain}/#/members/${handle}">${handle}</a> posted <a href="${domain}/#/forum/threads/${threadId}?replyIdx=${replyId}">${threadTitle}</a> in <a href="${domain}/#/forum/categories/${category.id}">${category.title}</a>:</b>\n\r<i>${excerpt}</i> <a href="${domain}/#/forum/threads/${threadId}?replyIdx=${replyId}">more</a>`
+      `<u>${s.category}</u> <b>${s.author}</b> posted in <b>${s.thread}</b>:\n\r${s.content}${s.link}`
     );
   }
+
   sendMessage(messages.join("\r\n\r\n"));
   return current;
 };
 
-const processActive = async (
-  id: number,
-  details: ProposalDetail,
-  sendMessage: (s: string) => void
-): Promise<boolean> => {
-  const { createdAt, finalizedAt, message, parameters, result } = details;
-  let msg = `Proposal ${id} <b>created</b> at block ${createdAt}.\r\n${message}`;
-  if (details.stage === "Finalized") {
-    let label: string = result;
-    if (result === "Approved") {
-      const executed = parameters.gracePeriod.toNumber() > 0 ? false : true;
-      label = executed ? "Finalized" : "Finalized and Executed";
-    }
-    msg = `Proposal ${id} <b>${label}</b> at block ${finalizedAt}.\r\n${message}`;
-    sendMessage(msg);
-    return true;
-  } else return processPending(id, details, sendMessage);
-};
-
-const processPending = async (
-  id: number,
-  details: ProposalDetail,
-  sendMessage: (s: string) => void
-): Promise<boolean> => {
-  const { createdAt, message, parameters, stage } = details;
-  if (stage === "Finalized") return processActive(id, details, sendMessage);
-  const votingEndsAt = createdAt + parameters.votingPeriod.toNumber();
-  const msg = `Proposal ${id} <b>created</b> at block ${createdAt}.\r\n${message}\r\nYou can vote until block ${votingEndsAt}.`;
-  sendMessage(msg);
-  return true;
-};
-
+// announce latest proposals
 export const proposals = async (
   api: Api,
   prop: Proposals,
+  block: number,
   sendMessage: (msg: string) => void
 ): Promise<Proposals> => {
-  let { current, last, active, pending } = prop;
+  let { current, last, active, executing } = prop;
 
-  for (let id: number = last++; id <= current; id++) active.push(id);
+  for (let id: number = +last + 1; id <= current; id++) {
+    const proposal: ProposalDetail = await proposalDetail(api, id);
+    const { createdAt, finalizedAt, message, parameters, result } = proposal;
+    const votingEndsAt = createdAt + parameters.votingPeriod.toNumber();
+    const endTime = moment()
+      .add(6 * (votingEndsAt - block), "second")
+      .format("DD/MM/YYYY HH:mm");
+    const msg = `Proposal ${id} <b>created</b> at block ${createdAt}.\r\n${message}\r\nYou can vote until ${endTime} UTC (block ${votingEndsAt}).`;
+    sendMessage(msg);
+    active.push(id);
+  }
 
-  for (const id of active)
-    if (processActive(id, await proposalDetail(api, id), sendMessage))
-      active = active.filter((e: number) => e !== id);
+  for (const id of active) {
+    const proposal: ProposalDetail = await proposalDetail(api, id);
+    const { finalizedAt, message, parameters, result, stage } = proposal;
+    if (stage === "Finalized") {
+      let label: string = result.toLowerCase();
+      if (result === "Approved") {
+        const executed = parameters.gracePeriod.toNumber() > 0 ? false : true;
+        label = executed ? "executed" : "finalized";
+        if (!executed) executing.push(id);
+      }
+      const msg = `Proposal ${id} <b>${label}</b> at block ${finalizedAt}.\r\n${message}`;
+      sendMessage(msg);
+      active = active.filter((a) => a !== id);
+    }
+  }
 
-  for (const id of pending)
-    if (processPending(id, await proposalDetail(api, id), sendMessage))
-      pending = pending.filter((e: number) => e !== id);
+  for (const id of executing) {
+    const proposal = await proposalDetail(api, id);
+    const { finalizedAt, message, parameters } = proposal;
+    const executesAt = +finalizedAt + parameters.gracePeriod.toNumber();
+    if (block < executesAt) continue;
+    const msg = `Proposal ${id} <b>executed</b> at block ${executesAt}.\r\n${message}`;
+    sendMessage(msg);
+    executing = executing.filter((e) => e !== id);
+  }
 
-  return { current, last: current, active, pending };
+  return { current, last: current, active, executing };
 };
 
-export const threads = async (
+// heartbeat
+
+const getAverage = (array: number[]): number =>
+  array.reduce((a: number, b: number) => a + b, 0) / array.length;
+
+export const heartbeat = (
   api: Api,
-  threads: number[],
+  blocks: Block[],
+  timePassed: string,
+  proposals: Proposals,
   sendMessage: (msg: string) => void
-): Promise<number> => {
-  const [last, current] = threads;
-  const messages: string[] = [];
-  let id: number = last + 1;
-  for (id; id <= current; id++) {
-    const thread: Thread = await query("title", () =>
-      api.query.forum.threadById(id)
-    );
-    const { title, author_id } = thread;
-    const memberName: string = await memberHandleByAccount(
-      api,
-      author_id.toJSON()
-    );
-    const category: Category = await query("title", () =>
-      categoryById(api, thread.category_id.toNumber())
-    );
-    messages.push(
-      `Thread ${id}: <a href="${domain}/#/forum/threads/${id}">"${title}"</a> by <a href="${domain}/#/members/${memberName}">${memberName}</a> in category "<a href="${domain}/#/forum/categories/${category.id}">${category.title}</a>" `
-    );
-  }
-  sendMessage(messages.join("\r\n\r\n"));
-  return id;
+): [] => {
+  const durations = blocks.map((b) => b.duration);
+  const blocktime = getAverage(durations) / 1000;
+
+  const stake = blocks.map((b) => b.stake);
+  const avgStake = getAverage(stake) / 1000000;
+  const issued = blocks.map((b) => b.issued);
+  const avgIssued = getAverage(issued) / 1000000;
+  const percent = ((100 * avgStake) / avgIssued).toFixed(2);
+
+  const noms = blocks.map((b) => b.noms);
+  const vals = blocks.map((b) => b.vals);
+  const avgVals = getAverage(vals);
+  const totalReward = blocks.map((b) => b.reward);
+  const avgReward = getAverage(totalReward);
+  const reward = (avgReward / avgVals).toFixed();
+
+  const pending = proposals.active.length;
+  const finalized = proposals.executing.length;
+  const p = (n: number) => (n > 1 ? "proposals" : "proposal");
+  let proposalString = pending
+    ? `<a href="${domain}/#/proposals">${pending} pending ${p(pending)}</a> `
+    : "";
+  if (finalized)
+    proposalString += `${finalized} ${p(finalized)} in grace period.`;
+
+  sendMessage(
+    `  ${blocks.length} blocks produced in ${timePassed}
+  Blocktime: ${blocktime.toFixed(3)}s
+  Stake: ${avgStake.toFixed(1)} / ${avgIssued.toFixed()} M tJOY (${percent}%)
+  Validators: ${avgVals.toFixed()} (${reward} tJOY/h)
+  Nominators: ${getAverage(noms).toFixed()}
+  ${proposalString}`
+  );
+
+  return [];
 };
 
 export const formatProposalMessage = (data: string[]): string => {
-  const [id, title, type, stage, result, memberHandle] = data;
-  return `<b>Type</b>: ${type}\r\n<b>Proposer</b>:<a href="${domain}/#/members/${memberHandle}"> ${memberHandle}</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}`;
+  const [id, title, type, stage, result, handle] = data;
+  return `<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}`;
 };

+ 33 - 57
community-contributions/joystreamtelegrambot/src/lib/getters.ts

@@ -1,17 +1,17 @@
-import { Api, Proposals, ProposalArray, ProposalDetail } from "../types";
+import { formatProposalMessage } from "./announcements";
+
+//types
+
+import { Api, ProposalArray, ProposalDetail } from "../types";
 import {
   ChannelId,
-  ElectionStage,
   PostId,
   ProposalDetailsOf,
-  ThreadId
+  ThreadId,
 } from "@joystream/types/augment";
 import { Category, CategoryId } from "@joystream/types/forum";
 import { MemberId, Membership } from "@joystream/types/members";
-import { Proposal, ProposalStatus } from "@joystream/types/proposals";
-
-import { formatProposalMessage } from "./announcements";
-import { domain } from "../../config";
+import { Proposal } from "@joystream/types/proposals";
 
 // channel
 
@@ -20,7 +20,7 @@ export const currentChannelId = async (api: Api): Promise<number> => {
   return id.toNumber() - 1;
 };
 
-export const memberHandle = async (api: Api, id: number): Promise<string> => {
+export const memberHandle = async (api: Api, id: MemberId): Promise<string> => {
   const membership: Membership = await api.query.members.membershipById(id);
   return membership.handle.toJSON();
 };
@@ -29,7 +29,9 @@ export const memberHandleByAccount = async (
   api: Api,
   account: string
 ): Promise<string> => {
-  const id: number = await api.query.members.memberIdsByRootAccountId(account);
+  const id: MemberId = await api.query.members.memberIdsByRootAccountId(
+    account
+  );
   const handle: string = await memberHandle(api, id);
   return handle;
 };
@@ -58,32 +60,20 @@ export const currentCategoryId = async (api: Api): Promise<number> => {
 
 // proposals
 
-export const proposalCount = async (api: Api): Promise<number> => {
-  const proposalCount: number = await api.query.proposalsEngine.proposalCount();
-  return proposalCount || 0;
-};
-
-const activeProposalCount = async (api: Api): Promise<number> => {
-  const proposalCount: number = await api.query.proposalsEngine.activeProposalCount();
-  return proposalCount || 0;
-};
-
-export const pendingProposals = async (api: Api): Promise<ProposalArray> => {
-  const pending: ProposalArray = await api.query.proposalsEngine.pendingExecutionProposalIds(
-    await activeProposalCount(api)
-  );
-  //const pending: ProposalArray = pendingProposals.toJSON();
-  if (pending.length) console.debug("pending proposals", pending);
-  return pending;
-};
+export const proposalCount = async (api: Api): Promise<number> =>
+  Number(await api.query.proposalsEngine.proposalCount());
 
-export const activeProposals = async (api: Api): Promise<ProposalArray> => {
-  const active: ProposalArray = await api.query.proposalsEngine.activeProposalIds(
-    await activeProposalCount(api)
-  );
-  //const active: ProposalArray = result.toJSON();
-  if (active.length) console.debug("active proposals", active);
-  return active;
+export const activeProposals = async (
+  api: Api,
+  last: number
+): Promise<number[]> => {
+  const count = Number(await api.query.proposalsEngine.activeProposalCount());
+  let ids: number[] = [];
+  for (let id = last; ids.length < count; id--) {
+    const proposal = await proposalDetail(api, id);
+    if (proposal.result === "Pending") ids.push(id);
+  }
+  return ids;
 };
 
 const getProposalType = async (api: Api, id: number): Promise<string> => {
@@ -99,15 +89,9 @@ export const proposalDetail = async (
   id: number
 ): Promise<ProposalDetail> => {
   const proposal: Proposal = await api.query.proposalsEngine.proposals(id);
-  const { parameters, proposerId, description } = proposal;
-  const author: string = await memberHandle(api, proposerId.toNumber());
-  const createdAt: number = proposal.createdAt.toNumber();
-  const title: string = proposal.title.toString();
-  const proposerHandle: string = await memberHandle(api, proposerId.toJSON());
   const status: { [key: string]: any } = proposal.status;
   const stage: string = status.isActive ? "Active" : "Finalized";
   const { finalizedAt, proposalStatus } = status[`as${stage}`];
-  const type: string = await getProposalType(api, id);
   const result: string = proposalStatus
     ? (proposalStatus.isApproved && "Approved") ||
       (proposalStatus.isCanceled && "Canceled") ||
@@ -116,22 +100,14 @@ export const proposalDetail = async (
       (proposalStatus.isSlashed && "Slashed") ||
       (proposalStatus.isVetoed && "Vetoed")
     : "Pending";
+  const exec = proposalStatus ? proposalStatus["Approved"] : null;
 
-  const message: string = formatProposalMessage([
-    String(id),
-    title,
-    type,
-    stage,
-    result,
-    author
-  ]);
-  const proposalDetail: ProposalDetail = {
-    createdAt,
-    finalizedAt,
-    parameters,
-    message,
-    stage,
-    result
-  };
-  return proposalDetail;
+  const { parameters, proposerId } = proposal;
+  const author: string = await memberHandle(api, proposerId);
+  const title: string = proposal.title.toString();
+  const type: string = await getProposalType(api, id);
+  const args: string[] = [String(id), title, type, stage, result, author];
+  const message: string = formatProposalMessage(args);
+  const createdAt: number = proposal.createdAt.toNumber();
+  return { createdAt, finalizedAt, parameters, message, stage, result, exec };
 };

+ 22 - 11
community-contributions/joystreamtelegrambot/src/lib/util.ts

@@ -1,10 +1,9 @@
-import { Options } from "../types";
-import { Proposals } from "../types";
+import { Api, Options, Proposals } from "../types";
 import moment from "moment";
 
 export const parseArgs = (args: string[]): Options => {
   const inArgs = (term: string): boolean => {
-    return args.find(a => a.search(term) > -1) ? true : false;
+    return args.find((a) => a.search(term) > -1) ? true : false;
   };
 
   const options: Options = {
@@ -12,7 +11,7 @@ export const parseArgs = (args: string[]): Options => {
     channel: inArgs("--channel"),
     council: inArgs("--council"),
     forum: inArgs("--forum"),
-    proposals: inArgs("--proposals")
+    proposals: inArgs("--proposals"),
   };
 
   if (options.verbose > 1) console.debug("args", args, "\noptions", options);
@@ -23,28 +22,40 @@ export const printStatus = (
   opts: Options,
   data: {
     block: number;
-    cats: number[];
     chain: string;
     posts: number[];
     proposals: Proposals;
-    threads: number[];
   }
 ): void => {
   if (opts.verbose < 1) return;
 
-  const { block, chain, proposals, cats, posts, threads } = data;
-  const date = moment().format("L HH:mm:ss");
+  const { block, chain, proposals, posts } = data;
+  const date = formatTime();
   let message = `[${date}] Chain:${chain} Block:${block} `;
 
-  if (opts.forum)
-    message += `Post:${posts[1]} Cat:${cats[1]} Thread:${threads[1]} `;
+  if (opts.forum) message += `Post:${posts[1]} `;
 
   if (opts.proposals)
-    message += `Proposals:${proposals.current} (Active:${proposals.active.length} Pending:${proposals.pending.length}) `;
+    message += `Proposals:${proposals.current} (Active:${proposals.active.length} Pending:${proposals.executing.length}) `;
 
   console.log(message);
 };
 
+// time
+export const formatTime = (time?: any, format = "H:mm:ss"): string =>
+  moment(time).format(format);
+
+export const passedTime = (start: number, now: number): string => {
+  const passed = moment.utc(moment(now).diff(start)).valueOf();
+  const format =
+    passed > 86400000
+      ? "d:HH:mm:ss[d]"
+      : passed > 3600000
+      ? "H:mm:ss[h]"
+      : "mm:ss[m]";
+  return formatTime(passed, format);
+};
+
 export const exit = (log: (s: string) => void) => {
   log("\nNo connection, exiting.\n");
   process.exit();

+ 9 - 16
community-contributions/joystreamtelegrambot/src/tests.ts

@@ -2,7 +2,7 @@
 import { wsLocation } from "../config";
 
 // types
-import { Proposals } from "./types";
+import { Council, Proposals } from "./types";
 import { types } from "@joystream/types";
 import { ApiPromise, WsProvider } from "@polkadot/api";
 import { Header } from "@polkadot/types/interfaces";
@@ -22,20 +22,20 @@ const main = async () => {
   const [chain, node, version] = await Promise.all([
     api.rpc.system.chain(),
     api.rpc.system.name(),
-    api.rpc.system.version()
+    api.rpc.system.version(),
   ]);
   log(`Connected to ${chain} on ${node} v${version}`);
 
-  let lastBlock = 0;
+  let council: Council = { round: 0, last: "" };
+  let lastBlock: number = 0;
   let proposals: Proposals = {
     last: 1,
     current: 2,
     active: [],
-    pending: []
+    executing: [],
   };
   let categories = [0, 0];
   let posts = [0, 0];
-  let threads = [0, 0];
   let channels = [0, 0];
 
   const unsubscribe = await api.rpc.chain.subscribeNewHeads(
@@ -45,15 +45,16 @@ const main = async () => {
       lastBlock = block.number.toNumber();
       const currentBlock = block.number.toNumber();
       log("current council");
-      announce.councils(api, currentBlock, sendMessage);
+      council = await announce.council(api, council, currentBlock, sendMessage);
+      lastBlock = currentBlock;
 
       log("first proposal");
-      announce.proposals(api, proposals, sendMessage);
+      announce.proposals(api, proposals, lastBlock, sendMessage);
 
       log("last proposal");
       proposals.current = await get.proposalCount(api);
       proposals.last = proposals.current - 1;
-      announce.proposals(api, proposals, sendMessage);
+      announce.proposals(api, proposals, lastBlock, sendMessage);
 
       log("first category");
       announce.categories(api, categories, sendMessage);
@@ -71,14 +72,6 @@ const main = async () => {
       posts[0] = posts[1] - 1;
       announce.posts(api, posts, sendMessage);
 
-      log("first thread");
-      announce.threads(api, threads, sendMessage);
-
-      log("last thread");
-      threads[1] = await get.currentThreadId(api);
-      threads[0] = threads[1] - 1;
-      announce.threads(api, threads, sendMessage);
-
       log("first channel");
       announce.channels(api, channels, sendMessage);
 

+ 21 - 1
community-contributions/joystreamtelegrambot/src/types/index.ts

@@ -2,11 +2,19 @@ import { ApiPromise } from "@polkadot/api";
 import { MemberId } from "@joystream/types/members";
 import { AnyJson } from "@polkadot/types/types/helpers";
 import { ProposalParameters, ProposalStatus } from "@joystream/types/proposals";
+import { Nominations } from "@polkadot/types/interfaces";
+import { Option } from "@polkadot/types/codec";
+import { StorageKey } from "@polkadot/types/primitive";
 
 export interface Api {
   query: any;
 }
 
+export interface Council {
+  round: number;
+  last: string;
+}
+
 export interface Options {
   verbose: number;
   channel: boolean;
@@ -22,6 +30,7 @@ export interface ProposalDetail {
   parameters: ProposalParameters;
   stage: string;
   result: string;
+  exec: any;
 }
 
 export type ProposalArray = number[];
@@ -30,7 +39,7 @@ export interface Proposals {
   current: number;
   last: number;
   active: ProposalArray;
-  pending: ProposalArray;
+  executing: ProposalArray;
 }
 
 export interface Member {
@@ -38,3 +47,14 @@ export interface Member {
   handle: string;
   url?: string;
 }
+
+export interface Block {
+  id: number;
+  timestamp: number;
+  duration: number;
+  stake: number;
+  noms: number;
+  vals: number;
+  issued: number;
+  reward: number;
+}