diff --git a/cog_scn.py b/cog_scn.py index 197193c..cab9ec4 100644 --- a/cog_scn.py +++ b/cog_scn.py @@ -240,6 +240,7 @@ async def unblock(self, ctx:discord.ApplicationContext, username:str): log.debug("trying to unblock unknown user '{username}', ignoring") await ctx.respond(f"Unknown user: {username}") +## FIXME move to DiscordFormatter async def print_team(self, ctx, team): msg = f"> **{team.name}**\n" diff --git a/cog_tickets.py b/cog_tickets.py index ffca60b..a3e1f8b 100644 --- a/cog_tickets.py +++ b/cog_tickets.py @@ -75,11 +75,11 @@ async def tickets(self, ctx: discord.ApplicationContext, params: str = ""): args = params.split() if len(args) == 0 or args[0] == "me": - await self.print_tickets("My Tickets", self.redmine.my_tickets(user.login), ctx) + await self.bot.formatter.print_tickets("My Tickets", self.redmine.my_tickets(user.login), ctx) elif len(args) == 1: query = args[0] results = self.resolve_query_term(query) - await self.print_tickets(f"Search for '{query}'", results, ctx) + await self.bot.formatter.print_tickets(f"Search for '{query}'", results, ctx) @commands.slash_command() @@ -94,31 +94,31 @@ async def ticket(self, ctx: discord.ApplicationContext, ticket_id:int, action:st case "show": ticket = self.redmine.get_ticket(ticket_id) if ticket: - await ctx.respond(self.format_ticket(ticket)[:2000]) #trunc + await self.bot.formatter.print_ticket(ticket, ctx) else: await ctx.respond(f"Ticket {ticket_id} not found.") case "details": # FIXME ticket = self.redmine.get_ticket(ticket_id) if ticket: - await ctx.respond(self.format_ticket(ticket)[:2000]) #trunc + await self.bot.formatter.print_ticket(ticket, ctx) else: await ctx.respond(f"Ticket {ticket_id} not found.") case "unassign": self.redmine.unassign_ticket(ticket_id, user.login) - await self.print_ticket(self.redmine.get_ticket(ticket_id), ctx) + await self.bot.formatter.print_ticket(self.redmine.get_ticket(ticket_id), ctx) case "resolve": self.redmine.resolve_ticket(ticket_id, user.login) - await self.print_ticket(self.redmine.get_ticket(ticket_id), ctx) + await self.bot.formatter.print_ticket(self.redmine.get_ticket(ticket_id), ctx) case "progress": self.redmine.progress_ticket(ticket_id, user.login) - await self.print_ticket(self.redmine.get_ticket(ticket_id), ctx) + await self.bot.formatter.print_ticket(self.redmine.get_ticket(ticket_id), ctx) #case "note": # msg = ??? # self.redmine.append_message(ticket_id, user.login, msg) case "assign": self.redmine.assign_ticket(ticket_id, user.login) - await self.print_ticket(self.redmine.get_ticket(ticket_id), ctx) + await self.bot.formatter.print_ticket(self.redmine.get_ticket(ticket_id), ctx) case _: await ctx.respond("unknown command: {action}") except Exception as e: @@ -141,8 +141,7 @@ async def create_new_ticket(self, ctx: discord.ApplicationContext, title:str): message.set_note(text) ticket = self.redmine.create_ticket(user, message) if ticket: - await ctx.respond(self.format_ticket(ticket)[:2000]) #trunc - # error handling? exception? + await self.bot.formatter.print_ticket(ticket, ctx) else: await ctx.respond(f"error creating ticket with title={title}") @@ -174,52 +173,3 @@ async def thread_ticket(self, ctx: discord.ApplicationContext, ticket_id:int): await ctx.respond(f"Created new thread for {ticket.id}: {thread}") # todo add some fancy formatting else: await ctx.respond(f"ERROR: Unkown ticket ID: {ticket_id}") # todo add some fancy formatting - - - ### formatting ### - - async def print_tickets(self, title, tickets, ctx): - msg = self.format_tickets(title, tickets) - - if len(msg) > 2000: - log.warning("message over 2000 chars. truncing.") - msg = msg[:2000] - await ctx.respond(msg) - - - async def print_ticket(self, ticket, ctx): - msg = self.format_ticket(ticket) - - if len(msg) > 2000: - log.warning("message over 2000 chars. truncing.") - msg = msg[:2000] - await ctx.respond(msg) - - - def format_tickets(self, title, tickets, fields=None, max_len=2000): - if tickets is None: - return "No tickets found." - - if fields is None: - fields = ["link","priority","updated_on","assigned_to","subject"] - - section = "**" + title + "**\n" - for ticket in tickets: - ticket_line = self.format_ticket(ticket, fields) - if len(section) + len(ticket_line) + 1 < max_len: - # make sure the lenght is less that the max - section += ticket_line + "\n" # append each ticket - else: - break # max_len hit - - return section.strip() - - - def format_ticket(self, ticket, fields=None): - section = "" - if fields is None: - fields = ["link","priority","updated_on","assigned_to","subject"] - - for field in fields: - section += str(self.redmine.get_field(ticket, field)) + " " # spacer, one space - return section.strip() # remove trailing whitespace diff --git a/formatting.py b/formatting.py new file mode 100644 index 0000000..0e51106 --- /dev/null +++ b/formatting.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 + +"""Formatting Discord messages""" + +import logging + +import discord + +from tickets import Ticket, TicketManager +from session import RedmineSession +import synctime + +log = logging.getLogger(__name__) + + +MAX_MESSAGE_LEN = 2000 + + +EMOJI = { + 'Resolved': 'βœ…', + 'Reject': '❌', + 'Spam': '❌', + 'New': '🟑', + 'In Progress': 'πŸ”·', + 'Low': 'πŸ”½', + 'Normal': '⏺️', + 'High': 'πŸ”Ό', + 'Urgent': '⚠️', + 'Immediate': '❗', +} + +class DiscordFormatter(): + """ + Format tickets and user information for Discord + """ + def __init__(self, url: str): + self.base_url = url + + + async def print_tickets(self, title:str, tickets:list[Ticket], ctx:discord.ApplicationContext): + msg = self.format_tickets(title, tickets) + if len(msg) > MAX_MESSAGE_LEN: + log.warning(f"message over {MAX_MESSAGE_LEN} chars. truncing.") + msg = msg[:MAX_MESSAGE_LEN] + await ctx.respond(msg) + + + async def print_ticket(self, ticket, ctx): + msg = self.format_ticket_details(ticket) + if len(msg) > MAX_MESSAGE_LEN: + log.warning("message over {MAX_MESSAGE_LEN} chars. truncing.") + msg = msg[:MAX_MESSAGE_LEN] + await ctx.respond(msg) + + + def format_tickets(self, title:str, tickets:list[Ticket], max_len=MAX_MESSAGE_LEN) -> str: + if tickets is None: + return "No tickets found." + + section = "**" + title + "**\n" + for ticket in tickets: + ticket_line = self.format_ticket_row(ticket) + if len(section) + len(ticket_line) + 1 < max_len: + # make sure the lenght is less that the max + section += ticket_line + "\n" # append each ticket + else: + break # max_len hit + + return section.strip() + + + # noting for future: https://docs.python.org/3/library/string.html#string.Template + + def format_link(self, ticket:Ticket) -> str: ## to Ticket.link(base_url)? + return f"[`#{ticket.id}`]({self.base_url}/issues/{ticket.id})" + + + def format_ticket_row(self, ticket:Ticket) -> str: + link = self.format_link(ticket) + # link is mostly hidden, so we can't use the length to format. + # but the length of the ticket id can be used + link_padding = ' ' * (5 - len(str(ticket.id))) # field width = 6 + status = EMOJI[ticket.status.name] + priority = EMOJI[ticket.priority.name] + age = synctime.age_str(ticket.updated_on) + assigned = ticket.assigned_to.name if ticket.assigned_to else "" + return f"`{link_padding}`{link}` {status} {priority} {age:<10} {assigned:<18} `{ticket.subject[:60]}" + + + def format_discord_note(self, note) -> str: + """Format a note for Discord""" + age = synctime.age_str(note.created_on) + log.info(f"### {note} {age} {note.user}") + return f"> **{note.user}** *{age} ago*\n> {note.notes}"[:MAX_MESSAGE_LEN] + + + def format_ticket_details(self, ticket:Ticket) -> str: + link = self.format_link(ticket) + # link is mostly hidden, so we can't use the length to format. + # but the length of the ticket id can be used + # layout, based on redmine: + # # Tracker #id + # ## **Subject** + # Added by author (created-ago). Updated (updated ago). + # Status: status Start date: date + # Priority: priority Due date: date|blank + # Assignee: assignee % Done: ... + # Category: category Estimated time: ... + # ### Description + # description text + #link_padding = ' ' * (5 - len(str(ticket.id))) # field width = 6 + status = f"{EMOJI[ticket.status.name]} {ticket.status}" + priority = f"{EMOJI[ticket.priority.name]} {ticket.priority}" + created_age = synctime.age_str(ticket.created_on) + updated_age = synctime.age_str(ticket.updated_on) + assigned = ticket.assigned_to.name if ticket.assigned_to else "" + + details = f"# {ticket.tracker} {link}\n" + details += f"## {ticket.subject}\n" + details += f"Added by {ticket.author} {created_age} ago. Updated {updated_age} ago.\n" + details += f"**Status:** {status}\n" + details += f"**Priority:** {priority}\n" + details += f"**Assignee:** {assigned}\n" + details += f"**Category:** {ticket.category}\n" + if ticket.to or ticket.cc: + details += f"**To:** {', '.join(ticket.to)} **Cc:** {', '.join(ticket.cc)}\n" + + details += f"### Description\n{ticket.description}" + return details + + +def main(): + ticket_manager = TicketManager(RedmineSession.fromenvfile()) + + # construct the formatter + formatter = DiscordFormatter(ticket_manager.session.url) + + tickets = ticket_manager.search("test") + output = formatter.format_tickets("Test Tickets", tickets) + print (output) + +if __name__ == '__main__': + main() diff --git a/netbot.py b/netbot.py index 8bb1cbb..3cca725 100755 --- a/netbot.py +++ b/netbot.py @@ -9,6 +9,7 @@ from dotenv import load_dotenv from discord.ext import commands +from formatting import DiscordFormatter from tickets import TicketNote import synctime import redmine @@ -17,8 +18,6 @@ log = logging.getLogger(__name__) -MAX_MESSAGE_LEN = 2000 - class NetbotException(Exception): """netbot exception""" @@ -33,6 +32,8 @@ def __init__(self, client: redmine.Client): self.lock = asyncio.Lock() self.ticket_locks = {} + self.formatter = DiscordFormatter(client.url) + self.redmine = client #guilds = os.getenv('DISCORD_GUILDS').split(', ') #if guilds: @@ -90,14 +91,6 @@ async def gather_discord_notes(self, thread: discord.Thread, sync_rec:synctime.S return notes - def format_discord_note(self, note): - """Format a note for Discord""" - age = synctime.age_str(note.created_on) - log.info(f"### {note} {age} {note.user}") - message = f"> **{note.user}** *{age} ago*\n> {note.notes}"[:MAX_MESSAGE_LEN] - return message - - def gather_redmine_notes(self, ticket, sync_rec:synctime.SyncRecord) -> list[TicketNote]: notes = [] # get the new notes from the redmine ticket @@ -162,7 +155,7 @@ async def synchronize_ticket(self, ticket, thread:discord.Thread) -> bool: for note in redmine_notes: # Write the note to the discord thread dirty_flag = True - await thread.send(self.format_discord_note(note)) + await thread.send(self.formatter.format_discord_note(note)) log.debug(f"synced {len(redmine_notes)} notes from #{ticket.id} --> {thread}") # get the new notes from discord diff --git a/test_cog_tickets.py b/test_cog_tickets.py index a0d2246..6e0b6c9 100755 --- a/test_cog_tickets.py +++ b/test_cog_tickets.py @@ -30,8 +30,8 @@ def setUp(self): def parse_markdown_link(self, text:str) -> tuple[str, str]: - regex = r"^\[(\d+)\]\((.+)\)" - m = re.match(regex, text) + regex = r"\[`#(\d+)`\]\((.+)\)" + m = re.search(regex, text) self.assertIsNotNone(m, f"could not find ticket number in response str: {text}") ticket_id = m.group(1) diff --git a/test_netbot.py b/test_netbot.py index fad8397..0fe2e72 100755 --- a/test_netbot.py +++ b/test_netbot.py @@ -8,6 +8,7 @@ from dotenv import load_dotenv import netbot +from formatting import MAX_MESSAGE_LEN import test_utils @@ -94,7 +95,7 @@ async def test_sync_ticket_long_message(self): # assert method send called on mock thread, with the correct values log.debug(f"### call args: {thread.send.call_args}") self.assertIn(self.tag, thread.send.call_args.args[0]) - self.assertLessEqual(len(thread.send.call_args.args[0]), netbot.MAX_MESSAGE_LEN, "Message sent to Discord is too long") + self.assertLessEqual(len(thread.send.call_args.args[0]), MAX_MESSAGE_LEN, "Message sent to Discord is too long") # clean up self.redmine.remove_ticket(ticket.id) diff --git a/test_redmine.py b/test_redmine.py index f9735b5..15409d6 100755 --- a/test_redmine.py +++ b/test_redmine.py @@ -47,6 +47,7 @@ def test_blocked_create_ticket(self): self.assertFalse(self.user_mgr.is_blocked(self.user)) + @unittest.skip("takes too long and fills the log with junk") def test_client_timeout(self): # construct an invalid client to try to get a timeout try: diff --git a/tickets.py b/tickets.py index 26b3261..f8c659d 100644 --- a/tickets.py +++ b/tickets.py @@ -91,7 +91,7 @@ class Ticket(): parent: NamedId|None = None spent_hours: float = 0.0 total_spent_hours: float = 0.0 - category: str|None = None + category: NamedId|None = None assigned_to: NamedId|None = None custom_fields: list[CustomField]|None = None journals: list[TicketNote]|None = None @@ -119,6 +119,8 @@ def __post_init__(self): self.custom_fields = [CustomField(**field) for field in self.custom_fields] if self.journals: self.journals = [TicketNote(**note) for note in self.journals] + if self.category: + self.category = NamedId(**self.category) def get_custom_field(self, name: str) -> str | None: if self.custom_fields: @@ -156,16 +158,22 @@ def json_str(self): def to(self) -> list[str]: val = self.get_custom_field(TO_CC_FIELD_NAME) if val: - # string contains to,to//cc,cc - to_str, _ = val.split('//') + if '//' in val: + # string contains to,to//cc,cc + to_str, _ = val.split('//') + else: + to_str = val return [to.strip() for to in to_str.split(',')] @property def cc(self) -> list[str]: val = self.get_custom_field(TO_CC_FIELD_NAME) if val: - # string contains to,to//cc,cc - _, cc_str = val.split('//') + if '//' in val: + # string contains to,to//cc,cc + _, cc_str = val.split('//') + else: + cc_str = val return [to.strip() for to in cc_str.split(',')] def __str__(self):