Skip to content

Commit 7714b41

Browse files
Add persistent synonyms with pdictng (#134)
* persistent synonyms working * synonyms in match_command * deal with edge cases for synonyms * move synonymbot to forest and update for new pdictng * switch from a protocol to only setting the synonyms in a decorator, similarly to requires_admin, hide, and group_help_text on the imogen branch Co-authored-by: technillogue <[email protected]>
1 parent 0a2ee9b commit 7714b41

File tree

1 file changed

+149
-0
lines changed

1 file changed

+149
-0
lines changed

forest/synonymbot.py

+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/python3.9
2+
# Copyright (c) 2021 MobileCoin Inc.
3+
# Copyright (c) 2021 The Forest Team
4+
from functools import wraps
5+
from typing import Tuple, Any, Callable, Coroutine
6+
from forest.core import Bot, Message, Response, requires_admin, is_admin, run_bot
7+
from forest.pdictng import aPersistDictOfLists
8+
9+
Command = Callable[[Bot, Message], Coroutine[Any, Any, Response]]
10+
11+
12+
def synonyms(*syns: str) -> Callable:
13+
def decorate(command: Command) -> Command:
14+
@wraps(command)
15+
async def synonym_command(self: "Bot", msg: Message) -> Response:
16+
return await command(self, msg)
17+
18+
synonym_command.syns = syns # type: ignore
19+
return synonym_command
20+
21+
return decorate
22+
23+
24+
class SynonymBot(Bot):
25+
def __init__(self) -> None:
26+
self.synonyms: aPersistDictOfLists[str] = aPersistDictOfLists("synonyms")
27+
super().__init__()
28+
29+
def get_valid_syns(self, msg: Message) -> Tuple:
30+
"Get commands and synonyms without leaking admin commands"
31+
valid_cmds = self.commands if is_admin(msg) else self.visible_commands
32+
valid_syns = {k: v for k, v in self.synonyms.dict_.items() if k in valid_cmds}
33+
return (valid_cmds, valid_syns)
34+
35+
@requires_admin
36+
async def do_build_synonyms(self, _: Message) -> str:
37+
"""Build synonyms from in-code definitions.
38+
39+
Run this command as admin when bot is first deployed.
40+
"""
41+
for cmd in self.commands:
42+
command = "do_" + cmd
43+
method = None
44+
# check for the command
45+
if hasattr(self, command):
46+
method = getattr(self, command)
47+
if method is not None:
48+
if hasattr(method, "syns"):
49+
syns = getattr(method, "syns")
50+
await self.synonyms.set(cmd, syns)
51+
return f"Built synonym list: {self.synonyms}"
52+
53+
@requires_admin
54+
async def do_clear_synonyms(self, _: Message) -> str:
55+
"Remove all synonyms from persistent storage. Admin-only"
56+
cmds = await self.synonyms.keys()
57+
for cmd in cmds:
58+
await self.synonyms.remove(cmd)
59+
return "Synonym list cleared"
60+
61+
async def do_list_synonyms(self, msg: Message) -> str:
62+
"Print synonyms for all commands, or a single command if included"
63+
valid_cmds, valid_syns = self.get_valid_syns(msg)
64+
if msg.arg1 in valid_cmds:
65+
syns = await self.synonyms.get(str(msg.arg1))
66+
return f"Synonyms for '{msg.arg1}' are: {syns}"
67+
if any(msg.arg1 in v for v in valid_syns.values()):
68+
cmds = [k for k, v in valid_syns.items() if msg.arg1 in v]
69+
return f"'{msg.arg1}' is a synonym for {cmds}"
70+
return f"Synonym list: {valid_syns}"
71+
72+
async def do_link(self, msg: Message) -> str:
73+
"Link a command to a synonym"
74+
valid_cmds, valid_syns = self.get_valid_syns(msg)
75+
if msg.arg1 in valid_cmds:
76+
if msg.arg2:
77+
# Check if the synonym already in use
78+
if msg.arg2 in valid_cmds:
79+
return f"Sorry, '{msg.arg2}' is a command"
80+
if any(msg.arg2 in v for v in valid_syns.values()):
81+
cmds = [k for k, v in valid_syns.items() if msg.arg2 in v]
82+
return f"Sorry, '{msg.arg2}' is already associated with one or more commands: {cmds}"
83+
# Happy path, add the synonym
84+
if msg.arg1 not in valid_syns.keys():
85+
await self.synonyms.set(str(msg.arg1), [msg.arg2])
86+
else:
87+
await self.synonyms.extend(str(msg.arg1), msg.arg2)
88+
return f"Linked synonym '{msg.arg2}' to command '{msg.arg1}'"
89+
# No synonym detected
90+
return f"Need a synonym to link to command '{msg.arg1}', try again"
91+
# No command detected
92+
return "Not a valid command. Syntax for linking commands is 'link command synonym'. Please try again"
93+
94+
async def do_unlink(self, msg: Message) -> str:
95+
"Remove a command from a synonym"
96+
valid_cmds, valid_syns = self.get_valid_syns(msg)
97+
# Look for a command
98+
if msg.arg1 in valid_cmds:
99+
syns = valid_syns[msg.arg1]
100+
# Happy path, remove the synonym
101+
if msg.arg2 and msg.arg2 in syns:
102+
await self.synonyms.remove_from(str(msg.arg1), str(msg.arg2))
103+
return f"Unlinked synonym '{msg.arg2}' from command '{msg.arg1}'"
104+
# No synonym detected
105+
return f"Need a synonym to unlink from command '{msg.arg1}'. Valid synonyms are {syns}"
106+
# Look for a synonym by itself
107+
if any(msg.arg1 in v for v in valid_syns.values()):
108+
cmds = [k for k, v in valid_syns.items() if msg.arg1 in v]
109+
print(cmds)
110+
# Synonym points to multiple commands
111+
if len(cmds) > 1:
112+
return f"Multiple commands have that synonym: {cmds}. Please try again in the form 'unlink command synonym'"
113+
# Only points to one command, remove the synonym
114+
if len(cmds) == 1:
115+
await self.synonyms.remove_from(cmds[0], str(msg.arg1))
116+
return f"Synonym '{msg.arg1}' removed from command '{cmds[0]}'"
117+
return "Syntax for unlinking commands is 'unlink command synonym', try again"
118+
119+
def match_command(self, msg: Message) -> str:
120+
if not msg.arg0:
121+
return ""
122+
# Look for direct match before checking synonyms
123+
if hasattr(self, "do_" + msg.arg0):
124+
return msg.arg0
125+
# Try synonyms
126+
_, valid_syns = self.get_valid_syns(msg)
127+
for k, v in valid_syns.items():
128+
if msg.arg0 in v:
129+
return k
130+
# Pass the buck
131+
return super().match_command(msg)
132+
133+
# We can add synonyms in development. give your command the
134+
# @synonyms decorator and pass some synonyms
135+
@synonyms("hi", "hey", "whatup", "aloha")
136+
async def do_hello(self, _: Message) -> str:
137+
return "Hello, world!"
138+
139+
@synonyms("bye", "goodby", "later", "aloha")
140+
async def do_goodbye(self, _: Message) -> str:
141+
return "Goodbye, cruel world!"
142+
143+
@synonyms("documentation", "docs", "commands", "man")
144+
async def do_help(self, msg: Message) -> Response:
145+
return await super().do_help(msg)
146+
147+
148+
if __name__ == "__main__":
149+
run_bot(SynonymBot)

0 commit comments

Comments
 (0)