33# All rights reserved.
44#
55
6+ import os
67import random
8+ import re
9+ import tempfile
10+ import threading
711from pathlib import Path
812
913import supybot .ircutils as utils
1014import supybot .callbacks as callbacks
1115from supybot .commands import optional , wrap
1216
1317DEFAULT_RECENT_QUOTES = 5
18+ HARD_MAX_QUOTES = 2000
19+ HARD_RECENT_QUOTES = 25
20+ MAX_QUOTE_LENGTH = 360
21+ CONTROL_CHARS_RE = re .compile (r"[\x00-\x1f\x7f]" )
1422
1523
1624class OnJoin (callbacks .Plugin ):
1725 """Send a notice to all users entering a channel."""
1826
1927 public = False
2028
29+ def __init__ (self , irc ):
30+ super ().__init__ (irc )
31+ self ._quote_lock = threading .Lock ()
32+
2133 def _quotes_path (self ):
2234 return Path (__file__ ).with_name ("quotes.txt" )
2335
2436 def _normalise_quote (self , text ):
25- quote = " " .join (text .splitlines ()).strip ()
37+ quote = " " .join (str (text ).splitlines ()).strip ()
38+ quote = CONTROL_CHARS_RE .sub ("" , quote ).strip ()
39+ if len (quote ) > MAX_QUOTE_LENGTH :
40+ quote = f"{ quote [: MAX_QUOTE_LENGTH - 3 ]} ..."
2641 return quote or None
2742
43+ def _max_stored_quotes (self ):
44+ try :
45+ configured = self .registryValue ("maxQuotes" )
46+ except Exception :
47+ configured = HARD_MAX_QUOTES
48+ return min (max (configured , 1 ), HARD_MAX_QUOTES )
49+
50+ def _quote_file_lock (self ):
51+ if not hasattr (self , "_quote_lock" ):
52+ self ._quote_lock = threading .Lock ()
53+ return self ._quote_lock
54+
2855 def _load_quotes (self ):
2956 quotes_path = self ._quotes_path ()
57+ max_quotes = self ._max_stored_quotes ()
58+ quotes = []
3059 try :
3160 with quotes_path .open (encoding = "utf-8" ) as quote_file :
32- return [line .rstrip ("\n " ) for line in quote_file if line .strip ()]
61+ for line in quote_file :
62+ quote = self ._normalise_quote (line )
63+ if quote is None :
64+ continue
65+ quotes .append (quote )
66+ if len (quotes ) > max_quotes :
67+ quotes .pop (0 )
3368 except OSError as err :
3469 self .log .warning ("OnJoin: failed to read %s: %s" , quotes_path , err )
3570 return None
71+ return quotes
3672
3773 def _write_quotes (self , quotes ):
3874 quotes_path = self ._quotes_path ()
75+ tmp_name = None
3976 try :
40- with quotes_path .open ("w" , encoding = "utf-8" ) as quote_file :
77+ with tempfile .NamedTemporaryFile (
78+ "w" ,
79+ delete = False ,
80+ dir = str (quotes_path .parent ),
81+ encoding = "utf-8" ,
82+ ) as quote_file :
83+ tmp_name = quote_file .name
4184 for quote in quotes :
42- quote_file .write (f"{ quote } \n " )
85+ normalised = self ._normalise_quote (quote )
86+ if normalised :
87+ quote_file .write (f"{ normalised } \n " )
88+ os .replace (tmp_name , str (quotes_path ))
4389 except OSError as err :
44- self .log .warning ("OnJoin: failed to write %s: %s" , quotes_path , err )
90+ self .log .warning (
91+ "OnJoin: failed to write %s: %s" , quotes_path , err
92+ )
93+ if tmp_name :
94+ try :
95+ os .unlink (tmp_name )
96+ except OSError :
97+ pass
4598 return False
4699 return True
47100
@@ -50,38 +103,41 @@ def _append_quote(self, text):
50103 if quote is None :
51104 return None
52105
53- quotes = self ._load_quotes ()
54- if quotes is None :
55- return False
106+ with self ._quote_file_lock ():
107+ quotes = self ._load_quotes ()
108+ if quotes is None :
109+ return False
56110
57- quotes .append (quote )
58- max_quotes = self .registryValue ( "maxQuotes" )
59- if len (quotes ) > max_quotes :
60- quotes = quotes [- max_quotes :]
111+ quotes .append (quote )
112+ max_quotes = self ._max_stored_quotes ( )
113+ if len (quotes ) > max_quotes :
114+ quotes = quotes [- max_quotes :]
61115
62- if not self ._write_quotes (quotes ):
63- return False
116+ if not self ._write_quotes (quotes ):
117+ return False
64118 return quote
65119
66120 def _recent_quotes (self , count ):
121+ count = min (count , HARD_RECENT_QUOTES )
67122 quotes = self ._load_quotes ()
68123 if quotes is None :
69124 return None
70125 start_index = max (len (quotes ) - count , 0 ) + 1
71126 return list (enumerate (quotes [- count :], start = start_index ))
72127
73128 def _delete_quote (self , quote_number ):
74- quotes = self ._load_quotes ()
75- if quotes is None :
76- return False
77-
78- index = quote_number - 1
79- if index < 0 or index >= len (quotes ):
80- return None
81-
82- deleted_quote = quotes .pop (index )
83- if not self ._write_quotes (quotes ):
84- return False
129+ with self ._quote_file_lock ():
130+ quotes = self ._load_quotes ()
131+ if quotes is None :
132+ return False
133+
134+ index = quote_number - 1
135+ if index < 0 or index >= len (quotes ):
136+ return None
137+
138+ deleted_quote = quotes .pop (index )
139+ if not self ._write_quotes (quotes ):
140+ return False
85141 return deleted_quote
86142
87143 def doJoin (self , irc , msg ):
@@ -117,12 +173,12 @@ def _read_random_quote(self):
117173 if not line .strip ():
118174 continue
119175 line_num += 1
120- if random .uniform (0 , line_num ) < 1 :
176+ if random .uniform (0 , line_num ) < 1 : # nosec B311
121177 selected_line = line
122178 except OSError as err :
123179 self .log .warning ("OnJoin: failed to read %s: %s" , quotes_path , err )
124180 return None
125- return selected_line or None
181+ return self . _normalise_quote ( selected_line ) if selected_line else None
126182
127183 def addquote (self , irc , msg , args , text ):
128184 """<text>
@@ -150,7 +206,7 @@ def recentquotes(self, irc, msg, args, count):
150206
151207 max_count = self .registryValue ("maxRecentQuotes" )
152208 quote_count = count or DEFAULT_RECENT_QUOTES
153- quote_count = min (quote_count , max_count )
209+ quote_count = min (quote_count , max_count , HARD_RECENT_QUOTES )
154210
155211 quotes = self ._recent_quotes (quote_count )
156212 if quotes is None :
@@ -161,7 +217,11 @@ def recentquotes(self, irc, msg, args, count):
161217 return
162218
163219 for quote_number , quote in reversed (quotes ):
164- irc .reply (f"{ quote_number } . { quote } " , notice = True , private = True )
220+ irc .reply (
221+ self ._normalise_quote (f"{ quote_number } . { quote } " ),
222+ notice = True ,
223+ private = True ,
224+ )
165225
166226 recentquotes = wrap (
167227 recentquotes ,
0 commit comments