Skip to content

Commit 153ce7c

Browse files
committed
Add chat notifier plugin
1 parent 0cabd63 commit 153ce7c

File tree

9 files changed

+537
-0
lines changed

9 files changed

+537
-0
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# CTFd Chat Notifier
2+
3+
A small CTFd plugin to send notifications to Slack, Discord or Telegram about solves and admin announcements. Can be easily extended to support other platforms.
4+
5+
![Configuration screenshot](screenshot.png)
6+
7+
## Installation
8+
9+
Clone this repo to `CTFd/plugins/CTFd_chat_notifier` in your CTFd installation directory and restart it. You should see the notifier settings in the admin panel.
10+
11+
Tested with CTFd 3.1.1.
12+
13+
## Extending
14+
15+
1. Create your own plugin (or, if you are implementing a popular service, modify this one and send me a pull request!)
16+
2. Create a class that extends from `BaseNotifier`
17+
3. If your notifier requires any configuration (it probably needs at least a webhook url), override the `get_settings` method and create a settings template in `templates/chat_notifier/admin_notifier_settings/your_notifier_type_id.html`. Override `is_configured` to return True only when all required settings are configured correctly.
18+
4. Implement the `notify_solve` and `notify_message` methods
19+
5. Register your notifier type by creating an instance of your class and adding it to the `NOTIFIER_CLASSES` dictionary
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
from CTFd.plugins.challenges import BaseChallenge
2+
from CTFd.utils.modes import TEAMS_MODE, get_mode_as_word, get_model
3+
from CTFd.utils.decorators import admins_only
4+
from CTFd.utils.humanize.numbers import ordinalize
5+
from CTFd.utils import get_config, set_config
6+
from CTFd.cache import clear_config
7+
from CTFd.models import Challenges, Solves, db
8+
from flask import url_for, Blueprint, render_template, redirect, request, session, abort, Markup
9+
from functools import wraps
10+
import requests
11+
import uuid
12+
13+
class BaseNotifier(object):
14+
def get_settings(self):
15+
return []
16+
def is_configured(self):
17+
return True
18+
def notify_solve(self, format, solver_name, solver_url, challenge_name, challenge_url, solve_num):
19+
pass
20+
def notify_message(self, title, content):
21+
pass
22+
23+
class SlackNotifier(BaseNotifier):
24+
def get_settings(self):
25+
return ['notifier_slack_webhook_url']
26+
def get_webhook_url(self):
27+
return get_config('notifier_slack_webhook_url')
28+
def is_configured(self):
29+
return bool(self.get_webhook_url())
30+
31+
def notify_solve(self, format, solver_name, solver_url, challenge_name, challenge_url, solve_num):
32+
plain_msg = format.format(
33+
solver=solver_name,
34+
challenge=challenge_name,
35+
solve_num=ordinalize(solve_num),
36+
)
37+
markdown_msg = format.format(
38+
solver='<{solver_url}|{solver_name}>'.format(solver_name=solver_name, solver_url=solver_url),
39+
challenge='<{challenge_url}|{challenge_name}>'.format(challenge_name=challenge_name, challenge_url=challenge_url),
40+
solve_num=ordinalize(solve_num),
41+
)
42+
43+
requests.post(self.get_webhook_url(), json={
44+
'text': plain_msg,
45+
'blocks': [
46+
{
47+
"type": "section",
48+
"text": {
49+
"type": "mrkdwn",
50+
"text": markdown_msg
51+
}
52+
},
53+
]
54+
})
55+
56+
def notify_message(self, title, content):
57+
requests.post(self.get_webhook_url(), json={
58+
'text': content,
59+
'blocks': [
60+
{
61+
"type": "header",
62+
"text": {
63+
"type": "plain_text",
64+
"text": title
65+
}
66+
},
67+
{
68+
"type": "section",
69+
"text": {
70+
"type": "mrkdwn",
71+
"text": content
72+
}
73+
},
74+
]
75+
})
76+
77+
class DiscordNotifier(BaseNotifier):
78+
def get_settings(self):
79+
return ['notifier_discord_webhook_url']
80+
def get_webhook_url(self):
81+
return get_config('notifier_discord_webhook_url')
82+
def is_configured(self):
83+
return bool(self.get_webhook_url())
84+
85+
def notify_solve(self, format, solver_name, solver_url, challenge_name, challenge_url, solve_num):
86+
markdown_msg = format.format(
87+
solver='[{solver_name}]({solver_url})'.format(solver_name=solver_name, solver_url=solver_url),
88+
challenge='[{challenge_name}]({challenge_url})'.format(challenge_name=challenge_name, challenge_url=challenge_url),
89+
solve_num=ordinalize(solve_num),
90+
)
91+
92+
requests.post(self.get_webhook_url(), json={
93+
'embeds': [{
94+
'description': markdown_msg,
95+
}]
96+
})
97+
98+
def notify_message(self, title, content):
99+
requests.post(self.get_webhook_url(), json={
100+
'embeds': [{
101+
'title': title,
102+
'description': content,
103+
}]
104+
})
105+
106+
class TelegramNotifier(BaseNotifier):
107+
def get_settings(self):
108+
return ['notifier_telegram_bot_token', 'notifier_telegram_chat_id']
109+
def get_bot_token(self):
110+
return get_config('notifier_telegram_bot_token')
111+
def get_chat_id(self):
112+
return get_config('notifier_telegram_chat_id')
113+
def is_configured(self):
114+
return bool(self.get_bot_token()) and bool(self.get_chat_id())
115+
116+
@staticmethod
117+
def _escape(s):
118+
badchars = ['\\', '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']
119+
for char in badchars:
120+
s = s.replace(char, '\\' + char)
121+
return s
122+
123+
def notify_solve(self, format, solver_name, solver_url, challenge_name, challenge_url, solve_num):
124+
markdown_msg = format.replace('(', '\\(').replace(')', '\\)').format(
125+
solver='[{solver_name}]({solver_url})'.format(solver_name=self._escape(solver_name), solver_url=self._escape(solver_url)),
126+
challenge='[{challenge_name}]({challenge_url})'.format(challenge_name=self._escape(challenge_name), challenge_url=self._escape(challenge_url)),
127+
solve_num=ordinalize(solve_num),
128+
)
129+
130+
requests.post('https://api.telegram.org/bot{bot_token}/sendMessage'.format(bot_token=self.get_bot_token()), json={
131+
'chat_id': self.get_chat_id(),
132+
'parse_mode': 'MarkdownV2',
133+
'text': markdown_msg,
134+
})
135+
136+
def notify_message(self, title, content):
137+
requests.post('https://api.telegram.org/bot{bot_token}/sendMessage'.format(bot_token=self.get_bot_token()), json={
138+
'chat_id': self.get_chat_id(),
139+
'parse_mode': 'MarkdownV2',
140+
'text': '*{title}*\n{content}'.format(title=self._escape(title), content=self._escape(content)),
141+
})
142+
143+
class MatrixNotifier(BaseNotifier):
144+
def get_settings(self):
145+
return ['notifier_matrix_bot_token', 'notifier_matrix_room_id', 'notifier_matrix_server_url']
146+
def get_bot_token(self):
147+
return get_config('notifier_matrix_bot_token')
148+
def get_room_id(self):
149+
return get_config('notifier_matrix_room_id')
150+
def get_server_url(self):
151+
return get_config('notifier_matrix_server_url')
152+
def is_configured(self):
153+
return bool(self.get_bot_token()) and bool(self.get_room_id()) and bool(self.get_server_url())
154+
155+
@staticmethod
156+
def _escape(s):
157+
badchars = ['\\', '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']
158+
for char in badchars:
159+
s = s.replace(char, '\\' + char)
160+
return s
161+
162+
def notify_solve(self, format, solver_name, solver_url, challenge_name, challenge_url, solve_num):
163+
164+
markdown_msg = format.replace('(', '\\(').replace(')', '\\)').format(
165+
solver='[{solver_name}]({solver_url})'.format(solver_name=self._escape(solver_name), solver_url=self._escape(solver_url)),
166+
challenge='[{challenge_name}]({challenge_url})'.format(challenge_name=self._escape(challenge_name), challenge_url=self._escape(challenge_url)),
167+
solve_num=ordinalize(solve_num),
168+
)
169+
plain_msg = format.format(
170+
solver=solver_name,
171+
challenge=challenge_name,
172+
solve_num=ordinalize(solve_num),
173+
)
174+
175+
headers = {"Authorization": f"Bearer {self.get_bot_token()}"}
176+
payload = {
177+
"msgtype": "m.text",
178+
"body": plain_msg
179+
}
180+
181+
txn_id = str(uuid.uuid4())
182+
path = f"/_matrix/client/r0/rooms/{self.get_room_id()}/send/m.room.message/{txn_id}"
183+
184+
requests.put(f'{self.get_server_url()}{path}', json=payload, headers=headers)
185+
186+
def notify_message(self, title, content):
187+
payload = {
188+
"msgtype": "m.text",
189+
"body": '*{title}*\n{content}'.format(title=self._escape(title), content=self._escape(content))
190+
}
191+
192+
headers = {
193+
"Authorization": f"Bearer {self.get_bot_token()}"
194+
}
195+
196+
txn_id = str(uuid.uuid4())
197+
path = f"/_matrix/client/r0/rooms/{self.get_room_id()}/send/m.room.message/{txn_id}"
198+
199+
requests.put(f'{self.get_server_url()}{path}', json=payload, headers=headers)
200+
201+
"""
202+
Global dictionary used to hold all the supported chat services. To add support for a new chat service, create a plugin and insert
203+
your BaseNotifier subclass instance into this dictionary to register it.
204+
"""
205+
NOTIFIER_CLASSES = {"slack": SlackNotifier(), "discord": DiscordNotifier(), "telegram": TelegramNotifier(), "matrix": MatrixNotifier()}
206+
207+
def get_configured_notifier():
208+
notifier_type = get_config('notifier_type')
209+
if not notifier_type:
210+
return None
211+
notifier = NOTIFIER_CLASSES[notifier_type]
212+
if not notifier.is_configured():
213+
return None
214+
return notifier
215+
216+
def get_all_notifier_settings():
217+
settings = set()
218+
for k,v in NOTIFIER_CLASSES.items():
219+
for setting in v.get_settings():
220+
if setting in settings:
221+
raise Exception('Notifier {0} uses duplicate setting name {1}', v, setting)
222+
settings.add(setting)
223+
return settings
224+
225+
def load(app):
226+
chat_notifier = Blueprint('chat_notifier', __name__, template_folder='templates')
227+
228+
@chat_notifier.route('/admin/chat_notifier', methods=['GET', 'POST'])
229+
@admins_only
230+
def chat_notifier_admin():
231+
clear_config()
232+
if request.method == "POST":
233+
if request.form['notifier_type'] and request.form['notifier_type'] not in NOTIFIER_CLASSES.keys():
234+
abort(400)
235+
set_config('notifier_type', request.form['notifier_type'])
236+
set_config('notifier_send_notifications', 'notifier_send_notifications' in request.form)
237+
set_config('notifier_send_solves', 'notifier_send_solves' in request.form)
238+
set_config('notifier_solve_msg', request.form['notifier_solve_msg'])
239+
if request.form['notifier_solve_count']:
240+
set_config('notifier_solve_count', int(request.form['notifier_solve_count']))
241+
else:
242+
set_config('notifier_solve_count', None)
243+
for setting in get_all_notifier_settings():
244+
set_config(setting, request.form[setting])
245+
return redirect(url_for('chat_notifier.chat_notifier_admin'))
246+
else:
247+
context = {
248+
'nonce': session['nonce'],
249+
'supported_notifier_types': NOTIFIER_CLASSES.keys(),
250+
'notifier_type': get_config('notifier_type'),
251+
'notifier_send_notifications': get_config('notifier_send_notifications'),
252+
'notifier_send_solves': get_config('notifier_send_solves'),
253+
'notifier_solve_msg': get_config('notifier_solve_msg'),
254+
'notifier_solve_count': get_config('notifier_solve_count'),
255+
}
256+
for setting in get_all_notifier_settings():
257+
context[setting] = get_config(setting)
258+
supported_notifier_settings = {}
259+
for k,v in NOTIFIER_CLASSES.items():
260+
supported_notifier_settings[k] = Markup(render_template('chat_notifier/admin_notifier_settings/{}.html'.format(k), **context))
261+
context['supported_notifier_settings'] = supported_notifier_settings
262+
return render_template('chat_notifier/admin.html', **context)
263+
264+
app.register_blueprint(chat_notifier)
265+
266+
def chal_solve_decorator(chal_solve_func):
267+
@wraps(chal_solve_func)
268+
def wrapper(user, team, challenge, request):
269+
chal_solve_func(user, team, challenge, request)
270+
271+
notifier = get_configured_notifier()
272+
if notifier and bool(get_config('notifier_send_solves')):
273+
if get_mode_as_word() == TEAMS_MODE:
274+
solver = team
275+
solver_url = url_for("teams.public", team_id=solver.account_id, _external=True)
276+
else:
277+
solver = user
278+
solver_url = url_for("users.public", user_id=solver.account_id, _external=True)
279+
challenge_url = url_for('challenges.listing', _external=True, _anchor='{challenge.name}-{challenge.id}'.format(challenge=challenge))
280+
281+
Model = get_model()
282+
solve_count = (
283+
db.session.query(
284+
db.func.count(Solves.id)
285+
)
286+
.filter(Solves.challenge_id == challenge.id)
287+
.join(Model, Solves.account_id == Model.id)
288+
.filter(Model.banned == False, Model.hidden == False)
289+
.scalar()
290+
)
291+
292+
max_solves = get_config('notifier_solve_count')
293+
max_solves = int(max_solves) if max_solves is not None else None
294+
295+
if max_solves is None or solve_count <= max_solves:
296+
notifier.notify_solve(get_config('notifier_solve_msg', '{solver} solved {challenge} ({solve_num} solve)'), solver.name, solver_url, challenge.name, challenge_url, solve_count)
297+
return wrapper
298+
BaseChallenge.solve = chal_solve_decorator(BaseChallenge.solve)
299+
300+
def event_publish_decorator(event_publish_func):
301+
@wraps(event_publish_func)
302+
def wrapper(*args, **kwargs):
303+
event_publish_func(args, kwargs)
304+
305+
if kwargs['type'] == 'notification':
306+
notifier = get_configured_notifier()
307+
if notifier and bool(get_config('notifier_send_notifications')):
308+
notification = kwargs['data']
309+
notifier.notify_message(notification['title'], notification['content'])
310+
return wrapper
311+
app.events_manager.publish = event_publish_decorator(app.events_manager.publish)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "Chat Notifier",
3+
"route": "/admin/chat_notifier"
4+
}
134 KB
Loading

0 commit comments

Comments
 (0)