diff --git a/Makefile b/Makefile index d37fdcc..10104e0 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,9 @@ htmlcov: $(VENV)/bin/activate $(PYTHON) -m coverage run -m unittest $(PYTHON) -m coverage html +lint: $(VENV)/bin/activate + $(PYTHON) -m pylint *.py + clean: rm -rf __pycache__ rm -rf $(VENV) diff --git a/README.md b/README.md index dfe6275..a3d144c 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ To enable netbot on your Discord server, you need to generate a valid `DISCORD_T 4. In the OAuth2/URL Generator section, generate a URL for a **bot** with **Administrator** permissions. 5. That URL will look something like: https://discord.com/api/oauth2/authorize?client_id=[client-id]&permissions=8&scope=bot -6. Open a browser with that URL and and login. +6. Open a browser with that URL and login. 7. You'll be presented with a window to add your bot to any server you have admin permissions for. 8. Select your server and click OK. 4. Back in the bot setup, in the "Bot" section, under "Build-A-Bot", **Copy** the Token. This is the DISCORD_TOKEN diff --git a/cog_scn.py b/cog_scn.py index 04e3aea..73fcdf7 100644 --- a/cog_scn.py +++ b/cog_scn.py @@ -1,25 +1,18 @@ #!/usr/bin/env python3 - -import os -import re +"""Cog to manage SCN-related functions""" import logging -import datetime as dt import discord -import redmine -from discord.commands import option from discord.commands import SlashCommandGroup - -from dotenv import load_dotenv - from discord.ext import commands, tasks + log = logging.getLogger(__name__) # scn add redmine_login - setup discord userid in redmine -# scn sync - manually sychs the current thread, or replies with warning -# scn sync +# scn sync - manually sychs the current thread, or replies with warning +# scn sync # scn join teamname - discord user joins team teamname (and maps user id) # scn leave teamname - discord user leaves team teamname (and maps user id) @@ -28,13 +21,14 @@ def setup(bot): bot.add_cog(SCNCog(bot)) - log.info(f"initialized SCN cog") + log.info("initialized SCN cog") class SCNCog(commands.Cog): + """Cog to mange SCN-related functions""" def __init__(self, bot): self.bot = bot self.redmine = bot.redmine - self.sync_all_threads.start() # start the sync task + self.sync_all_threads.start() # pylint: disable=no-member # see https://github.com/Pycord-Development/pycord/blob/master/examples/app_commands/slash_cog_groups.py @@ -49,7 +43,7 @@ async def add(self, ctx:discord.ApplicationContext, redmine_login:str, member:di discord_name = member.name user = self.redmine.find_discord_user(discord_name) - + if user: await ctx.respond(f"Discord user: {discord_name} is already configured as redmine user: {user.login}") else: @@ -70,23 +64,24 @@ async def sync_thread(self, thread:discord.Thread): return None - """ - Configured to run every 5 minutes using the tasks.loop annotation. - Get all Threads and sync each one. - """ + @tasks.loop(minutes=1.0) # FIXME to 5.0 minutes. set to 1 min for testing async def sync_all_threads(self): + """ + Configured to run every minute using the tasks.loop annotation. + Get all Threads and sync each one. + """ log.info(f"sync_all_threads: starting for {self.bot.guilds}") # get all threads for guild in self.bot.guilds: for thread in guild.threads: #log.debug(f"THREAD: guild:{guild}, thread:{thread}") - # sync each thread, + # sync each thread, ticket = await self.sync_thread(thread) if ticket: # successful sync - log.debug(f"SYNC: thread:{thread.name} with ticket {ticket.id}") + log.debug(f"SYNC complete for ticket #{ticket.id} to {thread.name}") else: log.debug(f"no ticket found for {thread.name}") @@ -101,14 +96,14 @@ async def sync(self, ctx:discord.ApplicationContext): else: await ctx.respond(f"Cannot find ticket# in thread name: {ctx.channel.name}") # error else: - await ctx.respond(f"Not a thread.") # error + await ctx.respond("Not a thread.") # error @scn.command() async def reindex(self, ctx:discord.ApplicationContext): """reindex the user and team information""" self.redmine.reindex() - await ctx.respond(f"Rebuilt redmine indices.") + await ctx.respond("Rebuilt redmine indices.") @scn.command(description="join the specified team") @@ -117,7 +112,7 @@ async def join(self, ctx:discord.ApplicationContext, teamname:str , member: disc if member: log.info(f"Overriding current user={ctx.user.name} with member={member.name}") discord_name = member.name - + user = self.redmine.find_discord_user(discord_name) if user is None: await ctx.respond(f"Unknown user, no Discord mapping: {discord_name}") @@ -135,13 +130,12 @@ async def leave(self, ctx:discord.ApplicationContext, teamname:str, member: disc log.info(f"Overriding current user={ctx.user.name} with member={member.name}") discord_name = member.name user = self.redmine.find_discord_user(discord_name) - + if user: self.redmine.leave_team(user.login, teamname) await ctx.respond(f"**{discord_name}** has left *{teamname}*") else: await ctx.respond(f"Unknown Discord user: {discord_name}.") - pass @scn.command(description="list teams and members") @@ -169,9 +163,9 @@ async def teams(self, ctx:discord.ApplicationContext, teamname:str=None): async def print_team(self, ctx, team): msg = f"> **{team.name}**\n" for user_rec in team.users: - user = self.redmine.get_user(user_rec.id) + #user = self.redmine.get_user(user_rec.id) #discord_user = user.custom_fields[0].value or "" # FIXME cf_* lookup - msg += f"{user_rec.name}, " + msg += f"{user_rec.name}, " #msg += f"[{user.id}] **{user_rec.name}** {user.login} {user.mail} {user.custom_fields[0].value}\n" msg = msg[:-2] + '\n\n' await ctx.channel.send(msg) @@ -180,4 +174,4 @@ async def print_team(self, ctx, team): def format_team(self, team): # single line format: teamname: member1, member2 if team: - return f"**{team.name}**: {', '.join([user.name for user in team.users])}\n" \ No newline at end of file + return f"**{team.name}**: {', '.join([user.name for user in team.users])}\n" diff --git a/cog_tickets.py b/cog_tickets.py index 805b9f2..40b7f6b 100644 --- a/cog_tickets.py +++ b/cog_tickets.py @@ -1,25 +1,20 @@ #!/usr/bin/env python3 -import os -import re +"""encapsulate Discord ticket functions""" + import logging -import datetime as dt import discord -import redmine from discord.commands import option -from discord.commands import SlashCommandGroup - -from dotenv import load_dotenv - from discord.ext import commands + log = logging.getLogger(__name__) # scn add redmine_login - setup discord userid in redmine -# scn sync - manually sychs the current thread, or replies with warning -# scn sync +# scn sync - manually sychs the current thread, or replies with warning +# scn sync # scn join teamname - discord user joins team teamname (and maps user id) # scn leave teamname - discord user leaves team teamname (and maps user id) @@ -28,11 +23,11 @@ def setup(bot): bot.add_cog(TicketsCog(bot)) - log.info(f"initialized tickets cog") - + log.info("initialized tickets cog") class TicketsCog(commands.Cog): + """encapsulate Discord ticket functions""" def __init__(self, bot): self.bot = bot self.redmine = bot.redmine @@ -52,8 +47,8 @@ def __init__(self, bot): def resolve_query_term(self, term): # special cases: ticket num and team name try: - id = int(term) - ticket = self.redmine.get_ticket(id) + int_id = int(term) + ticket = self.redmine.get_ticket(int_id) return [ticket] except ValueError: # not a numeric id, check team @@ -62,7 +57,7 @@ def resolve_query_term(self, term): else: # assume a search term return self.redmine.search_tickets(term) - + @commands.slash_command() # guild_ids=[...] # Create a slash command for the supplied guilds. async def tickets(self, ctx: discord.ApplicationContext, params: str = ""): """List tickets for you, or filtered by parameter""" @@ -82,7 +77,7 @@ async def tickets(self, ctx: discord.ApplicationContext, params: str = ""): await self.print_tickets(self.redmine.my_tickets(user.login), ctx) elif len(args) == 1: await self.print_tickets(self.resolve_query_term(args[0]), ctx) - + @commands.slash_command() async def ticket(self, ctx: discord.ApplicationContext, ticket_id:int, action:str="show"): @@ -105,7 +100,7 @@ async def ticket(self, ctx: discord.ApplicationContext, ticket_id:int, action:st if ticket: await ctx.respond(self.format_ticket(ticket)[:2000]) #trunc else: - await ctx.respond(f"Ticket {ticket_id} not found.") + 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) @@ -124,12 +119,11 @@ async def ticket(self, ctx: discord.ApplicationContext, ticket_id:int, action:st case _: await ctx.respond("unknown command: {action}") except Exception as e: - msg = f"Error {action} {ticket_id}: {e}" - log.error(msg) - await ctx.respond(msg) + log.exception(e) + await ctx.respond(f"Error {action} {ticket_id}: {e}") - @commands.slash_command(name="new", description="Create a new ticket") + @commands.slash_command(name="new", description="Create a new ticket") @option("title", description="Title of the new SCN ticket") @option("add_thread", description="Create a Discord thread for the new ticket", default=False) async def create_new_ticket(self, ctx: discord.ApplicationContext, title:str): @@ -137,27 +131,27 @@ async def create_new_ticket(self, ctx: discord.ApplicationContext, title:str): if user is None: await ctx.respond(f"Unknown user: {ctx.user.name}") return - + # text templating text = f"ticket created by Discord user {ctx.user.name} -> {user.login}, with the text: {title}" ticket = self.redmine.create_ticket(user, title, text) if ticket: await ctx.respond(self.format_ticket(ticket)[:2000]) #trunc - # error handling? exception? + # error handling? exception? else: await ctx.respond(f"error creating ticket with title={title}") async def create_thread(self, ticket, ctx): log.info(f"creating a new thread for ticket #{ticket.id} in channel: {ctx.channel}") - name = f"Ticket #{ticket.id}" + name = f"Ticket #{ticket.id}: {ticket.subject}" msg_txt = f"Syncing ticket {self.redmine.get_field(ticket, 'url')} to new thread '{name}'" message = await ctx.send(msg_txt) thread = await message.create_thread(name=name) return thread - - @commands.slash_command(description="Create a Discord thread for the specified ticket") + + @commands.slash_command(name="thread", description="Create a Discord thread for the specified ticket") @option("ticket_id", description="ID of tick to create thread for") async def thread_ticket(self, ctx: discord.ApplicationContext, ticket_id:int): ticket = self.redmine.get_ticket(ticket_id) @@ -171,27 +165,16 @@ async def thread_ticket(self, ctx: discord.ApplicationContext, ticket_id:int): user = self.redmine.find_discord_user(ctx.user.name) self.redmine.enable_discord_sync(ticket.id, user, note) - # REFACTOR: We know the thread has just been created, just get messages-since in redmine. - #notes = self.redmine.get_notes_since(ticket.id, None) # None since date for all. - #log.info(f"syncing {len(notes)} notes from {ticket.id} --> {thread.name}") - - # NOTE: There doesn't seem to be a method for acting as a specific user, - # so adding user and date to the sync note. - #for note in notes: - # msg = f"> **{note.user.name}** at *{note.created_on}*\n> {note.notes}\n\n" - # await thread.send(msg) - - # TODO format command for single ticket - await ctx.send(f"Created new thread for {ticket.id}: {thread}") # todo add some fancy formatting + 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, tickets, ctx): msg = self.format_tickets(tickets) - + if len(msg) > 2000: log.warning("message over 2000 chars. truncing.") msg = msg[:2000] @@ -199,23 +182,29 @@ async def print_tickets(self, tickets, ctx): 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, tickets, fields=["link","priority","updated","assigned","subject"]): + def format_tickets(self, tickets, fields=None): if tickets is None: return "No tickets found." - + + if fields is None: + fields = ["link","priority","updated","assigned","subject"] + section = "" for ticket in tickets: section += self.format_ticket(ticket, fields) + "\n" # append each ticket return section.strip() - def format_ticket(self, ticket, fields=["link","priority","updated","assigned","subject"]): + def format_ticket(self, ticket, fields=None): section = "" + if fields is None: + fields = ["link","priority","updated","assigned","subject"] + for field in fields: section += self.redmine.get_field(ticket, field) + " " # spacer, one space - return section.strip() # remove trailing whitespace \ No newline at end of file + return section.strip() # remove trailing whitespace diff --git a/imap.py b/imap.py index 6d7aa8e..b07852a 100755 --- a/imap.py +++ b/imap.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +"""IMAP module""" import os import logging @@ -8,19 +9,19 @@ import re import traceback -import redmine +from io import StringIO +from html.parser import HTMLParser from imapclient import IMAPClient, SEEN, DELETED from dotenv import load_dotenv -from io import StringIO -from html.parser import HTMLParser +import redmine + # imapclient docs: https://imapclient.readthedocs.io/en/3.0.0/index.html # source code: https://github.com/mjs/imapclient -## logging ## log = logging.getLogger(__name__) @@ -28,10 +29,12 @@ # Message will represent everything needed for creating and updating tickets, # including attachments. class Attachment(): - def __init__(self, name:str, type:str, payload): + """email attachment""" + def __init__(self, name:str, content_type:str, payload): self.name = name - self.content_type = type + self.content_type = content_type self.payload = payload + self.token = None def upload(self, client, user_id): self.token = client.upload_file(user_id, self.payload, self.name, self.content_type) @@ -41,6 +44,7 @@ def set_token(self, token): class Message(): + """email message""" def __init__(self, from_addr:str, subject:str): self.from_address = from_addr self.subject = subject @@ -53,32 +57,35 @@ def set_note(self, note:str): def add_attachment(self, attachment:Attachment): self.attachments.append(attachment) - + def subject_cleaned(self) -> str: # strip any re: and forwarded from a subject line # from: https://stackoverflow.com/questions/9153629/regex-code-for-removing-fwd-re-etc-from-email-subject p = re.compile(r'^([\[\(] *)?(RE?S?|FYI|RIF|I|FS|VB|RV|ENC|ODP|PD|YNT|ILT|SV|VS|VL|AW|WG|ΑΠ|ΣΧΕΤ|ΠΡΘ|תגובה|הועבר|主题|转发|FWD?) *([-:;)\]][ :;\])-]*|$)|\]+ *$', re.IGNORECASE) return p.sub('', self.subject).strip() - + def __str__(self): - return f"from:{self.from_address}, subject:{self.subject}, attached:{len(self.attachments)}; {self.note[0:20]}" + return f"from:{self.from_address}, subject:{self.subject}, attached:{len(self.attachments)}; {self.note[0:20]}" # from https://stackoverflow.com/questions/753052/strip-html-from-strings-in-python class MLStripper(HTMLParser): + """strip HTML from a string""" def __init__(self): super().__init__() self.reset() self.strict = False self.convert_charrefs= True self.text = StringIO() - def handle_data(self, d): - self.text.write(d + '\n') # force a newline + def handle_data(self, data): + self.text.write(data + '\n') # force a newline def get_data(self): return self.text.getvalue() class Client(): ## imap.Client() + """IMAP Client""" + def __init__(self): self.host = os.getenv('IMAP_HOST') self.user = os.getenv('IMAP_USER') @@ -109,7 +116,7 @@ def parse_email_address(self, email_addr): def parse_message(self, data): # NOTE this policy setting is important, default is "compat-mode" amd we need "default" root = email.message_from_bytes(data, policy=email.policy.default) - + from_address = root.get("From") subject = root.get("Subject") message = Message(from_address, subject) @@ -119,13 +126,13 @@ def parse_message(self, data): content_type = part.get_content_type() if part.is_attachment(): message.add_attachment( Attachment( - name=part.get_filename(), - type=content_type, + name=part.get_filename(), + content_type=content_type, payload=part.get_payload(decode=True))) log.debug(f"Added attachment: {part.get_filename()} {content_type}") elif content_type == 'text/plain': # FIXME std const? payload = part.get_payload(decode=True).decode('UTF-8') - + # http://10.10.0.218/issues/208 if payload == "": payload = root.get_body().get_content() @@ -138,7 +145,7 @@ def parse_message(self, data): payload = self.strip_forwards(payload) message.set_note(payload) log.debug(f"Setting note: {payload}") - + return message def strip_html_tags(self, text:str) -> str: @@ -166,13 +173,13 @@ def strip_forwards(self, text:str) -> str: idx = text.find(forward_tag) if idx > -1: text = text[0:idx] - + # search for "On ... wrote:" p = re.compile(r"^On .* <.*>\s+wrote:", flags=re.MULTILINE) match = p.search(text) if match: text = text[0:match.start()] - + # TODO search for -- # look for google content, as in http://10.10.0.218/issues/323 @@ -183,7 +190,7 @@ def strip_forwards(self, text:str) -> str: for skip_str in self.skip_strs: if line.startswith(skip_str): skip = True - break; + break if skip: log.debug(f"skipping {line}") else: @@ -207,15 +214,15 @@ def handle_message(self, msg_id:str, message:Message): # more than expected log.warning(f"subject query returned {len(tickets)} results, using first: {subject}") ticket = tickets[0] - - # next, find ticket using the subject, if possible + + # next, find ticket using the subject, if possible if ticket is None: # this uses a simple REGEX '#\d+' to match ticket numbers ticket = self.redmine.find_ticket_from_str(subject) # get user id from from_address user = self.redmine.find_user(addr) - if user == None: + if user is None: log.debug(f"Unknown email address, no user found: {addr}, {message.from_address}") # create new user user = self.redmine.create_user(addr, first, last) @@ -245,7 +252,7 @@ def synchronize(self): server.login(self.user, self.passwd) #server.oauthbearer_login(self.user, self.passwd) #server.oauth2_login(self.user, self.passwd) - + server.select_folder("INBOX", readonly=False) log.info(f'logged into imap {self.host}') @@ -272,7 +279,7 @@ def synchronize(self): with open(f"message-err-{uid}.eml", "wb") as file: file.write(data) server.add_flags(uid, [SEEN]) - + log.info(f"done. processed {len(messages)} messages") except Exception as ex: log.error(f"caught exception syncing IMAP: {ex}") @@ -283,9 +290,8 @@ def synchronize(self): if __name__ == '__main__': log.info('initializing IMAP threader') - # load credentials + # load credentials load_dotenv() # construct the client and run the email check Client().synchronize() - diff --git a/netbot.py b/netbot.py index f518873..169c76d 100755 --- a/netbot.py +++ b/netbot.py @@ -1,40 +1,32 @@ #!/usr/bin/env python3 +"""netbot""" import os import re import logging -import datetime as dt - +import asyncio import discord -import redmine - - from dotenv import load_dotenv - from discord.ext import commands - -def setup_logging(): - logging.basicConfig(level=logging.INFO, format="{asctime} {levelname:<8s} {name:<16} {message}", style='{') - logging.getLogger("discord.gateway").setLevel(logging.WARNING) - logging.getLogger("discord.http").setLevel(logging.WARNING) - logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING) - logging.getLogger("discord.client").setLevel(logging.WARNING) - logging.getLogger("discord.webhook.async_").setLevel(logging.WARNING) +import synctime +import redmine log = logging.getLogger(__name__) -log.info('initializing netbot') - class NetBot(commands.Bot): - def __init__(self, redmine: redmine.Client): + """netbot""" + def __init__(self, client: redmine.Client): log.info(f'initializing {self}') intents = discord.Intents.default() intents.message_content = True - self.redmine = redmine + self.lock = asyncio.Lock() + self.ticket_locks = {} + + self.redmine = client #guilds = os.getenv('DISCORD_GUILDS').split(', ') #if guilds: # log.info(f"setting guilds: {guilds}") @@ -43,114 +35,142 @@ def __init__(self, redmine: redmine.Client): # # exit? super().__init__( - command_prefix=commands.when_mentioned_or("!"), + command_prefix=commands.when_mentioned_or("!"), intents=intents, # debug_guilds = guilds ) - def run(self): + def run_bot(self): + """start netbot""" log.info(f"starting {self}") super().run(os.getenv('DISCORD_TOKEN')) #async def on_ready(self): # log.info(f"Logged in as {self.user} (ID: {self.user.id})") - # log.debug(f"bot: {self}, guilds: {self.guilds}") - + # log.debug(f"bot: {self}, guilds: {self.guilds}") + def parse_thread_title(self, title: str) -> int: + """parse the thread title to get the ticket number""" match = re.match(r'^Ticket #(\d+)', title) if match: return int(match.group(1)) - """ - # disabled for now... conflicting with the scheduled sync process - #async def on_message(self, message: discord.Message): - # Make sure we won't be replying to ourselves. - # if message.author.id == bot.user.id: - # return - # if isinstance(message.channel, discord.Thread): - # get the ticket id from the thread name - # ticket_id = self.parse_thread_title(message.channel.name) - - # if ticket_id: - # await self.sync_new_message(ticket_id, message) - # else just a normal thread, do nothing - - async def sync_new_message(self, ticket_id: int, message: discord.Message): - # ticket = redmine.get_ticket(ticket_id, include_journals=True) - # create a note with translated discord user id with the update (or one big one?) - # double-check that self.id <> author.id? - user = self.redmine.find_discord_user(message.author.name) - if user: - self.redmine.append_message(ticket_id, user.login, message.content) - log.debug( - f"SYNCED: ticket={ticket_id}, user={user.login}, msg={message.content}") - else: - log.warning( - f"sync_new_message - unknown discord user: {message.author.name}, skipping message") - """ - - """ - Synchronize a ticket to a thread - """ + async def gather_discord_notes(self, thread: discord.Thread, sync_rec:synctime.SyncRecord): + log.debug(f"calling history with thread={thread}, after={sync_rec.last_sync}, ts={sync_rec.last_sync.timestamp()}") + # TODO I'm sure there's a more python way to do this + notes = [] + async for message in thread.history(after=sync_rec.last_sync, oldest_first=True): + # ignore bot messages + if message.author.id != self.user.id: + notes.append(message) + return notes + + + def format_discord_note(self, note): + """Format a note for Discord""" + age = synctime.age_str(synctime.parse_str(note.created_on)) + return f"> **{note.user.name}** *{age} ago*\n> {note.notes}\n\n" + #TODO Move format table + + + def gather_redmine_notes(self, ticket, sync_rec:synctime.SyncRecord): + notes = [] + # get the new notes from the redmine ticket + redmine_notes = self.redmine.get_notes_since(ticket.id, sync_rec.last_sync) + for note in redmine_notes: + if not note.notes.startswith('"Discord":'): + # skip anything that start with the Discord tag + notes.append(note) + return notes + + + def format_redmine_note(self, message: discord.Message): + """Format a discord message for redmine""" + # redmine link format: "Link Text":http://whatever + return f'"Discord":{message.jump_url}: {message.content}' # NOTE: message.clean_content + + async def synchronize_ticket(self, ticket, thread:discord.Thread): - log.debug(f"ticket: {ticket.id}, thread: {thread}") - + """ + Synchronize a ticket to a thread + """ + # as this is an async method call, and we don't want to lock bot-level event processing, + # we need to create a per-ticket lock to make sure the same + + # TODO Sync files and attachments discord -> redmine, use ticket query to get them + + dirty_flag: bool = False + + # get the self lock before checking the lock collection + async with self.lock: + if ticket.id in self.ticket_locks: + log.debug(f"ticket #{ticket.id} locked, skipping") + return + else: + # create lock flag + self.ticket_locks[ticket.id] = True + log.debug(f"thread lock set, id: {ticket.id}, thread: {thread}") + # start of the process, will become "last update" - timestamp = dt.datetime.now(dt.timezone.utc) # UTC - - last_sync = self.redmine.get_field(ticket, "sync") - if last_sync is None: - last_sync = timestamp - dt.timedelta(days=2*365) # 2 years - - log.debug(f"ticket {ticket.id} last sync: {last_sync}, age: {self.redmine.get_field(ticket, 'age')}") - - notes = self.redmine.get_notes_since(ticket.id, last_sync) - log.info(f"syncing {len(notes)} notes from {ticket.id} --> {thread.name}") - - for note in notes: - msg = f"> **{note.user.name}** at *{note.created_on}*\n> {note.notes}\n\n" - await thread.send(msg) - - # query discord for updates to thread since last-update - # see https://docs.pycord.dev/en/stable/api/models.html#discord.Thread.history - log.debug(f"calling history with thread={thread}, after={last_sync}") - #messages = await thread.history(after=last_sync, oldest_first=True).flatten() - #for message in messages: - async for message in thread.history(after=last_sync, oldest_first=True): - # ignore bot messages! - if message.author.id != self.user.id: - # for each, create a note with translated discord user id with the update (or one big one?) + sync_start = synctime.now() + sync_rec = self.redmine.get_sync_record(ticket, expected_channel=thread.id) + if sync_rec: + log.debug(f"sync record: {sync_rec}") + + # get the new notes from the redmine ticket + redmine_notes = self.gather_redmine_notes(ticket, sync_rec) + for note in redmine_notes: + # Write the note to the discord thread + dirty_flag = True + await thread.send(self.format_discord_note(note)) + log.debug(f"synced {len(redmine_notes)} notes from #{ticket.id} --> {thread}") + + # get the new notes from discord + discord_notes = await self.gather_discord_notes(thread, sync_rec) + for message in discord_notes: + # make sure a user mapping exists user = self.redmine.find_discord_user(message.author.name) - if user: + # format and write the note + dirty_flag = True log.debug(f"SYNC: ticket={ticket.id}, user={user.login}, msg={message.content}") - self.redmine.append_message(ticket.id, user.login, message.content) + formatted = self.format_redmine_note(message) + self.redmine.append_message(ticket.id, user.login, formatted) else: - log.warning( - f"synchronize_ticket - unknown discord user: {message.author.name}, skipping message") + # FIXME + log.info(f"SYNC unknown Discord user: {message.author.name}, skipping") + log.debug(f"synced {len(discord_notes)} notes from {thread} -> #{ticket.id}") + + # update the SYNC timestamp + # only update if something has changed + if dirty_flag: + sync_rec.last_sync = sync_start + self.redmine.update_sync_record(sync_rec) + + # unset the sync lock + del self.ticket_locks[ticket.id] + + log.info(f"DONE sync {ticket.id} <-> {thread.name}, took {synctime.age_str(sync_start)}") else: - log.debug(f"No new discord messages found since {last_sync}") + log.debug(f"empty sync_rec for channel={thread.id}, assuming mismatch and skipping") + - # update the SYNC timestamp - self.redmine.update_syncdata(ticket.id, dt.datetime.now(dt.timezone.utc)) # fresh timestamp, instead of 'timestamp' - log.info(f"completed sync for {ticket.id} <--> {thread.name}") - - async def on_application_command_error(self, ctx: discord.ApplicationContext, error: discord.DiscordException): + async def on_application_command_error(self, context: discord.ApplicationContext, + exception: discord.DiscordException): """Bot-level error handler""" - if isinstance(error, commands.CommandOnCooldown): - await ctx.respond("This command is currently on cooldown!") + if isinstance(exception, commands.CommandOnCooldown): + await context.respond("This command is currently on cooldown!") else: - log.error(f"{error}", exc_info=True) + log.error(f"{context} - {exception}", exc_info=True) #raise error # Here we raise other errors to ensure they aren't ignored - await ctx.respond(f"{error}") + await context.respond(f"Error processing your request: {exception}") def main(): - setup_logging() - - log.info(f"initializing {__name__}") + """netbot main function""" + log.info(f"loading .env for {__name__}") load_dotenv() client = redmine.Client() @@ -161,8 +181,20 @@ def main(): bot.load_extension("cog_tickets") # run the bot - bot.run() + bot.run_bot() + + +def setup_logging(): + """set up logging for netbot""" + logging.basicConfig(level=logging.INFO, + format="{asctime} {levelname:<8s} {name:<16} {message}", style='{') + logging.getLogger("discord.gateway").setLevel(logging.WARNING) + logging.getLogger("discord.http").setLevel(logging.WARNING) + logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING) + logging.getLogger("discord.client").setLevel(logging.WARNING) + logging.getLogger("discord.webhook.async_").setLevel(logging.WARNING) if __name__ == '__main__': + setup_logging() main() diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..5f41c84 --- /dev/null +++ b/pylintrc @@ -0,0 +1,640 @@ +[MAIN] + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint +# in a server-like mode. +clear-cache-post-run=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths= + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.11 + +# Discover python modules and packages in the file system subtree. +recursive=no + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +source-roots= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. If left empty, argument names will be checked with the set +# naming style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. If left empty, class names will be checked with the set naming style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. If left empty, function names will be checked with the set +# naming style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Regular expression matching correct type alias names. If left empty, type +# alias names will be checked with the set naming style. +#typealias-rgx= + +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. If left empty, variable names will be checked with the set +# naming style. +#variable-rgx= + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + asyncSetUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= + +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=builtins.BaseException,builtins.Exception + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow explicit reexports by alias from a package __init__. +allow-reexport-from-package=no + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules= + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + use-implicit-booleaness-not-comparison-to-string, + use-implicit-booleaness-not-comparison-to-zero, + # from philion + logging-fstring-interpolation, + fixme, + line-too-long, + missing-function-docstring, + broad-exception-caught # see https://github.com/pylint-dev/pylint/issues/9010 + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable= + + +[METHOD_ARGS] + +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +notes-rgx= + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +msg-template= + +# Set the output format. Available formats are: text, parseable, colorized, +# json2 (improved json format), json (old json format) and msvs (visual +# studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +#output-format= + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[SIMILARITIES] + +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=yes + +# Signatures are removed from the similarity computation +ignore-signatures=yes + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. No available dictionaries : You need to install +# both the python package and the system dependency for enchant to work. +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/redmine.py b/redmine.py index 60275c6..52d9c02 100644 --- a/redmine.py +++ b/redmine.py @@ -1,41 +1,52 @@ #!/usr/bin/env python3 +"""redmine client""" import os import re import json -import requests import logging import datetime as dt +from types import SimpleNamespace -import humanize - +import requests from dotenv import load_dotenv -from types import SimpleNamespace + +import synctime log = logging.getLogger(__name__) DEFAULT_SORT = "status:desc,priority:desc,updated_on:desc" TIMEOUT = 2 # seconds +SYNC_FIELD_NAME = "syncdata" + class RedmineException(Exception): + """redmine exception""" def __init__(self, message: str, request_id: str) -> None: super().__init__(message + ", req_id=" + request_id) self.request_id = request_id - + class Client(): ## redmine.Client() + """redmine client""" def __init__(self): self.url = os.getenv('REDMINE_URL') if self.url is None: raise RedmineException("Unable to load REDMINE_URL", "[n/a]") - + self.token = os.getenv('REDMINE_TOKEN') if self.url is None: - raise RedmineException("Unable to load REDMINE_TOKEN") - + raise RedmineException("Unable to load REDMINE_TOKEN", "__init__") + + self.users = {} + self.user_ids = {} + self.user_emails = {} + self.discord_users = {} + self.groups = {} self.reindex() def create_ticket(self, user, subject, body, attachments=None): + """create a redmine ticket""" # https://www.redmine.org/projects/redmine/wiki/Rest_Issues#Creating-an-issue # tracker_id = 13 is test tracker. # would need full param handling to pass that thru discord to get to this invocation.... @@ -52,46 +63,53 @@ def create_ticket(self, user, subject, body, attachments=None): data['issue']['uploads'] = [] for a in attachments: data['issue']['uploads'].append({ - "token": a.token, + "token": a.token, "filename": a.name, "content_type": a.content_type, }) response = requests.post( - url=f"{self.url}/issues.json", - data=json.dumps(data), + url=f"{self.url}/issues.json", + data=json.dumps(data), headers=self.get_headers(user.login), timeout=TIMEOUT) - + # check status if response.ok: root = json.loads(response.text, object_hook= lambda x: SimpleNamespace(**x)) return root.issue else: - raise RedmineException(f"create_ticket failed, status=[{response.status_code}] {response.reason}", response.headers['X-Request-Id']) - + raise RedmineException( + f"create_ticket failed, status=[{response.status_code}] {response.reason}", + response.headers['X-Request-Id']) + def update_user(self, user, fields:dict): + """update a user record in redmine""" # PUT a simple JSON structure data = {} data['user'] = fields response = requests.put( - url=f"{self.url}/users/{user.id}.json", + url=f"{self.url}/users/{user.id}.json", + timeout=TIMEOUT, data=json.dumps(data), headers=self.get_headers()) # removed user.login impersonation header - + log.debug(f"update user: [{response.status_code}] {response.request.url}, fields: {fields}") - + # check status if response.ok: # TODO get and return the updated user? return user else: - raise RedmineException(f"update_user failed, status=[{response.status_code}] {response.reason}", response.headers['X-Request-Id']) + raise RedmineException( + f"update_user failed, status=[{response.status_code}] {response.reason}", + response.headers['X-Request-Id']) def update_ticket(self, ticket_id:str, fields:dict, user_login:str=None): + """update a redmine ticket""" # PUT a simple JSON structure data = { 'issue': {} @@ -100,21 +118,28 @@ def update_ticket(self, ticket_id:str, fields:dict, user_login:str=None): data['issue'] = fields response = requests.put( - url=f"{self.url}/issues/{ticket_id}.json", + url=f"{self.url}/issues/{ticket_id}.json", + timeout=TIMEOUT, data=json.dumps(data), headers=self.get_headers(user_login)) - - log.debug(f"update ticket: [{response.status_code}] {response.request.url}, fields: {fields}") - + + # ASIDE: this is a great example of lint standards that just make the code more difficult + # to read. There are no good answers for string-too-long. + log.debug( + f"update ticket: [{response.status_code}] {response.request.url}, fields: {fields}") + # check status if response.ok: # no body, so re-get the updated tickets? return self.get_ticket(ticket_id) else: - raise RedmineException(f"update_ticket failed, status=[{response.status_code}] {response.reason}", response.headers['X-Request-Id']) + raise RedmineException( + f"update_ticket failed, status=[{response.status_code}] {response.reason}", + response.headers['X-Request-Id']) def append_message(self, ticket_id:int, user_login:str, note:str, attachments=None): + """append a note to a ticket""" # PUT a simple JSON structure data = { 'issue': { @@ -127,16 +152,17 @@ def append_message(self, ticket_id:int, user_login:str, note:str, attachments=No data['issue']['uploads'] = [] for a in attachments: data['issue']['uploads'].append({ - "token": a.token, + "token": a.token, "filename": a.name, "content_type": a.content_type, }) r = requests.put( - url=f"{self.url}/issues/{ticket_id}.json", + url=f"{self.url}/issues/{ticket_id}.json", + timeout=TIMEOUT, data=json.dumps(data), headers=self.get_headers(user_login)) - + # check status if r.status_code == 204: # all good @@ -151,11 +177,12 @@ def append_message(self, ticket_id:int, user_login:str, note:str, attachments=No def upload_file(self, user_id, data, filename, content_type): + """Upload a file to redmine""" # POST /uploads.json?filename=image.png # Content-Type: application/octet-stream # (request body is the file content) - headers = { + headers = { 'User-Agent': 'netbot/0.0.1', # TODO update to project version, and add version management 'Content-Type': 'application/octet-stream', # <-- VERY IMPORTANT 'X-Redmine-API-Key': self.token, @@ -163,10 +190,11 @@ def upload_file(self, user_id, data, filename, content_type): } r = requests.post( - url=f"{self.url}/uploads.json?filename={filename}", + url=f"{self.url}/uploads.json?filename={filename}", + timeout=TIMEOUT, files={ 'upload_file': (filename, data, content_type) }, headers=headers) - + # 201 response: {"upload":{"token":"7167.ed1ccdb093229ca1bd0b043618d88743"}} if r.status_code == 201: # all good, get token @@ -181,25 +209,29 @@ def upload_file(self, user_id, data, filename, content_type): #TODO throw exception to show upload failed, and why def upload_attachments(self, user_id, attachments): - # uploads all the attachments, - # sets the upload token for each + """Upload a list of attachments""" + # uploads all the attachments, + # sets the upload token for each for a in attachments: token = self.upload_file(user_id, a.payload, a.name, a.content_type) a.set_token(token) def find_team(self, name): - response = self.query(f"/groups.json") + """find a team by name""" + response = self.query("/groups.json") for group in response.groups: if group.name == name: return group # not found return None - - def get_user(self, id:int): - if id: - return self.user_ids[id] - + + def get_user(self, user_id:int): + """get a user by ID""" + if user_id: + return self.user_ids[user_id] + def find_user(self, name): + """find a user by name""" # check if name is int, raw user id. then look up in userids # check the indicies if name in self.user_emails: @@ -212,18 +244,20 @@ def find_user(self, name): return self.groups[name] #ugly. put groups in user collection? else: return None - + def find_discord_user(self, discord_user_id:str): - if discord_user_id == None: + """find a user by their discord ID""" + if discord_user_id is None: return None - + if discord_user_id in self.discord_users: - id = self.discord_users[discord_user_id] - return self.user_ids[id] + user_id = self.discord_users[discord_user_id] + return self.user_ids[user_id] else: return None def get_ticket(self, ticket_id:int, include_journals:bool = False): + """get a ticket by ID""" if ticket_id is None or ticket_id == 0: log.warning(f"Invalid ticket number: {ticket_id}") return None @@ -238,59 +272,63 @@ def get_ticket(self, ticket_id:int, include_journals:bool = False): else: log.warning(f"Unknown ticket number: {ticket_id}") return None - + #GET /issues.xml?issue_id=1,2 def get_tickets(self, ticket_ids): + """get several tickets based on a list of IDs""" response = self.query(f"/issues.json?issue_id={','.join(ticket_ids)}&sort={DEFAULT_SORT}") - if response != None and response.total_count > 0: + if response is not None and response.total_count > 0: return response.issues else: log.info(f"Unknown ticket numbers: {ticket_ids}") return [] - - def find_ticket_from_str(self, str:str): + + def find_ticket_from_str(self, string:str): + """parse a ticket number from a string and get the associated ticket""" # for now, this is a trivial REGEX to match '#nnn' in a string, and return ticket #nnn - match = re.search(r'#(\d+)', str) + match = re.search(r'#(\d+)', string) if match: ticket_num = int(match.group(1)) return self.get_ticket(ticket_num) else: - log.debug(f"Unable to match ticket number in: {str}") + log.debug(f"Unable to match ticket number in: {string}") return [] - - + + def create_user(self, email:str, first:str, last:str): + """create a new redmine user""" data = { 'user': { 'login': email, 'firstname': first, 'lastname': last, - 'mail': email, + 'mail': email, } } # on create, assign watcher: sender? - + r = requests.post( - url=f"{self.url}/users.json", - data=json.dumps(data), + url=f"{self.url}/users.json", + timeout=TIMEOUT, + data=json.dumps(data), headers=self.get_headers()) - + # check status if r.status_code == 201: root = json.loads(r.text, object_hook= lambda x: SimpleNamespace(**x)) user = root.user - + log.info(f"created user: {user.id} {user.login} {user.mail}") self.reindex_users() # new user! - + # add user to User group and SCN project - + #self.join_project(user.login, "scn") ### scn project key #log.info("joined scn project") - + self.join_team(user.login, "users") ### FIXME move default team name to defaults somewhere log.info("joined users group") - + return user elif r.status_code == 403: # can't create existing user. log err, but return from cache @@ -301,32 +339,38 @@ def create_user(self, email:str, first:str, last:str): log.error(f"create_user, status={r.status_code}: {r.reason}, req-id={r.headers['X-Request-Id']}") #TODO throw exception? return None - + # used only in testing def remove_user(self, user_id:int): + """remove user frmo redmine. used for testing""" # DELETE to /users/{user_id}.json r = requests.delete( - url=f"{self.url}/users/{user_id}.json", + url=f"{self.url}/users/{user_id}.json", + timeout=TIMEOUT, headers=self.get_headers()) # check status if r.status_code != 204: log.error(f"Error removing user status={r.status_code}, url={r.request.url}") - + + def remove_ticket(self, ticket_id:int): + """delete a ticket in redmine. used for testing""" # DELETE to /issues/{ticket_id}.json response = requests.delete( - url=f"{self.url}/issues/{ticket_id}.json", + url=f"{self.url}/issues/{ticket_id}.json", + timeout=TIMEOUT, headers=self.get_headers()) - + if response.ok: log.info(f"remove_ticket {ticket_id}") else: - raise RedmineException(f"remove_ticket failed, status=[{response.status_code}] {response.reason}", response.headers['X-Request-Id']) - - + raise RedmineException(f"remove_ticket failed, status=[{response.status_code}] {response.reason}", response.headers['X-Request-Id']) + + def most_recent_ticket_for(self, email): + """get the most recent ticket for the user with the given email""" # get the user record for the email user = self.find_user(email) @@ -344,6 +388,7 @@ def most_recent_ticket_for(self, email): return None def new_tickets_since(self, timestamp:dt.datetime): + """get new tickets since provided timestamp""" # query for new tickets since date # To fetch issues created after a certain timestamp (uncrypted filter is ">=2014-01-02T08:12:32Z") : # GET /issues.xml?created_on=%3E%3D2014-01-02T08:12:32Z @@ -357,8 +402,9 @@ def new_tickets_since(self, timestamp:dt.datetime): log.debug(f"No tickets created since {timestamp}") return None - + def find_tickets(self): + """default ticket query""" # "kanban" query: all ticket open or closed recently project=1 tracker=4 @@ -368,12 +414,13 @@ def find_tickets(self): return response.issues def my_tickets(self, user=None): + """get my tickets""" response = self.query(f"/issues.json?assigned_to_id=me&status_id=open&sort={DEFAULT_SORT}&limit=100", user) if response.total_count > 0: return response.issues else: - log.info(f"No open ticket for me.") + log.info("No open ticket for me.") return None def tickets_for_team(self, team_str:str): @@ -393,7 +440,7 @@ def search_tickets(self, term): # todo url-encode term? # note: sort doesn't seem to be working for search query = f"/search.json?q={term}&titles_only=1&open_issues=1&limit=100" - + response = self.query(query) ids = [] @@ -415,62 +462,35 @@ def match_subject(self, subject): return self.get_tickets(ids) - # get the + # get the def get_notes_since(self, ticket_id, timestamp=None): notes = [] ticket = self.get_ticket(ticket_id, include_journals=True) log.debug(f"got ticket {ticket_id} with {len(ticket.journals)} notes") - #try: for note in ticket.journals: # note.notes is a text field with notes, or empty. if there are no notes, ignore the journal if note.notes and timestamp: - #log.debug(f"### get_notes_since - fromisoformat: {note.created_on}") - created = dt.datetime.fromisoformat(note.created_on) ## creates UTC - #log.debug(f"note {note.id} created {created} {age(created)} <--> {timestamp} {age(timestamp)}") + created = synctime.parse_str(note.created_on) if created > timestamp: notes.append(note) elif note.notes: notes.append(note) # append all notes when there's no timestamp - #except Exception as e: - # log.error(f"oops: {e}") return notes - - """ - def discord_tickets(self): - # todo: check updated field and track what's changed - threaded_issue_query = "/issues.json?status_id=open&cf_1=1&sort=updated_on:desc" - response = self.redmine.query(threaded_issue_query) - if response.total_count > 0: - return response.issues - else: - log.info(f"No open tickets found for: {response.request.url}") - return None - """ def enable_discord_sync(self, ticket_id, user, note): fields = { "note": note, #f"Created Discord thread: {thread.name}: {thread.jump_url}", "cf_1": "1", } - + self.update_ticket(ticket_id, fields, user.login) # currently doesn't return or throw anything # todo: better error reporting back to discord - def update_syncdata(self, ticket_id:int, timestamp:dt.datetime): - log.debug(f"Setting ticket {ticket_id} update_syncdata to: {timestamp} {age(timestamp)}") - # 2023-11-19T20:42:09Z - timestr = timestamp.strftime("%Y-%m-%dT%H:%M:%SZ") # timestamp.isoformat() - fields = { - "custom_fields": [ - { "id": 4, "value": timestr } # cf_4, custom field syncdata, #TODO search for it - ] - } - self.update_ticket(ticket_id, fields) def create_discord_mapping(self, redmine_login:str, discord_name:str): user = self.find_user(redmine_login) @@ -483,83 +503,55 @@ def create_discord_mapping(self, redmine_login:str, discord_name:str): } self.update_user(user, fields) - """ not used - def join_project(self, username, project:str): - # look up user ID - user = self.find_user(username) - if user == None: - log.warning(f"Unknown user name: {username}") - return None - - # check project name? just assume for now - - # POST /projects/{project}/memberships.json - data = { - "membership": { - "user_id": user.id, - "role_ids": [ 5 ], # this is the "User" role. need a mapping table, could be default for param - } - } - - r = requests.post( - url=f"{self.url}/projects/{project}/memberships.json", - data=json.dumps(data), - headers=self.get_headers()) - - # check status - if r.status_code == 204: - log.info(f"joined project {username}, {project}, {r.request.url}, data={data}") - else: - resp = json.loads(r.text, object_hook=lambda x: SimpleNamespace(**x)) - log.error(f"Error joining group: {resp.errors}, status={r.status_code}: {r.request.url}, data={data}") - """ def join_team(self, username, teamname:str): # look up user ID user = self.find_user(username) - if user == None: + if user is None: log.warning(f"Unknown user name: {username}") return None - + # map teamname to team team = self.find_team(teamname) - if team == None: + if team is None: log.warning(f"Unknown team name: {teamname}") return None - + # POST to /group/ID/users.json data = { "user_id": user.id } response = requests.post( - url=f"{self.url}/groups/{team.id}/users.json", - data=json.dumps(data), + url=f"{self.url}/groups/{team.id}/users.json", + data=json.dumps(data), + timeout=TIMEOUT, headers=self.get_headers()) - + # check status if response.ok: log.info(f"join_team {username}, {teamname}") else: raise RedmineException(f"join_team failed, status=[{response.status_code}] {response.reason}", response.headers['X-Request-Id']) - - + + def leave_team(self, username:int, teamname:str): # look up user ID user = self.find_user(username) - if user == None: + if user is None: log.warning(f"Unknown user name: {username}") return None - + # map teamname to team team = self.find_team(teamname) - if team == None: + if team is None: log.warning(f"Unknown team name: {teamname}") return None # DELETE to /groups/{team-id}/users/{user_id}.json r = requests.delete( - url=f"{self.url}/groups/{team.id}/users/{user.id}.json", + url=f"{self.url}/groups/{team.id}/users/{user.id}.json", + timeout=TIMEOUT, headers=self.get_headers()) # check status @@ -593,9 +585,9 @@ def query(self, query_str:str, user:str=None): else: log.warning(f"{r.status_code}: {r.request.url}") return None - - def assign_ticket(self, id, target, user_id=None): + + def assign_ticket(self, ticket_id, target, user_id=None): user = self.find_user(target) if user: fields = { @@ -605,26 +597,26 @@ def assign_ticket(self, id, target, user_id=None): if user_id is None: # use the user-id to self-assign user_id = user.login - self.update_ticket(id, fields, user_id) + self.update_ticket(ticket_id, fields, user_id) else: log.error(f"unknow user: {target}") - - def progress_ticket(self, id, user_id=None): # TODO notes - + + def progress_ticket(self, ticket_id, user_id=None): # TODO notes + fields = { "assigned_to_id": "me", "status_id": "2", # "In Progress" } - self.update_ticket(id, fields, user_id) + self.update_ticket(ticket_id, fields, user_id) - def unassign_ticket(self, id, user_id=None): + def unassign_ticket(self, ticket_id, user_id=None): fields = { "assigned_to_id": "", # FIXME this *should* be the team it was assigned to, but there's no way to calculate. "status_id": "1", # New } - self.update_ticket(id, fields, user_id) + self.update_ticket(ticket_id, fields, user_id) def resolve_ticket(self, ticket_id, user_id=None): @@ -636,7 +628,7 @@ def get_team(self, teamname:str): if team is None: log.debug(f"Unknown team name: {teamname}") return None - + # as per https://www.redmine.org/projects/redmine/wiki/Rest_Groups#GET-2 # GET /groups/20.json?include=users response = self.query(f"/groups/{team.id}.json?include=users") @@ -646,8 +638,63 @@ def get_team(self, teamname:str): #TODO exception? return None + + def get_sync_record(self, ticket, expected_channel: int) -> synctime.SyncRecord: + # Parse custom_field into datetime + # lookup field by name + token = None + try : + for field in ticket.custom_fields: + if field.name == SYNC_FIELD_NAME: + token = field.value + log.debug(f"found {field.name} => '{field.value}'") + break + except AttributeError: + # custom_fields not set, handle same as no sync field + pass + + if token: + record = synctime.SyncRecord.from_token(ticket.id, token) + log.debug(f"created sync_rec from token: {record}") + if record: + # check channel + if record.channel_id == 0: + # no valid channel set in sync data, assume lagacy + record.channel_id = expected_channel + # update the record in redmine after adding the channel info + self.update_sync_record(record) + return record + elif record.channel_id != expected_channel: + log.debug(f"channel mismatch: rec={record.channel_id} =/= {expected_channel}, token={token}") + return None + else: + return record + else: + # no token implies not-yet-initialized + record = synctime.SyncRecord(ticket.id, expected_channel, synctime.epoch_datetime()) + # apply the new sync record back to redmine + self.update_sync_record(record) + return record + + + def update_sync_record(self, record:synctime.SyncRecord): + log.debug(f"Updating sync record in redmine: {record}") + fields = { + "custom_fields": [ + { "id": 4, "value": record.token_str() } # cf_4, custom field syncdata, #TODO search for it + ] + } + self.update_ticket(record.ticket_id, fields) + + def get_updated_field(self, ticket) -> dt.datetime: + return synctime.parse_str(ticket.updated_on) + + + # NOTE: This implies that ticket should be a full object with methods. + # Starting to move fields out to their own methods, to eventually move to + # their own Ticket class. def get_field(self, ticket, fieldname): - try: + try: match fieldname: case "id": return f"{ticket.id}" @@ -667,27 +714,27 @@ def get_field(self, ticket, fieldname): return ticket.subject case "title": return ticket.title - case "age": - updated = dt.datetime.fromisoformat(ticket.updated_on) ### UTC - age = dt.datetime.now(dt.timezone.utc) - updated - return humanize.naturaldelta(age) - case "sync": - try: - # Parse custom_field into datetime - # FIXME: this is fragile: relies on specific index of custom field, add custom field lookup by name - timestr = ticket.custom_fields[1].value - return dt.datetime.fromisoformat(timestr) ### UTC - except Exception as e: - log.debug(f"sync tag not set") - return None + #case "age": + # updated = dt.datetime.fromisoformat(ticket.updated_on) ### UTC + # age = dt.datetime.now(dt.timezone.utc) - updated + # return humanize.naturaldelta(age) + #case "sync": + # try: + # # Parse custom_field into datetime + # # FIXME: this is fragile: relies on specific index of custom field, add custom field lookup by name + # timestr = ticket.custom_fields[1].value + # return dt.datetime.fromisoformat(timestr) ### UTC + # except Exception as e: + # log.debug(f"sync tag not set") + # return None except AttributeError: return "" # or None? - + def get_discord_id(self, user): if user: for field in user.custom_fields: - if field.name == "Discord ID": - return field.value + if field.name == "Discord ID": + return field.value return None def is_user_or_group(self, user:str) -> bool: @@ -701,13 +748,13 @@ def is_user_or_group(self, user:str) -> bool: # python method sync? def reindex_users(self): # reset the indices - self.users = {} - self.user_ids = {} - self.user_emails = {} - self.discord_users = {} + self.users.clear() + self.user_ids.clear() + self.user_emails.clear() + self.discord_users.clear() # rebuild the indicies - response = self.query(f"/users.json?limit=1000") ## fixme max limit? paging? + response = self.query("/users.json?limit=1000") ## fixme max limit? paging? if response.users: for user in response.users: self.users[user.login] = user.id @@ -717,7 +764,7 @@ def reindex_users(self): discord_id = self.get_discord_id(user) if discord_id: self.discord_users[discord_id] = user.id - log.info(f"indexed {len(self.users)} users") + log.debug(f"indexed {len(self.users)} users") else: log.error(f"No users: {response}") @@ -727,10 +774,10 @@ def get_teams(self): def reindex_groups(self): # reset the indices - self.groups = {} + self.groups.clear() # rebuild the indicies - response = self.query(f"/groups.json?limit=1000") ## FIXME max limit? paging? + response = self.query("/groups.json?limit=1000") ## FIXME max limit? paging? for group in response.groups: self.groups[group.name] = group @@ -749,23 +796,17 @@ def is_user_in_team(self, username:str, teamname:str) -> bool: def reindex(self): + start = synctime.now() self.reindex_users() self.reindex_groups() + log.debug(f"reindex took {synctime.age(start)}") -def age(time:dt.datetime): - #updated = dt.datetime.fromisoformat(time).astimezone(dt.timezone.utc) - now = dt.datetime.now().astimezone(dt.timezone.utc) - - #log.debug(f"### now tz: {now.tzinfo}, time tz: {time.tzinfo}") - - age = now - time - return humanize.naturaldelta(age) if __name__ == '__main__': - # load credentials + # load credentials load_dotenv() # construct the client and run the email check client = Client() tickets = client.find_tickets() - client.format_report(tickets) \ No newline at end of file + client.format_report(tickets) diff --git a/requirements.txt b/requirements.txt index d3f2833..1646ac1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,38 @@ aiohttp==3.8.5 aiosignal==1.3.1 +astroid==3.0.3 async-timeout==4.0.3 attrs==23.1.0 +bcrypt==4.1.2 certifi==2023.7.22 +cffi==1.16.0 charset-normalizer==3.2.0 click==8.1.7 coverage==7.4.0 +cryptography==42.0.0 +dill==0.3.8 frozenlist==1.4.0 humanize==4.8.0 idna==3.4 IMAPClient==3.0.0 +install==1.3.5 +isort==5.13.2 markdown-it-py==3.0.0 +mccabe==0.7.0 mdurl==0.1.2 multidict==6.0.4 +platformdirs==4.2.0 +proton-client==0.5.1 py-cord==2.4.1 +pycparser==2.21 Pygments==2.16.1 +pylint==3.0.3 pynetbox==7.0.1 +pyOpenSSL==24.0.0 python-dotenv==1.0.0 +python-gnupg==0.5.2 requests==2.31.0 rich==13.6.0 +tomlkit==0.12.3 urllib3==2.0.4 yarl==1.9.2 diff --git a/synctime.py b/synctime.py new file mode 100644 index 0000000..081e048 --- /dev/null +++ b/synctime.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""A collection of utilities to help manage time""" + +import logging +import datetime as dt + +import humanize + + +log = logging.getLogger(__name__) + + +def now() -> dt.datetime: + return dt.datetime.now(dt.timezone.utc) + +def now_millis() -> int: + return int(now().timestamp()*1000.0) + +def parse_millis(timestamp:int) -> dt.datetime: + return dt.datetime.fromtimestamp(timestamp, dt.timezone.utc) + +def epoch_datetime() -> dt.datetime: + # discord API fails when using 0 as a timestamp, + # so generate one for "three yeas ago" + return now() - dt.timedelta(days=3*365) + +def parse_str(timestamp:str) -> dt.datetime: + if timestamp is not None and len(timestamp) > 0: + return dt.datetime.fromisoformat(timestamp) + else: + return None + +def age(time:dt.datetime) -> dt.timedelta: + return now() - time + +def age_str(time:dt.datetime) -> str: + return humanize.naturaldelta(age(time)) + + +class SyncRecord(): + """encapulates the record of the last ticket syncronization""" + def __init__(self, ticket_id: int, channel_id: int, last_sync: dt.datetime): + assert last_sync.tzinfo is dt.timezone.utc # make sure TZ is set and correct + assert last_sync.timestamp() > 0 + self.ticket_id = ticket_id + self.channel_id = channel_id + self.last_sync = last_sync + + + @classmethod + def from_token(cls, ticket_id: int, token: str): + """Parse a custom field token into a SyncRecord. + If the token is legacy, channel=0 is returned with legacy sync. + If the token is invalid, it's treated as empty and a new token is + returned + """ + if '|' in token: + # token format {channel_id}|{last_sync_ms}, where + # channel_id is the ID of the Discord Thread + # last_sync is the ms-since-utc-epoch sunce the last + parts = token.split('|') + try: + channel_id = int(parts[0]) + except ValueError: + log.exception(f"error parsing channel ID: {parts[0]}, from token: '{token}'") + channel_id = 0 + + last_sync = parse_str(parts[1]) + + return cls(ticket_id, channel_id, last_sync) + else: + # legacy token - assume UTC ZULU + last_sync = parse_str(token) + return cls(ticket_id, 0, last_sync) + + + def age(self) -> dt.timedelta: + age(self.last_sync) + + def token_str(self) -> str: + return f"{self.channel_id}|{self.last_sync}" + + def __str__(self) -> str: + return f"SYNC #{self.ticket_id} <-> {self.channel_id}, {age_str(self.last_sync)}" diff --git a/test_cog_scn.py b/test_cog_scn.py index 1067abd..f897d10 100755 --- a/test_cog_scn.py +++ b/test_cog_scn.py @@ -1,25 +1,17 @@ #!/usr/bin/env python3 +"""testing the SCN cog""" -import os +import asyncio import unittest import logging import discord -import asyncio from dotenv import load_dotenv -from typing import Any - -from redmine import Client from netbot import NetBot import test_utils -#logging.basicConfig(level=logging.INFO) -#logging.basicConfig(level=logging.DEBUG, -# format="{asctime} {levelname:<8s} {name:<16} {message}", style='{') -#logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO) -#logging.getLogger("asyncio").setLevel(logging.ERROR) log = logging.getLogger(__name__) @@ -30,25 +22,26 @@ @unittest.skipUnless(load_dotenv(), "ENV settings not available") class TestSCNCog(test_utils.BotTestCase): - + """testing scn cog""" + def setUp(self): super().setUp() self.bot = NetBot(self.redmine) self.bot.load_extension("cog_scn") self.cog = self.bot.cogs["SCNCog"] # Note class name, note filename. - - + + async def test_team_join_leave(self): test_team_name = "test-team" - + # create temp discord mapping with scn add ctx = self.build_context() await self.cog.add(ctx, self.user.login) # invoke cog to add uer - + # check add result #ctx.respond.assert_called_with( # f"Discord user: {self.discord_user} has been paired with redmine user: {self.user.login}") - + # reindex using cog ctx = self.build_context() await self.cog.reindex(ctx) # invoke cog to add uer @@ -57,7 +50,7 @@ async def test_team_join_leave(self): ctx.respond.assert_called_with("Rebuilt redmine indices.") self.assertIsNotNone(self.redmine.find_user(self.user.login)) self.assertIsNotNone(self.redmine.find_user(self.discord_user)) - + # join team users ctx = self.build_context() #member = unittest.mock.AsyncMock(discord.Member) # for forced use case @@ -68,11 +61,11 @@ async def test_team_join_leave(self): #ctx.respond.assert_called_with(f"Unknown team name: {test_team_name}") # unknown team response! ctx.respond.assert_called_with(f"**{self.discord_user}** has joined *{test_team_name}*") self.assertTrue(self.redmine.is_user_in_team(self.user.login, test_team_name), f"{self.user.login} not in team {test_team_name}") - + # confirm in team via cog teams response ctx = self.build_context() await self.cog.teams(ctx, test_team_name) - self.assertIn(self.fullName, str(ctx.respond.call_args)) + self.assertIn(self.full_name, str(ctx.respond.call_args)) # leave team users ctx = self.build_context() @@ -81,25 +74,30 @@ async def test_team_join_leave(self): # confirm via API and callback self.assertFalse(self.redmine.is_user_in_team(self.user.login, test_team_name), f"{self.user.login} *in* team {test_team_name}") ctx.respond.assert_called_with(f"**{self.discord_user}** has left *{test_team_name}*") - + # confirm not in team via cog teams response ctx = self.build_context() await self.cog.teams(ctx, test_team_name) - self.assertNotIn(self.fullName, str(ctx.respond.call_args)) - + self.assertNotIn(self.full_name, str(ctx.respond.call_args)) + async def test_thread_sync(self): test_ticket = 218 - + ctx = self.build_context() ctx.channel = unittest.mock.AsyncMock(discord.Thread) ctx.channel.name = f"Ticket #{test_ticket}" - ctx.channel.id = self.tag - + #ctx.channel.id = 4321 + await self.cog.sync(ctx) ctx.respond.assert_called_with(f"SYNC ticket {test_ticket} to thread: {ctx.channel.name} complete") # check for actual changes! updated timestamp! if __name__ == '__main__': - unittest.main() \ No newline at end of file + # when running this main, turn on DEBUG + logging.basicConfig(level=logging.DEBUG, format="{asctime} {levelname:<8s} {name:<16} {message}", style='{') + logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO) + logging.getLogger("asyncio").setLevel(logging.ERROR) + + unittest.main() diff --git a/test_cog_tickets.py b/test_cog_tickets.py index 2baf4c4..456cd1b 100755 --- a/test_cog_tickets.py +++ b/test_cog_tickets.py @@ -1,24 +1,15 @@ #!/usr/bin/env python3 +"""Test case for the TicketsCog""" import unittest import logging import re -import os +import discord from dotenv import load_dotenv -from typing import Any - -from redmine import Client from netbot import NetBot -import discord import test_utils -import datetime as dt - - -#logging.basicConfig(level=logging.DEBUG, format="{asctime} {levelname:<8s} {name:<16} {message}", style='{') -#logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO) -#logging.getLogger("asyncio").setLevel(logging.ERROR) log = logging.getLogger(__name__) @@ -26,24 +17,25 @@ @unittest.skipUnless(load_dotenv(), "ENV settings not available") class TestTicketsCog(test_utils.BotTestCase): - + """Test suite for TicketsCog""" + def setUp(self): super().setUp() self.bot = NetBot(self.redmine) self.bot.load_extension("cog_tickets") self.cog = self.bot.cogs["TicketsCog"] # Note class name, note filename. - - - def parse_markdown_link(self, text:str) -> (str, str): - regex = "^\[(\d+)\]\((.+)\)" + + + def parse_markdown_link(self, text:str) -> tuple[str, str]: + regex = r"^\[(\d+)\]\((.+)\)" m = re.match(regex, text) self.assertIsNotNone(m, f"could not find ticket number in response str: {text}") - + ticket_id = m.group(1) url = m.group(2) return ticket_id, url - - + + async def test_new_ticket(self): # create ticket with discord user, assert test_title = f"This is a test ticket {self.tag}" @@ -60,46 +52,46 @@ async def test_new_ticket(self): response_str = ctx.respond.call_args.args[0] self.assertIn(ticket_id, response_str) self.assertIn(url, response_str) - + # get the ticket using tag ctx = self.build_context() await self.cog.tickets(ctx, self.tag) response_str = ctx.respond.call_args.args[0] self.assertIn(ticket_id, response_str) self.assertIn(url, response_str) - + # assign the ticket ctx = self.build_context() await self.cog.ticket(ctx, ticket_id, "assign") response_str = ctx.respond.call_args.args[0] self.assertIn(ticket_id, response_str) self.assertIn(url, response_str) - self.assertIn(self.fullName, response_str) - + self.assertIn(self.full_name, response_str) + # "progress" the ticket, setting it in-progress and assigning it to "me" ctx = self.build_context() await self.cog.ticket(ctx, ticket_id, "progress") response_str = ctx.respond.call_args.args[0] self.assertIn(ticket_id, response_str) self.assertIn(url, response_str) - self.assertIn(self.fullName, response_str) - + self.assertIn(self.full_name, response_str) + # resolve the ticket ctx = self.build_context() await self.cog.ticket(ctx, ticket_id, "resolve") response_str = ctx.respond.call_args.args[0] self.assertIn(ticket_id, response_str) self.assertIn(url, response_str) - self.assertIn(self.fullName, response_str) - + self.assertIn(self.full_name, response_str) + # delete ticket with redmine api, assert self.redmine.remove_ticket(int(ticket_id)) # check that the ticket has been removed self.assertIsNone(self.redmine.get_ticket(int(ticket_id))) - # create thread/sync + # create thread/sync async def test_thread_sync(self): - timestamp = dt.datetime.now().astimezone(dt.timezone.utc).replace(microsecond=0) # strip microseconds + #timestamp = dt.datetime.now().astimezone(dt.timezone.utc).replace(microsecond=0) # strip microseconds # create a ticket and add a note subject = f"Test Thread Ticket {self.tag}" @@ -110,40 +102,45 @@ async def test_thread_sync(self): ticket = self.redmine.create_ticket(self.user, subject, text) self.redmine.append_message(ticket.id, self.user.login, note) - # thread the ticket using + # thread the ticket using ctx = self.build_context() ctx.channel = unittest.mock.AsyncMock(discord.TextChannel) ctx.channel.name = f"Test Channel {self.tag}" #ctx.channel.id = self.tag - + thread = unittest.mock.AsyncMock(discord.Thread) - thread.name = f"Ticket #{ticket.id}" - + thread.name = f"Ticket #{ticket.id}: {subject}" + member = unittest.mock.AsyncMock(discord.Member) member.name=self.discord_user - + message = unittest.mock.AsyncMock(discord.Message) message.channel = ctx.channel message.content = old_message message.author = member message.create_thread = unittest.mock.AsyncMock(return_value=thread) - + # TODO setup history with a message from the user - disabled while I work out the history mock. #thread.history = unittest.mock.AsyncMock(name="history") #thread.history.flatten = unittest.mock.AsyncMock(name="flatten", return_value=[message]) - + ctx.send = unittest.mock.AsyncMock(return_value=message) - + await self.cog.thread_ticket(ctx, ticket.id) - + response = ctx.send.call_args.args[0] thread_response = str(message.create_thread.call_args) # FIXME self.assertIn(str(ticket.id), response) self.assertIn(str(ticket.id), thread_response) - + self.assertIn(subject, thread_response) + # delete the ticket self.redmine.remove_ticket(ticket.id) if __name__ == '__main__': - unittest.main() \ No newline at end of file + logging.basicConfig(level=logging.DEBUG, format="{asctime} {levelname:<8s} {name:<16} {message}", style='{') + logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO) + logging.getLogger("asyncio").setLevel(logging.ERROR) + + unittest.main() diff --git a/test_imap.py b/test_imap.py index dcbc384..61b89a3 100755 --- a/test_imap.py +++ b/test_imap.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 +"""IMAP test cases""" import unittest import logging -import os, glob -import datetime as dt +import os +import glob from dotenv import load_dotenv @@ -12,50 +13,47 @@ import test_utils -#logging.basicConfig(level=logging.DEBUG) -#logging.basicConfig(level=logging.DEBUG, format="{asctime} {levelname:<8s} {name:<16} {message}", style='{') -#logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO) - log = logging.getLogger(__name__) @unittest.skipUnless(load_dotenv(), "ENV settings not available") class TestMessages(unittest.TestCase): - + """Test suite for IMAP functions""" + def setUp(self): self.redmine = redmine.Client() self.imap = imap.Client() - + def test_messages_stripping(self): - # open + # open for filename in glob.glob('test/*.eml'): with open(os.path.join(os.getcwd(), filename), 'rb') as file: message = self.imap.parse_message(file.read()) self.assertNotIn("------ Forwarded message ---------", message.note) self.assertNotIn("wrote:", message.note, f"Found 'wrote:' after processing {filename}") self.assertNotIn("https://voice.google.com", message.note) - + def test_google_stripping(self): with open("test/New text message from 5551212.eml", 'rb') as file: - message = self.imap.parse_message(file.read()) - self.assertNotIn("Forwarded message", message.note) - self.assertNotIn("https://voice.google.com", message.note) - self.assertNotIn("YOUR ACCOUNT", message.note) - self.assertNotIn("https://support.google.com/voice#topic=3D1707989", message.note) - self.assertNotIn("https://productforums.google.com/forum/#!forum/voice", message.note) - self.assertNotIn("This email was sent to you because you indicated that you'd like to receive", message.note) - self.assertNotIn("email notifications for text messages. If you don't want to receive such", message.note) - self.assertNotIn("emails in the future, please update your email notification settings", message.note) - self.assertNotIn("https://voice.google.com/settings#messaging", message.note) - self.assertNotIn("Google LLC", message.note) - self.assertNotIn("1600 Amphitheatre Pkwy", message.note) - self.assertNotIn("Mountain View CA 94043 USA", message.note) - + message = self.imap.parse_message(file.read()) + self.assertNotIn("Forwarded message", message.note) + self.assertNotIn("https://voice.google.com", message.note) + self.assertNotIn("YOUR ACCOUNT", message.note) + self.assertNotIn("https://support.google.com/voice#topic=3D1707989", message.note) + self.assertNotIn("https://productforums.google.com/forum/#!forum/voice", message.note) + self.assertNotIn("This email was sent to you because you indicated that you'd like to receive", message.note) + self.assertNotIn("email notifications for text messages. If you don't want to receive such", message.note) + self.assertNotIn("emails in the future, please update your email notification settings", message.note) + self.assertNotIn("https://voice.google.com/settings#messaging", message.note) + self.assertNotIn("Google LLC", message.note) + self.assertNotIn("1600 Amphitheatre Pkwy", message.note) + self.assertNotIn("Mountain View CA 94043 USA", message.note) + def test_email_address_parsing(self): from_address = "Fred Example " first, last, addr = self.imap.parse_email_address(from_address) - self.assertEqual(first, "Esther") - self.assertEqual(last, "Chae") + self.assertEqual(first, "Fred") + self.assertEqual(last, "Example") self.assertEqual(addr, "freddy@example.com") # disabled so I don't flood the system with files @@ -70,16 +68,16 @@ def test_more_recent_ticket(self): self.assertIsNotNone(ticket) #print(ticket) - def test_email_address_parsing(self): + def test_email_address_parsing2(self): addr = 'philion ' - + first, last, email = self.imap.parse_email_address(addr) self.assertEqual("philion", first) self.assertEqual("", last) self.assertEqual("philion@gmail.com", email) - + addr2 = 'Paul Philion ' - + first, last, email = self.imap.parse_email_address(addr2) self.assertEqual("Paul", first) self.assertEqual("Philion", last) @@ -88,25 +86,25 @@ def test_email_address_parsing(self): def test_new_account_from_email(self): test_email = "philion@acmerocket.com" - + user = self.redmine.find_user(test_email) log.info(f"found {user} for {test_email}") if user: self.redmine.remove_user(user.id) # remove the user, for testing self.redmine.reindex_users() log.info(f"removed user id={user.id} and reindexed for test") - - email = "test/message-190.eml" + + #email = "test/message-190.eml" with open("test/message-190.eml", 'rb') as file: message = self.imap.parse_message(file.read()) self.imap.handle_message("test", message) - + self.redmine.reindex_users() user = self.redmine.find_user(test_email) self.assertIsNotNone(user, f"Couldn't find user for {test_email}") self.assertEqual(test_email, user.mail) self.assertTrue(self.redmine.is_user_in_team(user.login, "users")) - + self.redmine.remove_user(user.id) # remove the user, for testing self.redmine.reindex_users() self.assertIsNone(self.redmine.find_user(test_email)) @@ -125,15 +123,18 @@ def test_subject_search(self): self.assertIsNotNone(tickets) self.assertEqual(1, len(tickets)) self.assertEqual(ticket.id, tickets[0].id) - + tickets = self.redmine.search_tickets(tag) self.assertIsNotNone(tickets) self.assertEqual(1, len(tickets)) self.assertEqual(ticket.id, tickets[0].id) - + # clean up self.redmine.remove_ticket(ticket.id) if __name__ == '__main__': - unittest.main() \ No newline at end of file + logging.basicConfig(level=logging.DEBUG, format="{asctime} {levelname:<8s} {name:<16} {message}", style='{') + logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO) + + unittest.main() diff --git a/test_netbot.py b/test_netbot.py index add06f5..728af16 100755 --- a/test_netbot.py +++ b/test_netbot.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 +"""NetBot Test Suite""" import unittest import logging import discord -import asyncio from dotenv import load_dotenv @@ -17,12 +17,13 @@ @unittest.skipUnless(load_dotenv(), "ENV settings not available") class TestNetbot(test_utils.BotTestCase): - + """NetBot Test Suite""" + def setUp(self): super().setUp() - netbot.setup_logging() + #netbot.setup_logging() self.bot = netbot.NetBot(self.redmine) - + ##- call setup_logging in a test, (just for cov) ##- and each of on_ready ##- comment out on_guild_join and on_thread_join (just info logs) @@ -30,40 +31,7 @@ def setUp(self): ##- test-call sync with a *new* ticket, not existing #- add test messages to thread.history for syncronize_ticket <<< !!! FIXME #- add trivial test for on_application_command_error - - """ - @unittest.skip # disabled, not needed - async def test_on_ready(self): - try: - await self.bot.on_ready() - # pass - except Exception as ex: - log.error(f"Exception: {ex}") - self.fail(f'Exception raised: {ex}') - - - @unittest.skip # On message is disabled - async def test_new_message_synced_thread(self): - test_ticket = 218 - note = f"This is a new note about ticket #{test_ticket} for test {self.tag}" - - # create temp discord mapping with scn add - ctx = self.build_context() - - message = unittest.mock.AsyncMock(discord.Message) - message.content = note - message.channel = unittest.mock.AsyncMock(discord.Thread) - message.channel.name = f"Ticket #{test_ticket}" - message.author = unittest.mock.AsyncMock(discord.Member) - message.author.name = self.discord_user - - await self.bot.on_message(message) - - # check result in redmine, last note on ticket 218. - ticket = self.redmine.get_ticket(218, include_journals=True) # get the notes - self.assertIsNotNone(ticket) - self.assertIn(note, ticket.journals[-1].notes) - """ + async def test_synchronize_ticket(self): # create a new ticket, identified by the tag, with a note @@ -71,42 +39,42 @@ async def test_synchronize_ticket(self): body = f"Body for test {self.tag} {unittest.TestCase.id(self)}" ticket = self.redmine.create_ticket(self.user, subject, body) self.redmine.append_message(ticket.id, self.user.login, body) # re-using tagged str - + # create mock message and thread message = unittest.mock.AsyncMock(discord.Message) message.content = f"This is a new note about ticket #{ticket.id} for test {self.tag}" message.author = unittest.mock.AsyncMock(discord.Member) message.author.name = self.discord_user - + thread = unittest.mock.AsyncMock(discord.Thread) thread.name = f"Ticket #{ticket.id}" # https://docs.python.org/3/library/unittest.mock-examples.html#mocking-asynchronous-iterators ### FIXME #thread.history = unittest.mock.AsyncMock(discord.iterators.HistoryIterator) #thread.history.__aiter__.return_value = [message, message] - + # synchronize! await self.bot.synchronize_ticket(ticket, thread) - + # assert method send called on mock thread, with the correct values self.assertIn(self.tag, thread.send.call_args.args[0]) - self.assertIn(self.fullName, thread.send.call_args.args[0]) + self.assertIn(self.full_name, thread.send.call_args.args[0]) self.assertIn(body, thread.send.call_args.args[0]) - + # get notes from redmine, assert tags in most recent check_ticket = self.redmine.get_ticket(ticket.id, include_journals=True) # get the notes - self.assertIsNotNone(ticket) + self.assertIsNotNone(check_ticket) #log.info(f"### ticket: {ticket}") #self.assertIn(body, ticket.journals[-1].notes) NOT until thread history is working - - + + async def test_on_application_command_error(self): ctx = self.build_context() error = discord.DiscordException("this is exception " + self.tag) await self.bot.on_application_command_error(ctx, error) self.assertIn(self.tag, ctx.respond.call_args.args[0]) - + if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() diff --git a/test_synctime.py b/test_synctime.py new file mode 100755 index 0000000..6b8bc19 --- /dev/null +++ b/test_synctime.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""unittest for time""" + +import unittest +import logging + +from dotenv import load_dotenv + +import redmine +import synctime +import test_utils + + + +log = logging.getLogger(__name__) + + +@unittest.skipUnless(load_dotenv(), "ENV settings not available") +class TestTime(unittest.TestCase): + """testing""" + def setUp(self): + self.redmine = redmine.Client() + + def test_redmine_times(self): + #start = synctime.now() + + # create a new ticket with unique subject + tag = test_utils.tagstr() + user = self.redmine.find_user("philion") # FIXME: create a relaible test_user + self.assertIsNotNone(user) + subject = f"TEST ticket {tag}" + ticket = self.redmine.create_ticket(user, subject, f"This for {self.id}-{tag}") + updated = self.redmine.get_updated_field(ticket) + + test_channel = 4321 + sync_rec = self.redmine.get_sync_record(ticket, expected_channel=test_channel) + self.assertIsNotNone(sync_rec) + self.assertEqual(sync_rec.ticket_id, ticket.id) + self.assertEqual(sync_rec.channel_id, test_channel) + + #### NOTE to morning self: catch 42 with get_sync_record returning None or a valid new erc with the wrong channel. + #### FIX IN MORNING. + + # refetch ticket + ticket2 = self.redmine.get_ticket(ticket.id) + sync_rec2 = self.redmine.get_sync_record(ticket2, expected_channel=1111) # NOT the test_channel + log.info(f"ticket updated={updated}, {synctime.age(updated)} ago, sync: {sync_rec}") + + self.assertIsNone(sync_rec2) + + # clean up + self.redmine.remove_ticket(ticket.id) + + + +if __name__ == '__main__': + logging.basicConfig(level=logging.DEBUG, format="{asctime} {levelname:<8s} {name:<16} {message}", style='{') + logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO) + + unittest.main() diff --git a/test_utils.py b/test_utils.py index b404ae4..bc508ce 100644 --- a/test_utils.py +++ b/test_utils.py @@ -1,16 +1,19 @@ #test utils +"""Utilities to help testing""" import time import logging import unittest +from unittest import mock + import discord from discord import ApplicationContext -from unittest import mock from redmine import Client -from netbot import NetBot + log = logging.getLogger(__name__) + # from https://github.com/tonyseek/python-base36/blob/master/base36.py def dumps(num:int)-> str: """dump an in as a base36 lower-case string""" @@ -35,29 +38,30 @@ def tagstr() -> str: return dumps(int(time.time())) def create_test_user(redmine:Client, tag:str): - # create new test user name: test-12345@example.com, login test-12345 + # create new test user name: test-12345@example.com, login test-12345 first = "test-" + tag last = "Testy" #fullname = f"{first} {last}" ### <-- email = first + "@example.com" - + # create new redmine user, using redmine api user = redmine.create_user(email, first, last) - - # create temp discord mapping with redmine api, assert + + # create temp discord mapping with redmine api, assert discord_user = "discord-" + tag ### <-- redmine.create_discord_mapping(user.login, discord_user) - + # reindex users and lookup based on login redmine.reindex_users() return redmine.find_user(user.login) class BotTestCase(unittest.IsolatedAsyncioTestCase): + """Abstract base class for testing Bot features""" redmine = None usertag = None user = None - + @classmethod def setUpClass(cls): log.info("Setting up test fixtures") @@ -71,25 +75,25 @@ def setUpClass(cls): def tearDownClass(cls): log.info(f"Tearing down test fixtures: {cls.user}") cls.redmine.remove_user(cls.user.id) - - + + def build_context(self) -> ApplicationContext: ctx = mock.AsyncMock(ApplicationContext) ctx.user = mock.AsyncMock(discord.Member) ctx.user.name = self.discord_user log.debug(f"created ctx with {self.discord_user}: {ctx}") return ctx - - + + def setUp(self): self.tag = self.__class__.usertag # TODO just rename usertag to tag - represents the suite run self.assertIsNotNone(self.tag) self.assertIsNotNone(self.user) - - self.fullName = self.user.firstname + " " + self.user.lastname + + self.full_name = self.user.firstname + " " + self.user.lastname self.discord_user = self.redmine.get_discord_id(self.user) self.assertIsNotNone(self.redmine.find_user(self.user.login)) self.assertIsNotNone(self.redmine.find_user(self.discord_user)) - + log.debug(f"setUp user {self.user.login} {self.discord_user}") diff --git a/threader.py b/threader.py index 66328e7..39673da 100755 --- a/threader.py +++ b/threader.py @@ -1,14 +1,12 @@ #!/usr/bin/env python3 - +"""email threading module""" import logging -from pathlib import Path -import datetime as dt from dotenv import load_dotenv import imap # configure logging -logging.basicConfig(level=logging.INFO, +logging.basicConfig(level=logging.INFO, format="{asctime} {levelname:<8s} {name:<16} {message}", style='{') logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR) logging.getLogger("asyncio").setLevel(logging.ERROR) @@ -18,8 +16,8 @@ def main(): - log.info(f"starting threader") - # load credentials + log.info("starting threader") + # load credentials load_dotenv() # load some threading services @@ -33,4 +31,4 @@ def main(): if __name__ == '__main__': - main() \ No newline at end of file + main()