소스 검색

Merge branch 'improvement/singleton-data-management' of kirkleon/rollbot3 into master

kirkleon 6 년 전
부모
커밋
12452a7268
6개의 변경된 파일184개의 추가작업 그리고 155개의 파일을 삭제
  1. 62 52
      README.md
  2. 5 0
      rollbot-local.sh
  3. 3 0
      src/command_system/__init__.py
  4. 44 0
      src/command_system/database.py
  5. 0 103
      src/command_system/messaging.py
  6. 70 0
      src/command_system/plugins.py

+ 62 - 52
README.md

@@ -1,82 +1,86 @@
 # Rollbot
 
-## Build
+## Running the Project
 
-`docker build -t rollbot3:latest .`
+Running Rollbot requires either Docker or Python 3.7 to be installed on
+your machine. Additionally, there is some one-time set up, detailed here
 
-## Deploy
+### One Time Set-Up
 
-`docker run -p6070:6070 --name rollbot3-instance -d rollbot3`
+Open GroupMe through your preferred application, and create a new
+chat containing just yourself. This will be your testing chat, where your
+local copy of rollbot will post messages. Future plans for the project
+include a simpler local testing environment, but integrating with
+GroupMe is an important testing step regardless, and so it is reasonable
+to do it now anyway.
 
-## Local Run w/o Docker
-
-`cd` into `src/` and run
-`ROLLBOT_CFG_DIR=../config python3 app.py`
-
-Note this requires at least Python 3.7.1
-
-## Development/Contributing
-
-
-### First Time Set-Up
-If you are developing this project, start by cloning this repository and creating a new branch,
-replacing `my-awesome-branch` with your chosen branch name in the following
-
-```bash
-git clone ssh://git@kirkleon.ddns.net:10022/kirkleon/rollbot3.git
-cd rollbot3/
-git checkout -b feature/my-awesome-branch
-```
-
-Next, open GroupMe through your preferred application, and create a new chat containing just
-yourself. This will be your testing chat, where your local copy of rollbot will post messages.
-Future plans for the project include a simpler local testing environment, but integrating with
-GroupMe is an important testing step regardless, and so it is reasonable to do it now anyway.
-
-Next, create a `secrets.toml` file. Do **NOT** commit this file! It is by default added to `.gitignore`
-for you, but you should always take care you are not accidentally sharing this file.
+Next, create a `config/secrets.toml` file. Do **NOT** commit this file!
+It is by default added to `.gitignore` for you, but you should always take
+care you are not accidentally sharing this file.
 
 ```bash
 cp config/secrets.toml.template config/secrets.toml
 ```
-
-Open the new `secrets.toml` file in your preferred editor, and then navigate to the GroupMe
-[Bots](https://dev.groupme.com/bots) page. Then, on the bots page, create a new bot. For this
-bot's group, select the new chat you made above. Name can be whatever you like, and you can
-leave the other fields blank. Click submit, and retrieve your `Bot ID` and `Group ID` from the
-bots page. Put these in your `secrets.toml` under the `groupme_bots` section, with your `Group ID`
-serving as the key and the `Bot ID` serving as the value (which must be in quotes).
-
-For example, if your bot ID is `456`, and your group ID is `789`, your `secrets.toml` needs to
-start with the following
+Open the new `secrets.toml` file in your preferred editor, and then navigate
+to the GroupMe [Bots](https://dev.groupme.com/bots) page. Then, on the bots
+page, create a new bot. For this bot's group, select the new chat you made
+above. Name can be whatever you like, and you can leave the other fields blank.
+Click submit, and retrieve your `Bot ID` and `Group ID` from the bots page. Put
+these in your `secrets.toml` under the `groupme_bots` section, with your
+`Group ID` serving as the key and the `Bot ID` serving as the value (which must
+be in quotes).
+
+For example, if your bot ID is `456`, and your group ID is `789`, your
+`secrets.toml` needs to start with the following
 
 ```toml
 [groupme_bots]
 789 = "456"
 ```
 
-That's it for secrets! Save the file and move on to deciding if you want to do your local development
-with or without `docker`.
+That's it for secrets! Note that other plugins may require additional secrets,
+and that image uploads (namely with the `!seychelles` plugin), require an
+imgur client ID as well.
+
+Save the file and move on to deciding if you want to do your local execution
+and/or development with or without `docker`.
 
-### Developing w/o Docker
+### Build and Deploy w/ Docker
 
-If you have a Python 3.7.1 environment with `pip` available, you can install dependencies as follows
+Run `./rollbot-docker.sh run` or manually run
 
 ```bash
-pip install -r requirements.txt
+docker build -t rollbot3:latest .
+docker run -p6070:6070 --name rollbot3-instance -d rollbot3
 ```
 
-If your plugin or extension adds new dependencies, remember to include them in this `requirements.txt`.
+You can run `./rollbot-docker.sh clean` to tear down the container.
 
-Then, move to the `src/` directory and use the above command to start the local `Flask` server on
-port `6070`.
+### Local Run w/o Docker
+
+This requires Python 3.7 to be installed on your machine. The use of
+a Python virtual environment is recommended, and so you should run the
+following
 
 ```bash
-ROLLBOT_CFG_DIR=../config python3 app.py
+python3 -mvenv .venv
+. .venv/bin/activate
+pip install -r requirements.txt
 ```
 
-Use `Ctrl-C` to kill this server. Your development loop will probably look something like, modify
-your plugin, start the server, test it, kill the server, repeat.
+To leave the virtual environment, simply run `deactivate`, and when
+you want to run the project again, you need only run `. .venv/bin/activate`.
+
+
+To launch rollbot run `./rollbot-local.sh` or `cd` into `src/`
+and run `ROLLBOT_CFG_DIR=../config python3 app.py`, stopping
+the execution with `Ctrl-C`.
+
+## Development/Contributing
+
+Please keep new plugin branches to the scheme `feature/name-of-plugin`
+where possible. All work *must* be done on either a branch or a fork
+of the repository, and must go through the pull request process.
 
 ### Developing w/ Docker
 
@@ -186,6 +190,12 @@ TODO
 
 TODO
 
+ - `msg` is the RollbotMessage triggering the command
+ - `db` is the SQLAlchemy database session scope
+ - `log` is the command's logger
+ - `bot` is the Rollbot instance running the command
+ - `data.*` is supplied the group singleton of the annotated data type
+
 #### RollbotResponse
 
 TODO

+ 5 - 0
rollbot-local.sh

@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+pushd src/
+ROLLBOT_CFG_DIR=../config python3 app.py
+popd

+ 3 - 0
src/command_system/__init__.py

@@ -0,0 +1,3 @@
+from .messaging import pop_arg, RollbotMessage, RollbotFailure, RollbotResponse
+from .database import as_group_singleton, ModelBase
+from .plugins import as_plugin, RollbotPlugin

+ 44 - 0
src/command_system/database.py

@@ -0,0 +1,44 @@
+import datetime
+
+from sqlalchemy import Column, DateTime, Binary, String, Float, Integer
+from sqlalchemy.ext.declarative import declarative_base
+
+
+ModelBase = declarative_base()
+
+
+def as_group_singleton(cls):
+    columns = {}
+    for name, typ in cls.__annotations__.items():
+        if name == "group_id":
+            raise ValueError(f"Cannot have column named group_id in as_group_singleton class {cls.__name__}")
+        if typ == int:
+            columns[name] = Column(Integer)
+        elif typ == float:
+            columns[name] = Column(Float)
+        elif typ == str:
+            columns[name] = Column(String)
+        elif typ in (object, "binary"):
+            columns[name] = Column(Binary)
+        elif typ == datetime.datetime:
+            columns[name] = Column(DateTime)
+        else:
+            raise TypeError(f"Unsupported annotation {typ} for {name} in {cls.__name__}")
+
+    cons_params = {k: getattr(cls, k, None) for k in columns}
+
+    def get_or_create_standin(cls, db, group_id):
+        sing = db.query(cls).get(group_id)
+        if sing is None:
+            sing = cls(group_id=group_id, **cons_params)
+            db.add(sing)
+        return sing
+
+    columns["__tablename__"] = "".join(("_" + c.lower()) if "A" <= c <= "Z" else c for c in cls.__name__).strip("_")
+    columns["group_id"] = Column(String, primary_key=True)
+
+    return type(
+        cls.__name__,
+        (ModelBase,),
+        dict(**columns, get_or_create=classmethod(get_or_create_standin))
+    )

+ 0 - 103
src/command_system.py → src/command_system/messaging.py

@@ -1,46 +1,9 @@
-import logging
 from dataclasses import dataclass
 from enum import Enum, auto
-import inspect
-import functools
-
-from sqlalchemy import Column, DateTime, Binary, String, Float, Integer
-from sqlalchemy.ext.declarative import declarative_base
 
 
 BANGS = ('!',)
 
-ModelBase = declarative_base()
-
-
-class GroupBasedSingleton(ModelBase):
-    __tablename__ = "group_based_singleton"
-    group_id = Column(String, primary_key=True)
-    command_name = Column(String, primary_key=True)
-    subpart_name = Column(String, primary_key=True)
-    integer_data = Column(Integer)
-    float_data = Column(Float)
-    string_data = Column(String)
-    binary_data = Column(Binary)
-    datetime_data = Column(DateTime)
-
-    @staticmethod
-    def get_or_create(db, group_id, command_name, subpart_name):
-        sing = db.query(GroupBasedSingleton).get((group_id, command_name, subpart_name))
-        if sing is None:
-            sing = GroupBasedSingleton(
-                group_id=group_id,
-                command_name=command_name,
-                subpart_name=subpart_name,
-                integer_data=None,
-                float_data=None,
-                string_data=None,
-                binary_data=None,
-                datetime_data=None
-            )
-            db.add(sing)
-        return sing
-
 
 def pop_arg(text):
     if text is None:
@@ -161,69 +124,3 @@ class RollbotResponse:
 
         if self.debugging is not None and "explain" in self.debugging:
             self.failure_msg += " " + self.debugging["explain"]
-
-
-class RollbotPlugin:
-    def __init__(self, command, bot, logger=logging.getLogger(__name__)):
-        self.command = command
-        self.bot = bot
-        self.logger = logger
-        self.logger.info(f"Intializing {type(self).__name__} matching {command}")
-
-    def on_start(self, db):
-        self.logger.info(f"No on_start initialization of {type(self).__name__}")
-
-    def on_shutdown(self, db):
-        self.logger.info(f"No on_shutdown de-initialization of {type(self).__name__}")
-
-    def on_command(self, db, message):
-        raise NotImplementedError
-
-
-def as_plugin(command):
-    if isinstance(command, str):
-        command_name = command
-    else:
-        command_name = command.__name__
-
-    def init_standin(self, bot, logger=logging.getLogger(__name__)):
-        RollbotPlugin.__init__(self, command_name, bot, logger=logger)
-
-    def decorator(fn):
-        sig = inspect.signature(fn)
-        converters = []
-        for p in sig.parameters:
-            if p in ("msg", "message", "_msg"):
-                converters.append(lambda self, db, msg: msg)
-            elif p in ("db", "database"):
-                converters.append(lambda self, db, msg: db)
-            elif p in ("log", "logger"):
-                converters.append(lambda self, db, msg: self.logger)
-            elif p in ("bot", "rollbot"):
-                converters.append(lambda self, db, msg: self.bot)
-            elif p.startswith("data") or p.endswith("data") or p in ("group_singleton", "singleton"):
-                subp = fn.__annotations__.get(p, "")
-                converters.append(lambda self, db, msg, subp=subp: GroupBasedSingleton.get_or_create(db, msg.group_id, self.command, subp))
-            else:
-                raise ValueError(f"Illegal argument name {p} in decorated plugin {command_name}")
-
-        def on_command_standin(self, db, msg):
-            res = fn(*[c(self, db, msg) for c in converters])
-            if isinstance(res, RollbotResponse):
-                return res
-            else:
-                return RollbotResponse(msg, txt=str(res))
-
-        return type(
-            f"AutoGenerated`{command_name}`Command",
-            (RollbotPlugin,),
-            dict(
-                __init__=init_standin,
-                on_command=on_command_standin,
-            )
-        )
-
-    if isinstance(command, str):
-        return decorator
-    else:
-        return decorator(command)

+ 70 - 0
src/command_system/plugins.py

@@ -0,0 +1,70 @@
+import logging
+import inspect
+
+from .messaging import RollbotResponse
+
+
+class RollbotPlugin:
+    def __init__(self, command, bot, logger=logging.getLogger(__name__)):
+        self.command = command
+        self.bot = bot
+        self.logger = logger
+        self.logger.info(f"Intializing {type(self).__name__} matching {command}")
+
+    def on_start(self, db):
+        self.logger.info(f"No on_start initialization of {type(self).__name__}")
+
+    def on_shutdown(self, db):
+        self.logger.info(f"No on_shutdown de-initialization of {type(self).__name__}")
+
+    def on_command(self, db, message):
+        raise NotImplementedError
+
+
+def as_plugin(command):
+    if isinstance(command, str):
+        command_name = command
+    else:
+        command_name = command.__name__
+
+    def init_standin(self, bot, logger=logging.getLogger(__name__)):
+        RollbotPlugin.__init__(self, command_name, bot, logger=logger)
+
+    def decorator(fn):
+        sig = inspect.signature(fn)
+        converters = []
+        for p in sig.parameters:
+            if p in ("msg", "message", "_msg"):
+                converters.append(lambda cmd, db, msg: msg)
+            elif p in ("db", "database"):
+                converters.append(lambda cmd, db, msg: db)
+            elif p in ("log", "logger"):
+                converters.append(lambda cmd, db, msg: cmd.logger)
+            elif p in ("bot", "rollbot"):
+                converters.append(lambda cmd, db, msg: cmd.bot)
+            elif p.startswith("data") or p.endswith("data") or p in ("group_singleton", "singleton"):
+                annot = fn.__annotations__.get(p, p)
+                converters.append(lambda cmd, db, msg, sing_cls=annot: sing_cls.get_or_create(db, msg.group_id))
+            else:
+                raise ValueError(f"Illegal argument name {p} in decorated plugin {command_name}")
+
+        def on_command_standin(self, db, msg):
+            res = fn(*[c(self, db, msg) for c in converters])
+            if isinstance(res, RollbotResponse):
+                return res
+            else:
+                return RollbotResponse(msg, txt=str(res))
+
+        return type(
+            f"AutoGenerated`{command_name}`Command",
+            (RollbotPlugin,),
+            dict(
+                __init__=init_standin,
+                on_command=on_command_standin,
+            )
+        )
+
+    if isinstance(command, str):
+        return decorator
+    else:
+        return decorator(command)