#!/usr/bin/env python3 from datetime import date, datetime, timedelta import json from jsonrpc import JSONRPCResponseManager, dispatcher import os import re import sys from exceptions import AlreadyRegisteredError, InvalidArgumentError, InvalidAmountError, NotRegisteredError, NotAllowedError import tanken ### HELPERS ### def to_cent(euro): """Parse amount into cents""" match = re.match('^(\d+)([,.](\d+))?$', euro) if not match: raise InvalidAmountError(f'"{euro}" not in form .') cents = int(match.group(1)) * 100 if match.group(3): if len(match.group(3)) > 2: raise InvalidAmountError(f'too precise ({match.group(0)} only two decimals are supported)') else: if len(match.group(3)) == 1: cents += int(match.group(3)) * 10 else: cents += int(match.group(3)) return cents def to_euro(cents): """Format cents to euros""" return f"{cents/100:.2f}" 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] 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 Happy Geldschieben! """ class Geldschieberbot: """ Geldschieberbot state object Members ------- 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 """ # 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 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}' return ret_summary def create_total_summary(self): """Create summary for all balances""" summary = "Summary:" 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: 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 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 def usage(self, sender, args, msg): return USAGE def split(self, sender, args, msg): if not sender in self.num2name: raise NotRegisteredError('please register first using !register') if len(args) < 3: raise InvalidArgumentError(f'\"{" ".join(args)}\" not in form "{args[0]} amount [name]+"') amount = to_cent(args[1]) # len(args) - cmd - amount + sender persons = len(args) - 2 + 1 amount_per_person = int(amount/persons) recipient = self.num2name[sender] 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]) self.record_change(recipient, change) output += "New Balance:\n" output += self.create_summary(recipient) return output def transaction(self, sender, args, msg): if len(args) != 3: raise InvalidArgumentError(f'\"{" ".join(args)}\" not in form "{args[0]} amount recipient"') 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[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]}') amount = to_cent(amount) if args[0] in ["!zieh", "!nimm"]: amount *= -1 self.record(sender, recipient, amount) self.record_change(sender, ([args, [sender, recipient, amount]])) p_balance = self.balance[sender][recipient] return "New Balance: {} {} {} {}\n".format(sender, ("->" if p_balance > 0 else "<-"), to_euro(abs(p_balance)), recipient) def transfer(self, sender, args, msg): if len(args) < 4: raise InvalidArgumentError(f'\"{" ".join(args)}\" not in form "{args[0]} amount source destination"') if sender not in self.num2name: raise NotRegisteredError('please register first using !register') else: sender = self.num2name[sender] amount_raw = args[1] amount_cent = to_cent(amount_raw) source, destination = args[2:4] if source not in self.balance: raise NotRegisteredError(f'source "{source}" not known') elif destination not in self.balance: raise NotRegisteredError(f'destination "{destination}" not known') output = "" saved_record_changes = self.record_changes self.record_changes = False change = [args] try: ret = self.transaction(sender, ["!zieh", source, amount_raw], "") except Exception as err: # No changes yet we can fail raise else: output += ret change.append((sender, source, amount_cent)) 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: 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)) self.record_changes = saved_record_changes self.record_change(sender, change) return output 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." ret_msg = "" if len(args) > 2: cars_to_list = args[2:] else: cars_to_list = self.cars 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] car = args[2] if car not in self.cars: raise NotRegisteredError(f'car "{car}" not known') amount = to_cent(args[3]) amount_euro = to_euro(amount) 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(self, sender, args, msg): if len(args) < 2: raise InvalidArgumentError(f'"{" ".join(args)}" not in form "{args[0]} amount [person] [car] [descripton]"') amount = to_cent(args[1]) # 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: raise InvalidArgumentError('unable to determine receipient from {sender} or {args[2]}') # 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] service_charge = 0 if car: service_charge = self.cars[car] parts, err = tanken.tanken(msg.splitlines()[1:], amount, service_charge) if err != None: raise Exception(err) 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 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" self.record_change(self.num2name[sender], change) output += "New Balance:\n" output += self.create_summary(recipient) if car: output += "\nCar " output += self.create_summary(car) return output 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 output += ret return output def schedule(self, sender, args, msg): """Add a scheduled command Possible schedules are: {'weekly', 'monthly', 'yearly'}. """ if sender not in self.num2name: raise NotRegisteredError('please register first using !register') sender_name = self.num2name[sender] if len(args) < 3: raise InvalidArgumentError(f'"{" ".join(args)}" not in form "{args[0]} name cmd"') interval = args[0][1:] name = args[1] cmd = args[2:] if name in self.scheduled_cmds: raise Exception(f'there is already a scheduled command named "{name}"') # Test the command old_dry_run, self.dry_run = self.dry_run, True 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 scheduled_cmd = {"schedule": interval, "last_time": None, "sender": sender_name, "cmd": cmd} self.scheduled_cmds[name] = scheduled_cmd output = 'Recorded the {} command "{}" as "{}"\n'.format(interval, ' '.join(cmd), name) output += "Running {} command {} for {} initially\n".format(interval, name, sender_name) try: ret = self.cmds[cmd[0]](sender, cmd, "") output += ret except Exception as err: output += f'ERROR: {err}' self.changes[sender_name][0].append(["cancel", name]) now = datetime.now().date() scheduled_cmd["last_time"] = now.isoformat() return output 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] 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 not cmd["sender"] == sender: raise NotAllowedError(f'only {cmd["sender"]}, the original creator can cancel this command') del self.scheduled_cmds[cmd_name] return f'Cancelled the {cmd["schedule"]} command "{cmd_name}"' @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() 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."}] 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