forked from HoloTech/customcmd-plugin
feat: Data and client framework
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
+5
-6
@@ -2,9 +2,7 @@ import regex
|
||||
|
||||
from parsimonious.grammar import Grammar
|
||||
|
||||
from .context import ExecContext
|
||||
from .parser import Execuitor
|
||||
from .terms import ExecutorTerm
|
||||
from .parser import ExecContext, Execuitor, ExecutorTerm
|
||||
|
||||
fragment_re = regex.compile(r"\$(?<br>{(?:[^{}]++|(?&br))*})")
|
||||
|
||||
@@ -30,7 +28,7 @@ class ExecClient:
|
||||
term = self.execuitor.visit(tree)
|
||||
return term
|
||||
|
||||
async def run_command(self, ctx: ExecContext, command: str) -> str:
|
||||
async def run_command(self, ctx: ExecContext, command: str, dryrun=False) -> str:
|
||||
"""
|
||||
Process the given command string,
|
||||
executing and replacing each fragment.
|
||||
@@ -46,9 +44,10 @@ class ExecClient:
|
||||
|
||||
fragment = command[match.start() : match.end()]
|
||||
parsed = self._parse_fragment(fragment)
|
||||
fragment_result = await parsed.execute(ctx)
|
||||
if not dryrun:
|
||||
fragment_result = await parsed.execute(ctx)
|
||||
|
||||
result.append(fragment_result)
|
||||
result.append(fragment_result)
|
||||
last_end = match.end()
|
||||
|
||||
return "".join(result)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .terms import ExecutorTerm
|
||||
|
||||
class ExecContext:
|
||||
"""
|
||||
Base context class that carries the instantiation state.
|
||||
|
||||
This is passed to all CommandTerms upon execution.
|
||||
"""
|
||||
|
||||
def __init__(self, *, alias: str, args: list[str], content: str, **kwargs):
|
||||
self.alias = alias
|
||||
self.args = args
|
||||
self.content = content
|
||||
|
||||
# Dictionary of local variables for the command context.
|
||||
# Useful if the Terms need to share state
|
||||
self.scope: dict[str, Any] = {}
|
||||
|
||||
# Stack of terms that are *currently* executing with this context.
|
||||
self.termstack: list['ExecutorTerm'] = []
|
||||
@@ -0,0 +1,36 @@
|
||||
from data import Registry, RowModel, Table
|
||||
from data.columns import String, Timestamp, Integer, Bool
|
||||
|
||||
|
||||
class CustomCmd(RowModel):
|
||||
_tablename_ = 'customcmds'
|
||||
_cache_ = {}
|
||||
|
||||
commandid = Integer(primary=True)
|
||||
communityid = Integer()
|
||||
name = String()
|
||||
response = String()
|
||||
|
||||
cooldown_bucket_size = Integer()
|
||||
cooldown_bucket_dur = Integer()
|
||||
|
||||
permlevel = Integer()
|
||||
maskperms = Bool()
|
||||
|
||||
creatorid = Integer()
|
||||
|
||||
description = String()
|
||||
docstring = String()
|
||||
|
||||
created_at = Timestamp()
|
||||
_timestamp = Timestamp()
|
||||
|
||||
|
||||
|
||||
class CustomCmdData(Registry):
|
||||
VERSION = ('CUSTOMCMD', 1)
|
||||
|
||||
customcmds = CustomCmd.table
|
||||
|
||||
customcmd_aliases = Table('customcmd_aliases')
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
|
||||
from parsimonious.grammar import Grammar
|
||||
from parsimonious.nodes import NodeVisitor
|
||||
|
||||
from . import logger
|
||||
from .terms import (
|
||||
constant_term,
|
||||
define_equal,
|
||||
define_walrus,
|
||||
define_term,
|
||||
binop,
|
||||
compare,
|
||||
cmdarg_term,
|
||||
command_shell,
|
||||
get_var,
|
||||
ternary_term,
|
||||
run_function,
|
||||
cascade_term,
|
||||
)
|
||||
|
||||
|
||||
"""
|
||||
User defined functions are 'kind of special'.
|
||||
We also want users to be able to define objects with attributes in future.
|
||||
Attributes which are looked up live.
|
||||
|
||||
Probably want a protected class of functions that can't be redefined.
|
||||
|
||||
Maybe have a 'decorateable class' with type-decorated instance variables,
|
||||
construction logic, and methods.
|
||||
And make sure only those variables are accessible via lookup.
|
||||
|
||||
Then we can wrap a user in say,
|
||||
UserCmdCtx.channel = CmdCtxUser.from_user(ctx.channel)
|
||||
|
||||
Note that function 'names' might be vars then, and may need to be computed.
|
||||
E.g. channel.followage $1::user
|
||||
|
||||
A 'function' is ultimately an ExecTerm that returns a callable on context execution.
|
||||
Would even like to distinguish functions and keywords a little
|
||||
so that functions with no arguments actually get called.
|
||||
|
||||
Now, permissions.
|
||||
Commands have their own level of permissions, and also whether permissions are inherited by the child functions.
|
||||
I guess permissions are up to the implementor, not the abstract grammar.
|
||||
We should probably try to define the 'contructors' though, so the implementor doesn't have to interact with the grammar.
|
||||
While that won't have permissions baked in, it *should* have some kind of check or guard decorator ability baked in.
|
||||
|
||||
Client?
|
||||
|
||||
UserCommandClient
|
||||
- holds grammar
|
||||
- context class to instantiate
|
||||
- functions and global scope
|
||||
- function guards
|
||||
- Method to actually execute/run full command
|
||||
- Caching for parsed commands
|
||||
- Decorators for adding functions?
|
||||
- Decorators for adding objects?
|
||||
- We might want to create the client in existing context? Or purely rely on the context... It doesn't need to handle events?
|
||||
|
||||
|
||||
- Actually there's a thought, it would be really easy now to say 'respond with this custom command response' in response to, well, any event right. Could be a redeem, could be stream start, could be VIP add, could be subscription. Would just need the event object, and to construct the context locals correctly for that event, and then in the docs detail the 'available variables and functions' for each event... huh
|
||||
- follower -> "Welcome in ${follower.name}! We now have ${channel.total} members."
|
||||
- redeem (checkin) -> "${(counter checkins).add redeemer.id 1 "New checkin"}"
|
||||
- Here 'redeemer.id' is a computed key, that could be any expression. The string is an optional description for the log.
|
||||
|
||||
|
||||
|
||||
|
||||
Channels could want to define their own functions available for every command?
|
||||
Scopes.. client provides global ExecScope,
|
||||
then generate a scope for the channel and instantiate the context with joined scopes.
|
||||
|
||||
|
||||
Each function probably has an 'inherent' permission.
|
||||
|
||||
Need a check decorator, basically.
|
||||
|
||||
Also, how do we pass the functions in?
|
||||
|
||||
Eventually, we might want scope, as a stack of scopes that cascade with a lookup.
|
||||
ExecScope.enter_scope()
|
||||
ExecScope.get(varname)
|
||||
ExecScope.exit_scope()
|
||||
|
||||
We also really need some level of exception hierachy and unrolling nicely and error reporting.
|
||||
And reporting for parse errors at some level.
|
||||
|
||||
fragment_builder creates a CommandFragment.
|
||||
This hold instructions of how a command will be run.
|
||||
|
||||
Any child node should return a command fragment.
|
||||
They don't have to
|
||||
|
||||
The ExecutionContext holds the current state
|
||||
when we get around to executing the command.
|
||||
|
||||
With this setup, we could actually compile and cache all the commands into
|
||||
CommandFragments,
|
||||
and then run them on demand with the execution.. that probably makes sense.
|
||||
|
||||
Now, a CommandFragment instance needs to be callable, and the first arg needs to be a context.
|
||||
It.. either takes the other arguments as callable vars or has them already saved?
|
||||
|
||||
The generic execution of a CommandFragment instance takes the arguments its been given,
|
||||
which are also usually CommandFragments,
|
||||
and then.. yeah..
|
||||
|
||||
So a 'function' is a single command fragment, executed by cmdfunction(ctx) probably.
|
||||
Let's do the permission check on the dynamic side?
|
||||
Considering extensibility..
|
||||
|
||||
|
||||
return ternary_fragment(condition, arg1, arg2) with those being fragments.
|
||||
Maybe functions, maybe not.. they are 'term's
|
||||
|
||||
(maybe Term is a better notation for this)
|
||||
|
||||
Okay, when these are called at the higher level..
|
||||
We might want to pass them arguments from other pieces maybe?
|
||||
Or just have the other pieces modify the context and pass that in..
|
||||
essentially scope.
|
||||
|
||||
For a function,
|
||||
- Choice one, we have FunctionTerm as a command fragment.
|
||||
|
||||
|
||||
Okay yeah. Let's make a single FunctionTerm that will handle finding the functions itself.
|
||||
We can subclass CommandTerm if we like..
|
||||
|
||||
Remember that the nodes need to return an *instance* of CommandFrag
|
||||
or ExecTerm
|
||||
|
||||
make_fragment produces a function that makes an instance that can be called
|
||||
with the single ctx.
|
||||
"""
|
||||
|
||||
|
||||
# TODO: Function term builder
|
||||
# @function_term("user")
|
||||
# def user_function_term(ctx, *args): ...
|
||||
# TODO: Syntax for defining functions inline
|
||||
# Implement ifthenelse
|
||||
# Implement typecasts
|
||||
# TODO: Relatively safe attribute getter (custom objects?)
|
||||
|
||||
"""
|
||||
Functions can be obtained from context, as a special type of var.
|
||||
Actually vars will summon the function parser anyway..
|
||||
vars are functions?
|
||||
"""
|
||||
|
||||
# NOTE: Whatever constructs the context should accept a context subclass.
|
||||
# This allows for carrying additional information in the context
|
||||
# And thus extending the functions and available Terms.
|
||||
# We'll make the base parser and executor fully context-agnostic.
|
||||
|
||||
|
||||
class Execuitor(NodeVisitor):
|
||||
"""
|
||||
NodeVisitor that visits each node in a parsed command component,
|
||||
and builds a forward-directional ExecTerm from them.
|
||||
"""
|
||||
|
||||
def lift_child_log(self, node, visited_children):
|
||||
print(f"NODE: {node} WITH {visited_children}")
|
||||
return self.lift_child(node, visited_children)
|
||||
|
||||
visit_basicterm = lift_child_log
|
||||
|
||||
def visit_parenthised(self, node, visited_children):
|
||||
print(f"PARENTH: {visited_children}")
|
||||
_, child, _ = visited_children[0]
|
||||
return child
|
||||
|
||||
def visit_command(self, node, visited_children):
|
||||
print(f"COMMAND: {visited_children}")
|
||||
_, component, _ = visited_children
|
||||
return command_shell(node.text, component)
|
||||
|
||||
def visit_bool(self, node, boolterm):
|
||||
(value,) = boolterm[0]
|
||||
if value.text.lower() == "true":
|
||||
return constant_term(node.text, True)
|
||||
elif value.text.lower() == "false":
|
||||
return constant_term(node.text, False)
|
||||
else:
|
||||
raise ValueError(f"Unrecognised bool {value.text}")
|
||||
|
||||
def visit_test(self, node, testterm):
|
||||
(expr,) = testterm
|
||||
term1, op, term2 = expr
|
||||
return compare(node.text, term1, op.text, term2)
|
||||
|
||||
def generic_visit(self, node, visited_children):
|
||||
"""If we don't have a rule for this node return it as raw text, but evaluatable"""
|
||||
print(f"GENERIC: {node} WITH {visited_children}")
|
||||
return visited_children or node
|
||||
|
||||
def visit_quoted(self, node, visited_children):
|
||||
return constant_term(node.text, node.text[1:-1])
|
||||
|
||||
def visit_keyword(self, node, visited_children):
|
||||
return node.text
|
||||
|
||||
def visit_word(self, node, visited_children):
|
||||
return node.text
|
||||
|
||||
def visit_equals(self, node, defineterms):
|
||||
var, _, _, _, term = defineterms
|
||||
return define_equal(node.text, str(var), term)
|
||||
|
||||
def visit_defines(self, node, defineterms):
|
||||
var, _, _, _, term = defineterms
|
||||
return define_walrus(node.text, str(var), term)
|
||||
|
||||
def visit_definesterm(self, node, defineterms):
|
||||
var, _, _, _, term = defineterms
|
||||
return define_term(node.text, str(var), term)
|
||||
|
||||
def visit_numeric(self, node, numeric_term):
|
||||
return constant_term(node.text, int(node.text))
|
||||
|
||||
def visit_cmdarg(self, node, visited_children):
|
||||
_, argspec = visited_children
|
||||
return cmdarg_term(
|
||||
node.text, int(argspec.text.strip("$*")), argspec.text.endswith("*")
|
||||
)
|
||||
|
||||
def visit_ternary(self, node, visited_children):
|
||||
print(f"TERNARY: {visited_children}")
|
||||
cond, _, arg1, _, arg2 = visited_children
|
||||
return ternary_term(node.text, cond, arg1, arg2)
|
||||
|
||||
def visit_term(self, node, visited_children):
|
||||
print(f"TERM: {visited_children}")
|
||||
_, value, _ = visited_children
|
||||
(child,) = value
|
||||
return child
|
||||
|
||||
def visit_component(self, node, visited_children):
|
||||
print(f"COMPONENT: {visited_children}")
|
||||
_, value, _ = visited_children
|
||||
(child,) = value
|
||||
return child
|
||||
|
||||
def visit_var(self, node, varterm):
|
||||
root, remaining = varterm
|
||||
(root,) = root
|
||||
terms = (root, *(attr for _, attr in remaining))
|
||||
return get_var(node.text, *terms)
|
||||
|
||||
def visit_function(self, node, visited_children):
|
||||
print(f"FUNCTION: {node.text}, {visited_children}")
|
||||
func, remaining = visited_children
|
||||
args = (term for _, term in remaining)
|
||||
return run_function(node.text, func, *args)
|
||||
|
||||
def visit_cascade(self, node, visited_children):
|
||||
print(f"CASCADE: {visited_children}")
|
||||
term, remaining = visited_children
|
||||
terms = (term, *(bterm for _, bterm in remaining))
|
||||
return cascade_term(node.text, *terms)
|
||||
|
||||
def visit_binop(self, node, binopterm):
|
||||
(expr,) = binopterm
|
||||
term1, remaining = expr
|
||||
op = remaining[0][0]
|
||||
terms = (term1, *(bterm for _, bterm in remaining))
|
||||
return binop(node.text, op.text, *terms)
|
||||
@@ -0,0 +1,8 @@
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .execuitor import Execuitor
|
||||
from .context import ExecContext
|
||||
from .terms import *
|
||||
from .errors import CommandError, CommandValueError
|
||||
@@ -0,0 +1,48 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .terms import ExecutorTerm
|
||||
|
||||
|
||||
class ExecContext:
|
||||
"""
|
||||
Base context class that carries the instantiation state.
|
||||
|
||||
This is passed to all CommandTerms upon execution.
|
||||
"""
|
||||
|
||||
def __init__(self, *, alias: str, args: list[str], content: str, initial_globals: dict[str, Any] = {}, **kwargs):
|
||||
self.alias = alias
|
||||
self.args = args
|
||||
self.content = content
|
||||
|
||||
# Stack of scopes
|
||||
# Initialising globals this way to avoid modifying default arg
|
||||
globals = {} | initial_globals
|
||||
self.scopes: list[dict[str, Any]] = [globals]
|
||||
|
||||
# Stack of terms that are *currently* executing with this context.
|
||||
self.termstack: list['ExecutorTerm'] = []
|
||||
|
||||
def enter_scope(self, *args, **kwargs):
|
||||
new_scope = dict(*args, **kwargs)
|
||||
self.scopes.append(new_scope)
|
||||
|
||||
def leave_scope(self):
|
||||
if len(self.scopes) > 1:
|
||||
self.scopes.pop()
|
||||
else:
|
||||
raise ValueError("Cannot leave global scope")
|
||||
|
||||
@property
|
||||
def scope(self):
|
||||
# TODO: Move this to a more efficient ExecScope mapping type at some point
|
||||
fullscope = {}
|
||||
for scope in self.scopes:
|
||||
fullscope |= scope
|
||||
return fullscope
|
||||
|
||||
@property
|
||||
def this_scope(self):
|
||||
return self.scopes[-1]
|
||||
@@ -0,0 +1,132 @@
|
||||
from parsimonious.grammar import Grammar
|
||||
from parsimonious.nodes import NodeVisitor
|
||||
|
||||
from . import logger
|
||||
from .terms import (
|
||||
constant_term,
|
||||
define_equal,
|
||||
define_walrus,
|
||||
define_term,
|
||||
binop,
|
||||
compare,
|
||||
cmdarg_term,
|
||||
command_shell,
|
||||
get_var,
|
||||
ternary_term,
|
||||
run_function,
|
||||
cascade_term,
|
||||
)
|
||||
|
||||
|
||||
class Execuitor(NodeVisitor):
|
||||
"""
|
||||
NodeVisitor that visits each node in a parsed command component,
|
||||
and builds a forward-directional ExecTerm from them.
|
||||
"""
|
||||
|
||||
def lift_child_log(self, node, visited_children):
|
||||
print(f"NODE: {node} WITH {visited_children}")
|
||||
return self.lift_child(node, visited_children)
|
||||
|
||||
visit_basicterm = lift_child_log
|
||||
|
||||
def visit_parenthised(self, node, visited_children):
|
||||
print(f"PARENTH: {visited_children}")
|
||||
_, child, _ = visited_children[0]
|
||||
return child
|
||||
|
||||
def visit_command(self, node, visited_children):
|
||||
print(f"COMMAND: {visited_children}")
|
||||
_, component, _ = visited_children
|
||||
return command_shell(node.text, component)
|
||||
|
||||
def visit_bool(self, node, boolterm):
|
||||
(value,) = boolterm[0]
|
||||
if value.text.lower() == "true":
|
||||
return constant_term(node.text, True)
|
||||
elif value.text.lower() == "false":
|
||||
return constant_term(node.text, False)
|
||||
else:
|
||||
raise ValueError(f"Unrecognised bool {value.text}")
|
||||
|
||||
def visit_test(self, node, testterm):
|
||||
(expr,) = testterm
|
||||
term1, op, term2 = expr
|
||||
return compare(node.text, term1, op.text, term2)
|
||||
|
||||
def generic_visit(self, node, visited_children):
|
||||
"""If we don't have a rule for this node return it as raw text, but evaluatable"""
|
||||
print(f"GENERIC: {node} WITH {visited_children}")
|
||||
return visited_children or node
|
||||
|
||||
def visit_quoted(self, node, visited_children):
|
||||
return constant_term(node.text, node.text[1:-1])
|
||||
|
||||
def visit_keyword(self, node, visited_children):
|
||||
return node.text
|
||||
|
||||
def visit_word(self, node, visited_children):
|
||||
return node.text
|
||||
|
||||
def visit_equals(self, node, defineterms):
|
||||
var, _, _, _, term = defineterms
|
||||
return define_equal(node.text, str(var), term)
|
||||
|
||||
def visit_defines(self, node, defineterms):
|
||||
var, _, _, _, term = defineterms
|
||||
return define_walrus(node.text, str(var), term)
|
||||
|
||||
def visit_definesterm(self, node, defineterms):
|
||||
var, _, _, _, term = defineterms
|
||||
return define_term(node.text, str(var), term)
|
||||
|
||||
def visit_numeric(self, node, numeric_term):
|
||||
return constant_term(node.text, int(node.text))
|
||||
|
||||
def visit_cmdarg(self, node, visited_children):
|
||||
_, argspec = visited_children
|
||||
return cmdarg_term(
|
||||
node.text, int(argspec.text.strip("$*")), argspec.text.endswith("*")
|
||||
)
|
||||
|
||||
def visit_ternary(self, node, visited_children):
|
||||
print(f"TERNARY: {visited_children}")
|
||||
cond, _, arg1, _, arg2 = visited_children
|
||||
return ternary_term(node.text, cond, arg1, arg2)
|
||||
|
||||
def visit_term(self, node, visited_children):
|
||||
print(f"TERM: {visited_children}")
|
||||
_, value, _ = visited_children
|
||||
(child,) = value
|
||||
return child
|
||||
|
||||
def visit_component(self, node, visited_children):
|
||||
print(f"COMPONENT: {visited_children}")
|
||||
_, value, _ = visited_children
|
||||
(child,) = value
|
||||
return child
|
||||
|
||||
def visit_var(self, node, varterm):
|
||||
root, remaining = varterm
|
||||
(root,) = root
|
||||
terms = (root, *(attr for _, attr in remaining))
|
||||
return get_var(node.text, *terms)
|
||||
|
||||
def visit_function(self, node, visited_children):
|
||||
print(f"FUNCTION: {node.text}, {visited_children}")
|
||||
func, remaining = visited_children
|
||||
args = (term for _, term in remaining)
|
||||
return run_function(node.text, func, *args)
|
||||
|
||||
def visit_cascade(self, node, visited_children):
|
||||
print(f"CASCADE: {visited_children}")
|
||||
term, remaining = visited_children
|
||||
terms = (term, *(bterm for _, bterm in remaining))
|
||||
return cascade_term(node.text, *terms)
|
||||
|
||||
def visit_binop(self, node, binopterm):
|
||||
(expr,) = binopterm
|
||||
term1, remaining = expr
|
||||
op = remaining[0][0]
|
||||
terms = (term1, *(bterm for _, bterm in remaining))
|
||||
return binop(node.text, op.text, *terms)
|
||||
@@ -8,6 +8,25 @@ from .errors import CommandValueError
|
||||
if TYPE_CHECKING:
|
||||
from .context import ExecContext
|
||||
|
||||
|
||||
__all__ = (
|
||||
"constant_term",
|
||||
"define_equal",
|
||||
"define_walrus",
|
||||
"define_term",
|
||||
"binop",
|
||||
"compare",
|
||||
"cmdarg_term",
|
||||
"command_shell",
|
||||
"get_var",
|
||||
"ternary_term",
|
||||
"run_function",
|
||||
"cascade_term",
|
||||
'ExecutorTerm',
|
||||
'LateTerm',
|
||||
'lazy_term',
|
||||
)
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
T = TypeVar("T")
|
||||
@@ -152,18 +171,18 @@ async def command_shell(ctx, component_term: ExecutorTerm):
|
||||
|
||||
@lazy_term
|
||||
async def define_equal(ctx, var: str, value: ExecutorTerm):
|
||||
ctx.scope[var] = await value.execute(ctx)
|
||||
ctx.this_scope[var] = await value.execute(ctx)
|
||||
return None
|
||||
|
||||
@lazy_term
|
||||
async def define_walrus(ctx, var: str, value: ExecutorTerm):
|
||||
result = await value.execute(ctx)
|
||||
ctx.scope[var] = result
|
||||
ctx.this_scope[var] = result
|
||||
return result
|
||||
|
||||
@lazy_term
|
||||
async def define_term(ctx, var: str, value: ExecutorTerm):
|
||||
ctx.scope[var] = value
|
||||
ctx.this_scope[var] = value
|
||||
return None
|
||||
|
||||
@lazy_term
|
||||
@@ -0,0 +1,65 @@
|
||||
BEGIN;
|
||||
|
||||
|
||||
INSERT INTO version_history (component, from_version, to_version, author)
|
||||
VALUES ('CUSTOMCMD', 0, 1, 'Initial Creation');
|
||||
|
||||
CREATE TABLE customcmds(
|
||||
commandid INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
communityid INTEGER NOT NULL REFERENCES communities(communityid) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
cooldown_bucket_size INTEGER DEFAULT 1,
|
||||
cooldown_bucket_dur INTEGER,
|
||||
permlevel INTEGER,
|
||||
maskperms BOOLEAN DEFAULT FALSE,
|
||||
creatorid INTEGER NOT NULL REFERENCES profiles(profileid) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
description TEXT,
|
||||
docstring TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
_timestamp TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX customcmds_community_name ON customcmds (communityid, name);
|
||||
|
||||
CREATE TABLE customcmd_aliases(
|
||||
aliasid INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
commandid INTEGER REFERENCES customcmds (commandid) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
alias TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX custom_cmd_aliases_command_alias ON customcmd_aliases (commandid, alias);
|
||||
|
||||
-- Problem: Each alias should be unique in its community, not just for the command.
|
||||
-- Additionally, should the name always be available as an alias?
|
||||
-- Are we allowed to create an alias that clobbers another command?
|
||||
-- If we allow names to be associated via table only, then we have no canonical name (first name?) and no check that each command as "at least one" name.
|
||||
-- TODO: Will need to fix this later.
|
||||
-- Will start without aliases.
|
||||
|
||||
-- TODO: Command usage log so we can see the most commonly used commands
|
||||
|
||||
CREATE VIEW
|
||||
customcmd_info
|
||||
AS
|
||||
SELECT
|
||||
cmds.commandid,
|
||||
cmds.communityid,
|
||||
cmds.name,
|
||||
cmds.response,
|
||||
cmds.cooldown_bucket_size,
|
||||
cmds.cooldown_bucket_dur,
|
||||
cmds.permlevel,
|
||||
cmds.maskperms,
|
||||
cmds.creatorid,
|
||||
cmds.description,
|
||||
cmds.description,
|
||||
cmds.docstring,
|
||||
cmds.created_at,
|
||||
cmds._timestamp,
|
||||
aliases.aliasid,
|
||||
aliases.alias
|
||||
FROM customcmds AS cmds
|
||||
LEFT JOIN customcmd_aliases AS aliases USING (commandid);
|
||||
|
||||
|
||||
COMMIT;
|
||||
+52
-4
@@ -1,11 +1,13 @@
|
||||
from parsimonious.grammar import Grammar
|
||||
|
||||
from customcmd.parser import Execuitor, ExecContext
|
||||
from customcmd.client import ExecClient
|
||||
from customcmd.parser import Execuitor
|
||||
from customcmd.context import ExecContext
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
|
||||
# from customcmd.parser.context import ExecScope
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def grammar():
|
||||
@@ -21,7 +23,8 @@ def executor():
|
||||
@pytest.fixture
|
||||
def context():
|
||||
ctx = ExecContext(
|
||||
alias="test_cmd", args=["testarg1", "testarg2"], content="Base content"
|
||||
alias='test_cmd',
|
||||
args=["testarg1", "testarg2"], content="Base content"
|
||||
)
|
||||
return ctx
|
||||
|
||||
@@ -148,7 +151,7 @@ class TestGrammar:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_obj(self, grammar, executor, context):
|
||||
context.scope["obj"] = TestObject()
|
||||
context.this_scope["obj"] = TestObject()
|
||||
|
||||
expected_results = [
|
||||
("${obj}", "Testing Object"),
|
||||
@@ -225,3 +228,48 @@ class TestClient:
|
||||
async def test_command_2(self, client, context):
|
||||
actual = await client.run_command(context, "A test ${2} of the client ${$2}")
|
||||
assert actual == "A test 2 of the client testarg2"
|
||||
|
||||
|
||||
class LaterTestScope:
|
||||
def test_scope_toplevel(self):
|
||||
scope = ExecScope()
|
||||
scope['key'] = 'value'
|
||||
|
||||
assert scope['key'] == 'value'
|
||||
|
||||
|
||||
def test_scope_stack(self):
|
||||
scope = ExecScope()
|
||||
scope['key'] = 'value1'
|
||||
|
||||
scope.enter_scope()
|
||||
|
||||
assert scope['key'] == 'value1'
|
||||
|
||||
scope['key'] = 'value12'
|
||||
|
||||
assert scope['key'] == 'value12'
|
||||
|
||||
scope['key2'] = 'value2'
|
||||
|
||||
assert scope['key2'] == 'value2'
|
||||
|
||||
scope.leave_scope()
|
||||
|
||||
assert scope.get('key2') is None
|
||||
assert scope['key'] == 'value'
|
||||
|
||||
def test_scope_enter_init(self):
|
||||
scope = ExecScope(key="value1")
|
||||
|
||||
assert scope['key'] == 'value1'
|
||||
|
||||
scope.enter_scope(key2="value2")
|
||||
|
||||
assert scope['key2'] == 'value2'
|
||||
|
||||
scope.leave_scope()
|
||||
|
||||
assert scope.get('key2') is None
|
||||
assert scope.get('key') == 'value1'
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import twitchio
|
||||
|
||||
from customcmd.client import ExecClient
|
||||
from customcmd.data import CustomCmd, CustomCmdData
|
||||
from meta import Bot
|
||||
|
||||
from .context import CustomContext, CustomCmdUser
|
||||
|
||||
class TwitchCmdClient(ExecClient):
|
||||
def __init__(self, bot: Bot, data: CustomCmdData):
|
||||
super().__init__()
|
||||
self.bot = bot
|
||||
self.data = data
|
||||
|
||||
async def process_message(self, message: twitchio.ChatMessage):
|
||||
# Check the prefixes in this community
|
||||
# Or get the 'possible' prefixes in this context, which should be mostly cached
|
||||
prefixes = await self.get_prefixes(message)
|
||||
prefixes = sorted(prefixes, key=len, reverse=True)
|
||||
|
||||
content = message.text
|
||||
if message.reply:
|
||||
# Strip the hidden mention prefix from the start of the message
|
||||
prefix = message.reply.parent_user.mention
|
||||
content = content[len(prefix) + 1:]
|
||||
|
||||
# If we start with a valid prefix, parse out a command name
|
||||
if prefix := next((p for p in prefixes if content.startswith(p)), None):
|
||||
# Strip the valid prefix
|
||||
content = content[len(prefix):].strip()
|
||||
if not content:
|
||||
# Exit early, no command
|
||||
return
|
||||
|
||||
# Extract possible command word
|
||||
maybe_command, *args = content.split()
|
||||
maybe_command = maybe_command.lower()
|
||||
|
||||
# Lookup command words in this community (cache?)
|
||||
comm = await self.bot.profiles.fetch_community(message.broadcaster)
|
||||
cmdmap = await self.get_commands_in(comm.communityid)
|
||||
|
||||
if cmd := cmdmap.get(maybe_command):
|
||||
# We now finally have a command.
|
||||
# Read the arguments, build the context, run the command.
|
||||
# TODO: Check the permissions and log etc.
|
||||
# TODO: Exception handling, as usual
|
||||
# TODO: Nice string reprs
|
||||
ctx = CustomContext(
|
||||
bot=self.bot,
|
||||
args=args,
|
||||
alias=maybe_command,
|
||||
used_prefix=prefix,
|
||||
message=message,
|
||||
initial_globals={
|
||||
'author': CustomCmdUser(message.chatter),
|
||||
'channel': CustomCmdUser(message.broadcaster),
|
||||
}
|
||||
)
|
||||
result = await self.run_command(ctx, cmd.response)
|
||||
if result:
|
||||
await message.broadcaster.send_message(
|
||||
result,
|
||||
sender=self.bot.user
|
||||
)
|
||||
|
||||
async def get_prefixes(self, message: twitchio.ChatMessage):
|
||||
# TODO:
|
||||
return ('!',)
|
||||
|
||||
async def get_commands_in(self, communityid: int) -> dict[str, CustomCmd]:
|
||||
"""
|
||||
Get the custom commands in this community.
|
||||
|
||||
Returns a map of {alias -> CustomCommand}
|
||||
"""
|
||||
# TODO: Alias support
|
||||
commands = await CustomCmd.fetch_where(communityid=communityid)
|
||||
return {cmd.name: cmd for cmd in commands}
|
||||
@@ -0,0 +1,98 @@
|
||||
from typing import Optional
|
||||
|
||||
import twitchio
|
||||
from twitchio import PartialUser
|
||||
from twitchio.ext import commands as cmds
|
||||
|
||||
from customcmd.parser.context import ExecContext
|
||||
from meta import Bot
|
||||
from twitch.client import TwitchCmdClient
|
||||
from utils.lib import utc_now
|
||||
|
||||
from . import logger
|
||||
from ..customcmd.data import CustomCmd, CustomCmdData
|
||||
from ..customcmd.client import ExecClient
|
||||
|
||||
# TODO: Make an alphacroc or croccyalpha client for testing
|
||||
# TODO: Fix the tracker not actually guarding the commands properly
|
||||
|
||||
|
||||
|
||||
|
||||
class CustomCmdComponent(cmds.Component):
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
self.data = bot.dbconn.load_registry(CustomCmdData())
|
||||
self.cmdclient = TwitchCmdClient(self.bot, self.data)
|
||||
|
||||
# ----- API -----
|
||||
async def component_load(self):
|
||||
await self.data.init()
|
||||
await self.bot.version_check(*self.data.VERSION)
|
||||
|
||||
async def component_teardown(self):
|
||||
pass
|
||||
|
||||
# ----- Methods -----
|
||||
|
||||
# ----- Event handlers -----
|
||||
@cmds.Component.listener()
|
||||
async def event_message(self, payload: twitchio.ChatMessage):
|
||||
await self.cmdclient.process_message(payload)
|
||||
|
||||
# ----- Commands -----
|
||||
@cmds.group(name="commands")
|
||||
async def cmd_grp(self, ctx: cmds.Context):
|
||||
# TODO: Probably attempt to list commands or something here.
|
||||
pass
|
||||
|
||||
@cmd_grp.command(name="add", aliases=('create', 'new'))
|
||||
@cmds.is_broadcaster() # TODO: Better command creation perm checks
|
||||
async def cmd_add(self, ctx: cmds.Context, name: str, *, response: str):
|
||||
comm = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = comm.communityid
|
||||
|
||||
# Make sure the command doesn't already exist
|
||||
cmdmap = await self.cmdclient.get_commands_in(cid)
|
||||
if name.lower() in cmdmap:
|
||||
await ctx.reply(f"The command {name} is already defined!")
|
||||
return
|
||||
|
||||
# Make sure the command parses (can't guarantee it runs, but at least it can parse)
|
||||
try:
|
||||
await self.cmdclient.run_command(ExecContext(args=[], content=''), response)
|
||||
except Exception:
|
||||
# TODO: Nicer errors for pinpointing problem
|
||||
await ctx.reply(f"Sorry, could not parse your command!")
|
||||
logger.info(f"Failed to parse attempted customcmd '{response}'", exc_info=True)
|
||||
|
||||
# If everything is good, add it to the table
|
||||
# TODO: Again, permission resolution
|
||||
author_profile = await self.bot.profiles.fetch_profile(ctx.chatter)
|
||||
await CustomCmd.create(
|
||||
communityid=cid,
|
||||
name=name.lower(),
|
||||
response=response,
|
||||
permlevel=0,
|
||||
creatorid=author_profile.profileid,
|
||||
)
|
||||
|
||||
await ctx.reply(f"Created a new command !{name}")
|
||||
|
||||
@cmd_grp.command(name="del", aliases=('delete', 'remove', 'rm'))
|
||||
@cmds.is_broadcaster()
|
||||
async def cmd_del(self, ctx: cmds.Context, name: str):
|
||||
# Lookup the name to make sure a command does exist
|
||||
# Delete the command (again, consider permissions later)
|
||||
# Confirm with user.
|
||||
comm = await self.bot.profiles.fetch_community(ctx.broadcaster)
|
||||
cid = comm.communityid
|
||||
|
||||
# Make sure the command already exists
|
||||
cmdmap = await self.cmdclient.get_commands_in(cid)
|
||||
if name.lower() in cmdmap:
|
||||
cmd = cmdmap[name.lower()]
|
||||
await cmd.delete()
|
||||
await ctx.reply(f"Deleted the command !{name}")
|
||||
else:
|
||||
await ctx.reply(f"The command {name} is already defined!")
|
||||
@@ -0,0 +1,68 @@
|
||||
import twitchio
|
||||
|
||||
from customcmd.client import ExecClient
|
||||
from customcmd.data import CustomCmd, CustomCmdData
|
||||
from meta import Bot
|
||||
from utils.lib import utc_now, strfdur
|
||||
|
||||
from ..customcmd.parser import ExecContext
|
||||
from .ctxvars import stuff
|
||||
|
||||
|
||||
class CustomCmdUser:
|
||||
def __init__(self, user: twitchio.PartialUser):
|
||||
self.user = user
|
||||
|
||||
@property
|
||||
def _exec_attr_id(self):
|
||||
return self.user.id
|
||||
|
||||
@property
|
||||
def _exec_attr_name(self):
|
||||
return self.user.display_name
|
||||
|
||||
def __str__(self):
|
||||
return self.user.display_name or self.user.name or self.user.id
|
||||
|
||||
async def _exec_attr_whisper(self, ctx, message: str):
|
||||
await self.user.send_whisper(
|
||||
to_user=self.user,
|
||||
message=message
|
||||
)
|
||||
|
||||
async def _exec_attr_followage(self, ctx, channel: twitchio.PartialUser):
|
||||
followed_at = None
|
||||
query = await channel.fetch_followers(user=self.user)
|
||||
async for follower in query.followers:
|
||||
followed_at = follower.followed_at
|
||||
|
||||
if followed_at:
|
||||
durstr = strfdur(utc_now() - followed_at)
|
||||
resp = f"{self.user.mention} has been following {channel.mention} for {durstr}"
|
||||
else:
|
||||
return f"{self.user.mention} is not following {channel.mention}"
|
||||
|
||||
@classmethod
|
||||
def from_user(cls, user: twitchio.PartialUser):
|
||||
return cls(user)
|
||||
|
||||
|
||||
class CustomContext(ExecContext):
|
||||
"""
|
||||
CustomCommand ExecContext instantiated from a Twitch message
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
bot: Bot,
|
||||
message: twitchio.ChatMessage,
|
||||
alias: str,
|
||||
used_prefix: str,
|
||||
**kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.bot = bot
|
||||
self.message = message
|
||||
self.alias = alias
|
||||
self.used_prefix = used_prefix
|
||||
|
||||
self.enter_scope(stuff)
|
||||
# TODO: Need a nice way to define some global functions. Registry pattern?
|
||||
@@ -0,0 +1,30 @@
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
|
||||
stuff = {}
|
||||
|
||||
def register_var(name: str | None = None):
|
||||
def wrapper(coro):
|
||||
varname = name or coro.__name__
|
||||
stuff[varname] = coro
|
||||
return coro
|
||||
return wrapper
|
||||
|
||||
|
||||
@register_var('random')
|
||||
async def random_func(ctx, a: int, b: Optional[int] = None):
|
||||
if b is not None:
|
||||
low, high = a, b
|
||||
else:
|
||||
low, high = 0, a
|
||||
return random.randint(low, high)
|
||||
|
||||
@register_var('user')
|
||||
async def user_func(ctx, userstr: int | str):
|
||||
"""
|
||||
Attempt to turn the given userstr into a user.
|
||||
Currently just looks up the user directly.
|
||||
Some fuzzy matching may be appropriate in the future.
|
||||
"""
|
||||
...
|
||||
Reference in New Issue
Block a user