From ffe9bba96c5ae92a7aa91889ac4c794884177294 Mon Sep 17 00:00:00 2001 From: Florian Fischer Date: Tue, 17 Mar 2020 19:15:43 +0100 Subject: implement jsonrpc protocol and improve testing TODO: scheduled commands --- exceptions.py | 14 + geldschieberbot.py | 1298 ++++++++++++++++++++++++++-------------------------- test.py | 288 +++++++----- 3 files changed, 831 insertions(+), 769 deletions(-) create mode 100644 exceptions.py mode change 100644 => 100755 geldschieberbot.py diff --git a/exceptions.py b/exceptions.py new file mode 100644 index 0000000..ca68547 --- /dev/null +++ b/exceptions.py @@ -0,0 +1,14 @@ +class InvalidAmountError(ValueError): + '''Raise when some member is already registered''' + +class AlreadyRegisteredError(Exception): + '''Raise when some member is already registered''' + +class NotRegisteredError(Exception): + '''Raise when the sender is not registered''' + +class InvalidArgumentError(Exception): + '''Raise when some cmd is called with wrong arguments''' + +class NotAllowedError(Exception): + '''Raise when some member is already registered''' diff --git a/geldschieberbot.py b/geldschieberbot.py old mode 100644 new mode 100755 index c249dd2..617db51 --- a/geldschieberbot.py +++ b/geldschieberbot.py @@ -2,773 +2,763 @@ from datetime import date, datetime, timedelta import json +from jsonrpc import JSONRPCResponseManager, dispatcher import os -import subprocess +import re import sys +from exceptions import AlreadyRegisteredError, InvalidArgumentError, InvalidAmountError, NotRegisteredError, NotAllowedError import tanken -"""Path where our data is stored persistent on disk""" -state_file = os.environ["GSB_STATE_FILE"] - -if os.path.isfile(state_file): - state = json.load(open(state_file, "r")) -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""" - state = { - "balance" : {}, - "name2num" : {}, - "num2name" : {}, - "cars" : {}, - "scheduled_cmds" : {}, - "changes" : {}, - } - - # check if a legacy file layout is present - store_dir = os.path.dirname(state_file) - if os.path.isfile(os.path.join(store_dir, "balance.json")): - with open(os.path.join(store_dir, "balance.json"), "r") as f: - state["balance"] = json.load(f) - - with open(os.path.join(store_dir, "registration.json"), "r") as f: - state["name2num"] = json.load(f) - for name in state["name2num"]: - state["num2name"][state["name2num"][name]] = name - - with open(os.path.join(store_dir, "last_change.json"), "r") as f: - state["changes"] = json.load(f) - for num in state["changes"]: - name = state["num2name"][num] - state["changes"][name] = state["changes"][num] - del(state["changes"][num]) - -balance = state["balance"] -name2num = state["name2num"] -num2name = state["num2name"] -available_cars = state["cars"] -scheduled_cmds = state["scheduled_cmds"] -changes = state["changes"] - -group_id = os.environ["GSB_GROUP_ID"] - -send_cmd = os.environ["GSB_SEND_CMD"] -group_send_cmd = send_cmd + group_id - -"""Run without changing the stored state""" -dry_run = False - -"""Run without sending messages""" -quiet = False - -"""Should changes be recorded""" -record_changes = True - -def record(recipient, donor, amount): - """Apply changes to the balance""" - - # Only change anything if this is not a dry run - if not dry_run: - balance[donor][recipient] += amount - balance[recipient][donor] -= amount - +### HELPERS ### def to_cent(euro): - if '.' in euro: - euro = euro.split('.') - else: - euro = euro.split(',') - if len(euro) > 2: - raise TypeError - euro[0] = int(euro[0]) - if len(euro) < 2: - euro.append(0) - else: - if len(euro[1]) == 1: - euro[1] = int(euro[1]) * 10 - else: - euro[1] = int(euro[1]) - amount = euro[0] * 100 + euro[1] - if amount < 0: - raise ValueError - return amount + """Parse amount into cents""" + match = re.match('^(\d+)([,.](\d+))?$', euro) + if not match: + raise InvalidAmountError(f'"{euro}" not in form .') -def to_euro(cents): - return f"{cents/100:.2f}" + cents = int(match.group(1)) * 100 -def send(msg): - if not quiet: - subprocess.run(send_cmd.split(' '), input=msg.encode()) - -def create_summary(user): - summary = "" - cars_summary = "" - total = 0 - cars_total = 0 - p_balances = balance[user] # failes if user is not in balance - for person in p_balances: - amount = p_balances[person] - if amount == 0: - continue - if person in available_cars: - cars_total -= amount - cars_summary += f'\t{"<-" if amount < 0 else "->"} {person} {to_euro(abs(amount))}\n' + if match.group(3): + if len(match.group(3)) > 2: + raise InvalidAmountError(f'too precise ({match.group(0)} only two decimals are supported)') else: - total -= amount - summary += f'\t{"<-" if amount < 0 else "->"} {person} {to_euro(abs(amount))}\n' - - if not summary: - summary = "\tAll fine :)" - else: - summary += f"\tBalance: {to_euro(total)}" - - ret_summary = f'{user}:\n{summary}' - - if cars_summary: - cars_summary += f'\tLiability: {to_euro(cars_total)}' - ret_summary += f'\n\tCars:\n{cars_summary}' + if len(match.group(3)) == 1: + cents += int(match.group(3)) * 10 + else: + cents += int(match.group(3)) - return ret_summary + return cents -def create_total_summary(): - summary = "Summary:" +def to_euro(cents): + """Format cents to euros""" + return f"{cents/100:.2f}" - cars_summary = "" - for person in balance: - p_summary = create_summary(person) - if person in available_cars: - cars_summary += f'\n{p_summary}' - else: - summary += f'\n{p_summary}' - - if cars_summary: - summary += f'\nCars:{cars_summary}' - return summary - -def create_members(): - r = "" - for m in name2num: - r += m + ": " + name2num[m] + "\n" - return r - -def add_to_balance(name): - nb = {} - for m in balance: - balance[m][name] = 0 - nb[m] = 0 - balance[name] = nb - -def create_help(): - return """ -Usage: send a message starting with '!' followed by a command +USAGE =\ +"""Usage: send a message starting with '!' followed by a command Commands: -ls | list - print all registered members -help - print this help message -reg name - register the sender with the name: name -sum [name] - print a summary - -split amount person [persons] - split amount between the sender and persons -teil amount person [persons] - split amount between the sender and persons - -schieb amount recipient - give money to recipient -gib amount recipient - give money to recipient -zieh amount donor - get money from donor -nimm amount donor - get money from donor +ls | list + print all registered members +help + print this help message +reg name + register the sender with the name: name +sum [name] + print a summary + +split amount person [persons] + alias: teil + split amount between the sender and persons + +schieb amount recipient - + alias: gib + give money to recipient +zieh amount donor + alias: nimm + get money from donor + +transfer amount source destination + transfer amount of your balance from source to destination + +cars [cmd] + interact with the available cars +cars [list | ls] + list available cars and their service charge +cars add car-name service-charge + add new car +cars new car-name service-charge + add new car +cars pay car-name amount + pay a bill for the specified car + +tanken amount [person] [car] [info] + calculate fuel costs, service charge and add them to the person's and car's balance respectively + +fuck + rewind last change + +weekly name cmd + repeat cmd each week +monthly name cmd + repeat cmd each month +yearly name cmd + repeat cmd each year +cancel name + stop repeating cmd -transfer amount source destination - transfer amount of your balance from source to destination - -cars [cmd] - interact with the available cars -cars [list | ls] - list available cars and their service charge -cars add car-name service-charge - add new car -cars new car-name service-charge - add new car -cars pay car-name amount - pay a bill for the specified car - -tanken amount [person] [car] [info] - calculate fuel costs, service charge and add them to the person's and car's balance respectively +Happy Geldschieben! +""" -fuck - rewind last change +class Geldschieberbot: + """ Geldschieberbot state object -weekly name cmd - repeat cmd each week -monthly name cmd - repeat cmd each month -yearly name cmd - repeat cmd each year -cancel name - stop repeating cmd + Members + ------- + balance - dict of dicts associating two persons to an amount -Happy Geldschieben! -""" + name2num, num2name - dicts associating numbers to names and vice versa -cmds = {} + cars - dict associating car names to their service charge -def register(sender, args, msg): - if len(args) != 2: - return None, f'not in form "{args[0]} name"' - name = args[1] + scheduled_cmds - dict associating names to cmds, their schedule, and the last execution - if name in name2num: - return None, f"{name} already registered" + changes - dict associating users with their changes + """ + # Should changes be recorded + record_changes = True + + # Don't change the state + dry_run = False + + def get_state_path(path): + return path or os.environ.get("GSB_STATE_FILE", None) or "state.json" + + def __init__(self, path=None): + """Save state to disk""" + state_file = Geldschieberbot.get_state_path(path) + + self.cmds = { + "reg": self.register, + "register": self.register, + "sum": self.summary, + "summary": self.summary, + "ls": self.list_users, + "list": self.list_users, + "help": self.usage, + "usage": self.usage, + "split": self.split, + "teil": self.split, + "schieb": self.transaction, + "gib": self.transaction, + "zieh": self.transaction, + "nimm": self.transaction, + "transfer": self.transfer, + "cars": self._cars, + 'tanken': self._tanken, + 'cancel': self.cancel, + 'weekly': self.schedule, + 'monthly': self.schedule, + 'yearly': self.schedule, + "fuck": self.fuck, + "rewind": self.fuck, + "undo": self.fuck, + } + + if os.path.isfile(state_file): + with open(state_file, "r") as f: + for key, value in json.load(f).items(): + setattr(self, key, value) + return + + self.balance = {} + self.name2num = {} + self.num2name = {} + self.cars = {} + self.scheduled_cmds = {} + self.changes = {} + + def save(self, path=None): + """Save state to disk""" + if self.dry_run: + return + + state_file = Geldschieberbot.get_state_path(path) + with open(state_file, "w") as f: + json.dump({k: v for k, v in self.__dict__.items() if k != "cmds"}, f) + + def create_summary(self, user): + """Create summary string for a single user""" + summary = "" + cars_summary = "" + total = 0 + cars_total = 0 + p_balances = self.balance[user] # failes if user is not in balance + for person in p_balances: + amount = p_balances[person] + if amount == 0: + continue + if person in self.cars: + cars_total -= amount + cars_summary += f'\t{"<-" if amount < 0 else "->"} {person} {to_euro(abs(amount))}\n' + else: + total -= amount + summary += f'\t{"<-" if amount < 0 else "->"} {person} {to_euro(abs(amount))}\n' - if sender in num2name: - return None,"you are already registered" + if not summary: + summary = "\tAll fine :)" + else: + summary += f"\tBalance: {to_euro(total)}" - num2name[sender] = name - name2num[name] = sender + ret_summary = f'{user}:\n{summary}' - add_to_balance(name) + if cars_summary: + cars_summary += f'\tLiability: {to_euro(cars_total)}' + ret_summary += f'\n\tCars:\n{cars_summary}' - # add changes list - changes[name] = [] - return f"Happy geldschiebing {name}!", None + return ret_summary -cmds["reg"] = register -cmds["register"] = register + def create_total_summary(self): + """Create summary for all balances""" + summary = "Summary:" -def summary(sender, args, msg): - if len(args) == 1: - return create_total_summary(), None - elif len(args) > 1: - err = None - msg = "Summary:\n" - for name in args[1:]: - if name in name2num: - msg += create_summary(name) + "\n" + cars_summary = "" + for person in self.balance: + p_summary = self.create_summary(person) + if person in self.cars: + cars_summary += f'\n{p_summary}' else: - err = f'name "{name}" not registered' - return msg, err + summary += f'\n{p_summary}' + + if cars_summary: + summary += f'\nCars:{cars_summary}' + + return summary + + def record(self, recipient, donor, amount): + """Apply changes to the balance""" + if self.dry_run: + return + self.balance[donor][recipient] += amount + self.balance[recipient][donor] -= amount + + def record_change(self, sender, change): + """Add a change for some user""" + if self.dry_run or not self.record_changes: + return + self.changes[sender].append(change) + + def add_to_balance(self, name): + """Add new user to our balance""" + new_balance = {} + for member in self.balance: + self.balance[member][name] = 0 + new_balance[member] = 0 + self.balance[name] = new_balance + + def add_car(self, car, service_charge): + """Add a new car""" + if car in self.cars: + raise AlreadyRegisteredError(f'"{car}" already registered') + + if car in self.balance: + raise AlreadyRegisteredError(f'A user named "{car}" already exists. Please use a different name for this car') + + try: + service_charge = to_cent(service_charge) + except InvalidAmountError as err: + raise InvalidAmountError(f"service-charge ({service_charge}) must be a positive number") from err + + self.cars[car] = service_charge + self.add_to_balance(car) + + ### Commands ### + def register(self, sender, args, msg): + if len(args) != 2: + raise InvalidArgumentError(f'\"{" ".join(args)}\" not in form "{args[0]} name"') + name = args[1] + + if name in self.name2num: + raise AlreadyRegisteredError(f'"{name}" already registered') + + if sender in self.num2name: + raise AlreadyRegisteredError(f'you already registered using "{self.num2name[sender]}') + + self.num2name[sender] = name + self.name2num[name] = sender + + self.add_to_balance(name) + + # add changes list + self.changes[name] = [] + return f"Happy geldschiebing {name}!" + + def summary(self, sender, args, msg): + if len(args) == 1: + return self.create_total_summary() + elif len(args) > 1: + ret = "Summary:\n" + for name in args[1:]: + if name in self.name2num: + ret += self.create_summary(name) + "\n" + else: + raise NotRegisteredError(f'"{name}" not registered') + return ret -cmds["sum"] = summary -cmds["summary"] = summary -def list_users(sender, args, msg): - return create_members(), None + def list_users(self, sender, args, msg): + """Create list of all registered members""" + users = "" + for member in self.name2num: + users += f'{member}: {self.name2num[member]}\n' + return users -cmds["ls"] = list_users -cmds["list"] = list_users -def usage(sender, args, msg): - return create_help(), None + def usage(self, sender, args, msg): + return USAGE -cmds["help"] = usage -cmds["usage"] = usage -def split(sender, args, msg): - if not sender in num2name: - return None, 'you must register first' + def split(self, sender, args, msg): + if not sender in self.num2name: + raise NotRegisteredError('please register first using !register') - if len(args) < 3: - return None, f'not in form "{args[0]} amount [name]+"' + if len(args) < 3: + raise InvalidArgumentError(f'\"{" ".join(args)}\" not in form "{args[0]} amount [name]+"') - try: amount = to_cent(args[1]) - except: - return None, "amount must be a positive number" - - # len(args) - cmd - amount + sender - persons = len(args) - 2 + 1 - amount_per_person = int(amount/persons) - - if sender in num2name: - recipient = num2name[sender] - else: - return None, "you must register first" - - output = f"Split {to_euro(amount)} between {persons} -> {to_euro(amount_per_person)} each\n" - change = [args] - for p in args[2:]: - if not p in name2num: - output += p + " not known. Please take care manually\n" - else: - record(recipient, p, amount_per_person) - change.append([recipient, p, amount_per_person]) - if record_changes and not dry_run: - changes[recipient].append(change) + # len(args) - cmd - amount + sender + persons = len(args) - 2 + 1 + amount_per_person = int(amount/persons) + recipient = self.num2name[sender] - output += "New Balance:\n" - output += create_summary(recipient) - return output, None + output = f"Split {to_euro(amount)} between {persons} -> {to_euro(amount_per_person)} each\n" + change = [args] + for p in args[2:]: + if not p in self.name2num: + output += p + " not known. Please take care manually\n" + else: + self.record(recipient, p, amount_per_person) + change.append([recipient, p, amount_per_person]) -cmds["split"] = split -cmds["teil"] = split + self.record_change(recipient, change) -def transaction(sender, args, msg): - if len(args) != 3: - return None, f'not in form "{args[0]} amount recipient"' + output += "New Balance:\n" + output += self.create_summary(recipient) + return output - if not sender in balance: - if sender not in num2name: - return None, 'you must register first' - sender = num2name[sender] - if args[1] in balance: - recipient, amount = args[1:3] - elif args[2] in balance: - amount, recipient = args[1:3] - else: - return None, 'recipient not known' + def transaction(self, sender, args, msg): + if len(args) != 3: + raise InvalidArgumentError(f'\"{" ".join(args)}\" not in form "{args[0]} amount recipient"') - try: - amount = to_cent(amount) - except: - return None, "amount must be a positive number" + if sender not in self.balance: + if sender not in self.num2name: + raise NotRegisteredError('please register first using !register') + sender = self.num2name[sender] - if args[0] in ["!zieh", "!nimm"]: - amount *= -1 + if args[1] in self.balance: + recipient, amount = args[1:3] + elif args[2] in self.balance: + amount, recipient = args[1:3] + else: + raise InvalidArgumentError('unable to determine receipient from {args[1]} or {args[2]}') - if record_changes and not dry_run: - changes[sender].append([args, [sender, recipient, amount]]) + amount = to_cent(amount) - record(sender, recipient, amount) + if args[0] in ["!zieh", "!nimm"]: + amount *= -1 - p_balance = balance[sender][recipient] + self.record(sender, recipient, amount) + self.record_change(sender, ([args, [sender, recipient, amount]])) - output = ("New Balance: {} {} {} {}\n".format(sender, - ("->" if p_balance > 0 else "<-"), - to_euro(abs(p_balance)), - recipient)) - return output, None + p_balance = self.balance[sender][recipient] -cmds["schieb"] = transaction -cmds["gib"] = transaction -cmds["zieh"] = transaction -cmds["nimm"] = transaction + return "New Balance: {} {} {} {}\n".format(sender, + ("->" if p_balance > 0 else "<-"), + to_euro(abs(p_balance)), + recipient) -def transfer(sender, args, msg): - if len(args) < 4: - return None, f'not in form "{args[0]} amount source destination"' + def transfer(self, sender, args, msg): + if len(args) < 4: + raise InvalidArgumentError(f'\"{" ".join(args)}\" not in form "{args[0]} amount source destination"') - if not sender in num2name: - return None, 'you must register first' - else: - sender = num2name[sender] + if sender not in self.num2name: + raise NotRegisteredError('please register first using !register') + else: + sender = self.num2name[sender] - try: amount_raw = args[1] amount_cent = to_cent(amount_raw) - except: - return None, "amount must be a positive number" - - source, destination = args[2:4] - if source not in balance: - return None, f'source "{source}" not known' - - elif destination not in balance: - return None, f'destination "{destination}" not known' - - output = "" - global record_changes - saved_record_changes = record_changes - record_changes = False - change = [args] - - ret, err = transaction(sender, ["!zieh", source, amount_raw], "") - if err: - # No changes yet we can fail - return None, err - else: - output += ret - change.append((sender, source, amount_cent)) - - ret, err = transaction(sender, ["!schieb", destination, amount_raw], "") - if err: - output += err + "\nThe balance may be in a inconsistent state please take care manually" - return output, None - else: - output += ret - change.append((sender, source, amount_cent)) - - ret, err = transaction(source, ["!zieh", destination, amount_raw], "") - if err: - output += err + "\nThe balance may be in a inconsistent state please take care manually" - return output, None - else: - output += ret - change.append((sender, source, amount_cent)) - - record_changes = saved_record_changes - if err is None and record_changes and not dry_run: - changes[sender].append(change) - - return output, None - -cmds["transfer"] = transfer - -def cars(sender, args, msg): - # list cars - if len(args) < 2 or args[1] in ["ls", "list"]: - if len(available_cars) == 0: - return "No cars registered yet.", None - - ret_msg = "" - - if len(args) > 2: - cars_to_list = args[2:] - else: - cars_to_list = available_cars - - for car in cars_to_list: - if car in available_cars: - ret_msg += f"{car} - service charge {available_cars[car]}ct/km\n" - ret_msg += create_summary(car) + "\n" - else: - return None, f'"{car}" is no available car\n' - return ret_msg[:-1], None - # add car - elif args[1] in ["add", "new"]: - if len(args) < 4: - return None, f'not in form "{args[0]} {args[1]} car-name service-charge"' + source, destination = args[2:4] + if source not in self.balance: + raise NotRegisteredError(f'source "{source}" not known') - car = args[2] - if car in available_cars: - return None, '"{}" already registered'.format(car) + elif destination not in self.balance: + raise NotRegisteredError(f'destination "{destination}" not known') - if car in balance: - return None, f'A user named "{car}" already exists. Please use a different name for this car' + output = "" + saved_record_changes = self.record_changes + self.record_changes = False + change = [args] try: - service_charge = to_cent(args[3]) - except: - return None, "service-charge must be a positive number" - - available_cars[car] = service_charge - add_to_balance(car) - return f'added "{car}" as an available car', None - # pay bill - elif args[1] in ["pay"]: - if len(args) < 4: - return None, f'not in form "{args[0]} {args[1]} car-name amount"' - - if not sender in num2name: - return None, "you must register first" + ret = self.transaction(sender, ["!zieh", source, amount_raw], "") + except Exception as err: + # No changes yet we can fail + raise else: - sender_name = num2name[sender] + output += ret + change.append((sender, source, amount_cent)) - car = args[2] - if car not in available_cars: - return None, f'car "{car}" not known' + try: + ret = self.transaction(sender, ["!schieb", destination, amount_raw], "") + except Exception as err: + output += f"{err}\nThe balance may be in a inconsistent state please take care manually" + return output + else: + output += ret + change.append((sender, source, amount_cent)) try: - amount = to_cent(args[3]) - amount_euro = to_euro(amount) - except: - return None, "amount must be a positive number" + ret = self.transaction(source, ["!zieh", destination, amount_raw], "") + except Exceptoin as err: + output += f"{err}\nThe balance may be in a inconsistent state please take care manually" + return output + else: + output += ret + change.append((sender, source, amount_cent)) - output = "" + self.record_changes = saved_record_changes + self.record_change(sender, change) - global record_changes - saved_record_changes = record_changes - record_changes = False - change = [args] + return output - total_available_charge = 0 - available_charges = [] - for person in balance[car]: - _amount = balance[car][person] - if _amount < 0: - total_available_charge -= _amount - available_charges.append((person, _amount)) - - proportion = -1 - if amount < total_available_charge: - proportion = -1 * (amount / total_available_charge) - - _, err = transaction(sender, f"!gib {car} {amount_euro}".split(), "") - assert(err is None) - output += f"{sender_name} payed {amount_euro}\n" - - # transfere 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: - continue + def _cars(self, sender, args, msg): + # list cars + if len(args) < 2 or args[1] in ["ls", "list"]: + if len(self.cars) == 0: + return "No cars registered yet." - to_move = int(_amount * proportion) - to_move_euro = to_euro(to_move) - ret, err = transfer(sender, ["transfer", to_move_euro, car, person], "") - assert(err is None) + ret_msg = "" - output += "Transfere {} from {} to {}\n".format(to_move_euro, person, sender_name) + if len(args) > 2: + cars_to_list = args[2:] + else: + cars_to_list = self.cars - output += "New Balances:\n" - output += create_summary(sender_name) + "\n" - output += create_summary(car) + for car in cars_to_list: + if car in self.cars: + ret_msg += f"{car} - service charge {self.cars[car]}ct/km\n" + ret_msg += self.create_summary(car) + "\n" + else: + raise NotRegisteredError(f'"{car}" is no available car\n') + + return ret_msg[:-1] + # add car + elif args[1] in ["add", "new"]: + if len(args) < 4: + raise InvalidArgumentError(f'"{" ".join(args)}" not in form "car {args[1]} car-name service-charge"') + + self.add_car(args[2], args[3]) + return f'added "{args[2]}" as an available car' + # pay bill + elif args[1] in ["pay"]: + if len(args) < 4: + raise InvalidArgumentError(f'"{" ".join(args)}" not in form "car {args[1]} car-name amount"') + + if sender not in self.num2name: + raise NotRegisteredError('please register first using !register') + else: + sender_name = self.num2name[sender] - record_changes = saved_record_changes - if record_changes and not dry_run: - changes[sender_name].append(change) + car = args[2] + if car not in self.cars: + raise NotRegisteredError(f'car "{car}" not known') - return output, None - else: - return None, 'unknown car subcommand "{}".'.format(args[1]) + amount = to_cent(args[3]) + amount_euro = to_euro(amount) -cmds["cars"] = cars + output = "" + + saved_record_changes = self.record_changes + self.record_changes = False + change = [args] + + total_available_charge = 0 + available_charges = [] + for person in self.balance[car]: + _amount = self.balance[car][person] + if _amount < 0: + total_available_charge -= _amount + available_charges.append((person, _amount)) + + proportion = -1 + if amount < total_available_charge: + proportion = -1 * (amount / total_available_charge) + + try: + self.transaction(sender, ['!gib', car, amount_euro], "") + except Exception: + raise + output += f"{sender_name} 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: + continue + + to_move = int(_amount * proportion) + to_move_euro = to_euro(to_move) + try: + self.transfer(sender, ["transfer", to_move_euro, car, person], "") + except Exception: + raise + + output += f"Transfere {to_move_euro} from {person} to {sender_name}\n" + + output += "New Balances:\n" + output += self.create_summary(sender_name) + "\n" + output += self.create_summary(car) + + self.record_changes = saved_record_changes + self.record_change(sender_name, change) + + return output + else: + raise InvalidArgumentError(f'unknown car subcommand "{args[1]}".') -def _tanken(sender, args, msg): - if len(args) < 2: - return None, 'not in form "{} amount [person] [car] [info]"'.format(args[0]) - try: - amount = to_cent(args[1]) - except: - return None, "amount must be a number" - - # find recipient - if len(args) > 2 and args[2] in name2num: - recipient = args[2] - elif sender in num2name: - recipient = num2name[sender] - else: - return None, "recipient unknown" - - # find car - car = None - if len(args) > 2 and args[2] in available_cars: - car = args[2] - elif len(args) > 3 and args[3] in available_cars: - car = args[3] - - service_charge = 0 - if car: - service_charge = available_cars[car] - - parts, err = tanken.tanken(msg[1:], amount, service_charge) - - if err != None: - return None, err - - output = "" - change = [args] - 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"])) - # record service charges - if pname not in name2num: - output += pname + " not known." - if car: - person_to_charge = pname - if pname not in name2num: - person_to_charge = recipient - output += " {} held accountable for service charge.".format(recipient) - record(car, person_to_charge, values["service_charge"]) - change.append([car, person_to_charge, values["service_charge"]]) + def _tanken(self, sender, args, msg): + if len(args) < 2: + raise InvalidArgumentError(f'"{" ".join(args)}" not in form "{args[0]} amount [person] [car] [descripton]"') - # recipient paid the fuel -> don't charge them - if pname == recipient: - continue + amount = to_cent(args[1]) - if pname in name2num: - record(recipient, pname, values["cost"]) - change.append([recipient, pname, values["cost"]]) + # find recipient + if len(args) > 2 and args[2] in self.name2num: + recipient = args[2] + elif sender in self.num2name: + recipient = self.num2name[sender] else: - output += " Please collect fuel cost manually\n" - - if record_changes and not dry_run: - changes[num2name[sender]].append(change) - - output += "New Balance:\n" - output += create_summary(recipient) - if car: - output += "\nCar " - output += create_summary(car) - return output, None - -cmds["tanken"] = _tanken - -def fuck(sender, args, msg): - if not sender in num2name: - return None, "you must register first" - else: - name = num2name[sender] - - if len(changes[name]) == 0: - return "Nothing to rewind", None - - # pop last item - last_changes = changes[name].pop() - args, last_changes = last_changes[0], last_changes[1:] - - output = name + ": sorry I fucked up!\nRewinding:\n" - output += ' '.join(args) + "\n" - for change in last_changes: - if not change[0] in cmds: - output += "{} {} {} {}\n".format(change[0], - ("->" if change[2] < 0 else "<-"), - to_euro(abs(change[2])), - change[1]) - record(change[1], change[0], change[2]) - - for change in last_changes: - if change[0] in cmds: - ret, err = cmds[change[0]](sender, change, "") - - if err: - output += "ERROR: " + err - else: - output += ret + raise InvalidArgumentError('unable to determine receipient from {sender} or {args[2]}') - return output, None + # find car + car = None + if len(args) > 2 and args[2] in self.cars: + car = args[2] + elif len(args) > 3 and args[3] in self.cars: + car = args[3] -cmds["fuck"] = fuck -cmds["rewind"] = fuck -cmds["undo"] = fuck + service_charge = 0 + if car: + service_charge = self.cars[car] -def schedule(sender, args, msg): - if not sender in num2name: - return None, "you must register first" + parts, err = tanken.tanken(msg.splitlines()[1:], amount, service_charge) - sender_name = num2name[sender] + if err != None: + raise Exception(err) - if len(args) < 3: - return None, 'not in form "{} name cmd"'.format(args[0]) + output = "" + change = [args] + for pname, values in parts.items(): + output += f"{pname}: {values['distance']}km = fuel: {to_euro(values['cost'])}," + output += f" service charge: {to_euro(values['service_charge'])}\n" + # record service charges + if pname not in self.name2num: + output += pname + " not known." + if car: + person_to_charge = pname + if pname not in self.name2num: + person_to_charge = recipient + output += " {} held accountable for service charge.".format(recipient) + + self.record(car, person_to_charge, values["service_charge"]) + change.append([car, person_to_charge, values["service_charge"]]) + + # recipient paid the fuel -> don't charge them + if pname == recipient: + continue - name = args[1] - cmd = args[2:] + if pname in self.name2num: + self.record(recipient, pname, values["cost"]) + change.append([recipient, pname, values["cost"]]) + else: + output += " Please collect fuel cost manually\n" - if name in scheduled_cmds: - return None, 'there is already a scheduled command named "{}"'.format(name) + self.record_change(self.num2name[sender], change) - # Test the command - global dry_run - old_dry_run, dry_run = dry_run, True + output += "New Balance:\n" + output += self.create_summary(recipient) + if car: + output += "\nCar " + output += self.create_summary(car) + return output - ret, err = cmds[cmd[0]](sender, cmd, "") - dry_run = old_dry_run + def fuck(self, sender, args, msg): + if sender not in self.num2name: + raise NotRegisteredError('please register first using !register') + else: + name = self.num2name[sender] + + if len(self.changes[name]) == 0: + return "Nothing to rewind" + + # pop last item + last_changes = self.changes[name].pop() + args, last_changes = last_changes[0], last_changes[1:] + + output = f"{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: + try: + ret = self.cmds[change[0]](sender, change, "") + except: + raise - if err: - return None, 'the command "{}" failed and will not be recorded' + output += ret - scheduled_cmd = {"schedule": args[0][1:], - "last_time": None, - "sender": sender, - "cmd": cmd} + return output - scheduled_cmds[name] = scheduled_cmd - output = 'Recorded the {} command "{}" as "{}"\n'.format(args[0][1:], ' '.join(cmd), name) - output += "Running {} command {} for {} initially\n".format(scheduled_cmd["schedule"], - name, sender_name) + def schedule(self, sender, args, msg): + """Add a scheduled command - ret, err = cmds[cmd[0]](sender, cmd, "") - if err: - output += "ERROR: " + err - else: - output += ret + Possible schedules are: {'weekly', 'monthly', 'yearly'}. + """ + if sender not in self.num2name: + raise NotRegisteredError('please register first using !register') - changes[sender_name][0].append(["cancel", name]) + sender_name = self.num2name[sender] - now = datetime.now().date() - scheduled_cmd["last_time"] = now.isoformat() + if len(args) < 3: + raise InvalidArgumentError(f'"{" ".join(args)}" not in form "{args[0]} name cmd"') - return output, None + interval = args[0][1:] + name = args[1] + cmd = args[2:] -cmds["weekly"] = schedule -cmds["monthly"] = schedule -cmds["yearly"] = schedule + if name in self.scheduled_cmds: + raise Exception(f'there is already a scheduled command named "{name}"') -def cancel(sender, args, msg): - cmd_name = args[1] - if not cmd_name in scheduled_cmds: - return None, '"{}" is not a scheduled command'.format(cmd_name) - cmd = scheduled_cmds[cmd_name] + # Test the command + old_dry_run, self.dry_run = self.dry_run, True - if not cmd["sender"] == sender: - return None, 'only the original creator can cancel this command' + try: + ret = self.cmds[cmd[0]](sender, cmd, "") + except Exception as err: + raise Exception(f'the command "{args}" failed and will not be recorded') from err + finally: + self.dry_run = old_dry_run - del(scheduled_cmds[cmd_name]) - return 'Cancelled the {} cmd "{}"'.format(cmd["schedule"], cmd_name), None + scheduled_cmd = {"schedule": interval, + "last_time": None, + "sender": sender_name, + "cmd": cmd} -cmds["cancel"] = cancel + self.scheduled_cmds[name] = scheduled_cmd + output = 'Recorded the {} command "{}" as "{}"\n'.format(interval, ' '.join(cmd), name) -def main(): - if len(sys.argv) > 1 and sys.argv[1] in ["-d", "--dry-run"]: - global dry_run - dry_run = True - print("Dry Run no changes will apply!") + output += "Running {} command {} for {} initially\n".format(interval, + name, + sender_name) - # Read cmds from stdin - for l in sys.stdin.read().splitlines(): try: - message = json.loads(l)["envelope"] - except: - print(datetime.now(), l, "not valid json") - continue - - sender_number = message["source"] - if not message["dataMessage"]: - continue - else: - message = message["dataMessage"] - if message["groupInfo"] and message["groupInfo"]["groupId"] != group_id: - continue - body = [l.strip() for l in message["message"].lower().splitlines()] - - if len(body) == 0: - continue + ret = self.cmds[cmd[0]](sender, cmd, "") + output += ret + except Exception as err: + output += f'ERROR: {err}' - args = body[0].split(' ') + self.changes[sender_name][0].append(["cancel", name]) - if args[0].startswith("!"): - cmd = args[0][1:] - if cmd in cmds: - ret, err = cmds[cmd](sender_number, args, body) - if err: - send("ERROR: " + err) - else: - send(ret) - else: - send('ERROR: unknown cmd. Enter !help for a list of commands.') + now = datetime.now().date() + scheduled_cmd["last_time"] = now.isoformat() - # Handle scheduled commands - global record_changes - record_changes = False + return output - now = datetime.now().date() - week_delta = timedelta(days=7) - year_delta = timedelta(days=365) - for name, cmd in scheduled_cmds.items(): + def cancel(self, sender, args, msg): + """Cancel a scheduled command""" + if not sender in self.num2name: + raise NotRegisteredError('please register first using !register') + sender = self.num2name[sender] - last_time = cmd["last_time"] - if hasattr(date, "fromisoformat"): - last_time = date.fromisoformat(last_time) - else: - last_time = date(*map(int, last_time.split("-"))) - - d = last_time - while True: - if cmd["schedule"] == "yearly": - d = date(d.year+1, d.month, d.day) - elif cmd["schedule"] == "monthly": - if d.day > 28: - d = date(d.year, d.month, 28) - if d.month == 12: - d = date(d.year+1, 1, d.day) - else: - d = date(d.year, d.month+1, d.day) - else: - d = d + timedelta(7) + cmd_name = args[1] + if not cmd_name in self.scheduled_cmds: + raise NotRegisteredError(f'"{cmd_name}" is not a scheduled command') + cmd = self.scheduled_cmds[cmd_name] - if d <= now: - send("Running {} command {} for {} triggered on {}\n".format(cmd["schedule"], - name, - num2name[cmd["sender"]], - d.isoformat())) + if not cmd["sender"] == sender: + raise NotAllowedError(f'only {cmd["sender"]}, the original creator can cancel this command') - ret, err = cmds[cmd["cmd"][0]](cmd["sender"], cmd["cmd"], "") + del self.scheduled_cmds[cmd_name] + return f'Cancelled the {cmd["schedule"]} command "{cmd_name}"' - if err: - send("ERROR: " + err) - else: - send(ret) - cmd["last_time"] = d.isoformat() - else: - break +@dispatcher.add_method +def receive(messageId, sender, groupId=None, message=None, attachments=None, timestamp=None): + """Entry point for jsonrpc""" + # we only handle text messages + if not message or not message.startswith('!'): + return [] + state = Geldschieberbot() - with open(state_file, "w") as f: - json.dump(state, f) + args = message.splitlines()[0].split() + cmd = args[0][1:] + if cmd not in state.cmds: + return [{"receipients": groupId, "message": "ERROR: unknown cmd. Enter !help for a list of commands."}] -if __name__ == "__main__": - main() + func = state.cmds[cmd] + try: + msgs = func(sender, args, message) + except Exception as err: + msgs = f'{err.__class__.__name__}: {err}' + state.save() + + if not isinstance(msgs, list): + msgs = [msgs] + + return [{'recipients': groupId, 'message': msg} for msg in msgs] + +if __name__ == '__main__': + + while(True): + request = json.dumps(json.loads(sys.stdin.readline())) + response = JSONRPCResponseManager.handle(request, dispatcher) + print(response.json, flush=True) + + # Handle scheduled commands + # global RECORD_CHANGES + # RECORD_CHANGES = False + + # now = datetime.now().date() + # week_delta = timedelta(days=7) + # year_delta = timedelta(days=365) + + # for name, cmd in scheduled_cmds.items(): + # last_time = cmd["last_time"] + # if hasattr(date, "fromisoformat"): + # last_time = date.fromisoformat(last_time) + # else: + # last_time = date(*map(int, last_time.split("-"))) + + # d = last_time + # while True: + # if cmd["schedule"] == "yearly": + # d = date(d.year+1, d.month, d.day) + # elif cmd["schedule"] == "monthly": + # if d.day > 28: + # d = date(d.year, d.month, 28) + # if d.month == 12: + # d = date(d.year+1, 1, d.day) + # else: + # d = date(d.year, d.month+1, d.day) + # else: + # d = d + timedelta(7) + + # if d <= now: + # msgs.append((f'Running {cmd["schedule"]} command {name} for ' + # f'{num2name[cmd["sender"]]} triggert on {d.isoformat()}\n')) + + # ret, err = cmds[cmd["cmd"][0]](cmd["sender"], cmd["cmd"], "") + + # if err: + # msgs.append("ERROR: " + err) + # else: + # msgs.append(ret) + + # cmd["last_time"] = d.isoformat() + # else: + # break diff --git a/test.py b/test.py index 94235e1..4ffea93 100755 --- a/test.py +++ b/test.py @@ -8,17 +8,15 @@ from string import Template import subprocess import unittest +import geldschieberbot + alice, bob, charlie = "alice", "bob", "charlie" num = {alice: "+49123456", bob: "+49654321", charlie: "+49615243"} -os.environ["GSB_GROUP_ID"] = "test" -os.environ["GSB_STATE_FILE"] = "test/state.json" -os.environ["GSB_SEND_CMD"] = "cat" -os.environ["GSB_SEND_GROUP"] = "cat" -os.environ["GSB_SEND_USER"] = "cat" -os.environ["GSB_MODULES"] = "geldschiebing.py" now = datetime.now().date() +STATE_PATH = "test/state.json" + msg_template = Template(""" {"envelope": {"source":"$sender", @@ -52,37 +50,72 @@ scheduled_state_template = Template(""" "sender": "+49123456", "cmd": ["split", "3", "bob", "charlie"]}}, "changes": {"alice": [], "bob": [], "charlie": []}}""") +METHOD_COUNTER = 0 +BOT = None + def run_bot(test, sender, cmd): - msg = msg_template.substitute(sender=sender, msg=cmd).replace("\n", "\\n") + "\n" - res = subprocess.run(["python3", "./geldschieberbot.py"], text=True, capture_output=True, - # res = subprocess.run(["python3", "./bot.py"], text=True, capture_output=True, - input=msg) - - if res.returncode != 0: - print(res.stdout) - print(res.stderr) - test.assertEqual(res.returncode, 0) - test.assertEqual(res.stderr, "") + global BOT + if not BOT: + env = {"GSB_STATE_FILE": STATE_PATH} + BOT = subprocess.Popen("./geldschieberbot.py", + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + text=True, + env=env) + global METHOD_COUNTER + payload = { + "method": "receive", + "params": { + "messageId": 0, + "sender": sender, + "message": cmd, + "groupId": "test", + }, + "jsonrpc": "2.0", + "id": METHOD_COUNTER, + } + + METHOD_COUNTER += 1 + + if BOT.poll(): + print("BOT died!") + BOT = None + print(json.dumps(payload), file=BOT.stdin, flush=True) + response = json.loads(BOT.stdout.readline()) + + return "/n".join([result['message'] for result in response["result"]]) + + # res = geldschieberbot.receive(0, sender, message=cmd, timestamp=1544101248419, groupId='test') + # msg = msg_template.substitute(sender=sender, msg=cmd).replace("\n", "\\n") + "\n" + # res = subprocess.run(["python3", "./geldschieberbot.py"], text=True, capture_output=True, + # # res = subprocess.run(["python3", "./bot.py"], text=True, capture_output=True, + # input=msg) + + # if res.returncode != 0: + # print(res) + # print(res.stderr) + # test.assertEqual(res.returncode, 0) + # test.assertEqual(res.stderr, "") return res def save_state(dest): - copyfile(os.environ["GSB_STATE_FILE"], dest) + copyfile(STATE_PATH, dest) def reset_state(state=None): if state: - copyfile(state, os.environ["GSB_STATE_FILE"]) + copyfile(state, STATE_PATH) else: - state = os.environ["GSB_STATE_FILE"] + state = STATE_PATH if os.path.isfile(state): os.remove(state) def reset_state_string(string): - with open(os.environ["GSB_STATE_FILE"], "w") as f: + with open(STATE_PATH, "w") as f: json.dump(json.loads(string), f) def compare_state(comp_state): with open(comp_state, "r") as csf, \ - open(os.environ["GSB_STATE_FILE"], "r") as sf: + open(STATE_PATH, "r") as sf: cs = csf.read() s = sf.read() return cs == s @@ -94,25 +127,25 @@ class TestRegCmd(unittest.TestCase): def test_correct_reg(self): res = run_bot(self, num[alice], "!reg "+alice) - self.assertEqual(res.stdout, 'Happy geldschiebing {}!'.format(alice)) + self.assertEqual(res, 'Happy geldschiebing {}!'.format(alice)) def test_double_reg(self): - res = run_bot(self, num[alice], "!reg "+alice) - self.assertEqual(res.stdout, 'Happy geldschiebing {}!'.format(alice)) - res = run_bot(self, num[alice], "!reg "+alice) - self.assertEqual(res.stdout, 'ERROR: '+alice+' already registered') + res = run_bot(self, num[alice], f"!reg {alice}") + self.assertEqual(res, f'Happy geldschiebing {alice}!') + res = run_bot(self, num[alice], f"!reg {alice}") + self.assertEqual(res, f'AlreadyRegisteredError: "{alice}" already registered') def test_invalid_reg(self): res = run_bot(self, num[alice], "!reg nase 03") - self.assertEqual(res.stdout, 'ERROR: not in form "!reg name"') + self.assertEqual(res, 'InvalidArgumentError: "!reg nase 03" not in form "!reg name"') def test_additional_reg(self): res = run_bot(self, num[alice], "!reg "+alice) - self.assertEqual(res.stdout, 'Happy geldschiebing {}!'.format(alice)) + self.assertEqual(res, 'Happy geldschiebing {}!'.format(alice)) res = run_bot(self, num[bob], "!reg "+bob) - self.assertEqual(res.stdout, 'Happy geldschiebing {}!'.format(bob)) + self.assertEqual(res, 'Happy geldschiebing {}!'.format(bob)) res = run_bot(self, num[charlie], "!reg "+charlie) - self.assertEqual(res.stdout, 'Happy geldschiebing {}!'.format(charlie)) + self.assertEqual(res, 'Happy geldschiebing {}!'.format(charlie)) self.assertTrue(compare_state("test/state.json_3users")) @@ -128,72 +161,72 @@ class TestTransactionCmd(unittest.TestCase): def test_correct_schieb(self): res = run_bot(self, num[alice], "!schieb 10 "+bob) - self.assertEqual(res.stdout, 'New Balance: {} <- 10.00 {}\n'.format(alice, bob)) + self.assertEqual(res, 'New Balance: {} <- 10.00 {}\n'.format(alice, bob)) res = run_bot(self, num[bob], "!schieb 10 "+alice) - self.assertEqual(res.stdout, 'New Balance: {} <- 0.00 {}\n'.format(bob, alice)) + self.assertEqual(res, 'New Balance: {} <- 0.00 {}\n'.format(bob, alice)) def test_correct_gib(self): res = run_bot(self, num[alice], "!gib 10 "+bob) - self.assertEqual(res.stdout, 'New Balance: {} <- 10.00 {}\n'.format(alice, bob)) + self.assertEqual(res, 'New Balance: {} <- 10.00 {}\n'.format(alice, bob)) res = run_bot(self, num[bob], "!gib 10 "+alice) - self.assertEqual(res.stdout, 'New Balance: {} <- 0.00 {}\n'.format(bob, alice)) + self.assertEqual(res, 'New Balance: {} <- 0.00 {}\n'.format(bob, alice)) def test_correct_amount(self): res = run_bot(self, num[bob], "!schieb 1.1 "+alice) - self.assertEqual(res.stdout, 'New Balance: {} <- 1.10 {}\n'.format(bob, alice)) + self.assertEqual(res, 'New Balance: {} <- 1.10 {}\n'.format(bob, alice)) res = run_bot(self, num[bob], "!gib 1,1 "+alice) - self.assertEqual(res.stdout, 'New Balance: {} <- 2.20 {}\n'.format(bob, alice)) + self.assertEqual(res, 'New Balance: {} <- 2.20 {}\n'.format(bob, alice)) def test_invalid_amount(self): res = run_bot(self, num[bob], "!schieb 1b1 "+alice) - self.assertEqual(res.stdout, 'ERROR: amount must be a positive number') + self.assertEqual(res, 'InvalidAmountError: "1b1" not in form .') res = run_bot(self, num[bob], "!schieb ä€ "+alice) - self.assertEqual(res.stdout, 'ERROR: amount must be a positive number') + self.assertEqual(res, 'InvalidAmountError: "ä€" not in form .') def test_correct_schieb_name_before_amount(self): res = run_bot(self, num[alice], "!schieb "+bob+ " 10") - self.assertEqual(res.stdout, 'New Balance: {} <- 10.00 {}\n'.format(alice, bob)) + self.assertEqual(res, 'New Balance: {} <- 10.00 {}\n'.format(alice, bob)) res = run_bot(self, num[bob], "!schieb "+alice+ " 10") - self.assertEqual(res.stdout, 'New Balance: {} <- 0.00 {}\n'.format(bob, alice)) + self.assertEqual(res, 'New Balance: {} <- 0.00 {}\n'.format(bob, alice)) def test_correct_gib_name_before_amount(self): res = run_bot(self, num[alice], "!gib "+charlie+ " 10") - self.assertEqual(res.stdout, 'New Balance: {} <- 10.00 {}\n'.format(alice, charlie)) + self.assertEqual(res, 'New Balance: {} <- 10.00 {}\n'.format(alice, charlie)) res = run_bot(self, num[charlie], "!gib "+alice+ " 10") - self.assertEqual(res.stdout, 'New Balance: {} <- 0.00 {}\n'.format(charlie, alice)) + self.assertEqual(res, 'New Balance: {} <- 0.00 {}\n'.format(charlie, alice)) def test_correct_nimm(self): res = run_bot(self, num[alice], "!nimm 10 "+bob) - self.assertEqual(res.stdout, 'New Balance: {} -> 10.00 {}\n'.format(alice, bob)) + self.assertEqual(res, 'New Balance: {} -> 10.00 {}\n'.format(alice, bob)) res = run_bot(self, num[bob], "!nimm 10 "+alice) - self.assertEqual(res.stdout, 'New Balance: {} <- 0.00 {}\n'.format(bob, alice)) + self.assertEqual(res, 'New Balance: {} <- 0.00 {}\n'.format(bob, alice)) def test_correct_zieh_name_before_amount(self): res = run_bot(self, num[alice], "!zieh "+charlie+ " 10") - self.assertEqual(res.stdout, 'New Balance: {} -> 10.00 {}\n'.format(alice, charlie)) + self.assertEqual(res, 'New Balance: {} -> 10.00 {}\n'.format(alice, charlie)) res = run_bot(self, num[charlie], "!zieh "+alice+ " 10") - self.assertEqual(res.stdout, 'New Balance: {} <- 0.00 {}\n'.format(charlie, alice)) + self.assertEqual(res, 'New Balance: {} <- 0.00 {}\n'.format(charlie, alice)) def test_transactions_complex(self): res = run_bot(self, num[alice], "!schieb "+charlie+ " 1,1") - self.assertEqual(res.stdout, 'New Balance: {} <- 1.10 {}\n'.format(alice, charlie)) + self.assertEqual(res, 'New Balance: {} <- 1.10 {}\n'.format(alice, charlie)) res = run_bot(self, num[alice], "!zieh "+charlie+ " 2.1") - self.assertEqual(res.stdout, 'New Balance: {} -> 1.00 {}\n'.format(alice, charlie)) + self.assertEqual(res, 'New Balance: {} -> 1.00 {}\n'.format(alice, charlie)) res = run_bot(self, num[charlie], "!schieb "+bob+ " 42") - self.assertEqual(res.stdout, 'New Balance: {} <- 42.00 {}\n'.format(charlie, bob)) + self.assertEqual(res, 'New Balance: {} <- 42.00 {}\n'.format(charlie, bob)) res = run_bot(self, num[alice], "!zieh "+bob+ " 0.01") - self.assertEqual(res.stdout, 'New Balance: {} -> 0.01 {}\n'.format(alice, bob)) + self.assertEqual(res, 'New Balance: {} -> 0.01 {}\n'.format(alice, bob)) compare_state("test/state.json_transactions1") @@ -201,12 +234,12 @@ class TestSumCmd(unittest.TestCase): def test_summary_single_user(self): reset_state("test/state.json_transactions1") res = run_bot(self, num[alice], "!sum "+alice) - self.assertEqual(res.stdout, 'Summary:\nalice:\n\t-> bob 0.01\n\t-> charlie 1.00\n\tBalance: -1.01\n') + self.assertEqual(res, 'Summary:\nalice:\n\t-> bob 0.01\n\t-> charlie 1.00\n\tBalance: -1.01\n') - def test_summary_invalide_single_user(self): + def test_summary_invalid_single_user(self): reset_state() res = run_bot(self, num[alice], "!sum "+alice) - self.assertEqual(res.stdout, 'ERROR: name "alice" not registered') + self.assertEqual(res, 'NotRegisteredError: "alice" not registered') def test_summary_double_user(self): reset_state("test/state.json_transactions1") @@ -222,7 +255,7 @@ bob: \t-> charlie 42.00 \tBalance: -41.99 """ - self.assertEqual(res.stdout, summary) + self.assertEqual(res, summary) def test_summary(self): reset_state("test/state.json_transactions1") @@ -241,17 +274,17 @@ charlie: \t<- alice 1.00 \t<- bob 42.00 \tBalance: 43.00""" - self.assertEqual(res.stdout, summary) + self.assertEqual(res, summary) class TestMisc(unittest.TestCase): def test_unknown_command(self): res = run_bot(self, num[alice], "!foo") - self.assertEqual(res.stdout, "ERROR: unknown cmd. Enter !help for a list of commands.") + self.assertEqual(res, "ERROR: unknown cmd. Enter !help for a list of commands.") def test_no_command(self): res = run_bot(self, num[alice], "Hi, how are you?") - self.assertEqual(res.stdout, "") + self.assertEqual(res, "") class TestListCmd(unittest.TestCase): @@ -262,12 +295,12 @@ class TestListCmd(unittest.TestCase): def test_ls(self): res = run_bot(self, num[alice], "!ls") msg = "alice: {}\nbob: {}\ncharlie: {}\n".format(num[alice], num[bob], num[charlie]) - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) def test_list(self): res = run_bot(self, num[bob], "!list") msg = "alice: {}\nbob: {}\ncharlie: {}\n".format(num[alice], num[bob], num[charlie]) - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) class TestSplitCmd(unittest.TestCase): @@ -276,17 +309,17 @@ class TestSplitCmd(unittest.TestCase): def test_split_unregistered(self): res = run_bot(self, "+4971576357", "!split") - self.assertEqual(res.stdout, 'ERROR: you must register first') + self.assertEqual(res, 'NotRegisteredError: please register first using !register') def test_split_invalid_args(self): res = run_bot(self, num[alice], "!split") - self.assertEqual(res.stdout, 'ERROR: not in form "!split amount [name]+"') + self.assertEqual(res, 'InvalidArgumentError: "!split" not in form "!split amount [name]+"') res = run_bot(self, num[alice], "!split 10") - self.assertEqual(res.stdout, 'ERROR: not in form "!split amount [name]+"') + self.assertEqual(res, 'InvalidArgumentError: "!split 10" not in form "!split amount [name]+"') res = run_bot(self, num[alice], "!split foo 10") - self.assertEqual(res.stdout, 'ERROR: amount must be a positive number') + self.assertEqual(res, 'InvalidAmountError: "foo" not in form .') def test_split_one_unknown_user(self): @@ -298,7 +331,7 @@ New Balance: alice: \t<- charlie 10.00 \tBalance: 10.00""" - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) def test_split(self): res = run_bot(self, num[alice], "!split 30 " + bob + " " + charlie) @@ -309,7 +342,7 @@ alice: \t<- bob 10.00 \t<- charlie 10.00 \tBalance: 20.00""" - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) def test_split_whitespace(self): res = run_bot(self, num[alice], "!split 30 " + bob + " " + charlie + " ") @@ -320,7 +353,7 @@ alice: \t<- bob 10.00 \t<- charlie 10.00 \tBalance: 20.00""" - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) class TestCarsAddCmd(unittest.TestCase): def setUp(self): @@ -330,36 +363,36 @@ class TestCarsAddCmd(unittest.TestCase): i = "!cars add foo 0.04" res = run_bot(self, num[alice], i) o = 'added "foo" as an available car' - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) i = "!cars new bar 0.02" res = run_bot(self, num[alice], i) o = 'added "bar" as an available car' - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) save_state("test/state.json_2cars") def test_add_invalid_service_charge(self): i = "!cars add foo 0.04hut" res = run_bot(self, num[alice], i) - o = "ERROR: service-charge must be a positive number" - self.assertEqual(res.stdout, o) + o = "InvalidAmountError: service-charge (0.04hut) must be a positive number" + self.assertEqual(res, o) i = "!cars new bar -5" res = run_bot(self, num[alice], i) - o = "ERROR: service-charge must be a positive number" - self.assertEqual(res.stdout, o) + o = "InvalidAmountError: service-charge (-5) must be a positive number" + self.assertEqual(res, o) def test_add_name_conflict(self): i = "!cars add foo 0.04" res = run_bot(self, num[alice], i) o = 'added "foo" as an available car' - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) i = "!cars new alice 0.02" res = run_bot(self, num[alice], i) - o = 'ERROR: A user named "alice" already exists. Please use a different name for this car' - self.assertEqual(res.stdout, o) + o = 'AlreadyRegisteredError: A user named "alice" already exists. Please use a different name for this car' + self.assertEqual(res, o) class TestCarsTransactions(unittest.TestCase): def setUp(self): @@ -369,7 +402,7 @@ class TestCarsTransactions(unittest.TestCase): i = "!schieb foo 20" res = run_bot(self, num[alice], i) o = "New Balance: alice <- 20.00 foo\n" - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) class TestCarPayCmd(unittest.TestCase): def setUp(self): @@ -393,7 +426,7 @@ alice: \tBalance: 30.00 foo: \tAll fine :)""" - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) def test_alice_pays_more(self): run_bot(self, num[bob], "!zieh foo 20") @@ -417,7 +450,7 @@ alice: foo: \t-> alice 10.00 \tBalance: -10.00""" - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) def test_alice_pays_half(self): @@ -440,7 +473,7 @@ foo: \t<- bob 10.00 \t<- charlie 5.00 \tBalance: 15.00""" - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) class TestCarsListCmd(unittest.TestCase): def setUp(self): @@ -451,7 +484,7 @@ class TestCarsListCmd(unittest.TestCase): i = "!cars" res = run_bot(self, num[alice], i) o = "No cars registered yet." - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) def test_implicit_call(self): i = "!cars" @@ -463,7 +496,7 @@ foo: bar - service charge 2ct/km bar: \tAll fine :)""" - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) def test_list_all(self): i = "!cars ls" @@ -475,7 +508,7 @@ foo: bar - service charge 2ct/km bar: \tAll fine :)""" - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) i = "!cars list" res = run_bot(self, num[alice], i) @@ -486,7 +519,7 @@ foo: bar - service charge 2ct/km bar: \tAll fine :)""" - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) def test_list_explicit_car(self): i = "!cars ls foo" @@ -495,13 +528,13 @@ bar: """foo - service charge 4ct/km foo: \tAll fine :)""" - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) def test_list_invalid_explicit_car(self): i = "!cars ls alice" res = run_bot(self, num[alice], i) - o = 'ERROR: "alice" is no available car\n' - self.assertEqual(res.stdout, o) + o = 'NotRegisteredError: "alice" is no available car\n' + self.assertEqual(res, o) class TestTankenCmd(unittest.TestCase): @@ -523,7 +556,7 @@ alice: \t<- bob 3.33 \t<- charlie 3.33 \tBalance: 6.66""" - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) def test_tanken_unknown_user(self): i = \ @@ -540,13 +573,13 @@ New Balance: alice: \t<- charlie 3.33 \tBalance: 3.33""" - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) def test_tanken_3users_with_car(self): i = "!cars add foo 0.04" res = run_bot(self, num[alice], i) o = 'added "foo" as an available car' - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) i = \ """!tanken 10 alice foo @@ -570,13 +603,13 @@ Car foo: \t<- bob 0.40 \t<- charlie 0.40 \tBalance: 1.20""" - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) def test_tanken_unknown_user_with_car(self): i = "!cars add foo 0.04" res = run_bot(self, num[alice], i) o = 'added "foo" as an available car' - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) i = \ """!tanken 10 alice foo @@ -599,13 +632,13 @@ Car foo: \t<- alice 0.80 \t<- bob 0.40 \tBalance: 1.20""" - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) def test_tanken_3users_with_car(self): i = "!cars add foo 0.04" res = run_bot(self, num[alice], i) o = 'added "foo" as an available car' - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) i = \ """!tanken 10 alice foo @@ -629,7 +662,7 @@ Car foo: \t<- bob 0.40 \t<- charlie 0.40 \tBalance: 1.20""" - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) class TestTransferCmd(unittest.TestCase): @@ -645,7 +678,7 @@ class TestTransferCmd(unittest.TestCase): New Balance: alice <- 5.00 charlie New Balance: bob -> 5.00 charlie """ - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) def test_transfer_credit(self): res = run_bot(self, num[alice], "!schieb bob 5") @@ -657,7 +690,7 @@ New Balance: bob -> 5.00 charlie New Balance: alice <- 5.00 charlie New Balance: bob -> 5.00 charlie """ - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) def test_transfer_change_dept(self): res = run_bot(self, num[alice], "!schieb bob 5") @@ -669,7 +702,7 @@ New Balance: bob -> 5.00 charlie New Balance: alice <- 5.00 charlie New Balance: bob -> 5.00 charlie """ - self.assertEqual(res.stdout, o) + self.assertEqual(res, o) #TODO: tanken, transfer, cars pay @@ -680,11 +713,11 @@ class TestFuckCmd(unittest.TestCase): def test_fuck_unregistered(self): res = run_bot(self, "+4971576357", "!fuck") - self.assertEqual(res.stdout, 'ERROR: you must register first') + self.assertEqual(res, 'NotRegisteredError: please register first using !register') def test_fuck_nothing(self): res = run_bot(self, num[alice], "!fuck") - self.assertEqual(res.stdout, 'Nothing to rewind') + self.assertEqual(res, 'Nothing to rewind') def test_fuck_transaction(self): for cmd in ["fuck", "undo", "rewind"]: @@ -696,7 +729,7 @@ Rewinding: !schieb bob 10 alice <- 10.00 bob """ - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) compare_state("test/state.json_3users") def test_fuck_split(self): @@ -709,7 +742,7 @@ Rewinding: alice <- 1.00 bob alice <- 1.00 charlie """ - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) compare_state("test/state.json_3users") def test_fuck_transaction(self): @@ -721,7 +754,7 @@ Rewinding: !schieb bob 10 alice <- 10.00 bob """ - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) compare_state("test/state.json_3users") class TestScheduleCmd(unittest.TestCase): @@ -740,7 +773,7 @@ alice: \t<- bob 1.00 \t<- charlie 1.00 \tBalance: 2.00""" - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) save_state("test/state.json_schedule_weekly") @@ -751,8 +784,8 @@ Rewinding: split 3 bob charlie alice <- 1.00 bob alice <- 1.00 charlie -Cancelled the weekly cmd "stuff\"""" - self.assertEqual(res.stdout, msg) +Cancelled the weekly command "stuff\"""" + self.assertEqual(res, msg) # Last exec onw week ago reset_state_string(scheduled_state_template.substitute(last_time=now-timedelta(7),schedule="weekly")) @@ -767,12 +800,12 @@ alice: \t<- bob 2.00 \t<- charlie 2.00 \tBalance: 4.00""".format(now.isoformat()) - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) compare_state("test/state.json_schedule_weekly") res = run_bot(self, num[alice], "!fuck") - self.assertEqual(res.stdout, 'Nothing to rewind') + self.assertEqual(res, 'Nothing to rewind') # Last exec two week ago reset_state_string(scheduled_state_template.substitute(last_time=now-timedelta(14),schedule="weekly")) @@ -794,7 +827,7 @@ alice: \t<- charlie 3.00 \tBalance: 6.00""".format((now - timedelta(7)).isoformat(), now.isoformat()) - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) os.remove("test/state.json_schedule_weekly") @@ -810,7 +843,7 @@ alice: \t<- bob 1.00 \t<- charlie 1.00 \tBalance: 2.00""" - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) save_state("test/state.json_schedule_monthly") @@ -821,8 +854,8 @@ Rewinding: split 3 bob charlie alice <- 1.00 bob alice <- 1.00 charlie -Cancelled the monthly cmd "stuff\"""" - self.assertEqual(res.stdout, msg) +Cancelled the monthly command "stuff\"""" + self.assertEqual(res, msg) # Last exec one month ago if now.month > 1: @@ -842,12 +875,12 @@ alice: \t<- bob 2.00 \t<- charlie 2.00 \tBalance: 4.00""".format(now.isoformat()) - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) compare_state("test/state.json_schedule_monthly") res = run_bot(self, num[alice], "!fuck") - self.assertEqual(res.stdout, 'Nothing to rewind') + self.assertEqual(res, 'Nothing to rewind') # Last exec two month ago if now.month > 2: @@ -875,7 +908,7 @@ alice: \t<- charlie 3.00 \tBalance: 6.00""".format(one_month_ago.isoformat(), now.isoformat()) - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) os.remove("test/state.json_schedule_monthly") @@ -897,7 +930,32 @@ alice: fiat: \t-> alice 3.00 \tBalance: -3.00""" - self.assertEqual(res.stdout, msg) + self.assertEqual(res, msg) + + def test_cancel_not_allowed(self): + run_bot(self, num[alice], "!cars add fiat 0.5") + run_bot(self, num[alice], "!monthly versicherung cars pay fiat 3") + res = run_bot(self, num[bob], "!cancel versicherung") + msg = "NotAllowedError: only alice, the original creator can cancel this command" + self.assertEqual(res, msg) + + def test_cancel_not_registered(self): + res = run_bot(self, "+490000", "!cancel versicherung") + msg = "NotRegisteredError: please register first using !register" + self.assertEqual(res, msg) + + def test_cancel_not_scheduled_cmd(self): + res = run_bot(self, num[alice], "!cancel versicherung") + msg = 'NotRegisteredError: "versicherung" is not a scheduled command' + self.assertEqual(res, msg) + + def test_cancel(self): + run_bot(self, num[alice], "!cars add fiat 0.5") + run_bot(self, num[alice], "!monthly versicherung cars pay fiat 3") + res = run_bot(self, num[alice], "!cancel versicherung") + msg = 'Cancelled the monthly command "versicherung"' + self.assertEqual(res, msg) if __name__ == '__main__': unittest.main() + BOT.terminate() -- cgit v1.2.3