aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Gareth Coles <[email protected]>2018-02-20 20:44:07 +0000
committerGravatar GitHub <[email protected]>2018-02-20 20:44:07 +0000
commit9ce4ef675714bfeb0ce28eb1fa0fb68bb4e2dd2e (patch)
tree104a6e139e8e84ee2983fd658f780b4f78703268
parentMartmists: Fancier IPython-like eval command (#3) (diff)
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
-rw-r--r--bot/__main__.py1
-rw-r--r--bot/cogs/clickup.py337
-rw-r--r--bot/cogs/events.py16
-rw-r--r--bot/constants.py4
-rw-r--r--bot/utils.py109
-rw-r--r--requirements.txt1
6 files changed, 462 insertions, 6 deletions
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