1
- from datetime import datetime
2
- from hashlib import md5
3
1
import random
2
+ from datetime import datetime
3
+ from hashlib import sha256
4
+ from io import StringIO
5
+ from threading import RLock
4
6
from typing import Any
5
- from typing import List
6
7
from typing import Dict
7
- from threading import RLock
8
+ from typing import List
8
9
9
- from errbot .backends .base import Message as ErrbotMessage
10
- from errbot import BotPlugin
11
- from errbot import Command
12
- from errbot import ValidationException
10
+ from apscheduler .schedulers .background import BackgroundScheduler
11
+ from apscheduler .triggers .cron import CronTrigger
12
+ from decouple import config as get_config
13
13
from errbot import arg_botcmd
14
14
from errbot import botcmd
15
- from decouple import config as get_config
16
- import schedule
15
+ from errbot import BotPlugin
16
+ from errbot . backends . base import Message as ErrbotMessage
17
17
from wrapt import synchronized # https://stackoverflow.com/a/29403915
18
18
19
19
TOPICS_LOCK = RLock ()
@@ -32,7 +32,7 @@ def get_config_item(
32
32
config [key ] = get_config (key , ** decouple_kwargs )
33
33
34
34
35
- class Topics ( object ) :
35
+ class Topics :
36
36
"""
37
37
Topics are our topics that we want to post. This is basically a big wrapper class around a python list of
38
38
dicts that uses the Errbot Storage engine to store itself.
@@ -44,7 +44,7 @@ class Topics(object):
44
44
def __init__ (self , bot_plugin : BotPlugin ) -> None :
45
45
self .bot_plugin = bot_plugin
46
46
try :
47
- _ = self .bot_plugin ["TOPICS" ]
47
+ self .bot_plugin ["TOPICS" ]
48
48
except KeyError :
49
49
# this is the first time this plugin is starting up
50
50
self .bot_plugin ["TOPICS" ] = []
@@ -65,15 +65,18 @@ def add(self, topic: str) -> None:
65
65
}
66
66
)
67
67
self .bot_plugin ["TOPICS" ] = topics
68
- return
69
68
70
69
def get_random (self ) -> Dict :
71
70
"""
72
71
Returns a random, unused topic
73
72
"""
74
- return random .choice (
75
- list (filter (lambda d : not d ["used" ], self .bot_plugin ["TOPICS" ]))
76
- )
73
+ try :
74
+ return random .choice ( # nosec
75
+ list (filter (lambda d : not d ["used" ], self .bot_plugin ["TOPICS" ]))
76
+ )
77
+ except IndexError :
78
+ self .bot_plugin .log .error ("Topic list was empty when trying to get a topic" )
79
+ raise self .NoNewTopicsError ("No new topics" )
77
80
78
81
def list (self ) -> List [Dict ]:
79
82
"""
@@ -98,8 +101,7 @@ def set_used(self, topic_id: str) -> None:
98
101
self .bot_plugin ["TOPICS" ] = topics
99
102
found = True
100
103
if not found :
101
- raise KeyError ("%s not found in topic list" , topic_id )
102
- return
104
+ raise KeyError (f"{ topic_id } not found in topic list" )
103
105
104
106
@synchronized (TOPICS_LOCK )
105
107
def delete (self , topic_id : str ) -> None :
@@ -113,19 +115,19 @@ def delete(self, topic_id: str) -> None:
113
115
for index , topic in enumerate (topics ):
114
116
if topic ["id" ] == topic_id :
115
117
found = True
118
+ to_pop = index
116
119
break
117
120
if not found :
118
- raise KeyError ("%s not found in topic list", topic_id )
119
- topics .pop (index )
121
+ raise KeyError (f" { topic_id } not found in topic list" )
122
+ topics .pop (to_pop )
120
123
self .bot_plugin ["TOPICS" ] = topics
121
- return
122
124
123
125
@staticmethod
124
126
def hash_topic (topic : str ) -> str :
125
127
"""
126
128
Returns an 8 character id hash of a topic with the current datetime (for uniqueness)
127
129
"""
128
- return md5 (f"{ topic } -{ datetime .now ()} " .encode ("utf-8" )).hexdigest ()[:8 ]
130
+ return sha256 (f"{ topic } -{ datetime .now ()} " .encode ("utf-8" )).hexdigest ()[:8 ]
129
131
130
132
class NoNewTopicsError (Exception ):
131
133
pass
@@ -149,21 +151,13 @@ def activate(self) -> None:
149
151
super ().activate ()
150
152
self .topics = Topics (self )
151
153
# schedule our daily jobs
152
- for day in self .config ["TOPIC_DAYS" ]:
153
- getattr (schedule .every (), day ).at (self .config ["TOPIC_TIME" ]).do (
154
- self .post_topic
155
- )
156
-
157
- self .start_poller (
158
- self .config ["TOPIC_POLLER_INTERVAL" ], self .run_scheduled_jobs , None
154
+ self .sched = BackgroundScheduler (
155
+ {"apscheduler.timezome" : self .config ["TOPIC_TZ" ]}
159
156
)
160
-
161
- def deactivate (self ) -> None :
162
- """
163
- Deactivates the plugin by stopping our scheduled jobs poller
164
- """
165
- # self.stop_poller(self.config['TOPIC_POLLER_INTERVAL'], self.run_scheduled_jobs)
166
- super ().deactivate ()
157
+ self .sched .add_job (
158
+ self .post_topic , CronTrigger .from_crontab (self .config ["TOPIC_SCHEDULE" ])
159
+ )
160
+ self .sched .start ()
167
161
168
162
def configure (self , configuration : Dict ) -> None :
169
163
"""
@@ -175,44 +169,13 @@ def configure(self, configuration: Dict) -> None:
175
169
176
170
# name of the channel to post in
177
171
get_config_item ("TOPIC_CHANNEL" , configuration )
178
- configuration ["TOPIC_CHANNEL_ID" ] = self ._bot .channelname_to_channelid (
179
- configuration ["TOPIC_CHANNEL" ]
180
- )
181
- # Days to post a topic. Comma separated list of day names
182
- get_config_item (
183
- "TOPIC_DAYS" ,
184
- configuration ,
185
- cast = lambda v : [s .lower () for s in v .split ("," )],
186
- default = "monday,tuesday,wednesday,thursday,friday" ,
187
- )
188
- # what time the topic is posted every day, 24hr notation
189
- get_config_item ("TOPIC_TIME" , configuration , default = "09:00" )
190
- # How frequently the poller runs. Lower numbers might result in higher load
191
- get_config_item ("TOPIC_POLLER_INTERVAL" , configuration , default = 5 , cast = int )
192
- super ().configure (configuration )
193
-
194
- def check_configuration (self , configuration : Dict ) -> None :
195
- """
196
- Validates our config
197
- Raises:
198
- errbot.ValidationException when the configuration is invalid
199
- """
200
- if configuration ["TOPIC_CHANNEL" ][0 ] != "#" :
201
- raise ValidationException (
202
- "TOPIC_CHANNEL should be in the format #channel-name"
172
+ if getattr (self ._bot , "channelname_to_channelid" , None ) is not None :
173
+ configuration ["TOPIC_CHANNEL_ID" ] = self ._bot .channelname_to_channelid (
174
+ configuration ["TOPIC_CHANNEL" ]
203
175
)
204
-
205
- VALID_DAY_NAMES = set (
206
- "sunday" , "monday" , "tuesday" , "wednesday" , "thursday" , "friday" , "saturday"
207
- )
208
- invalid_days = [
209
- day for day in configuration ["TOPIC_DAYS" ] if day not in VALID_DAY_NAMES
210
- ]
211
- if len (invalid_days ) > 0 :
212
- raise ValidationException ("TOPIC_DAYS invalid %s" , invalid_days )
213
-
214
- # TODO: Write more configuration validation
215
- return
176
+ get_config_item ("TOPIC_SCHEDULE" , configuration , default = "0 9 * * 1,3,5" )
177
+ get_config_item ("TOPIC_TZ" , configuration , default = "UTC" )
178
+ super ().configure (configuration )
216
179
217
180
@botcmd
218
181
@arg_botcmd ("topic" , nargs = "*" , type = str , help = "Topic to add to our topic list" )
@@ -228,8 +191,30 @@ def add_topic(self, msg: ErrbotMessage, topic: List[str]) -> None:
228
191
msg .frm , f"Topic added to the list: ```{ topic_sentence } ```" , in_reply_to = msg
229
192
)
230
193
194
+ @botcmd (admin_only = True )
195
+ @arg_botcmd (
196
+ "topic_id" , type = str , help = "Hash of the topic to remove from list topics"
197
+ )
198
+ def delete_topic (self , msg : ErrbotMessage , topic_id : str ) -> str :
199
+ """
200
+ Deletes a topic from the topic list
201
+
202
+ """
203
+ if len (topic_id ) != 8 :
204
+ self .send (msg .frm , f"Invalid Topic ID" , in_reply_to = msg )
205
+ return
206
+
207
+ try :
208
+ self .topics .delete (topic_id )
209
+ except KeyError :
210
+ self .send (msg .frm , f"Invalid Topic ID" , in_reply_to = msg )
211
+ return
212
+
213
+ self .send (msg .frm , f"Topic Deleted" , in_reply_to = msg )
214
+ return
215
+
231
216
@botcmd
232
- def list_topics (self , msg : ErrbotMessage , args : List ) -> None :
217
+ def list_topics (self , msg : ErrbotMessage , _ : List ) -> None :
233
218
"""
234
219
Lists all of our topics
235
220
"""
@@ -255,18 +240,55 @@ def list_topics(self, msg: ErrbotMessage, args: List) -> None:
255
240
in_reply_to = msg ,
256
241
)
257
242
243
+ @botcmd (admin_only = True )
244
+ def list_topic_jobs (self , msg : ErrbotMessage , _ : List ) -> None :
245
+ """
246
+ List the scheduled jobs
247
+ """
248
+ pjobs_out = StringIO ()
249
+ self .sched .print_jobs (out = pjobs_out )
250
+ self .send (msg .frm , pjobs_out .getvalue (), in_reply_to = msg )
251
+
258
252
def post_topic (self ) -> None :
259
- new_topic = self .topics .get_random ()
253
+ """
254
+ Called by our scheduled jobs to post the topic message for the day. Also calls any backend specific
255
+ pre_post_topic methods
256
+ """
257
+ self .log .debug ("Calling post_topic" )
258
+ try :
259
+ new_topic = self .topics .get_random ()
260
+ except Topics .NoNewTopicsError :
261
+ self .log .error ("No new topics, cannot post" )
262
+ self .warn_admins (
263
+ "There are no new topics for topic a day so today's post failed"
264
+ )
265
+ return
260
266
topic_template = f"Today's Topic: { new_topic ['topic' ]} "
261
- self ._bot .api_call (
262
- "channels.setTopic" ,
263
- {"channel" : self .config ["TOPIC_CHANNEL_ID" ], "topic" : topic_template },
264
- )
267
+ self .log .debug ("Topic template: %s" , topic_template )
268
+ # call any special steps for the backend
269
+ try :
270
+ backend_specific = getattr (self , f"{ self ._bot .mode } _pre_post_topic" )
271
+ backend_specific (topic_template )
272
+ except AttributeError :
273
+ self .log .debug ("%s has no backend specific tasks" , self ._bot .mode )
274
+ self .log .debug ("Sending message to channel" )
265
275
self .send (self .build_identifier (self .config ["TOPIC_CHANNEL" ]), topic_template )
276
+ self .log .debug ("Setting topic to used" )
266
277
self .topics .set_used (new_topic ["id" ])
267
278
268
- def run_scheduled_jobs (self ) -> None :
279
+ # Backend specific pre_post tasks. Examples include setting channel topics
280
+ # Backend specific pre_post tasks should be named like {backend_name}_pre_post_topic and take two arguments, self
281
+ # and a topic: str. They should not return anything
282
+ def slack_pre_post_topic (self , topic : str ) -> None :
269
283
"""
270
- Run by an errbot poller to run schedule jobs
284
+ Called from post_topic before the topic is posted. For slack, this also sets the channel topic
271
285
"""
272
- schedule .run_pending ()
286
+ self ._bot .api_call (
287
+ "channels.setTopic" ,
288
+ {
289
+ "channel" : self ._bot .channelname_to_channelid (
290
+ self .config ["TOPIC_CHANNEL" ]
291
+ ),
292
+ "topic" : topic ,
293
+ },
294
+ )
0 commit comments