Skip to content

Commit 3dc2458

Browse files
v0.2b (#14)
* add gitignore options * working scheduler and posting with error messaging This commit now actually posts a single message on a cronlike scheduler and has working error handling when there are no topics. Scaffolded out delete command * Deletion works now * pre-commit is cool, might as well use it Setup some more pre-commit checks and address the changes they suggested in the plugin * add timezone to apscheduler * add the ability to list scheduled jobs
1 parent 2c31a80 commit 3dc2458

File tree

4 files changed

+135
-91
lines changed

4 files changed

+135
-91
lines changed

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,12 @@ venv.bak/
102102

103103
# mypy
104104
.mypy_cache/
105+
106+
# errbot
107+
data/
108+
plugins/
109+
config.py
110+
errbot.log
111+
112+
#pycharm
113+
.idea/

.pre-commit-config.yaml

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
11
repos:
22
- repo: https://github.com/ambv/black
3-
rev: stable
3+
rev: 20.8b1
44
hooks:
55
- id: black
66
language_version: python3
77
types: [python]
88
- repo: https://github.com/pre-commit/pre-commit-hooks
9-
rev: v2.3.0
9+
rev: v3.2.0
1010
hooks:
1111
- id: check-ast
12-
- repo: https://github.com/pre-commit/pre-commit-hooks
13-
rev: v2.3.0
14-
hooks:
1512
- id: end-of-file-fixer
16-
- repo: https://github.com/pre-commit/pre-commit-hooks
17-
rev: v2.3.0
18-
hooks:
1913
- id: requirements-txt-fixer
14+
- id: debug-statements
15+
- repo: https://github.com/pre-commit/pygrep-hooks
16+
rev: v1.6.0
17+
hooks:
18+
- id: python-no-eval
19+
- id: python-no-log-warn
20+
- id: python-use-type-annotations
21+
- repo: https://github.com/asottile/reorder_python_imports
22+
rev: v2.3.5
23+
hooks:
24+
- id: reorder-python-imports
25+
- repo: https://github.com/Lucas-C/pre-commit-hooks-safety
26+
rev: v1.1.3
27+
hooks:
28+
- id: python-safety-dependencies-check
29+
- repo: https://github.com/PyCQA/bandit
30+
rev: 1.6.2
31+
hooks:
32+
- id: bandit

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1+
apscheduler
12
python-decouple
2-
schedule
33
wrapt

topic-a-day.py

Lines changed: 104 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1-
from datetime import datetime
2-
from hashlib import md5
31
import random
2+
from datetime import datetime
3+
from hashlib import sha256
4+
from io import StringIO
5+
from threading import RLock
46
from typing import Any
5-
from typing import List
67
from typing import Dict
7-
from threading import RLock
8+
from typing import List
89

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
1313
from errbot import arg_botcmd
1414
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
1717
from wrapt import synchronized # https://stackoverflow.com/a/29403915
1818

1919
TOPICS_LOCK = RLock()
@@ -32,7 +32,7 @@ def get_config_item(
3232
config[key] = get_config(key, **decouple_kwargs)
3333

3434

35-
class Topics(object):
35+
class Topics:
3636
"""
3737
Topics are our topics that we want to post. This is basically a big wrapper class around a python list of
3838
dicts that uses the Errbot Storage engine to store itself.
@@ -44,7 +44,7 @@ class Topics(object):
4444
def __init__(self, bot_plugin: BotPlugin) -> None:
4545
self.bot_plugin = bot_plugin
4646
try:
47-
_ = self.bot_plugin["TOPICS"]
47+
self.bot_plugin["TOPICS"]
4848
except KeyError:
4949
# this is the first time this plugin is starting up
5050
self.bot_plugin["TOPICS"] = []
@@ -65,15 +65,18 @@ def add(self, topic: str) -> None:
6565
}
6666
)
6767
self.bot_plugin["TOPICS"] = topics
68-
return
6968

7069
def get_random(self) -> Dict:
7170
"""
7271
Returns a random, unused topic
7372
"""
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")
7780

7881
def list(self) -> List[Dict]:
7982
"""
@@ -98,8 +101,7 @@ def set_used(self, topic_id: str) -> None:
98101
self.bot_plugin["TOPICS"] = topics
99102
found = True
100103
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")
103105

104106
@synchronized(TOPICS_LOCK)
105107
def delete(self, topic_id: str) -> None:
@@ -113,19 +115,19 @@ def delete(self, topic_id: str) -> None:
113115
for index, topic in enumerate(topics):
114116
if topic["id"] == topic_id:
115117
found = True
118+
to_pop = index
116119
break
117120
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)
120123
self.bot_plugin["TOPICS"] = topics
121-
return
122124

123125
@staticmethod
124126
def hash_topic(topic: str) -> str:
125127
"""
126128
Returns an 8 character id hash of a topic with the current datetime (for uniqueness)
127129
"""
128-
return md5(f"{topic}-{datetime.now()}".encode("utf-8")).hexdigest()[:8]
130+
return sha256(f"{topic}-{datetime.now()}".encode("utf-8")).hexdigest()[:8]
129131

130132
class NoNewTopicsError(Exception):
131133
pass
@@ -149,21 +151,13 @@ def activate(self) -> None:
149151
super().activate()
150152
self.topics = Topics(self)
151153
# 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"]}
159156
)
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()
167161

168162
def configure(self, configuration: Dict) -> None:
169163
"""
@@ -175,44 +169,13 @@ def configure(self, configuration: Dict) -> None:
175169

176170
# name of the channel to post in
177171
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"]
203175
)
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)
216179

217180
@botcmd
218181
@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:
228191
msg.frm, f"Topic added to the list: ```{topic_sentence}```", in_reply_to=msg
229192
)
230193

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+
231216
@botcmd
232-
def list_topics(self, msg: ErrbotMessage, args: List) -> None:
217+
def list_topics(self, msg: ErrbotMessage, _: List) -> None:
233218
"""
234219
Lists all of our topics
235220
"""
@@ -255,18 +240,55 @@ def list_topics(self, msg: ErrbotMessage, args: List) -> None:
255240
in_reply_to=msg,
256241
)
257242

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+
258252
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
260266
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")
265275
self.send(self.build_identifier(self.config["TOPIC_CHANNEL"]), topic_template)
276+
self.log.debug("Setting topic to used")
266277
self.topics.set_used(new_topic["id"])
267278

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:
269283
"""
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
271285
"""
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

Comments
 (0)