import re from typing import List, Tuple, Type, Set, Dict from mautrix.types import (UserID, RoomID, EventType, TextMessageEventContent, MessageType, Format, ReactionEvent) from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper from maubot import Plugin, MessageEvent from maubot.handlers import command class Config(BaseProxyConfig): def do_update(self, helper: ConfigUpdateHelper) -> None: helper.copy('prefix') helper.copy('url') helper.copy('user') helper.copy('pass') helper.copy('whitelist') helper.copy('usermap') helper.copy('filter_properties') helper.copy('filter_entry') class RT(Plugin): prefix: str whitelist: Set[UserID] usermap: dict api: str login: dict headers = {'User-agent': 'maubot-rt'} regex_ticket = re.compile(r'(?:(?:[rR][tT]#?))([0-9]+)') regex_number = re.compile(r'[0-9]+') regex_properties = re.compile(r'([a-zA-z]+): (.+)') regex_history = re.compile(r'([0-9]+): (.+)') regex_entry = re.compile(r'([a-zA-z]+): (.+(?:\n {8}.*)*)', re.MULTILINE) regex_assigner = re.compile(r'^.*?>(@.+:.+)<.*? assigned') take_this = f'(\U0001F44D this to take the ticket)' interesting = [ 'Ticket created', 'Correspondence added', 'Comments added', ] async def start(self) -> None: self.on_external_config_update() def on_external_config_update(self) -> None: self.config.load_and_update() self.prefix = self.config['prefix'] self.whitelist = set(self.config['whitelist']) self.usermap = self.config['usermap'] self.url = self.config['url'] self.rest = f'{self.url}/REST/1.0/' self.display = f'{self.url}/Ticket/Display.html' self.login = {'user': self.config['user'], 'pass': self.config['pass']} self.filter_properties = set(self.config['filter_properties']) self.filter_entry = set(self.config['filter_entry']) @classmethod def get_config_class(cls) -> Type[BaseProxyConfig]: return Config def can_manage(self, evt: MessageEvent) -> bool: return True if evt.sender in self.whitelist else False def map_user(self, username: str) -> str: return self.usermap[username] if username in self.usermap else username def valid_number(self, number: str) -> bool: return True if self.regex_number.match(number) else False def filter_dict(self, raw: dict, keys: Set) -> dict: return {k: v for k, v in raw.items() if k in keys} def markdown_link(self, number: str) -> str: return f'[rt#{number}]({self.display}?id={number})' def html_link(self, number: str) -> str: return f'<a href="{self.display}?id={number}">rt#{number}</a>' async def _member_mxids(self, room_id: RoomID) -> Dict[UserID, str]: room_members = await self.client.get_joined_members(room_id) member_mxids = {} for mxid in room_members.keys(): displayname = await self._displayname(room_id, mxid) member_mxids[displayname] = mxid return member_mxids async def _displayname(self, room_id: RoomID, user_id: UserID) -> str: event = await self.client.get_state_event(room_id, EventType.ROOM_MEMBER, user_id) return event.displayname async def _properties(self, number: str) -> dict: await self.http.post(self.rest, data=self.login, headers=self.headers) rest = f'{self.rest}ticket/{number}/show' async with self.http.get(rest, headers=self.headers) as response: content = await response.text() raw = dict(self.regex_properties.findall(content)) return self.filter_dict(raw, self.filter_properties) async def _edit(self, number: str, properties: dict) -> None: rest = f'{self.rest}ticket/{number}/edit' content = {'content': '\n'.join([f'{k}: {v}' for k, v in properties.items()])} data = {**self.login, **content} await self.http.post(rest, data=data, headers=self.headers) async def _comment(self, number: str, action: str, text: str) -> None: rest = f'{self.rest}ticket/{number}/comment' multiline_text = text.replace('\n', '\n ') content = {'content': f'id: {number}\nAction: {action}\nText: {multiline_text}'} data = {**self.login, **content} await self.http.post(rest, data=data, headers=self.headers) async def _history(self, number: str) -> dict: await self.http.post(self.rest, data=self.login, headers=self.headers) rest = f'{self.rest}ticket/{number}/history' async with self.http.get(rest, headers=self.headers) as response: content = await response.text() return dict(self.regex_history.findall(content)) async def _entry(self, number: str, entry: str) -> dict: await self.http.post(self.rest, data=self.login, headers=self.headers) rest = f'{self.rest}ticket/{number}/history/id/{entry}' async with self.http.get(rest, headers=self.headers) as response: content = await response.text() raw = dict(self.regex_entry.findall(content)) entry = self.filter_dict(raw, self.filter_entry) if 'Content' in entry and '\n' in entry['Content']: block = ' \n```\n' + entry['Content'].replace('\n' + ' ' * 9, '\n').rstrip() + '\n```' entry['Content'] = block return entry async def _search(self, params: dict) -> dict: rest = f'{self.rest}search/ticket' async with self.http.get(rest, headers=self.headers, params=params) as response: content = await response.text() return dict(self.regex_history.findall(content)) @command.passive('((^| )([rR][tT]#?))([0-9]+)', multiple=True) async def handler(self, evt: MessageEvent, subs: List[Tuple[str, str]]) -> None: await evt.mark_read() msg_lines = [] await self.http.post(self.rest, data=self.login, headers=self.headers) for sub in subs: number = sub[4] rest = f'{self.rest}ticket/{number}/show' async with self.http.get(rest, headers=self.headers) as response: content = await response.text() ticket = dict(self.regex_properties.findall(content)) markdown = '{} is **{}** in **{}** from {} \n{}'.format( self.markdown_link(number), ticket['Status'], ticket['Queue'], ticket['Creator'], ticket['Subject'] ) msg_lines.append(markdown) if msg_lines: if len(msg_lines) == 1: msg_lines += [self.take_this] await evt.respond('\n\n'.join(msg_lines)) @command.passive(regex=r"(?:\U0001F44D[\U0001F3FB-\U0001F3FF]?)", field=lambda evt: evt.content.relates_to.key, event_type=EventType.REACTION, msgtypes=None) async def react_took(self, evt: ReactionEvent, _: Tuple[str]) -> None: username = evt.sender[1:].split(':')[0] displayname = await self._displayname(evt.room_id, evt.sender) target_evt = await self.client.get_event(evt.room_id, evt.content.relates_to.event_id) target_ticket = self.regex_ticket.findall(target_evt.content.body) if len(target_ticket) == 1: number = target_ticket[0] await self._edit(number, {'Owner': self.map_user(username)}) content = TextMessageEventContent( msgtype=MessageType.NOTICE, format=Format.HTML, body=f'{displayname} took {number}', formatted_body=f'<a href="https://matrix.to/#/{evt.sender}">{evt.sender}</a> ' f'took <code>{number}</code>') await self.client.send_message(evt.room_id, content) @command.passive(regex=r"(?:\U0001F595[\U0001F3FB-\U0001F3FF]?)", field=lambda evt: evt.content.relates_to.key, event_type=EventType.REACTION, msgtypes=None) async def react_reject(self, evt: ReactionEvent, _: Tuple[str]) -> None: displayname = await self._displayname(evt.room_id, evt.sender) target_evt = await self.client.get_event(evt.room_id, evt.content.relates_to.event_id) target_ticket = self.regex_ticket.findall(target_evt.content.body) target_mxid = self.regex_assigner.findall(target_evt.content.formatted_body)[0] target_username = target_mxid[1:].split(':')[0] target_displayname = await self._displayname(evt.room_id, target_mxid) if len(target_ticket) == 1: number = target_ticket[0] await self._edit(number, {'Owner': self.map_user(target_username)}) content = TextMessageEventContent( msgtype=MessageType.NOTICE, format=Format.HTML, body=f'{displayname} politely rejected {number} and gave it back to ' f'{target_displayname}', formatted_body=f'<a href="https://matrix.to/#/{evt.sender}">{evt.sender}</a> ' f'politely rejected <code>{number}</code> and gave it back to ' f'<a href="https://matrix.to/#/{target_mxid}">{target_mxid}</a>') await self.client.send_message(evt.room_id, content) @command.new(name=lambda self: self.prefix, help='Manage RT tickets', require_subcommand=True) async def rt(self) -> None: pass @rt.subcommand('properties', aliases=('p', 'prop'), help='Show all ticket properties.') @command.argument('number', 'ticket number', parser=str) async def properties(self, evt: MessageEvent, number: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() properties_dict = await self._properties(number) properties = ' \n'.join([f'{k}: {v}' for k, v in properties_dict.items()]) await evt.respond(f'{self.markdown_link(number)} properties: \n{properties}' f' \n{self.take_this}') @rt.subcommand('resolve', aliases=('r', 'res'), help='Mark the ticket as resolved.') @command.argument('number', 'ticket number', parser=str) async def resolve(self, evt: MessageEvent, number: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() await self._edit(number, {'Status': 'resolved'}) await evt.respond(f'{self.markdown_link(number)} resolved 😃 {self.take_this}') @rt.subcommand('open', aliases=('o', 'op'), help='Mark the ticket as open.') @command.argument('number', 'ticket number', parser=str) async def open(self, evt: MessageEvent, number: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() await self._edit(number, {'Status': 'open'}) await evt.respond(f'{self.markdown_link(number)} opened 😐️ {self.take_this}') @rt.subcommand('stall', aliases=('st', 'sta'), help='Mark the ticket as stalled.') @command.argument('number', 'ticket number', parser=str) async def stall(self, evt: MessageEvent, number: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() await self._edit(number, {'Status': 'stalled'}) await evt.respond(f'{self.markdown_link(number)} stalled 😴 {self.take_this}') @rt.subcommand('delete', aliases=('d', 'del'), help='Mark the ticket as deleted.') @command.argument('number', 'ticket number', parser=str) async def delete(self, evt: MessageEvent, number: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() await self._edit(number, {'Status': 'deleted'}) await evt.respond(f'{self.markdown_link(number)} deleted 🤬 {self.take_this}') @rt.subcommand('queue', aliases=('q', 'que'), help='Put the ticket in queue.') @command.argument('number', 'ticket number', parser=str) @command.argument('qid', 'queue id', parser=str) async def queue(self, evt: MessageEvent, number: str, qid: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() await self._edit(number, {'Status': 'open', 'Queue': qid}) await evt.respond(f'{self.markdown_link(number)} queued in **{qid}** 😐️ {self.take_this}') @rt.subcommand('comment', aliases=('c', 'com'), help='Add a comment.') @command.argument('number', 'ticket number', parser=str) @command.argument('text', 'comment text', pass_raw=True) async def comment(self, evt: MessageEvent, number: str, text: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() await self._comment(number, 'comment', text) displayname = await self._displayname(evt.room_id, evt.sender) content = TextMessageEventContent( msgtype=MessageType.NOTICE, format=Format.HTML, body=f'{displayname} commented 🤓 on {number} {self.take_this}', formatted_body=f'<a href="https://matrix.to/#/{evt.sender}">{evt.sender}</a> ' f'commented 🤓 on <code>{number}</code> {self.take_this}') await evt.respond(content) @rt.subcommand('reply', aliases=('re', 'rep'), help='Reply to requestor(s).') @command.argument('number', 'ticket number', parser=str) @command.argument('text', 'reply text', pass_raw=True) async def reply(self, evt: MessageEvent, number: str, text: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() await self._comment(number, 'correspond', text) displayname = await self._displayname(evt.room_id, evt.sender) content = TextMessageEventContent( msgtype=MessageType.NOTICE, format=Format.HTML, body=f'{displayname} replied 📨 to {number} {self.take_this}', formatted_body=f'<a href="https://matrix.to/#/{evt.sender}">{evt.sender}</a> ' f'replied 📨 to <code>{number}</code> {self.take_this}') await evt.respond(content) @rt.subcommand('history', aliases=('h', 'hist'), help='Get a list of all history entries.') @command.argument('number', 'ticket number', parser=str) async def history(self, evt: MessageEvent, number: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() history_dict = await self._history(number) history = ' \n'.join([f'{k}: {v}' for k, v in history_dict.items()]) await evt.respond(f'{self.markdown_link(number)} history entries: \n{history}') @rt.subcommand('entry', aliases=('e', 'ent'), help='Gets a single history entry.') @command.argument('number', 'ticket number', parser=str) @command.argument('entryid', 'entry number', parser=str) async def entry(self, evt: MessageEvent, number: str, entryid: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() entry_dict = await self._entry(number, entryid) entry = ' \n'.join([f'{k}: {v}' for k, v in entry_dict.items()]) await evt.respond(f'{self.markdown_link(number)} history entry {entryid}: \n{entry}' f' \n{self.take_this}') @rt.subcommand('last', aliases=('l', 'la'), help='Gets the last entry.') @command.argument('number', 'ticket number', parser=str) async def last(self, evt: MessageEvent, number: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() history = await self._history(number) mails = {k: v for k, v in history.items() if any(i in v for i in self.interesting)} entryid = max(mails, key=int) entry_dict = await self._entry(number, entryid) entry = ' \n'.join([f'{k}: {v}' for k, v in entry_dict.items()]) await evt.respond(f'{self.markdown_link(number)} history entry {entryid}: \n{entry}' f' \n{self.take_this}') @rt.subcommand('show', aliases=('s', 'sh'), help='Show all information about the ticket.') @command.argument('number', 'ticket number', parser=str) async def show(self, evt: MessageEvent, number: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() prop_dict = await self._properties(number) props = ' \n'.join([f'{k}: {v}' for k, v in prop_dict.items()]) await evt.respond(f'{self.markdown_link(number)} properties: \n{props}') history = await self._history(number) for entryid, entry_text in history.items(): if any(i in entry_text for i in self.interesting + ['Requestor']): if 'Requestor' in entry_text: await evt.respond(f'history entry {entryid}: {entry_text}') continue entry_dict = await self._entry(number, entryid) entry = ' \n'.join([f'{k}: {v}' for k, v in entry_dict.items()]) await evt.respond(f'history entry {entryid}: \n{entry}') @rt.subcommand('take', aliases=('t', 'ta', 'steal'), help='Take or steal the ticket.') @command.argument('number', 'ticket number', parser=str) async def take(self, evt: MessageEvent, number: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() displayname = await self._displayname(evt.room_id, evt.sender) username = evt.sender[1:].split(':')[0] await self._edit(number, {'Owner': self.map_user(username)}) content = TextMessageEventContent( msgtype=MessageType.NOTICE, format=Format.HTML, body=f'{displayname} took rt#{number} 👍️', formatted_body=f'<a href="https://matrix.to/#/{evt.sender}">{evt.sender}</a> ' f'took {self.html_link(number)} 👍️') await evt.respond(content) @rt.subcommand('give', aliases=('g', 'gi', 'assign'), help='Give the ticket to somebody.') @command.argument('number', 'ticket number', parser=str) @command.argument('user', 'matrix user', parser=str) async def give(self, evt: MessageEvent, number: str, user: str) -> None: if not self.can_manage(evt) or not self.valid_number(number): return await evt.mark_read() member_mxids = await self._member_mxids(evt.room_id) if user[0] == '@': if ':' in user: user = {v: k for k, v in member_mxids.items()}[user] else: user = user[1:] if user not in member_mxids.keys() and user not in member_mxids.values(): await evt.respond(f'hmm... **{user}** is not the in room 🤔') return displayname = await self._displayname(evt.room_id, evt.sender) target_mxid = member_mxids[user] target_username = target_mxid[1:].split(':')[0] await self._edit(number, {'Owner': self.map_user(target_username)}) react = f'(\U0001F44D to accept, \U0001F595 to reject)' content = TextMessageEventContent( msgtype=MessageType.NOTICE, format=Format.HTML, body=f'{displayname} assigned rt#{number} to {user} 😜 {react}', formatted_body=f'<a href="https://matrix.to/#/{evt.sender}">{evt.sender}</a> ' f'assigned {self.html_link(number)} to ' f'<a href="https://matrix.to/#/{target_mxid}">{target_mxid}</a> 😜 {react}') await evt.respond(content) @rt.subcommand('new', aliases=('n', 'new'), help='List all unowned new/open tickets.') async def new(self, evt: MessageEvent) -> None: if not self.can_manage(evt): return await evt.mark_read() params = {'query': 'Owner = "Nobody" AND ( Status = "new" OR Status = "open" )'} tickets_dict = await self._search(params) links = {k: f'[{v}]({self.display}?id={k})' for k, v in tickets_dict.items()} tickets = ' \n'.join([f'`{k}`: {links[k]}' for k, v in tickets_dict.items()]) if tickets: await evt.respond(f'Unowned open tickets: \n{tickets}') else: await evt.respond('All done ✅') @rt.subcommand('mine', aliases=('m', 'my'), help='List all your open tickets.') async def mine(self, evt: MessageEvent) -> None: if not self.can_manage(evt): return await evt.mark_read() displayname = await self._displayname(evt.room_id, evt.sender) username = evt.sender[1:].split(':')[0] mapped_username = self.map_user(username) params = {'query': f'Owner = "{mapped_username}" AND ( Status = "new" OR Status = "open" )'} tickets_dict = await self._search(params) links = {k: f'<a href="{self.display}?id={k}">{v}</a>' for k, v in tickets_dict.items()} if tickets_dict: body = '\n'.join([f'{k}: {v}' for k, v in tickets_dict.items()]) fbody = '<br/>'.join([f'<code>{k}</code>: {links[k]}' for k, v in tickets_dict.items()]) content = TextMessageEventContent( msgtype=MessageType.NOTICE, format=Format.HTML, body=f'Open tickets for {displayname}:\n{body}', formatted_body=f'Open tickets for <a href="https://matrix.to/#/{evt.sender}">' f'{evt.sender}</a>:<br/>{fbody}') await evt.respond(content) else: await evt.respond('All done 🤙') @rt.subcommand('unsolved', aliases=('u', 'un'), help='List all open tickets.') async def unsolved(self, evt: MessageEvent) -> None: if not self.can_manage(evt): return await evt.mark_read() params = {'query': f'Status = "new" OR Status = "open"'} tickets_dict = await self._search(params) links = {k: f'<a href="{self.display}?id={k}">{v}</a>' for k, v in tickets_dict.items()} if tickets_dict: body = '\n'.join([f'{k}: {v}' for k, v in tickets_dict.items()]) fbody = '<br/>'.join([f'<code>{k}</code>: {links[k]}' for k, v in tickets_dict.items()]) content = TextMessageEventContent( msgtype=MessageType.NOTICE, format=Format.HTML, body=f'Open tickets:\n{body}', formatted_body=f'Open tickets:<br/>{fbody}') await evt.respond(content) else: await evt.respond('All done ✅') @rt.subcommand('autoresolve', help='Ask the bot to automatically answer and resolve tickets.') async def autoresolve(self, evt: MessageEvent) -> None: if not self.can_manage(evt): return await evt.mark_read() await evt.react('😂🤣🦄🌈')