forked from kungming2/AssistantBOT
-
Notifications
You must be signed in to change notification settings - Fork 0
/
artemis_main.py
2843 lines (2495 loc) · 127 KB
/
artemis_main.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
"""The MAIN runtime provides the messaging and flair enforcement
operations for the bot.
"""
import os
import re
import sys
import time
import traceback
import yaml
from ast import literal_eval
from random import choice
import praw
import prawcore
import psutil
from pbwrap import Pastebin
from rapidfuzz import process
import connection
import database
import timekeeping
from common import (
flair_sanitizer,
flair_template_checker,
logger,
main_error_log,
markdown_escaper,
)
from settings import INFO, FILE_ADDRESS, SETTINGS
from text import *
# Number of regular top-level routine runs that have been made.
ISOCHRONISMS = 0
"""WIDGET UPDATING FUNCTIONS"""
def widget_operational_status_updater():
"""Widget updated on r/AssistantBOT with the current time.
This basically tells us that the bot is active with the time being
formatted according to ISO 8601: https://www.w3.org/TR/NOTE-datetime
It is run every isochronism.
:return: `None`.
"""
# Don't update this widget if it's being run on an alternate account
if INSTANCE != 99:
return
current_time = timekeeping.time_convert_to_string(time.time())
wa_time = "{} {} UTC".format(current_time.split("T")[0], current_time.split("T")[1][:-1])
wa_link = "https://www.wolframalpha.com/input/?i={}+to+current+geoip+location".format(wa_time)
current_time = current_time.replace("Z", "[Z]({})".format(wa_link)) # Add the link.
# Get the operational status widget.
operational_widget = None
for widget in reddit.subreddit(INFO.username).widgets.sidebar:
if isinstance(widget, praw.models.TextArea):
if widget.id == SETTINGS.widget_operational_status:
operational_widget = widget
break
# Update the widget with the current time.
if operational_widget is not None:
operational_status = "# ✅ {}".format(current_time)
operational_widget.mod.update(
text=operational_status,
styles={"backgroundColor": "#349e48", "headerColor": "#222222"},
)
return True
else:
return False
def wikipage_config(subreddit_name):
"""This will return the wikipage object that already exists or the
new one that was just created for the configuration page.
This function also validates the YAML content of the configuration
page to ensure that it is properly formed and that the data is as
expected.
:param subreddit_name: Name of a subreddit.
:return: A tuple. In the first, `False` if an error was encountered,
`True` if everything went right.
The second parameter is a string with the error text if
`False`, `None` if `True`.
"""
# The wikipage title to edit or create.
page_name = "{}_config".format(INFO.username[:12].lower())
r = reddit.subreddit(subreddit_name)
# This is the max length (in characters) of the custom flair
# enforcement message.
limit_msg = SETTINGS.advanced_limit_msg
# This is the max length (in characters) of the custom bot name and
# goodbye.
limit_name = SETTINGS.advanced_limit_name
# A list of Reddit's `tags` that are flair-external.
permitted_tags = ["nsfw", "oc", "spoiler"]
permitted_days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
# Check moderator permissions.
current_permissions = connection.obtain_mod_permissions(subreddit_name, INSTANCE)[1]
if not current_permissions:
logger.info("Wikipage Config: Not a moderator of r/{}.".format(subreddit_name))
error = "Artemis is not a moderator of this subreddit."
return False, error
if "wiki" not in current_permissions and "all" not in current_permissions:
logger.info(
"Wikipage Config: Insufficient mod permissions to edit "
"wiki config on r/{}.".format(subreddit_name)
)
error = (
"Artemis does not have the `wiki` mod permission "
"and thus cannot access the configuration page."
)
return False, error
# Check the subreddit subscriber number. This is only used in
# generating the initial default page. If there are enough
# subscribers for userflair statistics, replace the boolean.
if r.subscribers > SETTINGS.min_s_userflair:
page_template = ADV_DEFAULT.replace(
"userflair_statistics: False", "userflair_statistics: True"
)
else:
page_template = str(ADV_DEFAULT)
# Check if the page is there and try and get the text of the page.
# This will fail if the page does NOT exist.
try:
config_test = r.wiki[page_name].content_md
# If the page exists, then we get the PRAW Wikipage object here.
config_wikipage = r.wiki[page_name]
logger.debug("Wikipage Config: Config wikipage found, length {}.".format(len(config_test)))
except prawcore.exceptions.NotFound:
# The page does *not* exist. Let's create the config page.
reason_msg = "Creating the Artemis config wiki page."
config_wikipage = r.wiki.create(name=page_name, content=page_template, reason=reason_msg)
# Remove it from the public list and only let moderators see it.
# Also add Artemis as a approved submitter/editor for the wiki.
config_wikipage.mod.update(listed=False, permlevel=2)
config_wikipage.mod.add(USERNAME_REG)
logger.info(
"Wikipage Config: Created new config wiki page for r/{}.".format(subreddit_name)
)
# Now we have the `config_wikipage`. We pass its data to YAML and
# see if we can get proper data from it.
# If it's a newly created page then the default data will be what
# it gets from the page.
default_data = yaml.safe_load(ADV_DEFAULT)
# A list of the default variables (which are keys).
default_vs_keys = list(default_data.keys())
default_vs_keys.sort()
# noinspection PyUnresolvedReferences
try:
# `subreddit_config_data` should be a dictionary from the sub
# assuming the YAML parser is able to get it right.
subreddit_config_data = yaml.safe_load(config_wikipage.content_md)
subreddit_config_keys = list(subreddit_config_data.keys())
subreddit_config_keys.sort()
except yaml.composer.ComposerError as err:
# Encountered an error in the data's composition and this YAML
# data does not translate into a proper Python dictionary.
logger.info(
"Wikipage Config: The data on r/{} config page "
"has syntax errors.".format(subreddit_name)
)
error = (
"There was an error with the page's YAML syntax "
"and this error occurred: {}".format(repr(err))
)
return False, error
except yaml.parser.ParserError:
# Encountered an error in parsing the data. This is likely due
# to the inclusion of document markers (`---`) which are
# mandatory on AutoModerator configuration pages.
error = (
"There was an error with the page's YAML syntax. "
"Please make sure there are no `---` lines."
)
return False, error
except yaml.scanner.ScannerError:
# Encountered an error in formatting. This can happen if the
# indentation is faulty or invalid.
error = (
"There was an error with the page's YAML syntax. "
"Please make sure that all indents are *four* spaces, "
"and that there are spaces after each colon `:`."
)
return False, error
logger.info(
"Wikipage Config: Configuration data for "
"r/{} is {}.".format(subreddit_name, subreddit_config_data)
)
# Check to make sure that the subreddit's variables are a valid
# subset of the default configuration.
if not set(subreddit_config_keys).issubset(default_vs_keys):
logger.info(
"Wikipage Config: The r/{} config variables are incorrect.".format(subreddit_name)
)
error = "The configuration variables do not match the ones in the default specification."
return False, error
# Integrity check to make sure all of the subreddit config data is
# properly typed and will not cause problems.
for v in subreddit_config_keys:
default_type = type(default_data[v])
subreddit_config_type = type(subreddit_config_data[v])
if default_type != subreddit_config_type:
logger.info(
"Wikipage Config: Variable `{}` "
"wrongly set as `{}`.".format(v, subreddit_config_type)
)
error = (
"Configuration variable `{}` has a wrong type: "
"It should be of type `{}`.".format(v, default_type)
)
return False, error
# Make sure every username on the username lists are in
# lowercase, if it's a non-empty list.
if v == "flair_enforce_whitelist" and len(subreddit_config_data[v]) > 0:
subreddit_config_data[v] = [x.lower().strip() for x in subreddit_config_data[v]]
elif v == "flair_enforce_alert_list" and len(subreddit_config_data[v]) > 0:
subreddit_config_data[v] = [x.lower().strip() for x in subreddit_config_data[v]]
# Length checks to make sure the custom strings are not too
# long. If there are, they are truncated to the limits set
# above.
elif v == "flair_enforce_custom_message" and len(subreddit_config_data[v]) > limit_msg:
subreddit_config_data[v] = subreddit_config_data[v][:limit_msg].strip()
elif v == "custom_name" and len(subreddit_config_data[v]) > limit_name:
subreddit_config_data[v] = subreddit_config_data[v][:limit_name].strip()
elif v == "custom_goodbye" and len(subreddit_config_data[v]) > limit_name:
subreddit_config_data[v] = subreddit_config_data[v][:limit_name].strip()
# This checks the integrity of the `flair_tags` dictionary.
# It has the `spoiler` and `nsfw` keys (ONLY)
# and make sure each have lists of flair IDs that match a regex
# template and are valid.
elif v == "flair_tags":
# First check to make sure that the tags are allowed and the
# right ones, with no more variables than allowed.
if len(subreddit_config_data[v]) > len(permitted_tags):
return False, "There are more than the allowed number of tags in `flair_tags`."
if not set(subreddit_config_data["flair_tags"].keys()).issubset(permitted_tags):
return False, "There are tags in `flair_tags` that are not of the expected type."
# Now we check to make sure that the contents of the tags
# are LISTS, rather than strings. Return an error if they
# contain anything other than lists.
for key in subreddit_config_data["flair_tags"]:
if type(subreddit_config_data["flair_tags"][key]) != list:
error_msg = (
"Each tag in `flair_tags` should contain a *list* of flair templates."
)
return False, error_msg
# Next we iterate over the lists to make sure they contain
# proper post flair IDs. If not, return an error.
# Add all present flairs together and iterate over them.
tagged_flairs = sum(subreddit_config_data["flair_tags"].values(), [])
for flair in tagged_flairs:
if not flair_template_checker(flair):
error_msg = (
"Please ensure data in `flair_tags` has "
"valid flair IDs, not `{}`.".format(flair)
)
return False, error_msg
# Properly check the integrity of the `flair_schedule`
# dictionary. It should have three-letter weekdays as
# keys and each have lists of flair IDs that are valid.
elif v == "flair_schedule":
# Check to make sure that they
if not set(subreddit_config_data["flair_schedule"].keys()).issubset(permitted_days):
error_msg = (
"Please ensure that days in `flair_schedule` are listed as "
"**abbreviations in title case**. For example, `Sun`, `Tue`, etc."
)
return False, error_msg
# Now we check to make sure that the contents of the tags
# are LISTS, rather than strings. Return an error if they
# contain anything other than lists.
for key in subreddit_config_data["flair_schedule"]:
if type(subreddit_config_data["flair_schedule"][key]) != list:
error_msg = (
"Each day in `flair_schedule` should "
"contain a *list* of flair templates."
)
return False, error_msg
# Next we iterate over the lists to make sure they contain
# proper post flair IDs. If not, return an error.
# Add all present flairs together and iterate over them.
tagged_flairs = sum(subreddit_config_data["flair_schedule"].values(), [])
for flair in tagged_flairs:
if not flair_template_checker(flair):
error_msg = (
"Please ensure data in `flair_schedule` has "
"valid flair IDs, not `{}`.".format(flair)
)
return False, error_msg
# If we've reached this point, the data should be accurate and
# properly typed. Write to database.
database.extended_insert(subreddit_name, subreddit_config_data)
logger.info(
"Wikipage Config: Inserted configuration data for "
"r/{} into extended data.".format(subreddit_name)
)
return True, None
def wikipage_access_history(action, data_package):
"""Function to save a record of a removed subreddit to the wiki.
:param action: A string, one of `readd`, `remove`, or `read`.
* `remove` means that the subreddit will be entered
on this history page.
* `readd` means that the subreddit, if it exists
should be cleared from this history.
* `read` just fetches the existing data as a
dictionary.
:param data_package: A dictionary indexed with a subreddit's
lowercase name and with the former extended
data for it if the action is `remove`.
If the action is `readd` then it will be a
string.
If the action is `read`, `None` is fine.
"""
# Access the history wikipage and load its data.
history_wikipage = reddit.subreddit(SETTINGS.wiki).wiki["artemis_history"]
history_data = yaml.safe_load(history_wikipage.content_md)
# Update the dictionary depending on the action.
if action == "remove":
history_data.update(data_package)
elif action == "readd":
if data_package in history_data:
del history_data[data_package]
else: # This subreddit was never in the history.
return
elif action == "read":
return history_data
# Format the YAML code with indents for readability and edit.
history_yaml = yaml.safe_dump(history_data)
history_yaml = " " + history_yaml.replace("\n", "\n ")
history_wikipage.edit(content=history_yaml, reason="Updating with action `{}`.".format(action))
logger.info(
"Access History: Saved `{}`, "
"data `{}`, to history wikipage.".format(action, data_package)
)
return
"""TEMPLATE FUNCTIONS"""
def subreddit_templates_retrieve(subreddit_name, display_mod_flairs=False):
"""Retrieve the templates that are available for a particular
subreddit's post flairs.
Note that moderator-only post flairs ARE NOT included in the data
that Reddit returns, because we use the alternate `reddit_helper`
account, which is NOT a moderator account and can only see the post
flairs that regular users can see.
However, if the subreddit is private and only accessible to the
main account, we still use the main account to access the flairs.
:param subreddit_name: Name of a subreddit.
:param display_mod_flairs: A Boolean as to whether or not we want
to retrieve the mod-only post flairs.
Not used now but is an option.
* True: Display mod-only flairs.
* False (default): Don't.
:return: A dictionary of the templates available on that subreddit,
indexed by their flair text.
This dictionary will be empty if Artemis is unable to
access the templates for some reason.
Those reasons may include all flairs being mod-only,
no flairs at all, etc.
"""
subreddit_templates = {}
order = 1
# Determine the status of the subreddit.
# `public` is normal, `private`, and the `Forbidden` exception if
# it is a quarantined subreddit.
try:
subreddit_type = reddit.subreddit(subreddit_name).subreddit_type
except prawcore.exceptions.Forbidden:
subreddit_type = "private"
# Primarily we do not want to get mod-only flairs,
# so we use the helper account to get available flairs.
if not display_mod_flairs and subreddit_type == "public":
r = reddit_helper.subreddit(subreddit_name)
else:
r = reddit.subreddit(subreddit_name)
# Access the templates on the subreddit and assign their attributes
# to our dictionary.
try:
for template in r.flair.link_templates:
# This template has no text at all; do not process it.
if len(template["text"]) == 0:
continue
# Create an entry in the dictionary for this flair.
subreddit_templates[template["text"]] = {
"id": template["id"],
"order": order,
"css_class": template["css_class"],
}
# This variable presents the dictionary of templates in the
# same order it is on the sub.
order += 1
logger.debug(
"Templates Retrieve: r/{} templates are: {}".format(
subreddit_name, subreddit_templates
)
)
except prawcore.exceptions.Forbidden:
# The flairs don't appear to be available to me.
# It may be that they are mod-only. Return an empty dictionary.
logger.debug("Templates Retrieve: r/{} templates not accessible.".format(subreddit_name))
return subreddit_templates
def subreddit_templates_collater(subreddit_name, extended_data=None):
"""A function that generates a bulleted list of flairs available on
a subreddit based on a dictionary by the function
`subreddit_templates_retrieve()`. If the flairs are limited to
certain days,
:param subreddit_name: The name of a Reddit subreddit.
:param extended_data: An optional extended data dictionary.
This is so that the flairs can be paired with
their specific scheduled days.
:return: A Markdown-formatted bulleted list of templates.
"""
formatted_order = {}
# Get the flair schedule if present.
if extended_data is not None:
schedule = extended_data.get("flair_schedule", {})
else:
schedule = {}
# Iterate over our keys, indexing by the order in which they are
# displayed in the flair selector. The templates are also passed to
# the flair sanitizer for processing.
template_dictionary = subreddit_templates_retrieve(subreddit_name)
# Iterate over the templates and format them nicely as a list.
for template in template_dictionary.keys():
template_order = template_dictionary[template]["order"]
template_id = template_dictionary[template]["id"]
# Check if there's extended data.
if extended_data:
raw_data = flair_sanitizer(template, False)
specific_schedule = timekeeping.check_flair_schedule(template_id, schedule)
# If this specific flair template has permitted days on the
# schedule, add a small note next to the flair noting which
# days those flairs are for.
if specific_schedule[1]:
permitted = [timekeeping.convert_weekday_text(x) for x in specific_schedule[1]]
raw_data += " (only on {})".format(", ".join(permitted))
formatted_order[template_order] = raw_data
else:
# No schedule data, just format it accordingly.
formatted_order[template_order] = flair_sanitizer(template, False)
# Reorder and format each line.
lines = ["* {}".format(formatted_order[key]) for key in sorted(formatted_order.keys())]
return "\n".join(lines)
"""ADVANCED SUB-FUNCTIONS"""
def advanced_send_alert(submission_obj, list_of_users):
"""A small function to send a message to moderators who want to be
notified each time a removal action is taken. This is not a
widely-used function and in v1.6 was surfaced for others to use
if needed via advanced configuration.
:param submission_obj: A PRAW submission object.
:param list_of_users: A list of users to notify.
They must be moderators.
:return: Nothing.
"""
sub_name = submission_obj.subreddit.display_name.lower()
for user in list_of_users:
if flair_is_user_mod(user, sub_name):
# Form the message to send to the moderator.
alert = "I removed this [unflaired post here](https://www.reddit.com{}).".format(
submission_obj.permalink
)
if submission_obj.over_18:
alert += " (Warning: This post is marked as NSFW)"
alert += BOT_DISCLAIMER.format(sub_name)
# Send the message to the moderator, accounting for if there
# is a username error.
subject = "[Notification] Post on r/{} removed.".format(sub_name)
try:
reddit.redditor(user).message(subject=subject, message=alert)
logger.info(
"Send Alert: Messaged u/{} on r/{} about removal.".format(user, sub_name)
)
except praw.exceptions.APIException:
continue
return
def advanced_set_flair_tag(praw_submission, template_id=None):
"""A function to check if a submission has flairs associated with
certain Reddit tags, namely `nsfw`, `oc`, and `spoiler`.
This is defined through extended data as a dictionary of lists.
This requires the `posts` mod permission to work. If spoilers are
not enabled, nothing will happen for that.
:param praw_submission: A PRAW Reddit submission object.
:param template_id: Optionally, a template ID for usage to directly
assign instead of getting from the submission.
:return: Nothing.
"""
# Check for the post template.
if template_id is None:
try:
post_template = praw_submission.link_flair_template_id
except AttributeError: # No template ID assigned.
return
else:
post_template = template_id
# Fetch the extended data and check the flair tags dictionary.
# This is a dictionary with keys `spoiler`, `nsfw` etc. with lists.
post_id = praw_submission.id
ext_data = database.extended_retrieve(praw_submission.subreddit.display_name.lower())
if "flair_tags" not in ext_data:
return
else:
flair_tags = ext_data["flair_tags"]
# Iterate over our dictionary, checking for the template ID of the
# submission.
for tag in flair_tags:
flair_list = flair_tags[tag]
if tag == "nsfw":
if post_template in flair_list:
# This flair is specified to be marked as NSFW.
praw_submission.mod.nsfw()
logger.info("Set Tag: >> Marked post `{}` as NSFW.".format(post_id))
elif tag == "oc":
# We use an unsurfaced method here from u/nmtake. This will
# be integrated into a future version of PRAW.
# https://redd.it/dr4kti
if post_template in flair_list:
# This flair is specified to be marked as original
# content.
package = {
"id": post_id,
"fullname": "t3_" + post_id,
"should_set_oc": True,
"executed": False,
"r": praw_submission.subreddit.display_name,
}
reddit.post("api/set_original_content", data=package)
logger.info("Set Tag: >> Marked post `{}` as original content.".format(post_id))
elif tag == "spoiler":
if post_template in flair_list:
# This flair is specified to be marked as a spoiler.
praw_submission.mod.spoiler()
logger.info("Set Tag: >> Marked post `{}` as a spoiler.".format(post_id))
return
"""MESSAGING FUNCTIONS"""
def messaging_send_creator(subreddit_name, subject_type, message):
"""A function that messages Artemis's creator updates on certain
actions taken by this bot.
:param subreddit_name: Name of a subreddit.
:param subject_type: The type of message we want to send.
:param message: The text of the message we want to send,
passed in from above.
:return: None.
"""
subreddit_name = subreddit_name.lower()
# This is a dictionary that defines what the subject line will be
# based on the action. The add portion is currently unused.
subject_dict = {
"add": "Added former subreddit: r/{}",
"remove": "Demodded from subreddit: r/{}",
"forbidden": "Subscribers forbidden for subreddit: r/{}",
"not_found": "Subscribers not found for subreddit: r/{}",
"omit": "Omitted subreddit: r/{}",
"mention": "New item mentioning Artemis on r/{}",
}
# Taking note of exempted subreddits and exit early.
if subject_type == "mention" and subreddit_name in connection.CONFIG.sub_mention_omit:
logger.info(
"Messaging Send Creator: Mention in omitted subreddit r/{}.".format(subreddit_name)
)
return
# If we have a matching subject type, send a message to the creator.
if subject_type in subject_dict:
creator = reddit.redditor(INFO.creator)
creator.message(subject=subject_dict[subject_type].format(subreddit_name), message=message)
return
def messaging_parse_flair_response(subreddit_name, response_text, post_id):
"""This function looks at a user's response to determine if their
response is a valid flair in the subreddit that they posted in.
If it is a valid template, then the function returns a template ID.
The template ID is long and looks like this:
`c1503580-7c00-11e7-8b43-0e560b183184`
:param subreddit_name: Name of a subreddit.
:param response_text: The text that a user sent back as a response
to the message.
:param post_id: The ID of the submitter's post (used only for action
counting purposes).
:return: `None` if it does not match anything;
a template ID otherwise.
"""
# Whether or not the message should be saved to a file for record
# keeping and examination.
to_messages_save = False
flair_match_text = None
action_type = None
# Process the response from the user to make it consistent.
response_text = flair_sanitizer(response_text)
# Generate a new dictionary with template names all in lowercase.
lowercased_flair_dict = {}
# Get the flairs for this particular community.
template_dict = subreddit_templates_retrieve(subreddit_name)
# Iterate over the dictionary and assign its values in lowercase
# for the keys.
for key in template_dict.keys():
# The formatted key is what we check the user's message against
# to see if they match a flair on the sub.
# Assign the value to a new dictionary indexed with
# the formatted key.
formatted_key = flair_sanitizer(key)
lowercased_flair_dict[formatted_key] = template_dict[key]
# If we find the text that the user sent back in the templates, we
# return the template ID.
if response_text in lowercased_flair_dict:
returned_template = lowercased_flair_dict[response_text]["id"]
logger.debug(
"Parse Response: > Found r/{} template: `{}`.".format(
subreddit_name, returned_template
)
)
database.counter_updater(
subreddit_name, "Parsed exact flair in message", "main", post_id=post_id, id_only=True
)
else:
# No exact match found. Use fuzzy matching to determine the
# best match from the flair dictionary.
# Returns as tuple `('FLAIR' (text), INT)` or `None`.
# If the match is higher than or equal to `min_fuzz_ratio`, then
# assign that to `returned_template`. Otherwise, `None`.
best_match = process.extractOne(response_text, list(lowercased_flair_dict.keys()))
if best_match is None:
# No results at all.
returned_template = None
elif best_match[1] >= SETTINGS.min_fuzz_ratio:
# We are very sure this is right.
returned_template = lowercased_flair_dict[best_match[0]]["id"]
flair_match_text = best_match[0]
logger.info(
"Parse Response: > Fuzzed {:.2f}% certainty match for "
"`{}`: `{}`".format(best_match[1], flair_match_text, returned_template)
)
database.counter_updater(
subreddit_name,
"Fuzzed flair match in message",
"main",
post_id=post_id,
id_only=True,
)
to_messages_save = True
action_type = "Fuzzed"
else:
# No good match found.
returned_template = None
# If there was no match (either exact or fuzzed) then this will
# check the text itself to see if there are any matching post
# flairs contained within it. This is the last attempt.
if not returned_template:
for flair_text in lowercased_flair_dict.keys():
if flair_text in response_text:
returned_template = lowercased_flair_dict[flair_text]["id"]
logger.info(
"Parse Response: > Found `{}` in text: `{}`".format(
flair_text, returned_template
)
)
database.counter_updater(
subreddit_name,
"Found flair match in message",
"main",
post_id=post_id,
id_only=True,
)
to_messages_save = True
action_type = "Matched"
flair_match_text = flair_text
break
if not returned_template or to_messages_save:
message_package = {
"subreddit": subreddit_name,
"id": post_id,
"action": action_type,
"message": response_text,
"template_name": flair_match_text,
"template_id": returned_template,
}
main_messages_log(message_package)
logger.info("Parse Response: >> Recorded `{}` to messages log.".format(post_id))
return returned_template
def messaging_modlog_parser(praw_submission):
"""This function is used when restoring a post after it's been
flaired. It checks the mod log to see if a mod was the one to
assign the post a flair.
:param praw_submission: A PRAW submission object.
:return: `True` if the moderation log indicates a mod flaired it,
`False` otherwise.
"""
flaired_by_other_mods = []
# Here we iterate through the recent mod log for flair edits, and
# look for this submission. Look for the Reddit fullname of the item
# in question. We only want submissions.
specific_subreddit = reddit.subreddit(praw_submission.subreddit.display_name)
for item in specific_subreddit.mod.log(action="editflair", limit=25):
i_fullname = item.target_fullname
# If we cannot get the fullname, just ignore the item.
# (e.g. editing flair templates gives `None` in the log.)
if (i_fullname is None) or ("t3_" not in i_fullname):
continue
# Here we check for flair edits done by moderators, while making
# sure the flair edit was not done by the bot. Then append the
# submission ID of the edited link to our list.
if str(item.mod).lower() != USERNAME_REG.lower():
flaired_by_other_mods.append(i_fullname[3:])
# If the post was flaired by another mod, return `True`.
if praw_submission.id in flaired_by_other_mods:
return True
else:
return False
def messaging_op_approved(subreddit_name, praw_submission, strict_mode=True, mod_flaired=False):
"""This function messages an OP that their post has been approved.
This function will ALSO remove the post ID from the `posts_filtered`
table of the database, if applicable.
:param subreddit_name: Name of a subreddit.
:param praw_submission: A relevant PRAW submission that we're
messaging the OP about.
:param strict_mode: A Boolean denoting whether this message is for
Strict mode (that is, the post was removed)
:param mod_flaired: A Boolean denoting whether the submission was
flaired by the mods.
:return: Nothing.
"""
# Check to see if user is a valid name. If the author is deleted,
# we don't care about this post so skip it.
try:
post_author = praw_submission.author.name
except AttributeError:
post_author = None
# There is an author to send to. Message the OP that it's
# been approved.
if post_author is not None:
# Get variables.
post_permalink = praw_submission.permalink
post_id = praw_submission.id
post_subreddit = praw_submission.subreddit.display_name
# Form our message body, with slight variations depending on
# whether the addition was via strict mode or not.
subject_line = "[Notification] ✅ "
key_phrase = "Thanks for selecting"
# The wording will vary based on the mode. In strict mode, we
# add text noting that the post has been approved. In addition
# if a mod flaired this, we want to change the text to indicate
# that.
if strict_mode:
subject_line += "Your flaired post is approved on r/{}!".format(post_subreddit)
approval_message = MSG_USER_FLAIR_APPROVAL_STRICT.format(post_subreddit)
database.counter_updater(post_subreddit, "Restored post", "main", post_id=post_id)
if mod_flaired:
key_phrase = "It appears a mod has selected"
else:
# Otherwise, this is a Default mode post, so the post was
# never removed and there is no need for an approval section
# and instead the author is simply informed of the post's
# assignment.
subject_line += "Your post has been assigned a flair on r/{}!".format(post_subreddit)
approval_message = ""
database.counter_updater(subreddit_name, "Flaired post", "main", post_id=post_id)
# See if there's a custom name or custom goodbye in the extended
# data to use.
extended_data = database.extended_retrieve(subreddit_name)
name_to_use = extended_data.get("custom_name", "Artemis").replace(" ", " ^")
if not name_to_use:
name_to_use = "Artemis"
bye_phrase = extended_data.get("custom_goodbye", choice(GOODBYE_PHRASES)).capitalize()
if not bye_phrase:
bye_phrase = "Have a good day"
# Format the message together.
body = MSG_USER_FLAIR_APPROVAL.format(
post_author, key_phrase, post_permalink, approval_message, bye_phrase
)
body += BOT_DISCLAIMER.replace("Artemis", name_to_use).format(post_subreddit)
# Send the message.
try:
reddit.redditor(post_author).message(subject_line, body)
logger.info(
"Flair Checker: > Sent a message to u/{} "
"about post `{}`.".format(post_author, post_id)
)
except praw.exceptions.APIException:
# NOT_WHITELISTED_BY_USER_MESSAGE, see
# https://redd.it/h17rgd for more information.
pass
# Remove the post from database now that it's been flaired.
database.delete_filtered_post(post_id)
database.counter_updater(None, "Cleared post", "main", post_id=post_id, id_only=True)
return
def messaging_example_collater(subreddit):
"""This is a simple function that takes in a PRAW subreddit OBJECT
and then returns a Markdown chunk that is an example of the flair
enforcement message that users get.
:param subreddit: A PRAW subreddit *object*.
:return: A Markdown-formatted string.
"""
new_subreddit = subreddit.display_name.lower()
stored_extended_data = database.extended_retrieve(new_subreddit)
template_header = "*Here's an example flair enforcement message for r/{}:*"
template_header = template_header.format(subreddit.display_name)
sub_templates = subreddit_templates_collater(new_subreddit, stored_extended_data)
current_permissions = connection.obtain_mod_permissions(new_subreddit, INSTANCE)
# For the example, instead of putting a permalink to a post, we just
# use the subreddit URL itself.
post_permalink = "https://www.reddit.com{}".format(subreddit.url)
# Get our permissions for this subreddit as a list.
if not current_permissions[0]:
return
else:
current_permissions_list = current_permissions[1]
# Determine the permissions/appearances of flair removal message.
if "posts" in current_permissions_list or "all" in current_permissions_list:
# Check the extended data for auto-approval.
# If it's false, we can't approve it and change the text.
auto_approve = stored_extended_data.get("flair_enforce_approve_posts", True)
if auto_approve:
removal_section = MSG_USER_FLAIR_REMOVAL
else:
removal_section = MSG_USER_FLAIR_REMOVAL_NO_APPROVE
else:
removal_section = ""
if "flair" in current_permissions_list or "all" in current_permissions_list:
flair_option = MSG_USER_FLAIR_BODY_MESSAGING
else:
flair_option = ""
# Check to see if there's a custom message to send to the user from
# the extended config data.
if "flair_enforce_custom_message" in stored_extended_data:
if stored_extended_data["flair_enforce_custom_message"]:
custom_text = "**Message from the moderators:** {}"
custom_text = custom_text.format(stored_extended_data["flair_enforce_custom_message"])
else:
custom_text = ""
else:
custom_text = ""
# Check if there's a custom name and goodbye. If the phrase is an
# empty string, just use the default.
name_to_use = stored_extended_data.get("custom_name", "Artemis").replace(" ", " ^")
if not name_to_use:
name_to_use = "Artemis"
bye_phrase = stored_extended_data.get("custom_goodbye", "have a good day").lower()
if not bye_phrase:
bye_phrase = "have a good day"
# Combine everything together. This is one of the few places where
# `BOT_DISCLAIMER` is used outside a runtime.
message_to_send = MSG_USER_FLAIR_BODY.format(
"USERNAME",
subreddit.display_name,
sub_templates,
post_permalink,
post_permalink,
removal_section,
bye_phrase,
flair_option,
"EXAMPLE POST TITLE",
custom_text,
)
reply_text = "{}\n\n---\n\n{}".format(template_header, message_to_send)
reply_text += BOT_DISCLAIMER.format(subreddit.display_name).replace("Artemis", name_to_use)
return reply_text
"""FLAIR ENFORCING FUNCTIONS"""
def flair_notifier(post_object, message_to_send):
"""This function takes a PRAW Submission object - that of a post
that is missing flair - and messages its author about the missing
flair. It lets them know that they should select a flair.
This is also used by the scheduling function to send messages.
:param post_object: The PRAW Submission object of the post.
:param message_to_send: The text of the message to the author.
:return: Nothing.
"""
# Get some basic variables.
try:
author = post_object.author.name
except AttributeError: # Issue with the user. Suspended?
return
active_subreddit = post_object.subreddit.display_name
# Check if there's a custom name in the extended data.
extended_data = database.extended_retrieve(active_subreddit)
name_to_use = extended_data.get("custom_name", "Artemis").replace(" ", " ^")
if not name_to_use:
name_to_use = "Artemis"
# Format the subject accordingly.
if "scheduled weekday" in message_to_send:
subject_line = MSG_SCHEDULE_REMOVAL_SUBJECT.format(active_subreddit)