From 9ce4ef675714bfeb0ce28eb1fa0fb68bb4e2dd2e Mon Sep 17 00:00:00 2001 From: Gareth Coles Date: Tue, 20 Feb 2018 20:44:07 +0000 Subject: Clickup commands #z20f (#10) * Initial work on ClickUp cog * More listing commands * Fix stray square bracket * Need to await json() calls * Fix tasks call * Fix lists call * list -> task_list * Better error handling and _way_ better tasks command * Pagination convenience function * Pagination for the tasks command * Small fixes * Pagination only paginates with more than one page * Grab lists on startup; use strings for list in tasks command * Provide some embed colours * Clean up embed instantiation a bit * Don't show paginated page number when there's only one page * Finish up ClickUp commands * Update command documentation * Move a comment @Aperture * Fixes for Lemon's review --- bot/__main__.py | 1 + bot/cogs/clickup.py | 337 ++++++++++++++++++++++++++++++++++++++++++++++++++++ bot/cogs/events.py | 16 ++- bot/constants.py | 4 + bot/utils.py | 109 +++++++++++++++++ requirements.txt | 1 + 6 files changed, 462 insertions(+), 6 deletions(-) create mode 100644 bot/cogs/clickup.py diff --git a/bot/__main__.py b/bot/__main__.py index 87cb62d14..fa50ae220 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -29,6 +29,7 @@ bot.load_extension("bot.cogs.events") # Commands, etc bot.load_extension("bot.cogs.bot") +bot.load_extension("bot.cogs.clickup") bot.load_extension("bot.cogs.deployment") bot.load_extension("bot.cogs.fun") bot.load_extension("bot.cogs.eval") diff --git a/bot/cogs/clickup.py b/bot/cogs/clickup.py new file mode 100644 index 000000000..cde0f6a80 --- /dev/null +++ b/bot/cogs/clickup.py @@ -0,0 +1,337 @@ +# coding=utf-8 +from aiohttp import ClientSession + +from discord import Colour, Embed +from discord.ext.commands import AutoShardedBot, Context, command + +from multidict import MultiDict + +from bot.constants import ( + ADMIN_ROLE, CLICKUP_KEY, CLICKUP_SPACE, CLICKUP_TEAM, DEVOPS_ROLE, MODERATOR_ROLE, OWNER_ROLE +) +from bot.decorators import with_role +from bot.utils import CaseInsensitiveDict, paginate + +CREATE_TASK_URL = "https://api.clickup.com/api/v1/list/{list_id}/task" +EDIT_TASK_URL = "https://api.clickup.com/api/v1/task/{task_id}" +GET_TASKS_URL = "https://api.clickup.com/api/v1/team/{team_id}/task" +PROJECTS_URL = "https://api.clickup.com/api/v1/space/{space_id}/project" + +# Don't ask me why the below line is a syntax error, but that's what flake8 thinks... +SPACES_URL = "https://api.clickup.com/api/v1/team/{team_id}/space" # flake8: noqa +TEAM_URL = "https://api.clickup.com/api/v1/team/{team_id}" + +HEADERS = { + "Authorization": CLICKUP_KEY, + "Content-Type": "application/json" +} + +STATUSES = ["open", "in progress", "review", "closed"] + + +class ClickUp: + """ + ClickUp management commands + """ + + def __init__(self, bot: AutoShardedBot): + self.bot = bot + self.lists = CaseInsensitiveDict() + + async def on_ready(self): + with ClientSession() as session: + response = await session.get(PROJECTS_URL.format(space_id=CLICKUP_SPACE), headers=HEADERS) + result = await response.json() + + if "err" in result: + print(f"Failed to get ClickUp lists: `{result['ECODE']}`: {result['err']}") + else: + # Save all the lists with their IDs so that we can get at them later + for project in result["projects"]: + for list_ in project["lists"]: + self.lists[list_["name"]] = list_["id"] + self.lists[f"{project['name']}/{list_['name']}"] = list_["id"] # Just in case we have duplicates + + # Add the reverse so we can look up by ID as well + self.lists.update({v: k for k, v in self.lists.items()}) + + @command(name="clickup.tasks()", aliases=["clickup.tasks", "tasks", "list_tasks"]) + @with_role(MODERATOR_ROLE, ADMIN_ROLE, OWNER_ROLE, DEVOPS_ROLE) + async def tasks_command(self, ctx: Context, status: str = None, task_list: str = None): + """ + Get a list of tasks, optionally on a specific list or with a specific status + + Provide "*" for the status to match everything except for "Closed". + + When specifying a list you may use the list name on its own, but it is preferable to give the project name + as well - for example, "Bot/Cogs". This is case-insensitive. + """ + + params = {} + + embed = Embed(colour=Colour.blurple()) + embed.set_author( + name="ClickUp Tasks", + icon_url="https://clickup.com/landing/favicons/favicon-32x32.png", + url=f"https://app.clickup.com/{CLICKUP_TEAM}/{CLICKUP_SPACE}/" + ) + + if task_list: + if task_list in self.lists: + params["list_ids[]"] = self.lists[task_list] + else: + embed.colour = Colour.red() + embed.description = f"Unknown list: {task_list}" + return await ctx.send(embed=embed) + + if status and status != "*": + params["statuses[]"] = status + + with ClientSession() as session: + response = await session.get(GET_TASKS_URL.format(team_id=CLICKUP_TEAM), headers=HEADERS, params=params) + result = await response.json() + + if "err" in result: + embed.description = f"`{result['ECODE']}`: {result['err']}" + embed.colour = Colour.red() + + else: + tasks = result["tasks"] + + if not tasks: + embed.colour = Colour.red() + embed.description = "No tasks found." + else: + lines = [] + + for task in tasks: + task_url = f"http://app.clickup.com/{CLICKUP_TEAM}/{CLICKUP_SPACE}/t/{task['id']}" + id_fragment = f"[`#{task['id']: <5}`]({task_url})" + status = f"{task['status']['status'].title()}" + + lines.append(f"{id_fragment} ({status})\n\u00BB {task['name']}") + return await paginate(lines, ctx, embed, max_size=750) + return await ctx.send(embed=embed) + + @command(name="clickup.task()", aliases=["clickup.task", "task", "get_task"]) + @with_role(MODERATOR_ROLE, ADMIN_ROLE, OWNER_ROLE, DEVOPS_ROLE) + async def task_command(self, ctx: Context, task_id: str): + """ + Get a task and return information specific to it + """ + + embed = Embed(colour=Colour.blurple()) + embed.set_author( + name=f"ClickUp Task: #{task_id}", + icon_url="https://clickup.com/landing/favicons/favicon-32x32.png", + url=f"https://app.clickup.com/{CLICKUP_TEAM}/{CLICKUP_SPACE}/t/{task_id}" + ) + + params = MultiDict() + params.add("statuses[]", "Open") + params.add("statuses[]", "in progress") + params.add("statuses[]", "review") + params.add("statuses[]", "Closed") + + with ClientSession() as session: + response = await session.get(GET_TASKS_URL.format(team_id=CLICKUP_TEAM), headers=HEADERS, params=params) + result = await response.json() + + if "err" in result: + embed.description = f"`{result['ECODE']}`: {result['err']}" + embed.colour = Colour.red() + else: + task = None + + for task_ in result["tasks"]: + if task_["id"] == task_id: + task = task_ + break + + if task is None: + embed.description = f"Unable to find task with ID `#{task_id}`:" + embed.colour = Colour.red() + else: + status = task['status']['status'].title() + project, list_ = self.lists[task['list']['id']].split("/", 1) + list_ = f"{project.title()}/{list_.title()}" + first_line = f"**{list_}** \u00BB *{task['name']}* \n**Status**: {status}" + + if task.get("tags"): + tags = ", ".join(tag["name"].title() for tag in task["tags"]) + first_line += f" / **Tags**: {tags}" + + lines = [first_line] + + if task.get("text_content"): + lines.append(task["text_content"]) + + if task.get("assignees"): + assignees = ", ".join(user["username"] for user in task["assignees"]) + lines.append( + f"**Assignees**\n{assignees}" + ) + + return await paginate(lines, ctx, embed, max_size=750) + return await ctx.send(embed=embed) + + @command(name="clickup.team()", aliases=["clickup.team", "team", "list_team"]) + @with_role(MODERATOR_ROLE, ADMIN_ROLE, OWNER_ROLE, DEVOPS_ROLE) + async def team_command(self, ctx: Context): + """ + Get a list of every member of the team + """ + + with ClientSession() as session: + response = await session.get(TEAM_URL.format(team_id=CLICKUP_TEAM), headers=HEADERS) + result = await response.json() + + if "err" in result: + embed = Embed( + colour=Colour.red(), + description=f"`{result['ECODE']}`: {result['err']}" + ) + else: + embed = Embed( + colour=Colour.blurple() + ) + + for member in result["team"]["members"]: + embed.add_field( + name=member["user"]["username"], + value=member["user"]["id"] + ) + + embed.set_author( + name="ClickUp Members", + icon_url="https://clickup.com/landing/favicons/favicon-32x32.png", + url=f"https://app.clickup.com/{CLICKUP_TEAM}/{CLICKUP_SPACE}/" + ) + + await ctx.send(embed=embed) + + @command(name="clickup.lists()", aliases=["clickup.lists", "lists"]) + @with_role(MODERATOR_ROLE, ADMIN_ROLE, OWNER_ROLE, DEVOPS_ROLE) + async def lists_command(self, ctx: Context): + """ + Get all the lists belonging to the ClickUp space + """ + + with ClientSession() as session: + response = await session.get(PROJECTS_URL.format(space_id=CLICKUP_SPACE), headers=HEADERS) + result = await response.json() + + if "err" in result: + embed = Embed( + colour=Colour.red(), + description=f"`{result['ECODE']}`: {result['err']}" + ) + else: + embed = Embed( + colour=Colour.blurple() + ) + + for project in result["projects"]: + lists = [] + + for list_ in project["lists"]: + lists.append(f"{list_['name']} ({list_['id']})") + + lists = "\n".join(lists) + + embed.add_field( + name=f"{project['name']} ({project['id']})", + value=lists + ) + + embed.set_author( + name="ClickUp Projects", + icon_url="https://clickup.com/landing/favicons/favicon-32x32.png", + url=f"https://app.clickup.com/{CLICKUP_TEAM}/{CLICKUP_SPACE}/" + ) + + await ctx.send(embed=embed) + + @command(name="clickup.open()", aliases=["clickup.open", "open", "open_task"]) + @with_role(MODERATOR_ROLE, ADMIN_ROLE, OWNER_ROLE, DEVOPS_ROLE) + async def open_command(self, ctx: Context, task_list: str, *, title: str): + """ + Open a new task under a specific task list, with a title + + When specifying a list you may use the list name on its own, but it is preferable to give the project name + as well - for example, "Bot/Cogs". This is case-insensitive. + """ + + embed = Embed(colour=Colour.blurple()) + embed.set_author( + name="ClickUp Tasks", + icon_url="https://clickup.com/landing/favicons/favicon-32x32.png", + url=f"https://app.clickup.com/{CLICKUP_TEAM}/{CLICKUP_SPACE}/" + ) + + if task_list in self.lists: + task_list = self.lists[task_list] + else: + embed.colour = Colour.red() + embed.description = f"Unknown list: {task_list}" + return await ctx.send(embed=embed) + + with ClientSession() as session: + response = await session.post( + CREATE_TASK_URL.format(list_id=task_list), headers=HEADERS, json={ + "name": title, + "status": "Open" + } + ) + result = await response.json() + + if "err" in result: + embed.colour = Colour.red() + embed.description = f"`{result['ECODE']}`: {result['err']}" + else: + task_id = result.get("id") + task_url = f"https://app.clickup.com/{CLICKUP_TEAM}/{CLICKUP_SPACE}/t/{task_id}" + project, task_list = self.lists[task_list].split("/", 1) + task_list = f"{project.title()}/{task_list.title()}" + + embed.description = f"New task created: [{task_list} \u00BB `#{task_id}`]({task_url})" + + await ctx.send(embed=embed) + + @command(name="clickup.set_status()", aliases=["clickup.set_status", "set_status", "set_task_status"]) + @with_role(MODERATOR_ROLE, ADMIN_ROLE, OWNER_ROLE, DEVOPS_ROLE) + async def set_status_command(self, ctx: Context, task_id: str, *, status: str): + """ + Update the status of a specific task + """ + + embed = Embed(colour=Colour.blurple()) + embed.set_author( + name="ClickUp Tasks", + icon_url="https://clickup.com/landing/favicons/favicon-32x32.png", + url=f"https://app.clickup.com/{CLICKUP_TEAM}/{CLICKUP_SPACE}/" + ) + + if status.lower() not in STATUSES: + embed.colour = Colour.red() + embed.description = f"Unknown status: {status}" + else: + with ClientSession() as session: + response = await session.put( + EDIT_TASK_URL.format(task_id=task_id), headers=HEADERS, json={"status": status} + ) + result = await response.json() + + if "err" in result: + embed.description = f"`{result['ECODE']}`: {result['err']}" + embed.colour = Colour.red() + else: + task_url = f"https://app.clickup.com/{CLICKUP_TEAM}/{CLICKUP_SPACE}/t/{task_id}" + embed.description = f"Task updated: [`#{task_id}`]({task_url})" + + await ctx.send(embed=embed) + + +def setup(bot): + bot.add_cog(ClickUp(bot)) + print("Cog loaded: ClickUp") diff --git a/bot/cogs/events.py b/bot/cogs/events.py index 325675039..f689fcd92 100644 --- a/bot/cogs/events.py +++ b/bot/cogs/events.py @@ -22,13 +22,17 @@ class Events: self.bot = bot async def send_updated_users(self, *users): - with ClientSession(headers={"X-API-Key": SITE_API_KEY}) as session: - response = await session.post( - url=SITE_API_USER_URL, - json=list(users) - ) + try: + with ClientSession(headers={"X-API-Key": SITE_API_KEY}) as session: + response = await session.post( + url=SITE_API_USER_URL, + json=list(users) + ) - return await response.json() + return await response.json() + except Exception as e: + print(f"Failed to send role updates: {e}") + return {} async def on_command_error(self, ctx: Context, e: CommandError): command = ctx.command diff --git a/bot/constants.py b/bot/constants.py index 04d8e6a0c..582bca2fa 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -14,6 +14,10 @@ VERIFIED_ROLE = 352427296948486144 OWNER_ROLE = 267627879762755584 DEVOPS_ROLE = 409416496733880320 +CLICKUP_KEY = os.environ.get("CLICKUP_KEY") +CLICKUP_SPACE = 757069 +CLICKUP_TEAM = 754996 + DEPLOY_URL = os.environ.get("DEPLOY_URL") STATUS_URL = os.environ.get("STATUS_URL") diff --git a/bot/utils.py b/bot/utils.py index eac37a4b4..102abfc12 100644 --- a/bot/utils.py +++ b/bot/utils.py @@ -1,4 +1,16 @@ # coding=utf-8 +import asyncio +from typing import Iterable + +from discord import Embed, Member, Reaction +from discord.abc import User +from discord.ext.commands import Context, Paginator + +LEFT_EMOJI = "\u2B05" +RIGHT_EMOJI = "\u27A1" +DELETE_EMOJI = "\U0001F5D1" + +PAGINATION_EMOJI = [LEFT_EMOJI, RIGHT_EMOJI, DELETE_EMOJI] class CaseInsensitiveDict(dict): @@ -45,3 +57,100 @@ class CaseInsensitiveDict(dict): for k in list(self.keys()): v = super(CaseInsensitiveDict, self).pop(k) self.__setitem__(k, v) + + +async def paginate(lines: Iterable[str], ctx: Context, embed: Embed, + prefix: str = "", suffix: str = "", max_size: int = 500, empty: bool = True, + restrict_to_user: User = None, timeout=300): + """ + Use a paginator and set of reactions to provide pagination over a set of lines. The reactions are used to + switch page, or to finish with pagination. + + When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may + be used to change page, or to remove pagination from the message. Pagination will also be removed automatically + if no reaction is added for five minutes (300 seconds). + + >>> embed = Embed() + >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) + >>> await paginate( + ... (line for line in lines), + ... ctx, embed + ... ) + + :param lines: The lines to be paginated + :param ctx: Current context object + :param embed: A pre-configured embed to be used as a template for each page + :param prefix: Text to place before each page + :param suffix: Text to place after each page + :param max_size: The maximum number of characters on each page + :param empty: Whether to place an empty line between each given line + :param restrict_to_user: A user to lock pagination operations to for this message, if supplied + """ + + def event_check(reaction_: Reaction, user_: Member): + """ + Make sure that this reaction is what we want to operate on + """ + + return ( + reaction_.message.id == message.id and # Reaction on this specific message + reaction_.emoji in PAGINATION_EMOJI and # One of the reactions we handle + user_.id != ctx.bot.user.id and ( # Not applied by the bot itself + not restrict_to_user or # Unrestricted if there's no user to restrict to, or... + user_.id == restrict_to_user.id # Only by the restricted user + ) + ) + + paginator = Paginator(prefix=prefix, suffix=suffix, max_size=max_size) + current_page = 0 + + for line in lines: + paginator.add_line(line, empty=empty) + + embed.description = paginator.pages[current_page] + + message = await ctx.send(embed=embed) + + if len(paginator.pages) <= 1: + return # There's only one page + + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + + for emoji in PAGINATION_EMOJI: + # Add all the applicable emoji to the message + await message.add_reaction(emoji) + + while True: + try: + reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=event_check) + except asyncio.TimeoutError: + break # We're done, no reactions for the last 5 minutes + + if reaction.emoji == DELETE_EMOJI: + break + + if reaction.emoji == LEFT_EMOJI: + await message.remove_reaction(reaction.emoji, user) + + if current_page <= 0: + continue + + current_page -= 1 + + embed.description = paginator.pages[current_page] + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + await message.edit(embed=embed) + + if reaction.emoji == RIGHT_EMOJI: + await message.remove_reaction(reaction.emoji, user) + + if current_page >= len(paginator.pages) - 1: + continue + + current_page += 1 + + embed.description = paginator.pages[current_page] + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + await message.edit(embed=embed) + + await message.clear_reactions() diff --git a/requirements.txt b/requirements.txt index 1c0e49647..9e191988d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ https://github.com/Rapptz/discord.py/archive/rewrite.zip#egg=discord.py[voice] dulwich +multidict -- cgit v1.2.3