Skip to content

Commit 4084d9d

Browse files
fix: fix multiposts and new apscheduler options (#15)
* fix: fix multiposts and new apscheduler options The theory: I am seeing disconnects in the errbot log that the bot "restarts" itself. This is "activating" multiple apschedulers because we are using the in-memory store. This is causing the multiple posts. Probably was causing the multiple posts with scheduler too. This change does a couple of things: * Sets up a webhook that we can use as a bit of a "rpc". The blocker for using the apscheduler storage backends was trying to pickle the "plugin" object as a part of the schedule job. This allows us to call a method that is outside of the class, that can then "call" our class (via the webhook request). * Allows configuring the apscheduler by passing in a path to a json config file. This json file gets read into a python Dict to config like Method #2 from https://apscheduler.readthedocs.io/en/stable/userguide.html#configuring-the-scheduler * Changes config variable names a bit to "namespace" our env variables a bit * Adds the ability to "reset" a post so that it can be reposted * fix: shutdown scheduler on deactivate
1 parent 3dc2458 commit 4084d9d

File tree

2 files changed

+173
-23
lines changed

2 files changed

+173
-23
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,21 @@
11
# err-topic-a-day
22
An errbot plugin to post a topic a day to a channel for discussion
3+
4+
# Configuration
5+
topic-a-day reads its config from a combination of environment variables and config files. This is due to the complexity
6+
of expressing some config options as env variable strings.
7+
8+
Config options:
9+
10+
* TAD_CHANNEL: str, channel name to use as your topic channel
11+
* TAD_SCHEDULE: str, schedule to post on, expressed as a crontab. This uses apscheduler, which starts its cron day counts
12+
on monday, rather than sunday
13+
* TAD_APSCHEDULER_CONFIG_FILE: str, optional. Path to a config file for
14+
[AP Scheduler's config](https://apscheduler.readthedocs.io/en/stable/userguide.html#configuring-the-scheduler). If left
15+
blank, we setup a basic, working config
16+
* TAD_TZ: What timezone to set. Only used if TAD_APSCHEDULER_CONFIG_FILE is false.
17+
* TAD_ENABLE_WEBHOOK: bool, default false. If enabled, use a webhook + curl to perform posting. Allows using the AP
18+
scheduler sqlalchemy backend
19+
* TAD_WEBHOOK_URL, str, url for the Topic a day webhook. Defaults to localhost:3142/post_topic_rpc
20+
* AUTH_POST_WEBHOOK: bool, default True. If true, webhook only works if auth'd properly
21+
* AUTH_POST_WEBHOOK_TOKEN: str, default generated token. The token for our webhook auth

topic-a-day.py

Lines changed: 154 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import logging
12
import random
3+
import secrets
24
from datetime import datetime
35
from hashlib import sha256
46
from io import StringIO
@@ -7,18 +9,34 @@
79
from typing import Dict
810
from typing import List
911

12+
import requests
13+
from apscheduler.jobstores.base import ConflictingIdError
1014
from apscheduler.schedulers.background import BackgroundScheduler
1115
from apscheduler.triggers.cron import CronTrigger
1216
from decouple import config as get_config
1317
from errbot import arg_botcmd
1418
from errbot import botcmd
1519
from errbot import BotPlugin
20+
from errbot import webhook
1621
from errbot.backends.base import Message as ErrbotMessage
22+
from flask import abort
1723
from wrapt import synchronized # https://stackoverflow.com/a/29403915
1824

1925
TOPICS_LOCK = RLock()
2026

2127

28+
def do_webhook_post(url, headers={}, data={}):
29+
logger = logging.getLogger()
30+
logger.debug("Starting request for %s with headers: %s", url, headers)
31+
try:
32+
response = requests.post(url, headers=headers, data=data)
33+
except Exception as err:
34+
logger.error("Error while doing request: %s", err)
35+
return
36+
logger.debug("Webhook status code: %s", response.status_code)
37+
logger.debug("Webhook response: %s", response.text)
38+
39+
2240
def get_config_item(
2341
key: str, config: Dict, overwrite: bool = False, **decouple_kwargs
2442
) -> Any:
@@ -61,7 +79,7 @@ def add(self, topic: str) -> None:
6179
"id": self.hash_topic(topic),
6280
"topic": topic,
6381
"used": False,
64-
"date_used": None,
82+
"used_date": None,
6583
}
6684
)
6785
self.bot_plugin["TOPICS"] = topics
@@ -98,10 +116,10 @@ def set_used(self, topic_id: str) -> None:
98116
if topic["id"] == topic_id:
99117
topic["used"] = True
100118
topic["used_date"] = datetime.now()
101-
self.bot_plugin["TOPICS"] = topics
102119
found = True
103120
if not found:
104121
raise KeyError(f"{topic_id} not found in topic list")
122+
self.bot_plugin["TOPICS"] = topics
105123

106124
@synchronized(TOPICS_LOCK)
107125
def delete(self, topic_id: str) -> None:
@@ -122,6 +140,25 @@ def delete(self, topic_id: str) -> None:
122140
topics.pop(to_pop)
123141
self.bot_plugin["TOPICS"] = topics
124142

143+
@synchronized(TOPICS_LOCK)
144+
def reset(self, topic_id: str) -> None:
145+
"""
146+
resets the topic at topic_id
147+
148+
topic_id should be the 8 character topic hash from id in the topic
149+
"""
150+
found = False
151+
topics = self.bot_plugin["TOPICS"]
152+
for index, topic in enumerate(topics):
153+
if topic["id"] == topic_id:
154+
found = True
155+
topic["used"] = False
156+
topic["used_date"] = None
157+
break
158+
if not found:
159+
raise KeyError(f"{topic_id} not found in topic list")
160+
self.bot_plugin["TOPICS"] = topics
161+
125162
@staticmethod
126163
def hash_topic(topic: str) -> str:
127164
"""
@@ -151,14 +188,43 @@ def activate(self) -> None:
151188
super().activate()
152189
self.topics = Topics(self)
153190
# schedule our daily jobs
154-
self.sched = BackgroundScheduler(
155-
{"apscheduler.timezome": self.config["TOPIC_TZ"]}
156-
)
157-
self.sched.add_job(
158-
self.post_topic, CronTrigger.from_crontab(self.config["TOPIC_SCHEDULE"])
159-
)
191+
self.sched = BackgroundScheduler(self.config["TAD_APSCHEDULER_CONFIG"])
192+
try:
193+
if self.config["TAD_ENABLE_WEBHOOK"]:
194+
request_args = {"url": self.config["TAD_WEBHOOK_URL"]}
195+
if self.config["AUTH_POST_WEBHOOK"]:
196+
request_args["headers"] = {
197+
"x-auth-token": self.config["AUTH_POST_WEBHOOK_TOKEN"]
198+
}
199+
self.sched.add_job(
200+
do_webhook_post,
201+
CronTrigger.from_crontab(self.config["TAD_SCHEDULE"]),
202+
kwargs=request_args,
203+
name="topic-a-day",
204+
id="topic-a-day",
205+
replace_existing=True,
206+
)
207+
else:
208+
self.sched.add_job(
209+
self.post_topic,
210+
CronTrigger.from_crontab(self.config["TAD_SCHEDULE"]),
211+
name="topic-a-day",
212+
id="topic-a-day",
213+
replace_existing=True,
214+
)
215+
except ConflictingIdError as err:
216+
self.log.debug("Hit error when adding job: %s", err)
217+
except Exception as err:
218+
self.log.error("Hit error while adding job: %s", err)
160219
self.sched.start()
161220

221+
def deactivate(self) -> None:
222+
"""
223+
Shutsdown the scheduler and calls super deactivate
224+
"""
225+
self.sched.shutdown()
226+
super().deactivate()
227+
162228
def configure(self, configuration: Dict) -> None:
163229
"""
164230
Configures the plugin
@@ -168,15 +234,53 @@ def configure(self, configuration: Dict) -> None:
168234
configuration = dict()
169235

170236
# name of the channel to post in
171-
get_config_item("TOPIC_CHANNEL", configuration)
237+
get_config_item("TAD_CHANNEL", configuration)
172238
if getattr(self._bot, "channelname_to_channelid", None) is not None:
173239
configuration["TOPIC_CHANNEL_ID"] = self._bot.channelname_to_channelid(
174-
configuration["TOPIC_CHANNEL"]
240+
configuration["TAD_CHANNEL"]
175241
)
176-
get_config_item("TOPIC_SCHEDULE", configuration, default="0 9 * * 1,3,5")
177-
get_config_item("TOPIC_TZ", configuration, default="UTC")
242+
get_config_item("TAD_SCHEDULE", configuration, default="0 9 * * 1,3,5")
243+
244+
# apscheduler config
245+
get_config_item("TAD_APSCHEDULER_CONFIG_FILE", configuration, default="")
246+
if configuration["TAD_APSCHEDULER_CONFIG_FILE"] != "":
247+
configuration["TAD_APSCHEDULER_CONFIG"] = self._load_config_file(
248+
configuration["TAD_APSCHEDULER_CONFIG_FILE"]
249+
)
250+
else:
251+
get_config_item("TOPIC_TZ", configuration, default="UTC")
252+
configuration["TAD_APSCHEDULER_CONFIG"] = {
253+
"apscheduler.timezone": configuration["TOPIC_TZ"]
254+
}
255+
256+
# Webhook options
257+
get_config_item("TAD_ENABLE_WEBHOOK", configuration, default="False", cast=bool)
258+
if configuration["TAD_ENABLE_WEBHOOK"]:
259+
get_config_item(
260+
"TAD_WEBHOOK_URL",
261+
configuration,
262+
default="http://localhost:3142/post_topic_rpc",
263+
)
264+
get_config_item(
265+
"AUTH_POST_WEBHOOK", configuration, default="True", cast=bool
266+
)
267+
if configuration["AUTH_POST_WEBHOOK"]:
268+
get_config_item(
269+
"AUTH_POST_WEBHOOK_TOKEN",
270+
configuration,
271+
default=secrets.token_urlsafe(),
272+
)
178273
super().configure(configuration)
179274

275+
@staticmethod
276+
def _load_config_file(filepath: str) -> Dict:
277+
""""""
278+
import json
279+
280+
with open(filepath, "r") as config_file:
281+
data = json.load(config_file)
282+
return data
283+
180284
@botcmd
181285
@arg_botcmd("topic", nargs="*", type=str, help="Topic to add to our topic list")
182286
def add_topic(self, msg: ErrbotMessage, topic: List[str]) -> None:
@@ -191,6 +295,24 @@ def add_topic(self, msg: ErrbotMessage, topic: List[str]) -> None:
191295
msg.frm, f"Topic added to the list: ```{topic_sentence}```", in_reply_to=msg
192296
)
193297

298+
@botcmd(admin_only=True)
299+
@arg_botcmd(
300+
"topic_id", type=str, help="Hash of the topic to remove from list topics"
301+
)
302+
def reset_topic(self, msg: ErrbotMessage, topic_id: str) -> str:
303+
"""
304+
Resets a topic from the topic list so it can be posted again
305+
"""
306+
if len(topic_id) != 8:
307+
return "Invalid Topic ID"
308+
309+
try:
310+
self.topics.reset(topic_id)
311+
except KeyError:
312+
return "Invalid Topic ID"
313+
314+
return "Topic Reset"
315+
194316
@botcmd(admin_only=True)
195317
@arg_botcmd(
196318
"topic_id", type=str, help="Hash of the topic to remove from list topics"
@@ -201,17 +323,14 @@ def delete_topic(self, msg: ErrbotMessage, topic_id: str) -> str:
201323
202324
"""
203325
if len(topic_id) != 8:
204-
self.send(msg.frm, f"Invalid Topic ID", in_reply_to=msg)
205-
return
326+
return "Invalid Topic ID"
206327

207328
try:
208329
self.topics.delete(topic_id)
209330
except KeyError:
210-
self.send(msg.frm, f"Invalid Topic ID", in_reply_to=msg)
211-
return
331+
return "Invalid Topic ID"
212332

213-
self.send(msg.frm, f"Topic Deleted", in_reply_to=msg)
214-
return
333+
return "Topic Deleted"
215334

216335
@botcmd
217336
def list_topics(self, msg: ErrbotMessage, _: List) -> None:
@@ -224,7 +343,7 @@ def list_topics(self, msg: ErrbotMessage, _: List) -> None:
224343
for topic in topics:
225344
if topic["used"]:
226345
used_topics.append(
227-
f"{topic['id']}: {topic['topic']} -- Posted on {topic['date_used']}"
346+
f"{topic['id']}: {topic['topic']} -- Posted on {topic['used_date'].strftime('%Y-%m-%d %H:%M')}"
228347
)
229348
else:
230349
free_topics.append(f"{topic['id']}: {topic['topic']}")
@@ -249,6 +368,20 @@ def list_topic_jobs(self, msg: ErrbotMessage, _: List) -> None:
249368
self.sched.print_jobs(out=pjobs_out)
250369
self.send(msg.frm, pjobs_out.getvalue(), in_reply_to=msg)
251370

371+
@webhook(methods=["POST"], raw=True)
372+
def post_topic_rpc(self, request):
373+
if not self.config["TAD_ENABLE_WEBHOOK"]:
374+
abort(500)
375+
if self.config["AUTH_POST_WEBHOOK"]:
376+
if (
377+
request.headers.get("x-auth-token", "")
378+
!= self.config["AUTH_POST_WEBHOOK_TOKEN"]
379+
):
380+
abort(403, "Endpoint auth turned on and your auth token did not match")
381+
382+
self.post_topic()
383+
return "Ok"
384+
252385
def post_topic(self) -> None:
253386
"""
254387
Called by our scheduled jobs to post the topic message for the day. Also calls any backend specific
@@ -272,7 +405,7 @@ def post_topic(self) -> None:
272405
except AttributeError:
273406
self.log.debug("%s has no backend specific tasks", self._bot.mode)
274407
self.log.debug("Sending message to channel")
275-
self.send(self.build_identifier(self.config["TOPIC_CHANNEL"]), topic_template)
408+
self.send(self.build_identifier(self.config["TAD_CHANNEL"]), topic_template)
276409
self.log.debug("Setting topic to used")
277410
self.topics.set_used(new_topic["id"])
278411

@@ -286,9 +419,7 @@ def slack_pre_post_topic(self, topic: str) -> None:
286419
self._bot.api_call(
287420
"channels.setTopic",
288421
{
289-
"channel": self._bot.channelname_to_channelid(
290-
self.config["TOPIC_CHANNEL"]
291-
),
422+
"channel": self.config["TOPIC_CHANNEL_ID"],
292423
"topic": topic,
293424
},
294425
)

0 commit comments

Comments
 (0)