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 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