diff options
Diffstat (limited to 'geldschieberbot.py')
| -rw-r--r-- | geldschieberbot.py | 770 |
1 files changed, 442 insertions, 328 deletions
diff --git a/geldschieberbot.py b/geldschieberbot.py index 43dfce4..39352d2 100644 --- a/geldschieberbot.py +++ b/geldschieberbot.py @@ -3,11 +3,12 @@ import argparse from datetime import date, datetime, timedelta -from dataclasses import dataclass +from dataclasses import dataclass, field import json import os import subprocess import sys +import typing as T # Path where our data is stored persistent on disk STATE_FILE = os.environ["GSB_STATE_FILE"] @@ -19,13 +20,60 @@ GROUP_SEND_CMD = SEND_CMD + GROUP_ID @dataclass +class MessageContext: + """Class representing the context of a message passed to a command function""" + sender_number: str + sender: T.Optional[str] + args: list[str] + body: list[str] + timestamp: str + + +@dataclass +class Modification: + """Class representing a single modification to the balance + + Amount is transfered from donor to the recipient. + """ + recipient: str + donor: str + amount: int + + def in_string(self) -> str: + """Format the change using the recipient as initiator""" + return f'{self.recipient} {"->" if self.amount < 0 else "<-"} {to_euro(abs(self.amount))} {self.donor}' + + def out_string(self) -> str: + """Format the change using the donor as initiator""" + return f'{self.donor} {"->" if self.amount < 0 else "<-"} {to_euro(abs(self.amount))} {self.recipient}' + + +@dataclass +class Change: + """Class representing a change to the state caused by a single command""" + cmd: list[str] + modifications: list[Modification] + timestamp: str + rewind_cmds: list[list[str]] = field(default_factory=lambda: []) + + +@dataclass class Quote: """Class representing a message to quote""" timestamp: str author: str -def to_cent(euro): +class GeldschieberbotJSONEncoder(json.JSONEncoder): + """Custom JSONEncoder supporting our dataclasses""" + + def default(self, o): + if isinstance(o, (Modification, Change)): + return o.__dict__ + return json.JSONEncoder.default(self, o) + + +def to_cent(euro) -> int: """Parse string containing euros into a cent value""" if '.' in euro: euro = euro.split('.') @@ -47,7 +95,7 @@ def to_cent(euro): return amount -def to_euro(cents): +def to_euro(cents) -> str: """Format cents as euro""" return f"{cents/100:.2f}" @@ -55,54 +103,121 @@ def to_euro(cents): class Geldschieberbot: """ State of the geldschieberbot + + The state is stored in a central dict for convenient writing to and + reading from disk. + + The state contains: + * balance - dict of dicts associating two persons to an amount + * name2num, num2name - dicts associating numbers to names and vice versa + * cars - dict associating car names to their service charge + * scheduled_cmds - dict associating names to cmds, their schedule, + and their last execution + * changes - dict associating users with their changes + * aliases - list of names mapping to multiple other names + + The central component of the geldschieberbot's state is the balance dictionary + assigning a cent amount to each pair of participants (following A and B). + The tracked amount represents the money the first component has to provide + to the second to even out the balance. + If money is transferred from A to B it is expected from B to give the same amount + of money back to A. + Moving money from A to B is tracked in both balances A->B and B->A. + The amount is subtracted from the A->B balance and added to the B->A balance + meaning that B has to give A money to even out the balance. + """ + STATE_KEYS = ("balance", "name2num", "num2name", "available_cars", + "scheduled_cmds", "changes", "aliases") + def load_state(self, state_path=STATE_FILE): """Load state from disk""" if os.path.isfile(state_path): with open(state_path, 'r', encoding='utf-8') as state_f: self.state = json.load(state_f) else: - # Dict containing the whole state of geldschieberbot - # balance - dict of dicts associating two persons to an amount - # name2num, num2name - dicts associating numbers to names and vice versa - # cars - dict associating car names to their service charge - # scheduled_cmds - dict associating names to cmds, their schedule, and the last execution - # changes - dict associating users with their changes - self.state = { - "balance": {}, - "name2num": {}, - "num2name": {}, - "cars": {}, - "scheduled_cmds": {}, - "changes": {}, - "aliases": {}, + self.state = {key: {} for key in self.STATE_KEYS} + + try: + # decode JSON changes + self.state['changes'] = { + name: [ + Change(ch['cmd'], [ + Modification(*mod.values()) + for mod in ch['modifications'] + ], ch['timestamp'], ch['rewind_cmds']) for ch in changes + ] + for name, changes in self.state['changes'].items() } + except (KeyError, TypeError): + # convert from old plain changes format + if isinstance(list(self.state['changes'].values())[0], list): + self.state['changes'] = { + name: [ + Change(ch[0], + [Modification(r, d, a) + for r, d, a in ch[1:]], None) for ch in changes + ] + for name, changes in self.state['changes'].items() + } + + for key in self.STATE_KEYS: + # add missing keys to an existsing state + setattr(self, key, self.state.setdefault(key, {})) + # Do this simply to allow pylint to lint the Geldschieberbot and + # prevent false positive 'no-member' errors. self.balance = self.state["balance"] self.name2num = self.state["name2num"] self.num2name = self.state["num2name"] - self.available_cars = self.state["cars"] + # Workaround old states using the cars key instead of available_cars + if 'cars' in self.state: + all_cars = self.state['cars'] + del self.state['cars'] + all_cars.update(self.state['available_cars']) + self.state['available_cars'] = all_cars + self.available_cars = self.state["available_cars"] self.scheduled_cmds = self.state["scheduled_cmds"] self.changes = self.state["changes"] - self.aliases = self.state.setdefault("aliases", {}) + self.aliases = self.state["aliases"] def save_state(self, state_path=STATE_FILE): """Load state from disk""" - with open(state_path, 'w', encoding='utf-8') as f: - json.dump(self.state, f) + with open(state_path, 'w', encoding='utf-8') as state_file: + json.dump(self.state, state_file, cls=GeldschieberbotJSONEncoder) - def record(self, recipient, donor, amount): + def record(self, recipient: str, donor: str, + amount: int) -> T.Optional[Modification]: """Apply changes to the balance""" + return self.apply(Modification(recipient, donor, amount)) + + def apply(self, mod: Modification) -> T.Optional[Modification]: + """Apply a single modification to the balance""" + # Only change anything if this is not a dry run if self.dry_run: - return + return None + + self.balance[mod.donor][mod.recipient] += mod.amount + self.balance[mod.recipient][mod.donor] -= mod.amount + + return mod - self.balance[donor][recipient] += amount - self.balance[recipient][donor] -= amount + def reverse(self, mod: Modification) -> Modification: + """Reverse the effect of a single modification to the balance""" - def send(self, msg, attachment=None, cmd=SEND_CMD, quote: Quote = None): + self.balance[mod.donor][mod.recipient] -= mod.amount + self.balance[mod.recipient][mod.donor] += mod.amount + + return Modification(mod.donor, mod.recipient, mod.amount) + + def send(self, + msg, + attachment=None, + cmd=SEND_CMD, + quote: T.Optional[Quote] = None): """Send a message with optional attachment""" if not self.quiet: if attachment: @@ -163,24 +278,24 @@ class Geldschieberbot: def create_members(self) -> str: """Create a list of all group members""" - r = "" - for m in self.name2num: - r += m + ": " + self.name2num[m] + "\n" - return r + out = "" + for member in self.name2num: + out += f'{member}: {self.name2num[member]}\n' + return out - def add_to_balance(self, name): + def add_to_balance(self, name: str): """Add a new user balance""" - nb = {} - for m in self.balance: - self.balance[m][name] = 0 - nb[m] = 0 - self.balance[name] = nb + new_balance = {} + for member in self.balance: + self.balance[member][name] = 0 + new_balance[member] = 0 + self.balance[name] = new_balance - def remove_from_balance(self, name): + def remove_from_balance(self, name: str): """Remove a user balance""" del self.balance[name] - for m in self.balance: - del self.balance[m][name] + for member in self.balance: + del self.balance[member][name] def expand_aliases(self, users: list[str], @@ -211,6 +326,7 @@ class Geldschieberbot: @classmethod def create_help(cls) -> str: + """Return a help message explaining how to use geldschieberbot""" return """ Usage: send a message starting with '!' followed by a command Commands: @@ -256,11 +372,11 @@ class Geldschieberbot: Happy Geldschieben! """ - def register(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + def register(self, msg: MessageContext) -> dict[str, str]: """Register a new user""" - if len(args) != 2: - return {'err': f'not in form "{args[0]} name"'} - name = args[1] + if len(msg.args) != 2: + return {'err': f'not in form "{msg.args[0]} name"'} + name = msg.args[1] try: to_cent(name) @@ -271,11 +387,11 @@ class Geldschieberbot: if name in self.name2num: return {'err': f'{name} already registered'} - if sender in self.num2name: - return {'err': 'you are already registered'} + if msg.sender: + return {'err': f'you are already registered as {msg.sender}'} - self.num2name[sender] = name - self.name2num[name] = sender + self.num2name[msg.sender_number] = name + self.name2num[name] = msg.sender_number self.add_to_balance(name) @@ -283,61 +399,60 @@ class Geldschieberbot: self.changes[name] = [] return {'msg': f'Happy geldschiebing {name}!'} - def summary(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + def summary(self, msg: MessageContext) -> dict[str, str]: """Print summary for one or multiple balances""" - if len(args) == 1: - if not sender in self.num2name: + if len(msg.args) == 1: + if not msg.sender: return {'err': 'You must register first to print your summary'} - name = self.num2name[sender] - return {'msg': f'Summary:\n{self.create_summary(name)}'} + return {'msg': f'Summary:\n{self.create_summary(msg.sender)}'} - msg = "Summary:\n" - for name in self.expand_aliases(args[1:]): + out = "Summary:\n" + for name in self.expand_aliases(msg.args[1:]): if name in self.name2num or name in self.available_cars: - msg += self.create_summary(name) + "\n" + out += self.create_summary(name) + "\n" else: return {'err': f'name "{name}" not registered'} - return {'msg': msg} + return {'msg': out} - def full_summary(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + def full_summary(self, msg: MessageContext) -> dict[str, str]: """Print a summary of all balances""" - if len(args) == 1: + if len(msg.args) == 1: return {'msg': self.create_total_summary()} - return {'err': f'{args[0][1:]} takes no arguments'} + return {'err': f'{msg.args[0][1:]} takes no arguments'} - def list_users(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + def list_users(self, msg: MessageContext) -> dict[str, str]: # pylint: disable=unused-argument """List all registered users""" return {'msg': self.create_members()} @classmethod - def usage(cls, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + def usage(cls, msg: MessageContext) -> dict[str, str]: # pylint: disable=unused-argument """Return the usage""" return {'msg': cls.create_help()} - def split(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + def split(self, msg: MessageContext) -> dict[str, str]: """Split a fixed amount across multiple persons""" - if not sender in self.num2name: + if not msg.sender: return {'err': 'you must register first'} - if len(args) < 3: - return {'err': f'not in form "{args[0]} amount [name]+"'} + if len(msg.args) < 3: + return {'err': f'not in form "{msg.args[0]} amount [name]+"'} try: - amount = to_cent(args[1]) - persons = args[2:] + amount = to_cent(msg.args[1]) + persons = msg.args[2:] except (ValueError, TypeError): # support !split name amount - if len(args) == 3: + if len(msg.args) == 3: try: - amount = to_cent(args[2]) - persons = [args[1]] + amount = to_cent(msg.args[2]) + persons = [msg.args[1]] except (ValueError, TypeError): return {'err': 'amount must be a positive number'} else: return {'err': 'amount must be a positive number'} - recipient = self.num2name[sender] + recipient = msg.sender # exclude the implicit recipient from alias expension persons = self.expand_aliases(persons, exclude_users=[recipient]) # persons + sender @@ -345,54 +460,80 @@ class Geldschieberbot: amount_per_person = int(amount / npersons) output = f"Split {to_euro(amount)} between {npersons} -> {to_euro(amount_per_person)} each\n" - change = [args] - for p in persons: - if p in self.name2num: - if p == recipient: - output += (f'{p}, you will be charged multiple times. ' - 'This may not be what you want\n') + modifications: list[Modification] = [] + for person in persons: + if person in self.name2num: + if person == recipient: + output += ( + f'{person}, you will be charged multiple times. ' + 'This may not be what you want\n') else: - self.record(recipient, p, amount_per_person) - change.append([recipient, p, amount_per_person]) + modification = self.record(recipient, person, + amount_per_person) + if modification: + modifications.append(modification) else: - output += f"{p} not known. Please take care manually\n" + output += f"{person} not known. Please take care manually\n" - self.may_record_change(recipient, change) + self.may_record_change(recipient, + Change(msg.args, modifications, msg.timestamp)) output += "New Balance:\n" output += self.create_summary(recipient) return {'msg': output} - def transaction(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + def _transaction(self, initiator: str, recipients: list[str], + amount: int) -> tuple[str, list[Modification]]: """Record a transaction""" - if len(args) < 3: + transaction_sum = '' + + modifications: list[Modification] = [] + for recipient in recipients: + modification = self.record(initiator, recipient, amount) + if modification: + modifications.append(modification) + + p_balance = self.balance[initiator][recipient] + + transaction_sum += f'{"->" if amount > 0 else "<-"} {recipient} {to_euro(abs(amount))}\n' + + output = '' + if len(recipients) > 1: + output += transaction_sum + output += 'New Balance:\n' + output += self.create_summary(initiator, recipients) + else: + output = f'New Balance: {initiator} {"->" if p_balance > 0 else "<-"} {to_euro(abs(p_balance))} {recipients[0]}\n' + return output, modifications + + def transaction(self, msg: MessageContext) -> dict[str, str]: + """Record a transaction received in a message""" + if len(msg.args) < 3: return { 'err': - f'not in form "{args[0]} amount recipient [recipient ...]"' + f'not in form "{msg.args[0]} amount recipient [recipient ...]"' } - if not sender in self.balance: - if sender not in self.num2name: - return {'err': 'you must register first'} - sender = self.num2name[sender] + if not msg.sender: + return {'err': 'you must register first'} - if len(args) == 3: - if args[1] in self.balance or args[1] in self.aliases: - recipient, amount = args[1:3] - elif args[2] in self.balance or args[2] in self.aliases: - amount, recipient = args[1:3] + if len(msg.args) == 3: + if msg.args[1] in self.balance or msg.args[1] in self.aliases: + recipient, _amount = msg.args[1:3] + elif msg.args[2] in self.balance or msg.args[2] in self.aliases: + _amount, recipient = msg.args[1:3] else: return {'err': 'recipient not known'} recipients = self.expand_aliases([recipient]) else: - amount, recipients = args[1], args[2:] + _amount, recipients = msg.args[1], msg.args[2:] - if sender in recipients: + if msg.sender in recipients: return {'err': 'you can not transfer money to or from yourself'} try: - amount = to_cent(amount) + amount = to_cent(_amount) except (ValueError, TypeError): return {'err': 'amount must be a positive number'} @@ -400,109 +541,76 @@ class Geldschieberbot: if recipient not in self.balance: return {'err': f'recipient "{recipient}" not known'} - if args[0] in ["!zieh", "!nimm"]: + if msg.args[0] in ["!zieh", "!nimm"]: amount *= -1 - transaction_sum = '' - - change = [args] - for recipient in recipients: - change.append([sender, recipient, amount]) - self.record(sender, recipient, amount) + output, modifications = self._transaction(msg.sender, recipients, + amount) + self.may_record_change(msg.sender, + Change(msg.args, modifications, msg.timestamp)) + return {'msg': output} - p_balance = self.balance[sender][recipient] + def _transfer(self, sender: str, source: str, destination: str, + amount: int) -> tuple[str, list[Modification]]: + """Transfer amount from one balance to another""" + # Sender <- X Source + output, modifications = self._transaction(sender, [source], -amount) - transaction_sum += f'{"->" if amount > 0 else "<-"} {recipient} {to_euro(abs(amount))}\n' + # Sender -> X Destination + out, modification = self._transaction(sender, [destination], amount) + output += out + modifications += modification - self.may_record_change(sender, change) + # Destination -> X Source + out, modification = self._transaction(source, [destination], -amount) + output += out + modifications += modification - output = '' - if len(recipients) > 1: - output += transaction_sum - output += 'New Balance:\n' - output += self.create_summary(sender, recipients) - else: - output = f'New Balance: {sender} {"->" if p_balance > 0 else "<-"} {to_euro(abs(p_balance))} {recipient}\n' - return {'msg': f'{output}'} + return output, modifications - def transfer(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument - """Transfer amount from one balance to another""" - if len(args) < 4: + def transfer(self, msg: MessageContext) -> dict[str, str]: + """Message handler wrapping the transfer function""" + if len(msg.args) < 4: return { - 'err': f'not in form "{args[0]} amount source destination"' + 'err': f'not in form "{msg.args[0]} amount source destination"' } - if not sender in self.num2name: + if not msg.sender: return {'err': 'you must register first'} - sender = self.num2name[sender] - try: - amount_raw = args[1] + amount_raw = msg.args[1] amount_cent = to_cent(amount_raw) except (ValueError, TypeError): return {'err': 'amount must be a positive number'} - source, destination = args[2:4] + source, destination = msg.args[2:4] if source not in self.balance: return {'err': f'source "{source}" not known'} if destination not in self.balance: return {'err': f'destination "{destination}" not known'} - output = "" - saved_record_changes = self.disable_record_changes() - change = [args] - - ret = self.transaction(sender, ["!zieh", source, amount_raw], "") - if 'err' in ret: - # No changes yet we can fail - return {'err': ret['err']} - - output += ret['msg'] - # Sender <- X Source - change.append((sender, source, -amount_cent)) - - ret = self.transaction(sender, ["!schieb", destination, amount_raw], - "") - err = ret.get('err', None) - if err: - output += err + "\nThe balance may be in a inconsistent state please take care manually" - return {'msg': output} + out, modifications = self._transfer(msg.sender, source, destination, + amount_cent) + self.may_record_change(msg.sender, + Change(msg.args, modifications, msg.timestamp)) + return {'msg': out} - output += ret['msg'] - # Sender -> X Destination - change.append((sender, destination, amount_cent)) - - ret = self.transaction(source, ["!zieh", destination, amount_raw], "") - err = ret.get('err', None) - if err: - output += err + "\nThe balance may be in a inconsistent state please take care manually" - return {'msg': output} - - output += ret['msg'] - # Destination -> X Source - change.append((destination, source, amount_cent)) - - self.restore_record_changes(saved_record_changes) - self.may_record_change(sender, change) - - return {'msg': output} - - def cars(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + def cars(self, msg: MessageContext) -> dict[str, str]: """Manage available cars List, add, remove or pay a bill for a car. """ # list cars - if len(args) < 2 or args[1] in ["ls", "list"]: + if len(msg.args) < 2 or msg.args[1] in ["ls", "list"]: if len(self.available_cars) == 0: return {'msg': 'No cars registered yet.'} ret_msg = "" - if len(args) > 2: - cars_to_list = args[2:] + if len(msg.args) > 2: + cars_to_list = msg.args[2:] else: cars_to_list = self.available_cars @@ -516,14 +624,14 @@ class Geldschieberbot: return {'msg': ret_msg[:-1]} # add car - if args[1] in ["add", "new"]: - if len(args) < 4: + if msg.args[1] in ["add", "new"]: + if len(msg.args) < 4: return { 'err': - f'not in form "{args[0]} {args[1]} car-name service-charge"' + f'not in form "{msg.args[0]} {msg.args[1]} car-name service-charge"' } - car = args[2] + car = msg.args[2] if car in self.available_cars: return {'err': f'"{car}" already registered'} @@ -534,7 +642,7 @@ class Geldschieberbot: } try: - service_charge = to_cent(args[3]) + service_charge = to_cent(msg.args[3]) except (ValueError, TypeError): return {'err': 'service-charge must be a positive number'} @@ -543,11 +651,14 @@ class Geldschieberbot: return {'msg': f'added "{car}" as an available car'} # remove car - if args[1] in ["rm", "remove"]: - if len(args) < 3: - return {'err': f'not in form "{args[0]} {args[1]} car-name"'} + if msg.args[1] in ["rm", "remove"]: + if len(msg.args) < 3: + return { + 'err': + f'not in form "{msg.args[0]} {msg.args[1]} car-name"' + } - car = args[2] + car = msg.args[2] if car not in self.available_cars: return {'err': f'A car with the name "{car}" does not exists'} @@ -556,32 +667,29 @@ class Geldschieberbot: return {'msg': f'removed "{car}" from the available cars'} # pay bill - if args[1] in ["pay"]: - if len(args) < 4: + if msg.args[1] in ["pay"]: + if len(msg.args) < 4: return { - 'err': f'not in form "{args[0]} {args[1]} car-name amount"' + 'err': + f'not in form "{msg.args[0]} {msg.args[1]} car-name amount"' } - if not sender in self.num2name: + if not msg.sender: return {'err': 'you must register first'} - sender_name = self.num2name[sender] - - car = args[2] + car = msg.args[2] + # print(self.state, self.available_cars) if car not in self.available_cars: return {'err': f'car "{car}" not known'} try: - amount = to_cent(args[3]) + amount = to_cent(msg.args[3]) amount_euro = to_euro(amount) except (ValueError, TypeError): return {'err': 'amount must be a positive number'} output = "" - saved_record_changes = self.disable_record_changes() - change = [args] - total_available_charge = 0 available_charges = [] for person in self.balance[car]: @@ -590,41 +698,37 @@ class Geldschieberbot: total_available_charge -= _amount available_charges.append((person, _amount)) - proportion = -1 + proportion = -1.0 if amount < total_available_charge: proportion = -1 * (amount / total_available_charge) - ret = self.transaction(sender, f'!gib {car} {amount_euro}'.split(), - '') - assert 'err' not in ret - output += f"{sender_name} payed {amount_euro}\n" + _, modifications = self._transaction(msg.sender, [car], amount) + output += f"{msg.sender} payed {amount_euro}\n" # transfer money output += f"Transferring {proportion * -100:.2f}% of everybody's charges\n" for person, _amount in available_charges: - if person == sender_name or _amount >= 0: + if person == msg.sender or _amount >= 0: continue to_move = int(_amount * proportion) to_move_euro = to_euro(to_move) - ret = self.transfer(sender, - ['transfer', to_move_euro, car, person], - '') - assert 'err' not in ret + _, modification = self._transfer(msg.sender, car, person, + to_move) + modifications += modification - output += "Transfer {} from {} to {}\n".format( - to_move_euro, person, sender_name) + output += f'Transfer {to_move_euro} from {person} to {msg.sender}\n' output += "New Balances:\n" - output += self.create_summary(sender_name) + "\n" + output += self.create_summary(msg.sender) + "\n" output += self.create_summary(car) - self.restore_record_changes(saved_record_changes) - self.may_record_change(sender_name, change) + self.may_record_change( + msg.sender, Change(msg.args, modifications, msg.timestamp)) return {'msg': output} - return {'err': f'unknown car subcommand "{args[1]}".'} + return {'err': f'unknown car subcommand "{msg.args[1]}".'} def parse_tank_bill(self, _drives: list[str], fuel_charge: int, service_charge: int): @@ -670,70 +774,73 @@ class Geldschieberbot: return passengers, None - def tanken(self, sender, args, msg) -> dict[str, str]: + def tanken(self, msg: MessageContext) -> dict[str, str]: """Split a tank across all passengers""" - if len(args) < 2: + if len(msg.args) < 2: return { - 'err': f'not in form "{args[0]} amount [person] [car] [info]"' + 'err': + f'not in form "{msg.args[0]} amount [person] [car] [info]"' } try: - amount = to_cent(args[1]) + amount = to_cent(msg.args[1]) except (ValueError, TypeError): return {'err': 'amount must be a number'} # find recipient - if len(args) > 2 and args[2] in self.name2num: - recipient = args[2] - elif sender in self.num2name: - recipient = self.num2name[sender] + if len(msg.args) > 2 and msg.args[2] in self.name2num: + recipient = msg.args[2] + elif msg.sender: + recipient = msg.sender else: return {'err': 'recipient unknown'} # find car car = None - if len(args) > 2 and args[2] in self.available_cars: - car = args[2] - elif len(args) > 3 and args[3] in self.available_cars: - car = args[3] + if len(msg.args) > 2 and msg.args[2] in self.available_cars: + car = msg.args[2] + elif len(msg.args) > 3 and msg.args[3] in self.available_cars: + car = msg.args[3] service_charge = self.available_cars.get(car, 0) - parts, err = self.parse_tank_bill(msg[1:], amount, service_charge) + parts, err = self.parse_tank_bill(msg.body[1:], amount, service_charge) if err: return {'err': err} assert parts output = "" - change = [args] + modifications: list[Modification] = [] for pname, values in parts.items(): - output += pname + ": {}km = fuel: {}, service charge: {}\n".format( - values["distance"], to_euro(values["cost"]), - to_euro(values["service_charge"])) + output += f'{pname}: {values["distance"]}km = fuel: {to_euro(values["cost"])}, service charge: {to_euro(values["service_charge"])}\n' # record service charges if pname not in self.name2num: - output += pname + " not known." + output += f'{pname} not known.' if car: person_to_charge = pname if pname not in self.name2num: person_to_charge = recipient output += f" {recipient} held accountable for service charge." - self.record(car, person_to_charge, values["service_charge"]) - change.append( - [car, person_to_charge, values["service_charge"]]) + modification = self.record(car, person_to_charge, + values["service_charge"]) + if modification: + modifications.append(modification) # recipient paid the fuel -> don't charge them if pname == recipient: continue if pname in self.name2num: - self.record(recipient, pname, values["cost"]) - change.append([recipient, pname, values["cost"]]) + modification = self.record(recipient, pname, values["cost"]) + if modification: + modifications.append(modification) else: output += " Please collect fuel cost manually\n" - self.may_record_change(self.num2name[sender], change) + if msg.sender: + self.may_record_change( + msg.sender, Change(msg.args, modifications, msg.timestamp)) output += "New Balance:\n" output += self.create_summary(recipient) @@ -742,21 +849,21 @@ class Geldschieberbot: output += self.create_summary(car) return {'msg': output} - def fuck(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + def fuck(self, msg: MessageContext) -> dict[str, str]: """Rewind past changes""" - if not sender in self.num2name: + if not msg.sender: return {'err': 'you must register first'} - name = self.num2name[sender] + name = msg.sender nchanges = len(self.changes[name]) if nchanges == 0: return {'msg': 'Nothing to rewind'} change_to_rewind = -1 - if len(args) >= 2: + if len(msg.args) >= 2: try: - change_to_rewind = int(args[1]) - 1 + change_to_rewind = int(msg.args[1]) - 1 except ValueError: return {'err': 'change to rewind must be a number'} @@ -766,54 +873,47 @@ class Geldschieberbot: } # pop last item - last_changes = self.changes[name].pop(change_to_rewind) - args, last_changes = last_changes[0], last_changes[1:] + change = self.changes[name].pop(change_to_rewind) output = name + ": sorry I fucked up!\nRewinding:\n" - output += ' '.join(args) + "\n" - for change in last_changes: - if not change[0] in self.cmds: - output += "{} {} {} {}\n".format( - change[0], ("->" if change[2] < 0 else "<-"), - to_euro(abs(change[2])), change[1]) - self.record(change[1], change[0], change[2]) - - for change in last_changes: - if change[0] in self.cmds: - ret = self.cmds[change[0]](sender, change, "") - - if 'err' in ret: - output += "ERROR: " + ret['err'] - else: - output += ret['msg'] + output += ' '.join(change.cmd) + "\n" + for mod in change.modifications: + output += f'{self.reverse(mod).out_string()}\n' + + for cmd_args in change.rewind_cmds: + ret = self.cmds[cmd_args[0]](MessageContext( + msg.sender_number, msg.sender, cmd_args, [''.join(cmd_args)], + msg.timestamp)) + if 'err' in ret: + output += "ERROR: " + ret['err'] + else: + output += ret['msg'] return {'msg': output} - def list_changes(self, sender, args, msg) -> dict[str, str]: + def list_changes(self, msg: MessageContext) -> dict[str, str]: """List changes made by the sender""" - if not sender in self.num2name: + if not msg.sender: return {'err': 'you must register first'} - sender_name = self.num2name[sender] - changes_to_list = 5 - if len(args) >= 2: + if len(msg.args) >= 2: try: - changes_to_list = int(args[1]) + changes_to_list = int(msg.args[1]) except ValueError: return { 'err': 'the amount of changes to list must be a number' } - nchanges = len(self.changes[sender_name]) + nchanges = len(self.changes[msg.sender]) if nchanges == 0: return {'msg': 'Nothing to list'} first_to_list = max(nchanges - changes_to_list, 0) - if len(args) == 3: + if len(msg.args) == 3: try: - first_to_list = int(args[2]) - 1 + first_to_list = int(msg.args[2]) - 1 except ValueError: return {'err': 'the first change to list must be a number'} @@ -823,9 +923,9 @@ class Geldschieberbot: 'the first change to list is bigger than there are changes' } - msg = "" + out = "" i = 0 - for i, change in enumerate(self.changes[sender_name]): + for i, change in enumerate(self.changes[msg.sender]): if i < first_to_list: continue @@ -835,39 +935,43 @@ class Geldschieberbot: i -= 1 break - msg += f'Change {i + 1}:\n' - msg += f'\t{" ".join(change[0])}\n' - for sender, recipient, amount in change[1:]: - msg += "\t{} {} {} {}\n".format(sender, - ("->" if amount < 0 else "<-"), - to_euro(abs(amount)), - recipient) + out += f'Change {i + 1}:\n' + out += f'\t{" ".join(change.cmd)}\n' + for mod in change.modifications: + out += f'\t{mod.in_string()}\n' # prepend message header because we want to know how much changes we actually listed - msg = f'Changes from {sender_name} {first_to_list + 1}-{i + 1}\n' + msg + out = f'Changes from {msg.sender} {first_to_list + 1}-{i + 1}\n' + out - return {'msg': msg} + return {'msg': out} - def export_state(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + @classmethod + def export_state(cls, msg: MessageContext) -> dict[str, str]: """Send the state file as attachment""" - if not sender in self.num2name: + if not msg.sender: return {'err': 'you must register first'} - msg = f'State from {datetime.now().date().isoformat()}' - return {'msg': msg, 'attachment': STATE_FILE} + out = f'State from {datetime.now().date().isoformat()}' + return {'msg': out, 'attachment': STATE_FILE} - def schedule(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + def schedule(self, msg: MessageContext) -> dict[str, str]: """Schedule a command for periodic execution""" - if not sender in self.num2name: + if not msg.sender: return {'err': 'you must register first'} - sender_name = self.num2name[sender] + if len(msg.args) < 3: + return {'err': f'not in form "{msg.args[0]} name cmd"'} - if len(args) < 3: - return {'err': f'not in form "{args[0]} name cmd"'} + name = msg.args[1] + cmd = msg.args[2:] - name = args[1] - cmd = args[2:] + if not cmd[0] in self.cmds: + return { + 'err': + f'the command "{name}" can not be registered because "{cmd[0]}" is unknown' + } + initial_commad_msg = MessageContext(msg.sender_number, msg.sender, cmd, + [], msg.timestamp) if name in self.scheduled_cmds: return { @@ -876,76 +980,82 @@ class Geldschieberbot: # Test the command saved_dry_run = self.enable_dry_run() - ret = self.cmds[cmd[0]](sender, cmd, '') + ret = self.cmds[cmd[0]](initial_commad_msg) self.restore_dry_run(saved_dry_run) if 'err' in ret: - return {'err': 'the command "{}" failed and will not be recorded'} + return { + 'err': + f'the command "{name}" failed with "{ret["err"]}" and will not be recorded' + } scheduled_cmd = { - "schedule": args[0][1:], + "schedule": msg.args[0][1:], "last_time": None, - "sender": sender, + "sender": msg.sender, "cmd": cmd } self.scheduled_cmds[name] = scheduled_cmd - output = 'Recorded the {} command "{}" as "{}"\n'.format( - args[0][1:], ' '.join(cmd), name) + output = f'Recorded the {msg.args[0][1:]} command "{" ".join(cmd)}" as "{name}"\n' + output += f'Running {scheduled_cmd["schedule"]} command {name} for {msg.sender} initially\n' - output += "Running {} command {} for {} initially\n".format( - scheduled_cmd["schedule"], name, sender_name) - - ret = self.cmds[cmd[0]](sender, cmd, "") + previous_change_count = len(self.changes[msg.sender]) + ret = self.cmds[cmd[0]](initial_commad_msg) if 'err' in ret: output += 'ERROR: ' + ret['err'] else: output += ret['msg'] - self.changes[sender_name][0].append(["cancel", name]) + # The executed command did not create a new Change + # -> create a dummy change to cancel the scheduled command + if previous_change_count == len(self.changes[msg.sender]): + self.changes[msg.sender].append(Change(cmd, [], msg.timestamp)) + self.changes[msg.sender][-1].rewind_cmds.append(["cancel", name]) now = datetime.now().date() scheduled_cmd["last_time"] = now.isoformat() return {'msg': output} - def cancel(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + def cancel(self, msg: MessageContext) -> dict[str, str]: """Cancel a previously scheduled command""" - cmd_name = args[1] + cmd_name = msg.args[1] if not cmd_name in self.scheduled_cmds: return {'err': f'"{cmd_name}" is not a scheduled command'} cmd = self.scheduled_cmds[cmd_name] - if not cmd["sender"] == sender: + if not cmd["sender"] == msg.sender: return {'err': 'only the original creator can cancel this command'} del self.scheduled_cmds[cmd_name] return {'msg': f'Cancelled the {cmd["schedule"]} cmd "{cmd_name}"'} - def thanks(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + @classmethod + def thanks(cls, msg: MessageContext) -> dict[str, str]: """Thank geldschieberbot for its loyal service""" - sender_name = self.num2name.get(sender, sender) - msg = f'You are welcome. It is a pleasure to work with you, {sender_name}.' - nick = None if len(args) == 1 else args[1] + sender_name = msg.sender or msg.sender_number + out = f'You are welcome. It is a pleasure to work with you, {sender_name}.' + nick = None if len(msg.args) == 1 else msg.args[1] if nick: - msg = f"{msg}\nBut don't call me {nick}." + out = f"{out}\nBut don't call me {nick}." - return {'msg': msg} + return {'msg': out} - def alias(self, sender, args, msg) -> dict[str, str]: # pylint: disable=unused-argument + def alias(self, msg: MessageContext) -> dict[str, str]: """List or create aliases""" - if len(args) == 1: - msg = '' + if len(msg.args) == 1: + out = '' for alias, users in self.aliases.items(): - msg += f'\n\t{alias}: {" ".join(users)}' - return {'msg': f'Aliases:\n\tall: {" ".join(self.name2num)}{msg}'} + out += f'\n\t{alias}: {" ".join(users)}' + return {'msg': f'Aliases:\n\tall: {" ".join(self.name2num)}{out}'} - if len(args) == 2: + if len(msg.args) == 2: return {'err': 'Not a valid alias command'} - if args[1] == 'remove': - alias = args[2] + if msg.args[1] == 'remove': + alias = msg.args[2] if alias not in self.aliases: return {'err': f'Alias "{alias}" not registered'} @@ -953,7 +1063,7 @@ class Geldschieberbot: return {'msg': f'Alias "{alias}" removed'} # create a new alias - alias = args[1] + alias = msg.args[1] if alias in self.aliases or alias == 'all': return {'err': f'Alias "{alias}" is already registered'} @@ -966,7 +1076,7 @@ class Geldschieberbot: except (ValueError, TypeError): pass - users = args[2:] + users = msg.args[2:] for user in users: if user not in self.name2num: return {'err': f'User {user} is not registered'} @@ -1039,7 +1149,7 @@ class Geldschieberbot: """Restore record change setting to old value""" self.record_changes = old_value - def may_record_change(self, user: str, change): + def may_record_change(self, user: str, change: Change): """Record a change for a user if change recording is enabled""" if self.record_changes and not self.dry_run: self.changes[user].append(change) @@ -1062,9 +1172,12 @@ class Geldschieberbot: quote = Quote(timestamp=message["timestamp"], author=sender_number) args = body[0].split(' ') + msg_context = MessageContext(sender_number, + self.num2name.get(sender_number, None), + args, body, message['timestamp']) cmd = args[0][1:] if cmd in self.cmds: - ret = self.cmds[cmd](sender_number, args, body) + ret = self.cmds[cmd](msg_context) if 'err' in ret: self.send(f'ERROR: {ret["err"]}') else: @@ -1085,9 +1198,10 @@ class Geldschieberbot: now = datetime.now().date() week_delta = timedelta(days=7) - for name, cmd in self.scheduled_cmds.items(): + for name, scheduled_cmd in self.scheduled_cmds.items(): - last_time = cmd["last_time"] + last_time, interval = scheduled_cmd["last_time"], scheduled_cmd[ + "schedule"] if hasattr(date, "fromisoformat"): last_time = date.fromisoformat(last_time) else: @@ -1095,9 +1209,9 @@ class Geldschieberbot: d = last_time while True: - if cmd["schedule"] == "yearly": + if interval == "yearly": d = date(d.year + 1, d.month, d.day) - elif cmd["schedule"] == "monthly": + elif interval == "monthly": if d.day > 28: d = date(d.year, d.month, 28) if d.month == 12: @@ -1108,21 +1222,21 @@ class Geldschieberbot: d = d + week_delta if d <= now: - self.send("Running {} command {} for {} triggered on {}\n". - format(cmd["schedule"], - name, self.num2name[cmd["sender"]], - d.isoformat())) - - ret = self.cmds[cmd["cmd"][0]](cmd["sender"], cmd["cmd"], - "") + cmd_args, runas = scheduled_cmd['cmd'], scheduled_cmd[ + 'sender'] + out = f'Running {interval} command {name} for {runas} triggered on {d.isoformat()}' + # TODO: use d as timestamp + cmd_ctx = MessageContext(self.name2num[runas], runas, + cmd_args, [], None) + ret = self.cmds[cmd_args[0]](cmd_ctx) if 'err' in ret: - self.send("ERROR: " + ret['err']) + self.send(f'{out}\nERROR: {ret["err"]}') else: - self.send(ret['msg'], + self.send(f'{out}\n{ret["msg"]}', attachment=ret.get('attachment', None)) - cmd["last_time"] = d.isoformat() + scheduled_cmd["last_time"] = d.isoformat() else: break @@ -1145,11 +1259,11 @@ def main(): bot = Geldschieberbot(dry_run=args.dry_run, quote_cmd=not args.no_quote) # Read cmds from stdin - for l in sys.stdin.read().splitlines(): + for line in sys.stdin.read().splitlines(): try: - message = json.loads(l)["envelope"] + message = json.loads(line)["envelope"] except json.JSONDecodeError: - print(datetime.now(), l, "not valid json") + print(datetime.now(), line, "not valid json") continue bot.handle(message) |
