diff options
| author | Florian Fischer <florian.fischer@muhq.space> | 2022-10-28 18:16:47 +0200 |
|---|---|---|
| committer | Florian Fischer <florian.fischer@muhq.space> | 2023-08-10 15:04:16 +0200 |
| commit | 26831dca32c471415b1afa470ac8dbfd1fd348a6 (patch) | |
| tree | d7ae2a62eedd2a2af2638c2475cc6af93ca4af51 /geldschieberbot.py | |
| parent | 2cf937b89e5f0bcd0b86dc75835e8a2315f01fd7 (diff) | |
| download | geldschieberbot-26831dca32c471415b1afa470ac8dbfd1fd348a6.tar.gz geldschieberbot-26831dca32c471415b1afa470ac8dbfd1fd348a6.zip | |
introduce dataclasses for the passed data
Refactor all implicit dictionaries into explicit data classes.
This makes the code more explicit, easier to check for mypy and overall
more maintainable.
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) |
