csp-bot 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- csp_bot/__init__.py +13 -0
- csp_bot/bot.py +576 -0
- csp_bot/bot_config.py +67 -0
- csp_bot/cli.py +103 -0
- csp_bot/commands/__init__.py +18 -0
- csp_bot/commands/base.py +108 -0
- csp_bot/commands/common.py +689 -0
- csp_bot/commands/delaytest.py +70 -0
- csp_bot/commands/echo.py +36 -0
- csp_bot/commands/fun.py +67 -0
- csp_bot/commands/help.py +76 -0
- csp_bot/commands/mets.py +125 -0
- csp_bot/commands/schedule.py +135 -0
- csp_bot/commands/thanks.py +46 -0
- csp_bot/commands/trout.py +53 -0
- csp_bot/config/__init__.py +46 -0
- csp_bot/config/commands.yaml +23 -0
- csp_bot/config/conf.yaml +30 -0
- csp_bot/config/gateway/all.yaml +42 -0
- csp_bot/config/gateway/discord.yaml +22 -0
- csp_bot/config/gateway/mixed.yaml +32 -0
- csp_bot/config/gateway/slack.yaml +24 -0
- csp_bot/config/gateway/symphony.yaml +24 -0
- csp_bot/config/hydra/job_logging/custom.yaml +37 -0
- csp_bot/gateway/__init__.py +1 -0
- csp_bot/gateway/gateway.py +65 -0
- csp_bot/structs.py +158 -0
- csp_bot/tests/__init__.py +0 -0
- csp_bot/tests/conftest.py +0 -0
- csp_bot/tests/test_all.py +1 -0
- csp_bot/tests/test_config.py +21 -0
- csp_bot/tests/test_slack_entity_regex.py +14 -0
- csp_bot/utils.py +69 -0
- csp_bot-0.0.1.dist-info/METADATA +97 -0
- csp_bot-0.0.1.dist-info/RECORD +38 -0
- csp_bot-0.0.1.dist-info/WHEEL +4 -0
- csp_bot-0.0.1.dist-info/entry_points.txt +2 -0
- csp_bot-0.0.1.dist-info/licenses/LICENSE +201 -0
csp_bot/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# reexport
|
|
5
|
+
from csp_adapter_discord import DiscordAdapterConfig
|
|
6
|
+
from csp_adapter_slack import SlackAdapterConfig
|
|
7
|
+
from csp_adapter_symphony import SymphonyAdapterConfig
|
|
8
|
+
|
|
9
|
+
from .bot import Bot
|
|
10
|
+
from .bot_config import BotConfig, DiscordConfig, SlackConfig, SymphonyConfig
|
|
11
|
+
from .commands import BaseCommand, BaseCommandModel
|
|
12
|
+
from .gateway import CubistBotGateway, Gateway, GatewayChannels
|
|
13
|
+
from .structs import BotCommand, CommandVariant, Message
|
csp_bot/bot.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from csv import reader
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from io import StringIO
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
|
10
|
+
|
|
11
|
+
import csp
|
|
12
|
+
from bs4 import BeautifulSoup, Tag
|
|
13
|
+
from croniter import croniter
|
|
14
|
+
from csp import Outputs, ts
|
|
15
|
+
from csp_adapter_discord import DiscordAdapterManager, DiscordMessage as RawDiscordMessage
|
|
16
|
+
from csp_adapter_slack import SlackAdapterManager, SlackMessage as RawSlackMessage
|
|
17
|
+
from csp_adapter_symphony import SymphonyAdapter, SymphonyMessage as RawSymphonyMessage
|
|
18
|
+
from csp_adapter_symphony.adapter import Presence
|
|
19
|
+
from pydantic import PrivateAttr
|
|
20
|
+
|
|
21
|
+
from .bot_config import BotConfig
|
|
22
|
+
from .commands import (
|
|
23
|
+
BaseCommand,
|
|
24
|
+
BaseCommandModel,
|
|
25
|
+
HelpCommand,
|
|
26
|
+
ScheduleCommand,
|
|
27
|
+
)
|
|
28
|
+
from .gateway import GatewayChannels, GatewayModule
|
|
29
|
+
from .structs import (
|
|
30
|
+
BotCommand,
|
|
31
|
+
CommandVariant,
|
|
32
|
+
Message,
|
|
33
|
+
User,
|
|
34
|
+
)
|
|
35
|
+
from .utils import Backend
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
SLACK_ENTITY_REGEX = re.compile("<@.+?>")
|
|
40
|
+
DISCORD_ENTITY_REGEX = re.compile("<@.+?>")
|
|
41
|
+
csp.set_print_full_exception_stack(True)
|
|
42
|
+
__all__ = ("Bot",)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Bot(GatewayModule):
|
|
46
|
+
config: BotConfig
|
|
47
|
+
|
|
48
|
+
# FIXME do via hydra
|
|
49
|
+
# commands: List[BaseCommandModel]
|
|
50
|
+
_command_models: List[BaseCommandModel] = PrivateAttr(default_factory=list)
|
|
51
|
+
_commands: Dict[str, BaseCommand] = PrivateAttr(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
_configs: Dict[Backend, dict] = PrivateAttr(default_factory=dict) # convenience for lookups by name
|
|
54
|
+
_adapters: Dict[Backend, object] = PrivateAttr(default_factory=dict)
|
|
55
|
+
_scheduled = PrivateAttr(default_factory=dict)
|
|
56
|
+
_authorized_users: Dict[Backend, Set[str]] = PrivateAttr(default_factory=dict)
|
|
57
|
+
_thread: Optional[threading.Thread] = PrivateAttr(None)
|
|
58
|
+
_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
|
|
59
|
+
|
|
60
|
+
def connect(self, channels: GatewayChannels) -> None:
|
|
61
|
+
if self.config.discord_config:
|
|
62
|
+
self._configs["discord"] = self.config.discord_config
|
|
63
|
+
self._adapters["discord"] = DiscordAdapterManager(self.config.discord_config.adapter_config)
|
|
64
|
+
if self.config.slack_config:
|
|
65
|
+
self._configs["slack"] = self.config.slack_config
|
|
66
|
+
self._adapters["slack"] = SlackAdapterManager(self.config.slack_config.adapter_config)
|
|
67
|
+
if self.config.symphony_config:
|
|
68
|
+
self._configs["symphony"] = self.config.symphony_config
|
|
69
|
+
self._adapters["symphony"] = SymphonyAdapter(self.config.symphony_config.adapter_config)
|
|
70
|
+
|
|
71
|
+
# Get raw messages from adapter
|
|
72
|
+
messages_in = csp.null_ts(Message)
|
|
73
|
+
for adapter_type, adapter in self._adapters.items():
|
|
74
|
+
unrolled_raw_msgs = csp.unroll(adapter.subscribe(**self._configs[adapter_type].adapter_kwargs()))
|
|
75
|
+
processed_messages = self.raw_message_to_message(adapter_type, unrolled_raw_msgs)
|
|
76
|
+
messages_in = csp.flatten([messages_in, processed_messages])
|
|
77
|
+
# messages.append(self.raw_message_to_message(adapter_type, csp.unroll(adapter.subscribe(**self._configs[adapter_type].adapter_kwargs()))))
|
|
78
|
+
|
|
79
|
+
# messages_in = csp.flatten(messages)
|
|
80
|
+
|
|
81
|
+
# Echo to channel
|
|
82
|
+
channels.set_channel(GatewayChannels.messages_in, messages_in)
|
|
83
|
+
|
|
84
|
+
# Extract bot commands from raw feed
|
|
85
|
+
new_commands_and_authorization_rejections = self.message_to_bot_commands(messages_in)
|
|
86
|
+
bot_commands = csp.unroll(new_commands_and_authorization_rejections.bot_commands)
|
|
87
|
+
channels.set_channel(GatewayChannels.commands, bot_commands)
|
|
88
|
+
|
|
89
|
+
# Process bot commands and get responses/secondary commands
|
|
90
|
+
messages_and_commands = self.bot_command_handler(channels.get_channel(GatewayChannels.commands))
|
|
91
|
+
messages_out = csp.flatten([csp.unroll(messages_and_commands.messages), new_commands_and_authorization_rejections.unauthorized_message])
|
|
92
|
+
|
|
93
|
+
# Echo to channel
|
|
94
|
+
channels.set_channel(GatewayChannels.messages_out, messages_out)
|
|
95
|
+
|
|
96
|
+
# loopback commands to command channels
|
|
97
|
+
bot_commands_to_loopback = csp.unroll(messages_and_commands.commands)
|
|
98
|
+
channels.set_channel(GatewayChannels.commands, bot_commands_to_loopback)
|
|
99
|
+
|
|
100
|
+
# Process messages back to raw messages
|
|
101
|
+
raw_messages_out = self.message_to_raw_message(messages_out)
|
|
102
|
+
|
|
103
|
+
# And publish back to adapter
|
|
104
|
+
for adapter_type, adapter in self._adapters.items():
|
|
105
|
+
adapter.publish(msg=getattr(raw_messages_out, adapter_type))
|
|
106
|
+
|
|
107
|
+
# TODO generalize presence
|
|
108
|
+
if self._configs.get("symphony") and self._configs["symphony"].set_presence_seconds:
|
|
109
|
+
self._adapters["symphony"].publish_presence(
|
|
110
|
+
csp.timer(timedelta(seconds=self._configs["symphony"].set_presence_seconds), Presence.AVAILABLE)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Update user access
|
|
114
|
+
if self._configs.get("discord") and self._configs["discord"].user_access_channels:
|
|
115
|
+
log.warning("Slack user access is not fully implemented")
|
|
116
|
+
if self._configs.get("slack") and self._configs["slack"].user_access_channels:
|
|
117
|
+
log.warning("Slack user access is not fully implemented")
|
|
118
|
+
if self._configs.get("symphony") and self._configs["symphony"].user_access_channels:
|
|
119
|
+
# We want the first query to happen before starting the graph.
|
|
120
|
+
# This way, if the query fails for some reason
|
|
121
|
+
# we will raise and not start up the csp.graph,
|
|
122
|
+
# avoiding any issue of unauthorized user access.
|
|
123
|
+
self._update_user_access("symphony")
|
|
124
|
+
if self._configs["symphony"].query_user_access_channels_seconds:
|
|
125
|
+
self._thread = threading.Thread(target=self._update_user_access_loop, args=("symphony",), daemon=True)
|
|
126
|
+
self._thread.start()
|
|
127
|
+
|
|
128
|
+
def _update_user_access(self, adapter_type: str):
|
|
129
|
+
if adapter_type != "symphony":
|
|
130
|
+
raise NotImplementedError("Only Symphony is supported for user access for now")
|
|
131
|
+
users: Set[str] = set()
|
|
132
|
+
for channel_name in self._configs[adapter_type].user_access_channels:
|
|
133
|
+
user_list = self._configs[adapter_type].adapter_config.get_user_ids_in_room(room_name=channel_name)
|
|
134
|
+
users.update(user_list)
|
|
135
|
+
with self._lock:
|
|
136
|
+
self._authorized_users["symphony"] = users
|
|
137
|
+
|
|
138
|
+
def _update_user_access_loop(self, adapter_type: str):
|
|
139
|
+
if adapter_type != "symphony":
|
|
140
|
+
raise NotImplementedError("Only Symphony is supported for user access for now")
|
|
141
|
+
while True:
|
|
142
|
+
# We start with the sleep first since our first call occurs
|
|
143
|
+
# outside of this loop
|
|
144
|
+
time.sleep(self._configs[adapter_type].query_user_access_channels_seconds)
|
|
145
|
+
try:
|
|
146
|
+
self._update_user_access("symphony'")
|
|
147
|
+
except Exception:
|
|
148
|
+
log.exception(f"Error attempting to update user access for {adapter_type}")
|
|
149
|
+
|
|
150
|
+
def load_commands(self, command_models: List[BaseCommandModel]):
|
|
151
|
+
for command_model in command_models:
|
|
152
|
+
try:
|
|
153
|
+
# Instantiate to ensure completion
|
|
154
|
+
command = command_model.command()
|
|
155
|
+
except TypeError as e:
|
|
156
|
+
log.critical(f"Incomplete command type - ensure you've implemented all abstract methods: {command_model.command}")
|
|
157
|
+
raise e
|
|
158
|
+
|
|
159
|
+
# Register by name
|
|
160
|
+
command_str = command.command()
|
|
161
|
+
if command_str in self._commands:
|
|
162
|
+
raise Exception(f"Command already registered: {command_str}\n\t{command}\n\t{self._commands[command_str]}")
|
|
163
|
+
|
|
164
|
+
# keep track of model and command
|
|
165
|
+
self._commands[command_str] = command
|
|
166
|
+
# don't really care about this as its just a bridge to hydra
|
|
167
|
+
self._command_models.append(command_model)
|
|
168
|
+
|
|
169
|
+
#############
|
|
170
|
+
# CSP Nodes #
|
|
171
|
+
#############
|
|
172
|
+
@csp.node
|
|
173
|
+
def raw_message_to_message(self, adapter_type: str, raw_message: ts[Any]) -> ts[Message]:
|
|
174
|
+
return Message.from_raw_message(adapter_type, raw_message)
|
|
175
|
+
|
|
176
|
+
@csp.node
|
|
177
|
+
def message_to_raw_message(self, message: ts[Message]) -> Outputs(
|
|
178
|
+
discord=ts[RawDiscordMessage], slack=ts[RawSlackMessage], symphony=ts[RawSymphonyMessage]
|
|
179
|
+
):
|
|
180
|
+
if message.backend == "symphony":
|
|
181
|
+
csp.output(symphony=message.to_raw_message("symphony"))
|
|
182
|
+
elif message.backend == "slack":
|
|
183
|
+
csp.output(slack=message.to_raw_message("slack"))
|
|
184
|
+
elif message.backend == "discord":
|
|
185
|
+
csp.output(discord=message.to_raw_message("discord"))
|
|
186
|
+
else:
|
|
187
|
+
raise NotImplementedError(f"Message type not supported: {message.backend}")
|
|
188
|
+
|
|
189
|
+
@csp.node
|
|
190
|
+
def message_to_bot_commands(self, message: ts[Message]) -> Outputs(bot_commands=ts[[BotCommand]], unauthorized_message=ts[Message]):
|
|
191
|
+
if csp.ticked(message):
|
|
192
|
+
is_msg_to_bot = False
|
|
193
|
+
try:
|
|
194
|
+
is_msg_to_bot, channel, text, entities = self.is_msg_to_bot(message)
|
|
195
|
+
if not is_msg_to_bot:
|
|
196
|
+
# We intentionally avoid logging the message itself
|
|
197
|
+
# for security concerns.
|
|
198
|
+
log.info("Ignoring message (not to bot)")
|
|
199
|
+
else:
|
|
200
|
+
if not self.is_authorized(message):
|
|
201
|
+
if self._config[message.backend].unauthorized_msg:
|
|
202
|
+
channel = message.channel if message.channel != "IM" else message.user
|
|
203
|
+
unauthorized_message = Message(
|
|
204
|
+
msg=self._config[message.backend].unauthorized_msg, channel=channel, source=message.backend
|
|
205
|
+
)
|
|
206
|
+
csp.output(unauthorized_message=unauthorized_message)
|
|
207
|
+
else:
|
|
208
|
+
bot_commands = self.extract_bot_commands(message, channel, text, entities)
|
|
209
|
+
if bot_commands:
|
|
210
|
+
if not isinstance(bot_commands, list):
|
|
211
|
+
bot_commands = [bot_commands]
|
|
212
|
+
csp.output(bot_commands=bot_commands)
|
|
213
|
+
except KeyboardInterrupt:
|
|
214
|
+
raise
|
|
215
|
+
except Exception:
|
|
216
|
+
# Ignore
|
|
217
|
+
if is_msg_to_bot:
|
|
218
|
+
log.exception(f"Error processing message: {message}")
|
|
219
|
+
else:
|
|
220
|
+
log.exception("Error processing message (not to bot)")
|
|
221
|
+
|
|
222
|
+
@csp.node
|
|
223
|
+
def bot_command_handler(self, command: ts[BotCommand]) -> Outputs(messages=ts[[Message]], commands=ts[[BotCommand]]):
|
|
224
|
+
with csp.alarms():
|
|
225
|
+
a_scheduled_command: ts[BotCommand] = csp.alarm(BotCommand)
|
|
226
|
+
a_ratelimit: ts[bool] = csp.alarm(bool)
|
|
227
|
+
with csp.state():
|
|
228
|
+
s_buffer = []
|
|
229
|
+
s_buffer_last = []
|
|
230
|
+
s_commands_to_process = []
|
|
231
|
+
|
|
232
|
+
with csp.start():
|
|
233
|
+
csp.schedule_alarm(a_ratelimit, timedelta(seconds=self.config.ratelimit_seconds), True)
|
|
234
|
+
|
|
235
|
+
if csp.ticked(a_scheduled_command):
|
|
236
|
+
# check if its still scheduled, or if its been cancelled
|
|
237
|
+
if a_scheduled_command.id in self._scheduled:
|
|
238
|
+
# if its still scheduled, then lets run it
|
|
239
|
+
s_commands_to_process.append(a_scheduled_command)
|
|
240
|
+
|
|
241
|
+
# reschedule if its an interval command
|
|
242
|
+
if hasattr(a_scheduled_command, "schedule"):
|
|
243
|
+
now = csp.now()
|
|
244
|
+
next_time = croniter(a_scheduled_command.schedule, now).get_next(datetime)
|
|
245
|
+
if next_time >= now:
|
|
246
|
+
self._scheduled[a_scheduled_command.id] = a_scheduled_command
|
|
247
|
+
csp.schedule_alarm(a_scheduled_command, next_time, a_scheduled_command)
|
|
248
|
+
else:
|
|
249
|
+
log.warning(f"Scheduled time in past: current-time: {now} schedule time: {next_time}")
|
|
250
|
+
else:
|
|
251
|
+
# remove from schedule if it was a delayed command
|
|
252
|
+
self._scheduled.pop(a_scheduled_command.id, None)
|
|
253
|
+
|
|
254
|
+
if csp.ticked(command):
|
|
255
|
+
now = csp.now()
|
|
256
|
+
if hasattr(command, "delay") and command.delay >= now:
|
|
257
|
+
self._scheduled[command.id] = command
|
|
258
|
+
csp.schedule_alarm(a_scheduled_command, command.delay, command)
|
|
259
|
+
elif hasattr(command, "schedule"):
|
|
260
|
+
next_time = croniter(command.schedule, now).get_next(datetime)
|
|
261
|
+
if next_time >= now:
|
|
262
|
+
self._scheduled[command.id] = command
|
|
263
|
+
csp.schedule_alarm(a_scheduled_command, next_time, command)
|
|
264
|
+
else:
|
|
265
|
+
log.warning(f"Scheduled time in past: current-time: {now} schedule time: {next_time}")
|
|
266
|
+
else:
|
|
267
|
+
s_commands_to_process.append(command)
|
|
268
|
+
|
|
269
|
+
if csp.ticked(command) or csp.ticked(a_scheduled_command):
|
|
270
|
+
commands_to_process_next_cycle = []
|
|
271
|
+
for command_to_process in s_commands_to_process:
|
|
272
|
+
# this returns either another bot command, or a message
|
|
273
|
+
command_or_message_or_none = self.run_bot_command(command_to_process)
|
|
274
|
+
|
|
275
|
+
if command_or_message_or_none:
|
|
276
|
+
# promote to list
|
|
277
|
+
if not isinstance(command_or_message_or_none, list):
|
|
278
|
+
command_or_message_or_none = [command_or_message_or_none]
|
|
279
|
+
|
|
280
|
+
for command_or_message in command_or_message_or_none:
|
|
281
|
+
if isinstance(command_or_message, Message):
|
|
282
|
+
# if its a message, emit it
|
|
283
|
+
s_buffer.append(command_or_message)
|
|
284
|
+
elif isinstance(command_or_message, BotCommand):
|
|
285
|
+
# reprocess it next cycle
|
|
286
|
+
commands_to_process_next_cycle.append(command_or_message)
|
|
287
|
+
if commands_to_process_next_cycle:
|
|
288
|
+
csp.output(commands=commands_to_process_next_cycle)
|
|
289
|
+
|
|
290
|
+
# reinitialize state
|
|
291
|
+
s_commands_to_process = []
|
|
292
|
+
|
|
293
|
+
if csp.ticked(a_ratelimit):
|
|
294
|
+
if len(s_buffer) > 0:
|
|
295
|
+
# deduplicate messages to avoid spamming
|
|
296
|
+
# first, remove duplicates directly
|
|
297
|
+
s_buffer = set(s_buffer)
|
|
298
|
+
# now intersect with previous tick to avoid spam
|
|
299
|
+
s_buffer = s_buffer - set(s_buffer_last)
|
|
300
|
+
# convert back to list
|
|
301
|
+
s_buffer = list(s_buffer)
|
|
302
|
+
|
|
303
|
+
# output
|
|
304
|
+
csp.output(messages=s_buffer)
|
|
305
|
+
|
|
306
|
+
# store this round in _last
|
|
307
|
+
s_buffer_last = s_buffer.copy()
|
|
308
|
+
|
|
309
|
+
# and reset for next
|
|
310
|
+
s_buffer = []
|
|
311
|
+
|
|
312
|
+
csp.schedule_alarm(a_ratelimit, timedelta(seconds=self.config.ratelimit_seconds), True)
|
|
313
|
+
|
|
314
|
+
##############
|
|
315
|
+
# Processors #
|
|
316
|
+
##############
|
|
317
|
+
def bot_commands_from_command_string(self, tokens: List[str], message: Message, channel: str, entity_map: Dict[str, Tuple[str, str]] = None):
|
|
318
|
+
# Check malformed, this should never happen
|
|
319
|
+
if len(tokens) == 0:
|
|
320
|
+
# TODO malformed
|
|
321
|
+
log.critical(f"Malformed command: {message}")
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
command = tokens[0].replace("/", "", 1)
|
|
325
|
+
if command not in self._commands:
|
|
326
|
+
# TODO print help w/ unrecognized command
|
|
327
|
+
log.critical(f"Unrecognized/unregistered command: {command} - {message}")
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
# now interrogate the command string to align
|
|
331
|
+
# with these important bits
|
|
332
|
+
command_args = []
|
|
333
|
+
target_tags = []
|
|
334
|
+
target_channel = ""
|
|
335
|
+
skip_next = False
|
|
336
|
+
for i, token in enumerate(tokens):
|
|
337
|
+
# this is the command, skip
|
|
338
|
+
if i == 0:
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
# this has been processed in a prior step
|
|
342
|
+
if skip_next:
|
|
343
|
+
skip_next = False
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
# TODO: remove, deprecated
|
|
347
|
+
if token == "/room":
|
|
348
|
+
# /room should be followed by room name
|
|
349
|
+
# room name with special characters must be surrounded with double quotes
|
|
350
|
+
# ROOM NAMES WITH DOUBLE QUOTES IN THEM ARE NOT ALLOWED
|
|
351
|
+
if i + 1 >= len(tokens) or tokens[i + 1].startswith("@"):
|
|
352
|
+
# malformed
|
|
353
|
+
log.critical(f"Malformed room name: {message}")
|
|
354
|
+
else:
|
|
355
|
+
# grab the channel
|
|
356
|
+
target_channel = tokens[i + 1]
|
|
357
|
+
skip_next = True
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
if token == "/channel":
|
|
361
|
+
# /channel should be followed by channel name
|
|
362
|
+
# channel name with special characters must be surrounded with double quotes
|
|
363
|
+
# CHANNEL NAMES WITH DOUBLE QUOTES IN THEM ARE NOT ALLOWED
|
|
364
|
+
if i + 1 >= len(tokens) or tokens[i + 1].startswith("@"):
|
|
365
|
+
# malformed
|
|
366
|
+
log.critical(f"Malformed channel name: {message}")
|
|
367
|
+
else:
|
|
368
|
+
# grab the channel
|
|
369
|
+
target_channel = tokens[i + 1]
|
|
370
|
+
skip_next = True
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
# entity detection
|
|
374
|
+
if entity_map and token in entity_map:
|
|
375
|
+
# symphony and slack handle tagging opposite
|
|
376
|
+
if message.backend == "symphony":
|
|
377
|
+
name, id = entity_map[token]
|
|
378
|
+
elif message.backend == "slack":
|
|
379
|
+
id, name = entity_map[token]
|
|
380
|
+
elif message.backend == "discord":
|
|
381
|
+
name, id = entity_map[token]
|
|
382
|
+
target_tags.append(User(name=name, id=id, backend=message.backend))
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
command_args.append(token)
|
|
386
|
+
|
|
387
|
+
# if target room hasnt been set, reply in room message was sent
|
|
388
|
+
if target_channel == "":
|
|
389
|
+
target_channel = channel
|
|
390
|
+
return command, command_args, target_channel, target_tags
|
|
391
|
+
|
|
392
|
+
def _get_bot_tag(self, backend: str) -> str:
|
|
393
|
+
# TODO: normalize across adapters
|
|
394
|
+
if backend == "slack":
|
|
395
|
+
return self._adapters[backend]._get_user_from_name(self._configs[backend].bot_name)
|
|
396
|
+
return self._configs[backend].bot_name
|
|
397
|
+
|
|
398
|
+
def extract_bot_commands(self, message: Message, channel: str, text: str, entities: List[Tag]) -> Optional[Union[List[BotCommand], BotCommand]]:
|
|
399
|
+
try:
|
|
400
|
+
# allow @<bot name> /<cmd>
|
|
401
|
+
if (
|
|
402
|
+
text.startswith("/")
|
|
403
|
+
or text.startswith(f"@{self._get_bot_tag(message.backend)} /")
|
|
404
|
+
or text.startswith(f"<@{self._get_bot_tag(message.backend)}> /")
|
|
405
|
+
):
|
|
406
|
+
# most names and tags will have spaces, so before we tokenize lets replace those
|
|
407
|
+
entity_map: Dict[str, Tuple[str, str]] = {}
|
|
408
|
+
# entity_map is mapping of entity placeholder to name and user id
|
|
409
|
+
# e.g. @CCRT Bot /command @Tim Paine
|
|
410
|
+
# ENTITY_0 -> ("@CCRT Bot", "75179107555902")
|
|
411
|
+
# ENTITY_1 -> ("@Tim Paine", "75179107553587")
|
|
412
|
+
for i, entity in enumerate(entities):
|
|
413
|
+
entity_placeholder = f"ENTITY_{i}"
|
|
414
|
+
|
|
415
|
+
if message.backend == "symphony":
|
|
416
|
+
text = text.replace(entity, entity_placeholder, 1)
|
|
417
|
+
elif message.backend == "slack":
|
|
418
|
+
text = text.replace(f"<{entity}>", entity_placeholder, 1)
|
|
419
|
+
elif message.backend == "discord":
|
|
420
|
+
text = text.replace(f"<{entity}>", entity_placeholder, 1)
|
|
421
|
+
|
|
422
|
+
# NOTE: in symphony
|
|
423
|
+
entity_map[entity_placeholder] = (entity, message.tags[i])
|
|
424
|
+
|
|
425
|
+
tokens = next(reader(StringIO(text), delimiter=" ", quotechar='"', skipinitialspace=True))
|
|
426
|
+
|
|
427
|
+
if tokens[0] in entity_map and entity_map[tokens[0]][0] == f"@{self._get_bot_tag(message.backend)}":
|
|
428
|
+
# remove initial bot directive
|
|
429
|
+
tokens = tokens[1:]
|
|
430
|
+
|
|
431
|
+
ret = self.bot_commands_from_command_string(tokens, message, channel, entity_map)
|
|
432
|
+
if not ret:
|
|
433
|
+
return
|
|
434
|
+
command, command_args, target_channel, target_tags = ret
|
|
435
|
+
|
|
436
|
+
# determine the command's supported backends and skip if unsupported
|
|
437
|
+
backends: Backend = self._commands[command].backends()
|
|
438
|
+
if backends and message.backend not in backends:
|
|
439
|
+
log.warning(f"Command {command} not supported on backend {message.backend}")
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
variant: CommandVariant = self._commands[command].kind()
|
|
443
|
+
|
|
444
|
+
# determine the command variant
|
|
445
|
+
variant: CommandVariant = self._commands[command].kind()
|
|
446
|
+
|
|
447
|
+
# parse out the required info from the command
|
|
448
|
+
command_instance = BotCommand(
|
|
449
|
+
command=command,
|
|
450
|
+
args=tuple(command_args),
|
|
451
|
+
source=User(name=message.user, id=message.user_id, backend=message.backend),
|
|
452
|
+
targets=tuple(target_tags),
|
|
453
|
+
channel=target_channel,
|
|
454
|
+
backend=message.backend,
|
|
455
|
+
variant=variant,
|
|
456
|
+
message=message,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
command_runner = self._commands[command]
|
|
460
|
+
|
|
461
|
+
if isinstance(command_runner, ScheduleCommand):
|
|
462
|
+
# Special command, gets access to schedule of commands
|
|
463
|
+
return command_runner.preexecute(command_instance, self._scheduled, self)
|
|
464
|
+
return self._commands[command].preexecute(command_instance)
|
|
465
|
+
else:
|
|
466
|
+
log.info(f"Defaulting to help command from message to bot with no command: {message}")
|
|
467
|
+
command_runner = self._commands["help"]
|
|
468
|
+
command_instance = BotCommand(
|
|
469
|
+
command="help",
|
|
470
|
+
args=tuple(),
|
|
471
|
+
source=User(name=message.user, id=message.user_id, backend=message.backend),
|
|
472
|
+
channel=channel,
|
|
473
|
+
backend=message.backend,
|
|
474
|
+
variant=command_runner.kind(),
|
|
475
|
+
message=message,
|
|
476
|
+
)
|
|
477
|
+
return command_runner.preexecute(command_instance)
|
|
478
|
+
|
|
479
|
+
except KeyboardInterrupt:
|
|
480
|
+
raise
|
|
481
|
+
except Exception:
|
|
482
|
+
# Ignore
|
|
483
|
+
log.exception(f"Error processing message: {message}")
|
|
484
|
+
|
|
485
|
+
def run_bot_command(self, command_instance: BotCommand) -> Optional[Union[List[Message], List[BotCommand]]]:
|
|
486
|
+
# grab the important bits of the command
|
|
487
|
+
command_runner: BaseCommand = self._commands[command_instance.command]
|
|
488
|
+
# num_recipients: int = command_runner.num_recipients()
|
|
489
|
+
|
|
490
|
+
# execute the command to get a response
|
|
491
|
+
try:
|
|
492
|
+
if isinstance(command_runner, HelpCommand):
|
|
493
|
+
# Special command, gets access to all other commands
|
|
494
|
+
responses = command_runner.execute(command_instance, MappingProxyType(self._commands))
|
|
495
|
+
elif isinstance(command_runner, ScheduleCommand):
|
|
496
|
+
# Special command, gets access to schedule of commands
|
|
497
|
+
responses = command_runner.execute(command_instance, self._scheduled)
|
|
498
|
+
else:
|
|
499
|
+
# normal command
|
|
500
|
+
responses = command_runner.execute(command_instance)
|
|
501
|
+
except BaseException:
|
|
502
|
+
log.exception(f"Error executing command for msg: {command_instance.message}")
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
if not isinstance(responses, list):
|
|
506
|
+
# convert to list
|
|
507
|
+
responses = [responses]
|
|
508
|
+
|
|
509
|
+
# remap all IM rooms to the user id so that bot
|
|
510
|
+
# can DM correctly
|
|
511
|
+
if responses:
|
|
512
|
+
for response in responses:
|
|
513
|
+
if isinstance(response, Message):
|
|
514
|
+
# replace "IM" convenience with actual user
|
|
515
|
+
if response.channel == "IM":
|
|
516
|
+
response.channel = command_instance.message.user
|
|
517
|
+
return responses
|
|
518
|
+
|
|
519
|
+
###########
|
|
520
|
+
# Helpers #
|
|
521
|
+
###########
|
|
522
|
+
@staticmethod
|
|
523
|
+
def parse_msg(msg: Message):
|
|
524
|
+
if msg.backend == "symphony":
|
|
525
|
+
soup = BeautifulSoup(msg.msg, features="html.parser")
|
|
526
|
+
text = soup.get_text()
|
|
527
|
+
entities = [e.text for e in soup.find_all("span", class_="entity")]
|
|
528
|
+
|
|
529
|
+
# clean up a bit
|
|
530
|
+
text = text.strip()
|
|
531
|
+
while " " in text:
|
|
532
|
+
text = text.replace(" ", " ")
|
|
533
|
+
elif msg.backend == "slack":
|
|
534
|
+
text = msg.msg
|
|
535
|
+
# NOTE: tag is like `<@USER_ID>` so prune to match canonical `@USER_ID`
|
|
536
|
+
entities = [m[1:-1] for m in SLACK_ENTITY_REGEX.findall(text)]
|
|
537
|
+
elif msg.backend == "discord":
|
|
538
|
+
text = msg.msg
|
|
539
|
+
# NOTE: tag is like `<@USER_ID>` so prune to match canonical `@USER_ID`
|
|
540
|
+
entities = [m[1:-1] for m in DISCORD_ENTITY_REGEX.findall(text)]
|
|
541
|
+
return text, entities
|
|
542
|
+
|
|
543
|
+
def is_msg_to_bot(self, msg: Message) -> Tuple[bool, str, str, List[Tag]]:
|
|
544
|
+
text, entities = Bot.parse_msg(msg)
|
|
545
|
+
|
|
546
|
+
# remove content that is in the reply
|
|
547
|
+
if msg.backend == "symphony":
|
|
548
|
+
text = text.rsplit("_———————————", 1)[-1]
|
|
549
|
+
|
|
550
|
+
# NOTE: return is:
|
|
551
|
+
# is_msg_to_bot, channel, text, entities
|
|
552
|
+
# if its a DM to bot, the channel will be the user's name
|
|
553
|
+
# if the bot is not tagged, first argument is false so ignore second arg
|
|
554
|
+
bot_name = self._configs[msg.backend].bot_name
|
|
555
|
+
bot_tag_to_find = self._get_bot_tag(msg.backend)
|
|
556
|
+
bot_tag = f"@{bot_tag_to_find}" in text
|
|
557
|
+
|
|
558
|
+
if bot_tag:
|
|
559
|
+
return True, msg.channel, text, entities
|
|
560
|
+
elif msg.channel == "IM" and msg.user != bot_name:
|
|
561
|
+
return True, msg.user, text, entities
|
|
562
|
+
return False, "", text, entities
|
|
563
|
+
|
|
564
|
+
def is_authorized(self, msg: Message) -> bool:
|
|
565
|
+
if msg.backend == "symphony":
|
|
566
|
+
if not self.config.symphony_config.user_access_channels:
|
|
567
|
+
return True
|
|
568
|
+
with self._lock:
|
|
569
|
+
res = msg.user_id in self._authorized_users[msg.backend]
|
|
570
|
+
return res
|
|
571
|
+
elif msg.backend == "slack":
|
|
572
|
+
# TODO implement
|
|
573
|
+
return True
|
|
574
|
+
elif msg.backend == "discord":
|
|
575
|
+
# TODO implement
|
|
576
|
+
return True
|
csp_bot/bot_config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from typing import List, Optional, Union
|
|
2
|
+
|
|
3
|
+
from csp_adapter_discord import DiscordAdapterConfig
|
|
4
|
+
from csp_adapter_slack import SlackAdapterConfig
|
|
5
|
+
from csp_adapter_symphony import SymphonyAdapterConfig
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"BaseConfig",
|
|
10
|
+
"BotConfig",
|
|
11
|
+
"DiscordConfig",
|
|
12
|
+
"SlackConfig",
|
|
13
|
+
"SymphonyConfig",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseConfig(BaseModel):
|
|
18
|
+
bot_name: str = Field(description="Name of the bot as displayed")
|
|
19
|
+
adapter_config: Union[DiscordAdapterConfig, SlackAdapterConfig, SymphonyAdapterConfig]
|
|
20
|
+
|
|
21
|
+
user_access_channels: List[str] = Field(
|
|
22
|
+
[],
|
|
23
|
+
description="If non-empty, only users from these channels can interact with the bot. However, they can interact with the bot in any channel.",
|
|
24
|
+
)
|
|
25
|
+
query_user_access_channels_seconds: int = Field(
|
|
26
|
+
300, description="How frequently to query the 'user_access_channels' for a list of allowed users. If 0, this is only queried once at startup."
|
|
27
|
+
)
|
|
28
|
+
unauthorized_msg: Optional[str] = Field(
|
|
29
|
+
"You are not authorized to interact with this bot.",
|
|
30
|
+
description="What message to send (if any) when an unauthorized user attempts to interact with the bot. This is only relevant if 'user_access_channels' is set. If None, no message is sent.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def adapter_kwargs(self):
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DiscordConfig(BaseConfig):
|
|
38
|
+
adapter_config: DiscordAdapterConfig
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SlackConfig(BaseConfig):
|
|
42
|
+
adapter_config: SlackAdapterConfig
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SymphonyConfig(BaseConfig):
|
|
46
|
+
adapter_config: SymphonyAdapterConfig
|
|
47
|
+
exit_msg: str = ""
|
|
48
|
+
initial_rooms: List[str] = []
|
|
49
|
+
allow_new_rooms: bool = True
|
|
50
|
+
set_presence_seconds: int = Field(
|
|
51
|
+
0,
|
|
52
|
+
description="How many seconds to wait between periods of sending requests to set prescence as AVAILABLE. If 0, presence is never set. Calling this endpoint requires the ADMIN_PRESENCE_UPDATE privilege. According to the Symphony docs, after 5 minutes of inactivity, a user turns from AVAILABLE to AWAY, if still connected to symphony. Otherwise, OFFLINE",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def adapter_kwargs(self):
|
|
56
|
+
return {
|
|
57
|
+
"exit_msg": self.exit_msg,
|
|
58
|
+
"rooms": set(self.initial_rooms),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BotConfig(BaseModel):
|
|
63
|
+
discord_config: Optional[DiscordConfig] = None
|
|
64
|
+
slack_config: Optional[SlackConfig] = None
|
|
65
|
+
symphony_config: Optional[SymphonyConfig] = None
|
|
66
|
+
|
|
67
|
+
ratelimit_seconds: int = 1
|