Skip to content

Commit a03504d

Browse files
committed
filter responses, overhaul filter configuration
This adds an ability to send responses to filtered messages either back to (just) the user who sent them (via a whisper), or to the whole room. This allows configuration of the message (or messages, for random selection), profile name, and uses a (simple) templating mechanism for the message so that you can (somewhat) personalize it for the user. This builds off PR #129 for language filtering, but revamps how languages are specified and overridden with a mechanism that we can use in the future for other room-specific settings, and that doesn't rely on room ids (but rather tokens). Other changes included here: - You can now control whether or not mods/admins are affected by filtering via a new `filter_mods` setting. - profanity filtering can be room specific. - alphabet filtering can be room specific (revamping how PR #129 did room-specific settings). - alphabet filtering can be configurably silent or not, like profanity filtering. - added a new, separate example .ini file showing how to set things up with room-specific settings.
1 parent 251b1eb commit a03504d

File tree

5 files changed

+420
-71
lines changed

5 files changed

+420
-71
lines changed

sogs.ini.filter-sample

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
; This file describes the settings you can use for advanced filtering controls.
2+
;
3+
; Note that when configuring this, it does *not* go in a separate file but rather in your active
4+
; sogs.ini configuration file. (Since everything goes here in a separate section, it doesn't matter
5+
; where in sogs.ini you add it).
6+
7+
8+
;
9+
; Room-specific filtering
10+
;
11+
; To set filtration rules for a specific room you add a [room:TOKEN] section and then set the
12+
; rules that should apply to this specific room. For example, to enable the profanity filter and
13+
; disallow (only) cyrillic characters in the room with token 'sudoku' you would add:
14+
;
15+
;[room:sudoku]
16+
;profanity_filter=yes
17+
;profanity_silent=yes
18+
;alphabet_filters=cyrillic
19+
;
20+
; This overrides the default from the main [messages] config section for any given keys, so it can
21+
; be used to replace or change the rules that apply to a given room. Currently only the
22+
; profanity_filter, profanity_silent, alphabet_filters can be overridden in this way.
23+
24+
;
25+
; Filtration responses
26+
;
27+
; When a message is filtered because of the profanity or alphabet filtrations SOGS can optionally
28+
; send a reply in the room; this reply can either be visible to everyone, or just to the specific
29+
; user. To enable such a reply, add a filter section here: the section name consists of
30+
; 'filter:TYPE:ROOM' where TYPE and ROOM are the filtration type and room token, or '*' to match all
31+
; types/rooms.
32+
;
33+
; Section names for all filtered messages:
34+
;[filter:*:*]
35+
;
36+
; Section names for a particular filtration type:
37+
;[filter:*:profanity]
38+
;[filter:*:alphabet]
39+
;
40+
; The "type" can also be a specific language:
41+
;[filter:*:arabic]
42+
;[filter:*:cyrillic]
43+
; etc.
44+
;
45+
; Room-specific filtration section names:
46+
;
47+
;[filter:fishing:*]
48+
;[filter:sudoku:profanity]
49+
;
50+
; If using both '*' and specific values, the value from the more specific section will be used where
51+
; present.
52+
;
53+
; Within this section there are currently three settings:
54+
;
55+
; - reply -- the body of a reply to send (see details below). If omitted or empty then no reply
56+
; will be sent.
57+
; - profile_name -- the profile name to use in that reply.
58+
; - public -- whether the reply should be seen by everyone or just the poster. The default is 'no'
59+
; (i.e. only the user will see the reply).
60+
;
61+
; The `reply` value should be specified on a single line of the config, and supports the following
62+
; substitutions:
63+
;
64+
; \@ - the profile name, in @tag form, of the poster whose message was declined.
65+
; \p - the profile name in plain text.
66+
; \r - the name of the room
67+
; \t - the token of the room
68+
; \n - a line break
69+
; \\ - a literal \ character
70+
;
71+
; You can also randomize among multiple responses by specifying multiple lines in the config: each
72+
; additional line must be indented in the .ini file to be properly recognized.
73+
;
74+
; For example if you use this config:
75+
;
76+
77+
[messages]
78+
profanity_filter=yes
79+
profanity_silent=yes
80+
alphabet_filters=arabic cyrillic
81+
alphabet_silent=yes
82+
83+
[room:sailors]
84+
profanity_filter=no
85+
86+
[filter:*:*]
87+
profile_name=LanguagePolice
88+
reply=Hi \@, I'm afraid your message couldn't be sent: \r is English-only!
89+
90+
[filter:*:profanity]
91+
profile_name=Swear Jar
92+
reply=Whoa there, \@! That language is too strong for the \r group! Try the Sailors group instead.
93+
94+
[filter:sudoku:profanity]
95+
profile_name=Bot45
96+
public=yes
97+
reply=\@ got a little too enthusiastic today with their solve. Maybe someone can assist?
98+
Uh oh, I think \@ has two 3s in the same row!
99+
I think \@'s sudoku broke 😦
100+
101+
; then arabic/cyrillic/person would be blocked everywhere, profanity would be blocked everywhere
102+
; except the 'sailors' room, and when a message is blocked you would get a message such as one of
103+
; the following depending on the room and the rule applied:
104+
;
105+
;
106+
; (LanguagePolice)
107+
; Hi @Foreignsailor1988, I'm afraid your message couldn't be set: Salty Sailors is English-only!
108+
;
109+
;
110+
; (Swear Jar)
111+
; Whoa there @87yearoldgrandma! That language is too strong for the Cuddly Kittens group! Try the Sailors group instead.
112+
;
113+
;
114+
; (Bot45); [one of the following would be sent randomly, visible to everyone in the group]
115+
; @87yearoldgrandma got a little too enthusiastic today with their solve. Maybe someone can assist?
116+
;
117+
; Uh oh, I think @87yearoldgrandma has two 3s in the same row!
118+
;
119+
; I think @87yearoldgrandma's sudoku broke 😦

sogs.ini.sample

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,7 @@
9393

9494

9595
; Whether we should pass words through a profanity filter, rejecting messages that contain profane
96-
; words (and common permutations of those words). (Note that profanity filters are never applied to
97-
; room or server admins).
96+
; words (and common permutations of those words).
9897
;
9998
;profanity_filter = no
10099

@@ -115,19 +114,31 @@
115114
;
116115
;profanity_custom =
117116

118-
; Whether we should reject messages that use a particular alphabet.
117+
; Whether we should reject messages that use a particular alphabet. This is a space or
118+
; comma-separated list of alphabet names; posts with characters in the given language ranges will be
119+
; blocked (unless posted by a mod/admin). Currently supported are: arabic, cyrillic, and persian
120+
; (note that persian is included within arabic).
119121
;
120-
;alphabet_filters = [ arabic, cyrillic, persian ]
121-
;alphabet_filters = []
122+
; *This* setting is the default setting for all rooms, but you can also make room-specific filtering
123+
; (see sogs.ini.filter-sample for details).
124+
;
125+
;alphabet_filters =
126+
127+
128+
; Whether the alphabet filter should silently drop (true) or return an error (false).
129+
;
130+
;alphabet_silent = yes
122131

123132

124-
; If we reject messages written in a given alphabet, we should still allow them in
125-
; specific rooms. A list of whitelisted room ids can be given here, as returned by
126-
; `SELECT * FROM rooms;`. An empty list means to reject from all rooms.
133+
; Whether the above filters should be applied to moderators and admins (=yes) or not (=no). The
134+
; default is no, that is, mods and admins messages are not filtered by default.
127135
;
128-
;alphabet_whitelist_arabic = []
129-
;alphabet_whitelist_cyrillic = []
130-
;alphabet_whitelist_persian = []
136+
;filter_mods = no
137+
138+
139+
; The profanity and alphabet filters can be controlled on a per-room setting (which overrides the
140+
; global default set above) and can have automated responses sent by the SOGS server. For details
141+
; and examples see the sogs.ini.filter-sample file.
131142

132143

133144
[web]

sogs/config.py

Lines changed: 86 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@
3232
PROFANITY_FILTER = False
3333
PROFANITY_SILENT = True
3434
PROFANITY_CUSTOM = None
35-
ALPHABET_FILTERS = []
36-
ALPHABET_WHITELIST_ARABIC = []
37-
ALPHABET_WHITELIST_CYRILLIC = []
38-
ALPHABET_WHITELIST_PERSIAN = []
35+
ALPHABET_FILTERS = set()
36+
ALPHABET_SILENT = True
37+
FILTER_MODS = False
3938
REQUIRE_BLIND_KEYS = False
4039
TEMPLATE_PATH = 'templates'
4140
STATIC_PATH = 'static'
4241
UPLOAD_PATH = 'uploads'
42+
ROOM_OVERRIDES = {}
43+
FILTER_SETTINGS = {}
4344

4445
# Will be true if we're running as a uwsgi app, false otherwise; used where we need to do things
4546
# only in one case or another (e.g. database initialization only via app mode).
@@ -87,11 +88,8 @@ def days_to_seconds(v):
8788
def days_to_seconds_or_none(v):
8889
return days_to_seconds(v) if v else None
8990

90-
def list_of_strs(v):
91-
return re.split('[,\s]+', value[1:-1].strip())
92-
93-
def list_of_ints(v):
94-
return [int(i) for i in list_of_strs(v)]
91+
def set_of_strs(v):
92+
return {s for s in re.split('[,\\s]+', v) if s != ''}
9593

9694
truthy = ('y', 'yes', 'Y', 'Yes', 'true', 'True', 'on', 'On', '1')
9795
falsey = ('n', 'no', 'N', 'No', 'false', 'False', 'off', 'Off', '0')
@@ -100,6 +98,25 @@ def list_of_ints(v):
10098
def bool_opt(name):
10199
return (name, lambda x: x in booly, lambda x: x in truthy)
102100

101+
reply_fields = {
102+
r'\@': '{profile_at}',
103+
r'\p': '{profile_name}',
104+
r'\r': '{room_name}',
105+
r'\t': '{room token}',
106+
'{': '{{',
107+
'}': '}}',
108+
r'\\': '\\',
109+
r'\n': '\n',
110+
}
111+
reply_fields_re = '(?:' + '|'.join(re.escape(k) for k in reply_fields.keys()) + ')'
112+
113+
def reply_to_format(v):
114+
return [
115+
re.sub(reply_fields_re, lambda x: reply_fields[x.group(0)], reply)
116+
for reply in v.split("\n")
117+
if reply != ''
118+
]
119+
103120
# Map of: section => { param => ('GLOBAL', test lambda, value lambda) }
104121
# global is the string name of the global variable to set
105122
# test lambda returns True/False for validation (if None/omitted, accept anything)
@@ -136,10 +153,9 @@ def bool_opt(name):
136153
'profanity_filter': bool_opt('PROFANITY_FILTER'),
137154
'profanity_silent': bool_opt('PROFANITY_SILENT'),
138155
'profanity_custom': ('PROFANITY_CUSTOM', path_exists, val_or_none),
139-
'alphabet_filters': ('ALPHABET_FILTERS', None, list_of_strs),
140-
'alphabet_whitelist_arabic': ('ALPHABET_WHITELIST_ARABIC', None, list_of_ints),
141-
'alphabet_whitelist_cyrillic': ('ALPHABET_WHITELIST_CYRILLIC', None, list_of_ints),
142-
'alphabet_whitelist_persian': ('ALPHABET_WHITELIST_PERSIAN', None, list_of_ints),
156+
'alphabet_filters': ('ALPHABET_FILTERS', None, set_of_strs),
157+
'alphabet_silent': bool_opt('ALPHABET_SILENT'),
158+
'filter_mods': bool_opt('FILTER_MODS'),
143159
},
144160
'web': {
145161
'template_path': ('TEMPLATE_PATH', path_exists, val_or_none),
@@ -148,33 +164,73 @@ def bool_opt(name):
148164
'log': {'level': ('LOG_LEVEL',)},
149165
}
150166

151-
for s in cp.sections():
152-
if s not in setting_map:
153-
logger.warning(f"Ignoring unknown section [{s}] in {conf_ini}")
154-
continue
155-
for opt in cp[s]:
156-
if opt not in setting_map[s]:
157-
logger.warning(f"Ignoring unknown config setting [{s}].{opt} in {conf_ini}")
158-
continue
167+
room_setting_map = {
168+
'profanity_filter': bool_opt('profanity_filter'),
169+
'profanity_silent': bool_opt('profanity_silent'),
170+
'alphabet_filters': ('alphabet_filters', None, set_of_strs),
171+
}
172+
173+
filter_setting_map = {
174+
'public': bool_opt('public'),
175+
'profile_name': ('profile_name',),
176+
'reply': ('reply', None, reply_to_format),
177+
}
159178

160-
value = cp[s][opt]
161-
conf = setting_map[s][opt]
179+
def parse_option(fields, s, opt, *, room=None, filt=None):
180+
conf_type = 'room-specific ' if room else 'filter ' if filt else ''
181+
if opt not in fields:
182+
logger.warning(f"Ignoring unknown {conf_type} config setting [{s}].{opt} in {conf_ini}")
183+
return
184+
conf = fields[opt]
185+
value = cp[s][opt]
162186

163-
assert isinstance(conf, tuple) and 1 <= len(conf) <= 3
187+
assert isinstance(conf, tuple) and 1 <= len(conf) <= 3
188+
if not room and not filt:
164189
assert conf[0] in globals()
165190

166-
logger.debug(f"Loaded config setting [{s}].{opt}={value}")
191+
logger.debug(f"Loaded {'room-specific ' if room else ''}config setting [{s}].{opt}={value}")
167192

168-
if len(conf) >= 2 and conf[1]:
169-
if not conf[1](value):
170-
raise RuntimeError(f"Invalid value [{s}].{opt}={value} in {conf_ini}")
193+
if len(conf) >= 2 and conf[1]:
194+
if not conf[1](value):
195+
raise RuntimeError(f"Invalid value [{s}].{opt}={value} in {conf_ini}")
171196

172-
if len(conf) >= 3 and conf[2]:
173-
value = conf[2](value)
197+
if len(conf) >= 3 and conf[2]:
198+
value = conf[2](value)
174199

200+
if room:
201+
logger.debug(f"Set config.ROOM_OVERRIDES[{room}][{conf[0]}] = {value}")
202+
ROOM_OVERRIDES[room][conf[0]] = value
203+
elif filt:
204+
logger.debug(f"Set config.FILTER_SETTINGS[{filt[0]}][{filt[1]}][{conf[0]}] = {value}")
205+
FILTER_SETTINGS.setdefault(filt[0], {}).setdefault(filt[1], {})[conf[0]] = value
206+
else:
175207
logger.debug(f"Set config.{conf[0]} = {value}")
176208
globals()[conf[0]] = value
177209

210+
for s in cp.sections():
211+
if len(s) > 5 and s.startswith('room:'):
212+
token = s[5:]
213+
if token not in ROOM_OVERRIDES:
214+
ROOM_OVERRIDES[token] = {}
215+
for opt in cp[s]:
216+
parse_option(room_setting_map, s, opt, room=token)
217+
218+
elif s.startswith('filter:'):
219+
filt = s.split(':')[1:]
220+
if len(filt) != 2:
221+
raise RuntimeError(
222+
f"Invalid filter section [{s}] in {conf_ini}: expected [filter:TYPE:ROOM]"
223+
)
224+
for opt in cp[s]:
225+
parse_option(filter_setting_map, s, opt, filt=filt)
226+
227+
elif s in setting_map:
228+
for opt in cp[s]:
229+
parse_option(setting_map[s], s, opt)
230+
231+
else:
232+
logger.warning(f"Ignoring unknown section [{s}] in {conf_ini}")
233+
178234

179235
try:
180236
load_config()

sogs/crypto.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,11 @@ def verify_sig_from_pk(data, sig, pk):
7272
return VerifyKey(pk).verify(data, sig)
7373

7474

75-
_server_signkey = SigningKey(_privkey_bytes)
75+
server_signkey = SigningKey(_privkey_bytes)
76+
server_verifykey = server_signkey.verify_key
7677

77-
server_verify = _server_signkey.verify_key.verify
78-
79-
server_sign = _server_signkey.sign
78+
server_verify = server_verifykey.verify
79+
server_sign = server_signkey.sign
8080

8181

8282
def server_encrypt(pk, data):

0 commit comments

Comments
 (0)