Bläddra i källkod

Merge pull request #3147 from Lezek123/olympia-cli-forum

Olympia CLI: Forum
Lezek123 3 år sedan
förälder
incheckning
def18167d9

+ 268 - 10
cli/README.md

@@ -23,7 +23,7 @@ $ npm install -g @joystream/cli
 $ joystream-cli COMMAND
 running command...
 $ joystream-cli (-v|--version|version)
-@joystream/cli/0.6.0 linux-x64 node-v14.18.0
+@joystream/cli/0.7.0 linux-x64 node-v14.18.0
 $ joystream-cli --help [COMMAND]
 USAGE
   $ joystream-cli COMMAND
@@ -111,6 +111,20 @@ When using the CLI for the first time there are a few common steps you might wan
 * [`joystream-cli content:updateVideoCensorshipStatus ID [STATUS]`](#joystream-cli-contentupdatevideocensorshipstatus-id-status)
 * [`joystream-cli content:video VIDEOID`](#joystream-cli-contentvideo-videoid)
 * [`joystream-cli content:videos [CHANNELID]`](#joystream-cli-contentvideos-channelid)
+* [`joystream-cli forum:addPost`](#joystream-cli-forumaddpost)
+* [`joystream-cli forum:categories`](#joystream-cli-forumcategories)
+* [`joystream-cli forum:category`](#joystream-cli-forumcategory)
+* [`joystream-cli forum:createCategory`](#joystream-cli-forumcreatecategory)
+* [`joystream-cli forum:createThread`](#joystream-cli-forumcreatethread)
+* [`joystream-cli forum:deleteCategory`](#joystream-cli-forumdeletecategory)
+* [`joystream-cli forum:moderatePost`](#joystream-cli-forummoderatepost)
+* [`joystream-cli forum:moderateThread`](#joystream-cli-forummoderatethread)
+* [`joystream-cli forum:moveThread`](#joystream-cli-forummovethread)
+* [`joystream-cli forum:posts`](#joystream-cli-forumposts)
+* [`joystream-cli forum:setStickiedThreads`](#joystream-cli-forumsetstickiedthreads)
+* [`joystream-cli forum:threads`](#joystream-cli-forumthreads)
+* [`joystream-cli forum:updateCategoryArchivalStatus`](#joystream-cli-forumupdatecategoryarchivalstatus)
+* [`joystream-cli forum:updateCategoryModeratorStatus`](#joystream-cli-forumupdatecategorymoderatorstatus)
 * [`joystream-cli help [COMMAND]`](#joystream-cli-help-command)
 * [`joystream-cli membership:addStakingAccount`](#joystream-cli-membershipaddstakingaccount)
 * [`joystream-cli membership:buy`](#joystream-cli-membershipbuy)
@@ -284,15 +298,15 @@ OPTIONS
       If no "--method" flag is provided then all methods in that module will be listed along with the descriptions.
 
   -a, --callArgs=callArgs
-      Specifies the arguments to use when calling a method. Multiple arguments can be separated with a comma, ie.
+      Specifies the arguments to use when calling a method. Multiple arguments can be separated with a comma, ie. 
       "-a=arg1,arg2".
       You can omit this flag even if the method requires some aguments.
       In that case you will be promted to provide value for each required argument.
-      Ommiting this flag is recommended when input parameters are of more complex types (and it's hard to specify them as
+      Ommiting this flag is recommended when input parameters are of more complex types (and it's hard to specify them as 
       just simple comma-separated strings)
 
   -e, --exec
-      Provide this flag if you want to execute the actual call, instead of displaying the method description (which is
+      Provide this flag if you want to execute the actual call, instead of displaying the method description (which is 
       default)
 
   -m, --method=method
@@ -876,6 +890,256 @@ OPTIONS
 
 _See code: [src/commands/content/videos.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content/videos.ts)_
 
+## `joystream-cli forum:addPost`
+
+Add forum post.
+
+```
+USAGE
+  $ joystream-cli forum:addPost
+
+OPTIONS
+  --categoryId=categoryId    (required) Id of the forum category of the parent thread
+  --editable                 Whether the post should be editable
+  --text=text                (required) Post content (md-formatted text)
+  --threadId=threadId        (required) Post's parent thread
+  --useMemberId=useMemberId  Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId  Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/addPost.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/addPost.ts)_
+
+## `joystream-cli forum:categories`
+
+List existing forum categories.
+
+```
+USAGE
+  $ joystream-cli forum:categories
+
+OPTIONS
+  -c, --tree                               Display a category tree (with parentCategoryId as root, if specified)
+  -p, --parentCategoryId=parentCategoryId  Parent category id (only child categories will be listed)
+  --useMemberId=useMemberId                Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId                Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/categories.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/categories.ts)_
+
+## `joystream-cli forum:category`
+
+Display forum category details.
+
+```
+USAGE
+  $ joystream-cli forum:category
+
+OPTIONS
+  -c, --categoryId=categoryId  (required) Forum category id
+  --useMemberId=useMemberId    Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId    Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/category.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/category.ts)_
+
+## `joystream-cli forum:createCategory`
+
+Create forum category.
+
+```
+USAGE
+  $ joystream-cli forum:createCategory
+
+OPTIONS
+  -d, --description=description            (required) Category description
+  -p, --parentCategoryId=parentCategoryId  Parent category id (in case of creating a subcategory)
+  -t, --title=title                        (required) Category title
+  --useMemberId=useMemberId                Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId                Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/createCategory.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/createCategory.ts)_
+
+## `joystream-cli forum:createThread`
+
+Create forum thread.
+
+```
+USAGE
+  $ joystream-cli forum:createThread
+
+OPTIONS
+  --categoryId=categoryId    (required) Id of the forum category the thread should be created in
+  --tags=tags                Space-separated tags to associate with the thread
+  --text=text                (required) Initial post text
+  --title=title              (required) Thread title
+  --useMemberId=useMemberId  Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId  Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/createThread.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/createThread.ts)_
+
+## `joystream-cli forum:deleteCategory`
+
+Delete forum category provided it has no existing subcategories and threads.
+
+```
+USAGE
+  $ joystream-cli forum:deleteCategory
+
+OPTIONS
+  -c, --categoryId=categoryId   (required) Id of the category to delete
+  --context=(Leader|Moderator)  Actor context to execute the command in (Leader/Moderator)
+  --useMemberId=useMemberId     Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId     Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/deleteCategory.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/deleteCategory.ts)_
+
+## `joystream-cli forum:moderatePost`
+
+Moderate a forum post and slash the associated stake.
+
+```
+USAGE
+  $ joystream-cli forum:moderatePost
+
+OPTIONS
+  -c, --categoryId=categoryId   (required) Forum category id
+  -p, --postId=postId           (required) Forum post id
+  -r, --rationale=rationale     (required) Rationale behind the post moderation.
+  -t, --threadId=threadId       (required) Forum thread id
+  --context=(Leader|Moderator)  Actor context to execute the command in (Leader/Moderator)
+  --useMemberId=useMemberId     Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId     Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/moderatePost.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/moderatePost.ts)_
+
+## `joystream-cli forum:moderateThread`
+
+Moderate a forum thread and slash the associated stake.
+
+```
+USAGE
+  $ joystream-cli forum:moderateThread
+
+OPTIONS
+  -c, --categoryId=categoryId   (required) Id of the forum category the thread is currently in
+  -r, --rationale=rationale     (required) Rationale behind the thread moderation.
+  -t, --threadId=threadId       (required) Forum thread id
+  --context=(Leader|Moderator)  Actor context to execute the command in (Leader/Moderator)
+  --useMemberId=useMemberId     Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId     Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/moderateThread.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/moderateThread.ts)_
+
+## `joystream-cli forum:moveThread`
+
+Move forum thread to a different category.
+
+```
+USAGE
+  $ joystream-cli forum:moveThread
+
+OPTIONS
+  -c, --categoryId=categoryId        (required) Thread's current category id
+  -n, --newCategoryId=newCategoryId  (required) Thread's new category id
+  -t, --threadId=threadId            (required) Forum thread id
+  --context=(Leader|Moderator)       Actor context to execute the command in (Leader/Moderator)
+  --useMemberId=useMemberId          Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId          Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/moveThread.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/moveThread.ts)_
+
+## `joystream-cli forum:posts`
+
+List existing forum posts in given thread.
+
+```
+USAGE
+  $ joystream-cli forum:posts
+
+OPTIONS
+  -t, --threadId=threadId    (required) Thread id (only posts in this thread will be listed)
+  --useMemberId=useMemberId  Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId  Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/posts.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/posts.ts)_
+
+## `joystream-cli forum:setStickiedThreads`
+
+Set stickied threads in a given category.
+
+```
+USAGE
+  $ joystream-cli forum:setStickiedThreads
+
+OPTIONS
+  --categoryId=categoryId       (required) Forum category id
+  --context=(Leader|Moderator)  Actor context to execute the command in (Leader/Moderator)
+  --threadIds=threadIds         Space-separated thread ids
+  --useMemberId=useMemberId     Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId     Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/setStickiedThreads.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/setStickiedThreads.ts)_
+
+## `joystream-cli forum:threads`
+
+List existing forum threads in given category.
+
+```
+USAGE
+  $ joystream-cli forum:threads
+
+OPTIONS
+  -c, --categoryId=categoryId  (required) Category id (only threads in this category will be listed)
+  --useMemberId=useMemberId    Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId    Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/threads.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/threads.ts)_
+
+## `joystream-cli forum:updateCategoryArchivalStatus`
+
+Update archival status of a forum category.
+
+```
+USAGE
+  $ joystream-cli forum:updateCategoryArchivalStatus
+
+OPTIONS
+  -c, --categoryId=categoryId   (required) Forum category id
+  --archived=(yes|no)           (required) Whether the category should be archived
+  --context=(Leader|Moderator)  Actor context to execute the command in (Leader/Moderator)
+  --useMemberId=useMemberId     Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId     Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/updateCategoryArchivalStatus.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/updateCategoryArchivalStatus.ts)_
+
+## `joystream-cli forum:updateCategoryModeratorStatus`
+
+Update moderator status of a worker in relation to a category.
+
+```
+USAGE
+  $ joystream-cli forum:updateCategoryModeratorStatus
+
+OPTIONS
+  -c, --categoryId=categoryId  (required) Forum category id
+  -w, --workerId=workerId      (required) Forum working group worker id
+  --status=(active|disabled)   (required) Status of the moderator membership in the category
+  --useMemberId=useMemberId    Try using the specified member id as context whenever possible
+  --useWorkerId=useWorkerId    Try using the specified worker id as context whenever possible
+```
+
+_See code: [src/commands/forum/updateCategoryModeratorStatus.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/forum/updateCategoryModeratorStatus.ts)_
+
 ## `joystream-cli help [COMMAND]`
 
 display help for joystream-cli
@@ -1506,9 +1770,3 @@ OPTIONS
 
 _See code: [src/commands/working-groups/updateWorkerReward.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/working-groups/updateWorkerReward.ts)_
 <!-- commandsstop -->
-
-# Environment variables
-<!-- env -->
-- `FORCE_COLOR` - can be set to `0` to disable output coloring
-- `AUTO_CONFIRM` - can be set to `1` or `true` to skip any required confirmations (can be useful for creating bash scripts)
-<!-- envstop -->

+ 3 - 0
cli/package.json

@@ -122,6 +122,9 @@
       },
       "membership": {
         "description": "Membership management - buy a new membership, update membership, manage membership keys"
+      },
+      "forum": {
+        "description": "Forum working group activities (moderation, category management)"
       }
     }
   },

+ 93 - 0
cli/scripts/forum-test.sh

@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+set -e
+
+SCRIPT_PATH="$(dirname "${BASH_SOURCE[0]}")"
+cd $SCRIPT_PATH
+
+export AUTO_CONFIRM=true
+export OCLIF_TS_NODE=0
+
+yarn workspace @joystream/cli build
+
+CLI=../bin/run
+
+# Init forum lead
+GROUP=forumWorkingGroup yarn workspace api-scripts initialize-lead
+# Add integration tests lead key (in case the script is executed after ./start.sh)
+yarn joystream-cli account:forget --name "Test forum lead key" || true
+yarn joystream-cli account:import --suri //testing//worker//Forum//0 --name "Test forum lead key" --password ""
+
+# Assume leader is the first worker
+LEADER_WORKER_ID="0"
+
+# Create test categories
+CATEGORY_1_ID=`${CLI} forum:createCategory -t "Test category 1" -d "Test category 1 description"`
+CATEGORY_2_ID=`${CLI} forum:createCategory -t "Test category 2" -d "Test category 2 description" -p ${CATEGORY_1_ID}`
+# Create test threads
+THREAD_1_ID=`${CLI} forum:createThread \
+  --categoryId ${CATEGORY_1_ID}\
+  --title "Test thread 1"\
+  --tags "tag1" "tag2" "tag3"\
+  --text "Test thread 1 initial post text"`
+THREAD_2_ID=`${CLI} forum:createThread \
+  --categoryId ${CATEGORY_2_ID}\
+  --title "Test thread 2"\
+  --tags "tag1" "tag2" "tag3"\
+  --text "Test thread 2 initial post text"`
+# Create test posts
+POST_1_ID=`${CLI} forum:addPost \
+  --categoryId ${CATEGORY_1_ID}\
+  --threadId ${THREAD_1_ID}\
+  --text "Test post 1"\
+  --editable`
+POST_2_ID=`${CLI} forum:addPost \
+  --categoryId ${CATEGORY_2_ID}\
+  --threadId ${THREAD_2_ID}\
+  --text "Test post 2"\
+  --editable`
+
+# Update category modrator permissions
+${CLI} forum:updateCategoryModeratorStatus --categoryId ${CATEGORY_1_ID} --workerId ${LEADER_WORKER_ID} --status active
+
+# Update category archival status as lead
+${CLI} forum:updateCategoryArchivalStatus --categoryId ${CATEGORY_1_ID} --archived yes --context Leader
+# Update category archival status as moderator
+${CLI} forum:updateCategoryArchivalStatus --categoryId ${CATEGORY_1_ID} --archived no --context Moderator
+
+# Move thread as lead
+${CLI} forum:moveThread --categoryId ${CATEGORY_1_ID} --threadId ${THREAD_1_ID} --newCategoryId ${CATEGORY_2_ID} --context Leader
+# Move thread as moderator
+${CLI} forum:moveThread --categoryId ${CATEGORY_2_ID} --threadId ${THREAD_2_ID} --newCategoryId ${CATEGORY_1_ID} --context Moderator
+
+# Set stickied threads as lead
+${CLI} forum:setStickiedThreads --categoryId ${CATEGORY_1_ID} --threadIds ${THREAD_2_ID} --context Leader
+# Set stickied threads as moderator
+${CLI} forum:setStickiedThreads --categoryId ${CATEGORY_2_ID} --threadIds ${THREAD_1_ID} --context Moderator
+
+# Moderate post as lead
+${CLI} forum:moderatePost \
+  --categoryId ${CATEGORY_2_ID}\
+  --threadId ${THREAD_1_ID}\
+  --postId ${POST_1_ID}\
+  --rationale "Leader test"\
+  --context Leader
+# Moderate post as moderator
+${CLI} forum:moderatePost \
+  --categoryId ${CATEGORY_1_ID}\
+  --threadId ${THREAD_2_ID}\
+  --postId ${POST_2_ID}\
+  --rationale "Moderator test"\
+  --context Moderator
+
+# Moderate thread as lead
+${CLI} forum:moderateThread --categoryId ${CATEGORY_2_ID} --threadId ${THREAD_1_ID} --rationale "Leader test" --context Leader
+# Moderate thread as moderator
+${CLI} forum:moderateThread --categoryId ${CATEGORY_1_ID} --threadId ${THREAD_2_ID} --rationale "Moderator test" --context Moderator
+
+# Delete category as moderator
+${CLI} forum:deleteCategory --categoryId ${CATEGORY_2_ID} --context Moderator
+# Delete category as lead
+${CLI} forum:deleteCategory --categoryId ${CATEGORY_1_ID} --context Leader
+
+# Forget test lead account
+yarn joystream-cli account:forget --name "Test forum lead key"

+ 86 - 11
cli/src/Api.ts

@@ -21,7 +21,7 @@ import { DeriveBalancesAll } from '@polkadot/api-derive/types'
 import { CLIError } from '@oclif/errors'
 import { Worker, WorkerId, OpeningId, Application, ApplicationId, Opening } from '@joystream/types/working-group'
 import { Membership, StakingAccountMemberBinding } from '@joystream/types/members'
-import { MemberId, ChannelId, AccountId } from '@joystream/types/common'
+import { MemberId, ChannelId, AccountId, ThreadId, PostId } from '@joystream/types/common'
 import {
   Channel,
   Video,
@@ -35,6 +35,9 @@ import { BagId, DataObject, DataObjectId } from '@joystream/types/storage'
 import QueryNodeApi from './QueryNodeApi'
 import { MembershipFieldsFragment } from './graphql/generated/queries'
 import { blake2AsHex } from '@polkadot/util-crypto'
+import { Category, CategoryId, Post, Thread } from '@joystream/types/forum'
+import chalk from 'chalk'
+import _ from 'lodash'
 
 export const DEFAULT_API_URI = 'ws://localhost:9944/'
 
@@ -156,7 +159,9 @@ export default class Api {
   }
 
   async membersDetails(entries: [MemberId, Membership][]): Promise<MemberDetails[]> {
-    const membersQnData = await this._qnApi?.membersByIds(entries.map(([id]) => id))
+    const membersQnData: MembershipFieldsFragment[] | undefined = await this._qnApi?.membersByIds(
+      entries.map(([id]) => id)
+    )
     const memberQnDataById = new Map<string, MembershipFieldsFragment>()
     membersQnData?.forEach((m) => {
       memberQnDataById.set(m.id, m)
@@ -251,18 +256,11 @@ export default class Api {
     }
   }
 
-  async workerByWorkerId(group: WorkingGroups, workerId: number): Promise<Worker> {
-    const nextId = await this.workingGroupApiQuery(group).nextWorkerId()
-
-    // This is chain specfic, but if next id is still 0, it means no workers have been added yet
-    if (workerId < 0 || workerId >= nextId.toNumber()) {
-      throw new CLIError('Invalid worker id!')
-    }
-
+  async workerByWorkerId(group: WorkingGroups, workerId: WorkerId | number): Promise<Worker> {
     const worker = await this.workingGroupApiQuery(group).workerById(workerId)
 
     if (worker.isEmpty) {
-      throw new CLIError('This worker is not active anymore')
+      throw new CLIError(`Worker ${chalk.magentaBright(workerId)} does not exist!`)
     }
 
     return worker
@@ -459,4 +457,81 @@ export default class Api {
     const existingMeber = await this._api.query.members.memberIdByHandleHash(handleHash)
     return !existingMeber.isEmpty
   }
+
+  async forumCategoryExists(categoryId: CategoryId | number): Promise<boolean> {
+    const size = await this._api.query.forum.categoryById.size(categoryId)
+    return size.gtn(0)
+  }
+
+  async forumThreadExists(categoryId: CategoryId | number, threadId: ThreadId | number): Promise<boolean> {
+    const size = await this._api.query.forum.threadById.size(categoryId, threadId)
+    return size.gtn(0)
+  }
+
+  async forumPostExists(threadId: ThreadId | number, postId: PostId | number): Promise<boolean> {
+    const size = await this._api.query.forum.postById.size(threadId, postId)
+    return size.gtn(0)
+  }
+
+  async forumCategoryAncestors(categoryId: CategoryId | number): Promise<[CategoryId, Category][]> {
+    const ancestors: [CategoryId, Category][] = []
+    let category = await this._api.query.forum.categoryById(categoryId)
+    while (category.parent_category_id.isSome) {
+      const parentCategoryId = category.parent_category_id.unwrap()
+      category = await this._api.query.forum.categoryById(parentCategoryId)
+      ancestors.push([parentCategoryId, category])
+    }
+    return ancestors
+  }
+
+  async forumCategoryModerators(categoryId: CategoryId | number): Promise<[CategoryId, WorkerId][]> {
+    const categoryAncestors = await this.forumCategoryAncestors(categoryId)
+
+    const moderatorIds = _.uniqWith(
+      _.flatten(
+        await Promise.all(
+          categoryAncestors
+            .map(([id]) => id as CategoryId | number)
+            .reverse()
+            .concat([categoryId])
+            .map(async (id) => {
+              const storageKeys = await this._api.query.forum.categoryByModerator.keys(id)
+              return storageKeys.map((k) => k.args)
+            })
+        )
+      ),
+      (a, b) => a[1].eq(b[1])
+    )
+
+    return moderatorIds
+  }
+
+  async getForumCategory(categoryId: CategoryId | number): Promise<Category> {
+    const category = await this._api.query.forum.categoryById(categoryId)
+    return category
+  }
+
+  async getForumThread(categoryId: CategoryId | number, threadId: ThreadId | number): Promise<Thread> {
+    const thread = await this._api.query.forum.threadById(categoryId, threadId)
+    return thread
+  }
+
+  async getForumPost(threadId: ThreadId | number, postId: PostId | number): Promise<Post> {
+    const post = await this._api.query.forum.postById(threadId, postId)
+    return post
+  }
+
+  async forumCategories(): Promise<[CategoryId, Category][]> {
+    return this.entriesByIds(this._api.query.forum.categoryById)
+  }
+
+  async forumThreads(categoryId: CategoryId | number): Promise<[ThreadId, Thread][]> {
+    const entries = await this._api.query.forum.threadById.entries(categoryId)
+    return entries.map(([storageKey, thread]) => [storageKey.args[1], thread])
+  }
+
+  async forumPosts(threadId: ThreadId | number): Promise<[PostId, Post][]> {
+    const entries = await this._api.query.forum.postById.entries(threadId)
+    return entries.map(([storageKey, thread]) => [storageKey.args[1], thread])
+  }
 }

+ 165 - 0
cli/src/base/ForumCommandBase.ts

@@ -0,0 +1,165 @@
+import { Category, CategoryId, Post, PrivilegedActor, Thread } from '@joystream/types/forum'
+import { WorkingGroups } from '../Types'
+import { flags } from '@oclif/command'
+import WorkingGroupCommandBase from './WorkingGroupCommandBase'
+import chalk from 'chalk'
+import ExitCodes from '../ExitCodes'
+import { createType } from '@joystream/types'
+import { AccountId, PostId, ThreadId } from '@joystream/types/common'
+import { WorkerId } from '@joystream/types/working-group'
+
+const FORUM_MODERATION_CONTEXT = ['Leader', 'Moderator'] as const
+
+type ForumModerationContext = typeof FORUM_MODERATION_CONTEXT[number]
+
+/**
+ * Abstract base class for commands related to forum management
+ */
+export default abstract class ForumCommandBase extends WorkingGroupCommandBase {
+  static flags = {
+    ...WorkingGroupCommandBase.flags,
+  }
+
+  static forumModerationContextFlag = flags.enum({
+    required: false,
+    description: `Actor context to execute the command in (${FORUM_MODERATION_CONTEXT.join('/')})`,
+    options: [...FORUM_MODERATION_CONTEXT],
+  })
+
+  async init(): Promise<void> {
+    await super.init()
+    this._group = WorkingGroups.Forum // override group for RolesCommandBase
+  }
+
+  async ensureCategoryExists(categoryId: CategoryId | number): Promise<void> {
+    const categoryExists = await this.getApi().forumCategoryExists(categoryId)
+    if (!categoryExists) {
+      this.error(`Category ${chalk.magentaBright(categoryId)} does not exist!`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+  }
+
+  async ensureCategoryMutable(categoryId: CategoryId | number): Promise<void> {
+    const category = await this.getCategory(categoryId)
+    const ancestors = await this.getApi().forumCategoryAncestors(categoryId)
+    if (category.archived.isTrue || ancestors.some(([, category]) => category.archived.isTrue)) {
+      this.error(`Category ${chalk.magentaBright(categoryId)} is not mutable (belongs to archived category tree)!`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+  }
+
+  async ensureThreadExists(categoryId: CategoryId | number, threadId: ThreadId | number): Promise<void> {
+    const threadExists = await this.getApi().forumThreadExists(categoryId, threadId)
+    if (!threadExists) {
+      this.error(
+        `Thread ${chalk.magentaBright(threadId)} in category ${chalk.magentaBright(categoryId)} does not exist!`,
+        {
+          exit: ExitCodes.InvalidInput,
+        }
+      )
+    }
+  }
+
+  async ensurePostExists(threadId: ThreadId | number, postId: PostId | number): Promise<void> {
+    const postExists = await this.getApi().forumPostExists(threadId, postId)
+    if (!postExists) {
+      this.error(`Post ${chalk.magentaBright(postId)} in thread ${chalk.magentaBright(threadId)} does not exist!`, {
+        exit: ExitCodes.InvalidInput,
+      })
+    }
+  }
+
+  async getThread(categoryId: CategoryId | number, threadId: ThreadId | number): Promise<Thread> {
+    await this.ensureThreadExists(categoryId, threadId)
+    const thread = await this.getApi().getForumThread(categoryId, threadId)
+    return thread
+  }
+
+  async getCategory(categoryId: CategoryId | number): Promise<Category> {
+    await this.ensureCategoryExists(categoryId)
+    const category = await this.getApi().getForumCategory(categoryId)
+    return category
+  }
+
+  async getPost(threadId: ThreadId | number, postId: PostId | number): Promise<Post> {
+    await this.ensurePostExists(threadId, postId)
+    const post = await this.getApi().getForumPost(threadId, postId)
+    return post
+  }
+
+  async getIdsOfModeratorsWithAccessToCategories(categories: CategoryId[] | number[]): Promise<WorkerId[]> {
+    const moderatorsByCategory = await Promise.all(categories.map((id) => this.getApi().forumCategoryModerators(id)))
+    const categoriesCountByModeratorId = new Map<number, number>()
+    for (const moderators of moderatorsByCategory) {
+      for (const [, id] of moderators) {
+        categoriesCountByModeratorId.set(id.toNumber(), (categoriesCountByModeratorId.get(id.toNumber()) || 0) + 1)
+      }
+    }
+    return Array.from(categoriesCountByModeratorId.entries())
+      .filter(([, count]) => count === categories.length)
+      .map(([id]) => createType<WorkerId, 'WorkerId'>('WorkerId', id))
+  }
+
+  async getForumModeratorContext(categories: CategoryId[] | number[]): Promise<[AccountId, PrivilegedActor]> {
+    const moderators = await this.getIdsOfModeratorsWithAccessToCategories(categories)
+    try {
+      const worker = await this.getRequiredWorkerContext('Role', moderators)
+      return [
+        worker.roleAccount,
+        createType<PrivilegedActor, 'PrivilegedActor'>('PrivilegedActor', { Moderator: worker.workerId }),
+      ]
+    } catch (e) {
+      this.error(
+        `Moderator access to categories: ${categories
+          .map((id) => chalk.magentaBright(id.toString()))
+          .join(', ')} is required!`,
+        { exit: ExitCodes.AccessDenied }
+      )
+    }
+  }
+
+  async getForumLeadContext(): Promise<[AccountId, PrivilegedActor]> {
+    const lead = await this.getRequiredLeadContext()
+    return [lead.roleAccount, createType<PrivilegedActor, 'PrivilegedActor'>('PrivilegedActor', 'Lead')]
+  }
+
+  async getForumModerationContext(
+    categories: CategoryId[] | number[],
+    context?: ForumModerationContext
+  ): Promise<[AccountId, PrivilegedActor]> {
+    if (context === 'Leader') {
+      return this.getForumLeadContext()
+    }
+
+    if (context === 'Moderator') {
+      return this.getForumModeratorContext(categories)
+    }
+
+    // Context not explicitly set...
+
+    try {
+      const context = await this.getForumLeadContext()
+      this.log('Derived context: Forum Working Group Leader')
+      return context
+    } catch (e) {
+      // continue...
+    }
+
+    try {
+      const context = await this.getForumModeratorContext(categories)
+      this.log('Derived context: Moderator')
+      return context
+    } catch (e) {
+      // continue...
+    }
+
+    this.error(
+      `You need moderator permissions for forum categories: ${categories
+        .map((id) => chalk.magentaBright(id.toString()))
+        .join(', ')} in order to continue!`,
+      { exit: ExitCodes.AccessDenied }
+    )
+  }
+}

+ 20 - 5
cli/src/base/WorkingGroupCommandBase.ts

@@ -3,6 +3,7 @@ import { flags } from '@oclif/command'
 import { WorkingGroups, GroupMember } from '../Types'
 import _ from 'lodash'
 import MembershipsCommandBase from './MembershipsCommandBase'
+import { WorkerId } from '@joystream/types/working-group'
 
 /**
  * Abstract base class for commands relying on a specific working group context
@@ -46,20 +47,30 @@ export default abstract class WorkingGroupCommandBase extends MembershipsCommand
   }
 
   // Use when worker access is required in given command
-  async getRequiredWorkerContext(expectedKeyType: 'Role' | 'MemberController' = 'Role'): Promise<GroupMember> {
+  async getRequiredWorkerContext(
+    expectedKeyType: 'Role' | 'MemberController' = 'Role',
+    allowedIds?: WorkerId[]
+  ): Promise<GroupMember> {
     const flags = this.parse(this.constructor as typeof WorkingGroupCommandBase).flags
 
     const groupMembers = await this.getApi().groupMembers(this.group)
-    const availableGroupMemberContexts = groupMembers.filter((m) =>
+    const allowedGroupMembers = groupMembers.filter((m) => !allowedIds || allowedIds.some((id) => id.eq(m.workerId)))
+
+    const availableGroupMemberContexts = allowedGroupMembers.filter((m) =>
       expectedKeyType === 'Role'
         ? this.isKeyAvailable(m.roleAccount.toString())
         : this.isKeyAvailable(m.profile.membership.controller_account.toString())
     )
 
     if (!availableGroupMemberContexts.length) {
-      this.error(`No ${_.startCase(this.group)} Group Worker ${_.startCase(expectedKeyType)} key available!`, {
-        exit: ExitCodes.AccessDenied,
-      })
+      this.error(
+        `No ${_.startCase(this.group)} Group Worker ${_.startCase(expectedKeyType)} key ${
+          allowedIds ? ` (from the allowed set of workers: ${allowedIds.map((id) => id.toString())})` : ''
+        } available!`,
+        {
+          exit: ExitCodes.AccessDenied,
+        }
+      )
     } else if (availableGroupMemberContexts.length === 1) {
       return availableGroupMemberContexts[0]
     } else {
@@ -86,4 +97,8 @@ export default abstract class WorkingGroupCommandBase extends MembershipsCommand
 
     return groupMembers[chosenWorkerIndex]
   }
+
+  async ensureWorkerExists(workerId: WorkerId | number): Promise<void> {
+    await this.getApi().workerByWorkerId(this.group, workerId)
+  }
 }

+ 52 - 0
cli/src/commands/forum/addPost.ts

@@ -0,0 +1,52 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { ForumPostMetadata, IForumPostMetadata } from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+import { PostId } from '@joystream/types/common'
+
+export default class ForumAddPostCommand extends ForumCommandBase {
+  static description = 'Add forum post.'
+  static flags = {
+    categoryId: flags.integer({
+      required: true,
+      description: 'Id of the forum category of the parent thread',
+    }),
+    threadId: flags.integer({
+      required: true,
+      description: "Post's parent thread",
+    }),
+    text: flags.string({
+      required: true,
+      description: 'Post content (md-formatted text)',
+    }),
+    editable: flags.boolean({
+      required: false,
+      description: 'Whether the post should be editable',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, threadId, text, editable } = this.parse(ForumAddPostCommand).flags
+
+    await this.ensureThreadExists(categoryId, threadId)
+    await this.ensureCategoryMutable(categoryId)
+    const member = await this.getRequiredMemberContext()
+
+    // Replies not supported atm
+    const metadata: IForumPostMetadata = { text }
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, threadId, text, editable }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(member.membership.controller_account),
+      api.tx.forum.addPost(member.id, categoryId, threadId, metadataToBytes(ForumPostMetadata, metadata), editable)
+    )
+
+    const postId: PostId = this.getEvent(result, 'forum', 'PostAdded').data[0]
+    this.log(chalk.green(`ForumPost with id ${chalk.magentaBright(postId.toString())} successfully created!`))
+    this.output(postId.toString())
+  }
+}

+ 91 - 0
cli/src/commands/forum/categories.ts

@@ -0,0 +1,91 @@
+import { Category, CategoryId } from '@joystream/types/forum'
+import { flags } from '@oclif/command'
+import { cli } from 'cli-ux'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { Tree } from 'cli-ux/lib/styled/tree'
+import { displayTable } from '../../helpers/display'
+
+export default class ForumCategoriesCommand extends ForumCommandBase {
+  static description =
+    'List existing forum categories by parent id (root categories by default) or displays a category tree.'
+
+  static flags = {
+    parentCategoryId: flags.integer({
+      char: 'p',
+      required: false,
+      description: 'Parent category id (only child categories will be listed)',
+    }),
+    tree: flags.boolean({
+      char: 'c',
+      required: false,
+      description: 'Display a category tree (with parentCategoryId as root, if specified)',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  recursivelyGenerateCategoryTree(
+    tree: Tree,
+    parents: [CategoryId, Category][],
+    allCategories: [CategoryId, Category][]
+  ): void {
+    for (const [parentId] of parents) {
+      const children = allCategories.filter(([, c]) => c.parent_category_id.unwrapOr(undefined)?.eq(parentId))
+      const childSubtree = cli.tree()
+      this.recursivelyGenerateCategoryTree(childSubtree, children, allCategories)
+      tree.insert(parentId.toString(), childSubtree)
+    }
+  }
+
+  buildCategoryTree(categories: [CategoryId, Category][], root?: number): Tree {
+    const tree = cli.tree()
+    let rootCategory: [CategoryId, Category] | undefined
+    if (root) {
+      rootCategory = categories.find(([id]) => id.toNumber() === root)
+      if (!rootCategory) {
+        this.error(`Category ${chalk.magentaBright(root)} not found!`)
+      }
+    }
+    const treeRootCategories = rootCategory ? [rootCategory] : categories.filter(([, c]) => c.parent_category_id.isNone)
+    this.recursivelyGenerateCategoryTree(tree, treeRootCategories, categories)
+    return tree
+  }
+
+  async run(): Promise<void> {
+    const { parentCategoryId, tree } = this.parse(ForumCategoriesCommand).flags
+
+    if (parentCategoryId !== undefined) {
+      await this.ensureCategoryExists(parentCategoryId)
+    }
+
+    const categories = await this.getApi().forumCategories()
+
+    if (tree) {
+      const categoryTree = this.buildCategoryTree(categories, parentCategoryId)
+      categoryTree.display()
+    } else {
+      const children = categories.filter(
+        ([, c]) => c.parent_category_id.unwrapOr(undefined)?.toNumber() === parentCategoryId
+      )
+      if (children.length) {
+        displayTable(
+          children.map(([id, c]) => ({
+            'ID': id.toString(),
+            'Direct subcategories': c.num_direct_subcategories.toNumber(),
+            'Direct threads': c.num_direct_threads.toNumber(),
+            'Direct modreators': c.num_direct_moderators.toNumber(),
+          })),
+          5
+        )
+      } else {
+        this.log(
+          `No ${
+            parentCategoryId !== undefined
+              ? `subcategories of category ${chalk.magentaBright(parentCategoryId)}`
+              : 'root categories'
+          } found`
+        )
+      }
+    }
+  }
+}

+ 65 - 0
cli/src/commands/forum/category.ts

@@ -0,0 +1,65 @@
+import { CategoryId } from '@joystream/types/forum'
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { displayCollapsedRow, displayHeader, displayTable, memberHandle } from '../../helpers/display'
+import { GroupMember } from '../../Types'
+
+export default class ForumCategoryCommand extends ForumCommandBase {
+  static description = 'Display forum category details.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Forum category id',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const { categoryId } = this.parse(ForumCategoryCommand).flags
+    const category = await this.getCategory(categoryId)
+    const allCategories = await this.getApi().forumCategories()
+    const directSubcategories = allCategories.filter(
+      ([, c]) => c.parent_category_id.unwrapOr(undefined)?.toNumber() === categoryId
+    )
+    const moderatorsEntries = await this.getApi().forumCategoryModerators(categoryId)
+    const moderators = await Promise.all(
+      moderatorsEntries.map(
+        async ([categoryId, workerId]) =>
+          [categoryId, await this.getApi().groupMember(this.group, workerId.toNumber())] as [CategoryId, GroupMember]
+      )
+    )
+
+    displayCollapsedRow({
+      'ID': categoryId.toString(),
+      'No. direct subcategories': category.num_direct_subcategories.toString(),
+      'No. direct threads': category.num_direct_threads.toString(),
+      'No. direct moderators': category.num_direct_moderators.toString(),
+    })
+
+    displayHeader('Stickied threads')
+    if (category.sticky_thread_ids.length) {
+      this.log(category.sticky_thread_ids.map((id) => chalk.magentaBright(id.toString())).join(', '))
+    } else {
+      this.log('No stickied threads')
+    }
+
+    displayHeader('Direct subcategories')
+    this.log(directSubcategories.map(([id]) => chalk.magentaBright(id.toString())).join(', '))
+
+    displayHeader('Moderators')
+    if (moderators.length) {
+      displayTable(
+        moderators.map(([cId, moderator]) => ({
+          'Worker ID': moderator.workerId.toString(),
+          'Member Handle': memberHandle(moderator.profile),
+          'Access': cId.eq(categoryId) ? 'Direct' : `Ancestor (${cId.toString()})`,
+        })),
+        5
+      )
+    } else {
+      this.log('No moderators')
+    }
+  }
+}

+ 55 - 0
cli/src/commands/forum/createCategory.ts

@@ -0,0 +1,55 @@
+import { createType } from '@joystream/types'
+import { CategoryId } from '@joystream/types/forum'
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { Option } from '@polkadot/types'
+
+export default class ForumCreateCategoryCommand extends ForumCommandBase {
+  static description = 'Create forum category.'
+  static flags = {
+    parentCategoryId: flags.integer({
+      char: 'p',
+      required: false,
+      description: 'Parent category id (in case of creating a subcategory)',
+    }),
+    title: flags.string({
+      char: 't',
+      required: true,
+      description: 'Category title',
+    }),
+    description: flags.string({
+      char: 'd',
+      required: true,
+      description: 'Category description',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { parentCategoryId, title, description } = this.parse(ForumCreateCategoryCommand).flags
+
+    if (parentCategoryId !== undefined) {
+      await this.ensureCategoryMutable(parentCategoryId)
+    }
+
+    const lead = await this.getRequiredLeadContext()
+
+    this.jsonPrettyPrint(JSON.stringify({ parentCategoryId, title, description }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      api.tx.forum.createCategory(
+        createType<Option<CategoryId>, 'Option<CategoryId>'>('Option<CategoryId>', parentCategoryId ?? null),
+        title,
+        description
+      )
+    )
+
+    const categoryId: CategoryId = this.getEvent(result, 'forum', 'CategoryCreated').data[0]
+    this.log(chalk.green(`ForumCategory with id ${chalk.magentaBright(categoryId.toString())} successfully created!`))
+    this.output(categoryId.toString())
+  }
+}

+ 52 - 0
cli/src/commands/forum/createThread.ts

@@ -0,0 +1,52 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { ForumThreadMetadata, IForumThreadMetadata } from '@joystream/metadata-protobuf'
+import { metadataToBytes } from '../../helpers/serialization'
+import { ThreadId } from '@joystream/types/common'
+
+export default class ForumCreateThreadCommand extends ForumCommandBase {
+  static description = 'Create forum thread.'
+  static flags = {
+    categoryId: flags.integer({
+      required: true,
+      description: 'Id of the forum category the thread should be created in',
+    }),
+    title: flags.string({
+      required: true,
+      description: 'Thread title',
+    }),
+    tags: flags.string({
+      required: false,
+      multiple: true,
+      description: 'Space-separated tags to associate with the thread',
+    }),
+    text: flags.string({
+      required: true,
+      description: 'Initial post text',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, title, tags, text } = this.parse(ForumCreateThreadCommand).flags
+
+    await this.ensureCategoryMutable(categoryId)
+    const member = await this.getRequiredMemberContext()
+
+    const metadata: IForumThreadMetadata = { title, tags }
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, metadata }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowTx(
+      await this.getDecodedPair(member.membership.controller_account),
+      // Polls not supported atm
+      api.tx.forum.createThread(member.id, categoryId, metadataToBytes(ForumThreadMetadata, metadata), text, null)
+    )
+
+    const threadId: ThreadId = this.getEvent(result, 'forum', 'ThreadCreated').data[1]
+    this.log(chalk.green(`ForumThread with id ${chalk.magentaBright(threadId.toString())} successfully created!`))
+    this.output(threadId.toString())
+  }
+}

+ 53 - 0
cli/src/commands/forum/deleteCategory.ts

@@ -0,0 +1,53 @@
+import { AccountId } from '@joystream/types/common'
+import { PrivilegedActor } from '@joystream/types/forum'
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import ExitCodes from '../../ExitCodes'
+
+export default class ForumDeleteCategoryCommand extends ForumCommandBase {
+  static description = 'Delete forum category provided it has no existing subcategories and threads.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Id of the category to delete',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, context } = this.parse(ForumDeleteCategoryCommand).flags
+
+    const category = await this.getCategory(categoryId)
+    let key: AccountId, actor: PrivilegedActor
+
+    if (category.parent_category_id.isNone) {
+      if (context === 'Moderator') {
+        this.error('Moderator cannot delete root categories!', { exit: ExitCodes.AccessDenied })
+      }
+      ;[key, actor] = await this.getForumLeadContext()
+    } else {
+      ;[key, actor] = await this.getForumModerationContext([category.parent_category_id.unwrap()], context)
+    }
+
+    if (category.num_direct_subcategories.gtn(0)) {
+      this.error('Cannot remove a category with existing subcategories!', { exit: ExitCodes.InvalidInput })
+    }
+
+    if (category.num_direct_threads.gtn(0)) {
+      this.error('Cannot remove a category with existing threads!', { exit: ExitCodes.InvalidInput })
+    }
+
+    await this.requireConfirmation(
+      `Are you sure you want to remove forum category ${chalk.magentaBright(categoryId)}?`,
+      true
+    )
+
+    await this.sendAndFollowTx(await this.getDecodedPair(key), api.tx.forum.deleteCategory(actor, categoryId))
+
+    this.log(chalk.green(`Forum category ${chalk.magentaBright(categoryId)} successfully removed!`))
+  }
+}

+ 54 - 0
cli/src/commands/forum/moderatePost.ts

@@ -0,0 +1,54 @@
+import { flags } from '@oclif/command'
+import { formatBalance } from '@polkadot/util'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumModeratePostCommand extends ForumCommandBase {
+  static description = 'Moderate a forum post and slash the associated stake.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Forum category id',
+    }),
+    threadId: flags.integer({
+      char: 't',
+      required: true,
+      description: 'Forum thread id',
+    }),
+    postId: flags.integer({
+      char: 'p',
+      required: true,
+      description: 'Forum post id',
+    }),
+    rationale: flags.string({
+      char: 'r',
+      required: true,
+      description: 'Rationale behind the post moderation.',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, threadId, postId, rationale, context } = this.parse(ForumModeratePostCommand).flags
+
+    await this.ensureCategoryExists(categoryId)
+    await this.ensureCategoryMutable(categoryId)
+    await this.ensureThreadExists(categoryId, threadId)
+    const post = await this.getPost(threadId, postId)
+    const [key, actor] = await this.getForumModerationContext([categoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, threadId, postId, rationale }))
+    this.warn(`Post stake of ${formatBalance(post.cleanup_pay_off)} will be slashed!`)
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.moderatePost(actor, categoryId, threadId, postId, rationale)
+    )
+
+    this.log(chalk.green(`Post ${chalk.magentaBright(postId.toString())} successfully moderated!`))
+  }
+}

+ 46 - 0
cli/src/commands/forum/moderateThread.ts

@@ -0,0 +1,46 @@
+import { flags } from '@oclif/command'
+import { formatBalance } from '@polkadot/util'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumModerateThreadCommand extends ForumCommandBase {
+  static description = 'Moderate a forum thread and slash the associated stake.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Id of the forum category the thread is currently in',
+    }),
+    threadId: flags.integer({
+      char: 't',
+      required: true,
+      description: 'Forum thread id',
+    }),
+    rationale: flags.string({
+      char: 'r',
+      required: true,
+      description: 'Rationale behind the thread moderation.',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, threadId, context, rationale } = this.parse(ForumModerateThreadCommand).flags
+
+    const thread = await this.getThread(categoryId, threadId)
+    const [key, actor] = await this.getForumModerationContext([categoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, threadId, rationale }))
+    this.warn(`Thread stake of ${formatBalance(thread.cleanup_pay_off)} will be slashed!`)
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.moderateThread(actor, categoryId, threadId, rationale)
+    )
+
+    this.log(chalk.green(`Thread ${chalk.magentaBright(threadId.toString())} successfully moderated!`))
+  }
+}

+ 51 - 0
cli/src/commands/forum/moveThread.ts

@@ -0,0 +1,51 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumMoveThreadCommand extends ForumCommandBase {
+  static description = 'Move forum thread to a different category.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: "Thread's current category id",
+    }),
+    threadId: flags.integer({
+      char: 't',
+      required: true,
+      description: 'Forum thread id',
+    }),
+    newCategoryId: flags.integer({
+      char: 'n',
+      required: true,
+      description: "Thread's new category id",
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, newCategoryId, threadId, context } = this.parse(ForumMoveThreadCommand).flags
+
+    await this.ensureThreadExists(categoryId, threadId)
+    await this.ensureCategoryExists(newCategoryId)
+    const [key, actor] = await this.getForumModerationContext([categoryId, newCategoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ threadId, categoryId, newCategoryId }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.moveThreadToCategory(actor, categoryId, threadId, newCategoryId)
+    )
+
+    this.log(
+      chalk.green(
+        `Thread ${chalk.magentaBright(threadId.toString())} successfully moved from category ${chalk.magentaBright(
+          categoryId.toString()
+        )} to category ${chalk.magentaBright(newCategoryId.toString())}!`
+      )
+    )
+  }
+}

+ 37 - 0
cli/src/commands/forum/posts.ts

@@ -0,0 +1,37 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { displayTable } from '../../helpers/display'
+import { formatBalance } from '@polkadot/util'
+
+export default class ForumPostsCommand extends ForumCommandBase {
+  static description = 'List existing forum posts in given thread.'
+  static flags = {
+    threadId: flags.integer({
+      char: 't',
+      required: true,
+      description: 'Thread id (only posts in this thread will be listed)',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const { threadId } = this.parse(ForumPostsCommand).flags
+
+    const posts = await this.getApi().forumPosts(threadId)
+
+    if (posts.length) {
+      displayTable(
+        posts.map(([id, p]) => ({
+          'ID': id.toString(),
+          'Cleanup payoff': formatBalance(p.cleanup_pay_off),
+          'Author member id': p.author_id.toString(),
+          'Last edited': `#${p.last_edited.toNumber()}`,
+        })),
+        5
+      )
+    } else {
+      this.log(`No posts in thread ${chalk.magentaBright(threadId)} found`)
+    }
+  }
+}

+ 46 - 0
cli/src/commands/forum/setStickiedThreads.ts

@@ -0,0 +1,46 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumSetStickiedThreadsCommand extends ForumCommandBase {
+  static description = 'Set stickied threads in a given category.'
+  static flags = {
+    categoryId: flags.integer({
+      required: true,
+      description: 'Forum category id',
+    }),
+    threadIds: flags.integer({
+      required: false,
+      multiple: true,
+      description: 'Space-separated thread ids',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, threadIds, context } = this.parse(ForumSetStickiedThreadsCommand).flags
+
+    await this.ensureCategoryExists(categoryId)
+    await Promise.all((threadIds || []).map((threadId) => this.ensureThreadExists(categoryId, threadId)))
+
+    const [key, actor] = await this.getForumModerationContext([categoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, threadIds }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.setStickiedThreads(actor, categoryId, threadIds)
+    )
+
+    this.log(
+      chalk.green(
+        `Threads ${chalk.magentaBright(
+          threadIds.map((id) => chalk.magentaBright(id)).join(', ')
+        )} successfully set as stickied in category ${categoryId}!`
+      )
+    )
+  }
+}

+ 38 - 0
cli/src/commands/forum/threads.ts

@@ -0,0 +1,38 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+import { displayTable } from '../../helpers/display'
+import { formatBalance } from '@polkadot/util'
+
+export default class ForumThreadsCommand extends ForumCommandBase {
+  static description = 'List existing forum threads in given category.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Category id (only threads in this category will be listed)',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const { categoryId } = this.parse(ForumThreadsCommand).flags
+
+    await this.ensureCategoryExists(categoryId)
+    const threads = await this.getApi().forumThreads(categoryId)
+
+    if (threads.length) {
+      displayTable(
+        threads.map(([id, t]) => ({
+          'ID': id.toString(),
+          'Cleanup payoff': formatBalance(t.cleanup_pay_off),
+          'Author member id': t.author_id.toString(),
+          'No. posts': t.number_of_posts.toString(),
+        })),
+        5
+      )
+    } else {
+      this.log(`No threads in category ${chalk.magentaBright(categoryId)} found`)
+    }
+  }
+}

+ 49 - 0
cli/src/commands/forum/updateCategoryArchivalStatus.ts

@@ -0,0 +1,49 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumUpdateCategoryArchivalStatusCommand extends ForumCommandBase {
+  static description = 'Update archival status of a forum category.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Forum category id',
+    }),
+    archived: flags.enum<'yes' | 'no'>({
+      options: ['yes', 'no'],
+      required: true,
+      description: 'Whether the category should be archived',
+    }),
+    context: ForumCommandBase.forumModerationContextFlag,
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, archived, context } = this.parse(ForumUpdateCategoryArchivalStatusCommand).flags
+    const category = await this.getCategory(categoryId)
+    if (category.archived.isTrue === (archived === 'yes')) {
+      this.error(
+        `Category ${chalk.magentaBright(categoryId.toString())} is already ${archived === 'no' ? 'not ' : ''}archived!`
+      )
+    }
+    const [key, actor] = await this.getForumModerationContext([categoryId], context)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, archived }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(key),
+      api.tx.forum.updateCategoryArchivalStatus(actor, categoryId, archived === 'yes')
+    )
+
+    this.log(
+      chalk.green(
+        `Archival status of category ${chalk.magentaBright(
+          categoryId.toString()
+        )} successfully updated to: ${chalk.magentaBright(archived === 'yes' ? 'archived' : 'not archived')}!`
+      )
+    )
+  }
+}

+ 50 - 0
cli/src/commands/forum/updateCategoryModeratorStatus.ts

@@ -0,0 +1,50 @@
+import { flags } from '@oclif/command'
+import chalk from 'chalk'
+import ForumCommandBase from '../../base/ForumCommandBase'
+
+export default class ForumUpdateCategoryModeratorStatusCommand extends ForumCommandBase {
+  static description = 'Update moderator status of a worker in relation to a category.'
+  static flags = {
+    categoryId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'Forum category id',
+    }),
+    workerId: flags.integer({
+      char: 'w',
+      required: true,
+      description: 'Forum working group worker id',
+    }),
+    status: flags.enum<'active' | 'disabled'>({
+      options: ['active', 'disabled'],
+      required: true,
+      description: 'Status of the moderator membership in the category',
+    }),
+    ...ForumCommandBase.flags,
+  }
+
+  async run(): Promise<void> {
+    const api = await this.getOriginalApi()
+    const { categoryId, workerId, status } = this.parse(ForumUpdateCategoryModeratorStatusCommand).flags
+    const lead = await this.getRequiredLeadContext()
+
+    await this.ensureCategoryExists(categoryId)
+    await this.ensureWorkerExists(workerId)
+
+    this.jsonPrettyPrint(JSON.stringify({ categoryId, workerId, status }))
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowTx(
+      await this.getDecodedPair(lead.roleAccount),
+      api.tx.forum.updateCategoryMembershipOfModerator(workerId, categoryId, status === 'active')
+    )
+
+    this.log(
+      chalk.green(
+        `Worker ${chalk.magentaBright(workerId.toString())} moderation permissions for category ${chalk.magentaBright(
+          categoryId.toString()
+        )} successfully ${status === 'active' ? 'granted' : 'revoked'}`
+      )
+    )
+  }
+}