]> git.somenet.org - irc/bugbot.git/blob - BotModules/Quotes.bm
GITOLITE.txt
[irc/bugbot.git] / BotModules / Quotes.bm
1 # -*- Mode: perl; tab-width: 4; indent-tabs-mode: nil; -*-
2 ################################
3 # Quotes Module                #
4 ################################
5 # Based on a request from Nortis http://www.blomstereng.org/
6
7 # XXX need to support multiple quote servers:
8 # !discworld
9
10 package BotModules::Quotes;
11 use vars qw(@ISA);
12 @ISA = qw(BotModules);
13 use Fcntl;
14 use DBI;
15 1;
16
17 # This uses a number of MySQL-specific features.
18
19 sub Help {
20     my $self = shift;
21     my ($event) = @_;
22     my $help = {
23         '' => 'A module to manage quotes.',
24         'quote' => 'Search for a quote, or return a random one. To search for a quote, you must specify search parameters, see the help entries for id, text, author, note, match. Otherwise, a random quote is returned.',
25         'match' => 'If there are multiple matches, you can specify which match you want by appending the match number to your search terms, for example \'quote author=blake 4\' will return the fourth quote whose author is \'blake\'. The default is 1.',
26         'id' => 'To search for a quote by its numeric ID, append the ID to the \'quote\' command. For example, \'quote 42\'. If you specify other search parameters, this will return the relevant match from that list, see the help entry for \'match\'.',
27         'text' => 'To search for a quote by text, append \'text="foo"\' to the \'quote\' command. For example, \'quote text="meaning of life"\' or \'quote text=life\'. You could also just say \'quote hello world\' or \'quote hello world 2\' (to get the second match).',
28         'author' => 'To search for a quote by author or attribution, append \'author="foo"\' to the \'quote\' command. For example, \'quote author="Douglas Adams"\' or \'quote author=asimov\'.',
29         'note' => 'To search for a quote by text in its note, append \'note="foo"\' to the \'quote\' command. For example, \'quote note=""\' or \'quote author=asimov\'.',
30         'quotelast' => 'Returns the last quote added. Append a numer to return the nth but last quote added, as in \'lastquote 2\'.',
31         'status' => 'Prints some information about the status of the quotes database.',
32     };
33     if ($self->canAdd($event)) {
34         $help->{'addquote'} = 'Add a quote to the database. The format is \'addquote quote - author (note)\'. The \'(note)\' part may be omitted. The author may not.';
35     }
36     if ($self->canDelete($event)) {
37         $help->{'delquote'} = 'Delete a quote from the database. The format is \'delquote id\'.';
38     }
39     if ($self->canEdit($event)) {
40         $help->{'editquote'} = 'Edit a quote in the database. The format is \'editquote id quote - author (note)\' which will update the quote with that ID, using the new text, author, etc, in the same way as for \'addquote\'.';
41     }
42     if ($self->isAdmin($event)) {
43         $help->{'setupquotes'} = 'Configure the quotes database connection. Format: \'setupquotes dbhost.example.com:dbport dbname dbuser dbpass\'. Port is optional (default 3306). You can also just say \'setupquotes\' to check on the configuration. See also \'help quote-defaults\'.';
44         $help->{'quote-defaults'} = 'To get the default configuration, use \'setupquotes mozbotquotes.damowmow.com:3306 mozbotquotes mozbotquotes mozbotquotes\'.';
45     }
46     return $help;
47 }
48
49 # RegisterConfig - Called when initialised, should call registerVariables
50 sub RegisterConfig {
51     my $self = shift;
52     $self->SUPER::RegisterConfig(@_);
53     $self->registerVariables(
54       # [ name, save?, settable? ]
55       ['prefix', 1, 1, '!'], # the prefix to put before the undirected quote commands
56       ['dbhost', 1, 1, 'mozbotquotes.damowmow.com'],
57       ['dbport', 1, 1, '3306'],
58       ['dbname', 1, 1, 'mozbotquotes'],
59       ['dbuser', 1, 1, 'mozbotquotes'],
60       ['dbpass', 1, 1, 'mozbotquotes'],
61       ['tableName', 1, 1, 'quotes'],
62       ['usersAdd', 1, 1, []],
63       ['usersDelete', 1, 1, []],
64       ['usersEdit', 1, 1, []],
65     );
66 }
67
68 # call this at the top of any function that uses tableName
69 sub sanitiseTableName {
70     my $self = shift;
71     $self->{tableName} =~ s/[^a-zA-Z]//gos;
72     if (length($self->{tableName}) < 1) {
73         $self->{tableName} = 'quotes';
74     }
75     $self->saveConfig();
76 }
77
78 sub canAdd {
79     my $self = shift;
80     return $self->checkRights('Add', @_);
81 }
82
83 sub canDelete {
84     my $self = shift;
85     return $self->checkRights('Delete', @_);
86 }
87
88 sub canEdit {
89     my $self = shift;
90     return $self->checkRights('Edit', @_);
91 }
92
93 sub checkRights {
94     my $self = shift;
95     my ($right, $event) = @_;
96     return 1 if $self->isAdmin($event);
97     foreach my $user (@{$self->{"users$right"}}) {
98         return 1 if $user eq $event->{userName};
99     }
100     return 0;
101 }
102
103 sub Schedule {
104     my $self = shift;
105     my ($event) = @_;
106     unless ($self->dbconnect()) {
107         $self->say($event, "Failed to connect to quotes database: $self->{dberror}");
108         $self->say($event, 'Use the \'setupquotes\' command to configure the database.');
109     }
110     $self->SUPER::Schedule($event);
111 }
112
113 sub dbconnect {
114     my $self = shift;
115     eval {
116         $self->{dbhandle} =
117           DBI->connect("DBI:mysql:$self->{dbname}:$self->{dbhost}:$self->{dbport}",
118                        $self->{dbuser}, $self->{dbpass},
119                        {RaiseError => 1, PrintError => 1, AutoCommit => 1, Taint => 0});
120     };
121     if (not $self->{dbhandle}) {
122         $self->{dberror} = $@;
123         $self->debug("Failed to connect to quotes database: $self->{dberror}");
124         return 0;
125     }
126     return 1;
127 }
128
129 sub dbdisconnect {
130     my $self = shift;
131     my ($event) = @_;
132     if ($self->{dbhandle}) {
133         $self->{dbhandle}->disconnect();
134         $self->{dbhandle} = undef;
135     }
136 }
137
138 sub Unload {
139     my $self = shift;
140     my ($event) = @_;
141     $self->dbdisconnect($event);
142 }
143
144 sub dbcheckconfig {
145     my $self = shift;
146     my ($event) = @_;
147
148     $self->sanitiseTableName();
149
150     # count tables
151     my $tables = $self->{dbhandle}->selectall_arrayref('SHOW TABLES');
152     my $wantedTable = undef;
153     $tables = [] unless defined $tables;
154     foreach (@$tables) {
155         $_ = $_->[0];
156     }
157     if (@$tables == 1) {
158         # if only one, assume that's the one we want to use
159         $wantedTable = $tables->[0];
160     } else {
161         # otherwise, assume the name is 'quotes'
162         $wantedTable = $self->{tableName} || 'quotes';
163     }
164
165     # check table exists
166     $self->{dbtables} = $tables;
167     foreach my $table (@$tables) {
168         if (lc $table eq lc $wantedTable) {
169             $self->{tableName} = $table;
170             $self->saveConfig();
171             return 1;
172         }
173     }
174     return 0;
175 }
176
177 sub dbcreatetables {
178     my $self = shift;
179     my ($event) = @_;
180
181     $self->sanitiseTableName();
182
183     # create table
184     eval {
185         $self->{dbhandle}->do("CREATE TABLE IF NOT EXISTS $self->{tableName} (
186                                id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
187                                quote TEXT NOT NULL DEFAULT '',
188                                author VARCHAR(100) NOT NULL DEFAULT 'Unknown',
189                                date DATETIME NOT NULL DEFAULT 0,
190                                note TEXT NULL DEFAULT NULL,
191                                shown INTEGER UNSIGNED NOT NULL DEFAULT 0,
192                                age INTEGER UNSIGNED NOT NULL DEFAULT 1,
193                                INDEX (author), INDEX(shown), INDEX(age)
194                              )");
195     };
196     if ($@) {
197         $self->{dberror} = $@;
198         $self->debug("Failed to create quotes table: $self->{dberror}");
199         return 0;
200     }
201     return 1;
202 }
203
204 sub verifyConnection {
205     my $self = shift;
206     my ($event) = @_;
207     if ($self->dbconnect()) {
208         if (not $self->dbcheckconfig($event)) {
209             if (@{$self->{dbtables}}) {
210                 local $" = '\', \'';
211                 $self->say($event, "Connected, but I there were several tables and I wasn't sure which to use. The tables in this database are: '@{$self->{dbtables}}'");
212                 $self->say($event, "To make me create a new table (called '$self->{tableName}') use 'setupquotes table'. To make me use a particular table from the list above, use 'setupquotes use table $self->{dbtables}->[0]' (or whatever table you want to use).");
213             } else {
214                 $self->say($event, "Connected, but I couldn't find a quotes table in the database. If you want me to create a table (named '$self->{tableName}') for you, use 'setupquotes tables'. To create one with a specific name, e.g. 'mozQuotes', use 'setupquotes tables mozQuotes'.");
215             }
216         } else {
217             $self->say($event, "Connected (using table '$self->{tableName}').");
218         }
219     } else {
220         $self->say($event, "Failed to connect to quotes database: $self->{dberror}");
221     }
222 }
223
224 sub Told {
225     my $self = shift;
226     my ($event, $message) = @_;
227     if ($message =~ /^\s*set\s*up\s*quotes?(?:\s+(.*?))?\s*$/osi and $self->isAdmin($event)) {
228         my $data = $1;
229         if ($data =~ m/^(\S+?)(?::(\S+))?\s+(\S+)\s+(\S+)\s+(\S+)$/osi) {
230             $self->dbdisconnect($event);
231             $self->{'dbhost'} = $1;
232             $self->{'dbport'} = $2 || 3306;
233             $self->{'dbname'} = $3;
234             $self->{'dbuser'} = $4;
235             $self->{'dbpass'} = $5;
236             $self->saveConfig();
237             $self->say($event, "Ok, trying to connect...");
238             $self->verifyConnection($event);
239         } elsif ($data =~ m/^tables?(?:\s+(\S+))?$/osi) {
240             if ($self->{dbhandle}) {
241                 if ($1) {
242                     $self->{tableName} = $1;
243                     $self->sanitiseTableName();
244                 }
245                 if ($self->dbcreatetables($event)) {
246                     $self->say($event, "Connected (using table '$self->{tableName}').");
247                 } else {
248                     $self->say($event, "Failed to create the table ('$self->{dberror}') -- make sure you have the right permissions set up.");
249                 }
250             } else {
251                 $self->say($event, 'I haven\'t yet successfully connected to a database. Please select a MySQL server to connect to, e.g. \'setupquotes mozbotquotes.damowmow.com:3306 mozbotquotes mozbotquotes mozbotquotes\'');
252             }
253         } elsif ($data =~ m/^use\s*tables?\s+(\S+)$/osi) {
254             $self->{tableName} = $1;
255             $self->sanitiseTableName();
256             if ($self->{dbhandle}) {
257                 if (not $self->dbcheckconfig($event)) {
258                     if (@{$self->{dbtables}}) {
259                         local $" = '\', \'';
260                         $self->say($event, "The table you requested, '$self->{tableName}', doesn't exist in this database. The tables in this database are: '@{$self->{dbtables}}'");
261                         $self->say($event, "To make me create this new table (called '$self->{tableName}') use 'setupquotes table'. To make me use one of the tables from the list above, use 'setupquotes use table $self->{dbtables}->[0]' (or whatever table you want to use).");
262                     } else {
263                         $self->say($event, "The table you requested, '$self->{tableName}', doesn't exist in this database. In fact this database has no tables at all. If you want me to create a table (called '$self->{tableName}') for you, use 'setupquotes tables'.");
264                     }
265                 } else {
266                     $self->say($event, "Connected (using table '$self->{tableName}').");
267                 }
268             } else {
269                 $self->say($event, 'Noted. However, I haven\'t yet successfully connected to a database, so this is not enough to complete configuration.');
270                 $self->say($event, 'Please select a MySQL server to connect to, e.g. \'setupquotes mozbotquotes.damowmow.com:3306 mozbotquotes mozbotquotes mozbotquotes\'');
271             }
272         } elsif ($data =~ m/^\s*$/osi) {
273             $self->dbdisconnect($event);
274             $self->say($event, "Checking connection...");
275             $self->verifyConnection($event);
276         } else {
277             $self->say($event, 'The format is: \'setupquotes host.domain.tld:port database username password\' (\':port\' is optional, defaults to 3306) or just \'setupquotes\' to check the configuration.');
278         }
279     } elsif ($message =~ /^\s*quote(?:\s+(.+?))?\s*$/osi) {
280         $self->getQuote($event, $1);
281     } elsif ($message =~ /^\s*(?:quotelast|last\s*quote)(?:\s+(.+?))?\s*$/osi) {
282         $self->getLastQuote($event, $1);
283     } elsif ($message =~ /^\s*add\s*quote(?:\s+(.+?))?\s*$/osi) {
284         $self->addQuote($event, $1);
285     } elsif ($message =~ /^\s*(?:delete|del|remove|rem)?\s*quote(?:\s+(.+?))?\s*$/osi) {
286         $self->deleteQuote($event, $1);
287     } elsif ($message =~ /^\s*edit\s*quote(?:\s+(.+?))?\s*$/osi) {
288         $self->editQuote($event, $1);
289     } elsif ($message =~ /^\s*(?:quotes?\s*)?status\s*$/osi) {
290         $self->printStatus($event);
291     } elsif ($self->checkBangCommands(@_)) {
292         return $self->SUPER::Told(@_);
293     }
294     return 0; # we've dealt with it, no need to do anything else.
295 }
296
297 sub Heard {
298     my $self = shift;
299     if ($self->checkBangCommands(@_)) {
300         return $self->SUPER::Heard(@_);
301     }
302     return 0; # we've dealt with it, no need to do anything else.
303 }
304
305 sub checkBangCommands {
306     my $self = shift;
307     my ($event, $message) = @_;
308     if ($message =~ /^$self->{prefix}quote(?:\s+(.+?))?\s*$/si) {
309         $self->getQuote($event, $1);
310     } elsif ($message =~ /^$self->{prefix}(?:quotelast|lastquote)(?:\s+(.+?))?\s*$/si) {
311         $self->getLastQuote($event, $1);
312     } elsif ($message =~ /^$self->{prefix}addquote(?:\s+(.+?))?\s*$/si) {
313         $self->addQuote($event, $1);
314     } elsif ($message =~ /^$self->{prefix}delquote(?:\s+(.+?))?\s*$/si) {
315         $self->deleteQuote($event, $1);
316     } elsif ($message =~ /^$self->{prefix}editquote(?:\s+(.+?))?\s*$/si) {
317         $self->editQuote($event, $1);
318     } else {
319         return 1; # nope
320     }
321     return 0; # we've dealt with it, no need to do anything else.
322 }
323
324 sub markRead {
325     my $self = shift;
326     my ($id) = @_;
327     eval {
328         $self->{dbhandle}->do("UPDATE $self->{tableName} SET shown = shown + 1 WHERE id = ?", undef, $id);
329         $self->{dbhandle}->do("UPDATE $self->{tableName} SET age = age + 1");
330     };
331     # ignore errors (don't have to worry about timeouts, this is only
332     # ever done after recent db access)
333 }
334
335 sub getQuote {
336     my $self = shift;
337     my ($event, $data) = @_;
338     if (not $self->{dbhandle}) {
339         $self->say($event, "$event->{from}: I haven't got a connection to a database yet, sorry.");
340         return;
341     }
342     if (defined $data) {
343         if ($data =~ m/^\s*([0-9]+)\s*$/os) {
344             $self->getQuoteById($event, $1);
345         } else {
346             $self->searchQuote($event, $data);
347         }
348     } else {
349         $self->randomQuote($event);
350     }
351 }
352
353 sub randomQuoteInternal {
354     my $self = shift;
355     my ($event) = @_;
356     my($id, $quote, $author, $note);
357     return 0 unless $self->attempt($event, sub { ($id, $quote, $author, $note) = $self->{dbhandle}->selectrow_array("SELECT id, quote, author, note, shown/age AS freq FROM $self->{tableName} ORDER BY freq, RAND() LIMIT 1", undef); }, 'read from the database for some reason', 'read a random quote from');
358     if (defined $quote) {
359         $self->markRead($id);
360         $note = defined $note ? " ($note)" : '';
361         $self->say($event, "Quote $id: $quote - $author$note");
362         return 0;
363     }
364     return 1; # try again
365 }
366
367 sub randomQuote {
368     my $self = shift;
369     my ($event) = @_;
370     $self->sanitiseTableName();
371     if ($self->randomQuoteInternal($event)) {
372         # no quotes?
373         # weird... let's see if reconnecting helps
374         if ($self->dbconnect()) {
375             if ($self->randomQuoteInternal($event)) {
376                 # there must really be no quotes
377                 $self->say($event, "$event->{from}: There are no quotes in the database yet.");
378             } # else ok
379         } else {
380             $self->say($event, "$event->{from}: I'm sorry, I can't reach the database right now.");
381             $self->tellAdmin($event, "While trying to get a random quote from the database, I found no quotes, so I tried reconnecting to the database, but it said '$self->{dberror}'!");
382         }
383     } # else ok
384 }
385
386 sub getQuoteById {
387     my $self = shift;
388     my ($event, $id, $action) = @_;
389     $self->sanitiseTableName();
390     my($quote, $author, $note);
391     return unless $self->attempt($event, sub {
392         ($quote, $author, $note) = $self->{dbhandle}->selectrow_array("SELECT quote, author, note FROM $self->{tableName} WHERE id=?", undef, $id);
393     }, 'read from the database for some reason', 'read a quote from');
394     if (defined $quote) {
395         $self->markRead($id);
396         $note = defined $note ? " ($note)" : '';
397         $action = defined $action ? "$action: " : '';
398         $self->say($event, "\u${action}Quote $id: $quote - $author$note");
399     } elsif (defined $action) {
400         return 0;
401     } else {
402         $self->say($event, "$event->{from}: There is no quote with ID $id as far as I can tell.");
403     }
404     return 1;
405 }
406
407 sub searchQuote {
408     my $self = shift;
409     my ($event, $data) = @_;
410     # [author=""] [text=""] [note=""] [text] [n]
411     my (@columns, @values);
412     my $skip = 0;
413     while (length $data) {
414         if ($data =~ s/^\s*text="([^"]*)"(?:\s|\z)//osi or
415             $data =~ s/^\s*text='([^']*)'(?:\s|\z)//osi or
416             $data =~ s/^\s*text=(\S+)(?:\s|\z)//osi) {
417             push(@columns, 'quote LIKE ?');
418             push(@values, "%$1%");
419         } elsif ($data =~ s/^\s*author="([^"]*)"(?:\s|\z)//osi or
420                  $data =~ s/^\s*author='([^']*)'(?:\s|\z)//osi or
421                  $data =~ s/^\s*author=(\S+)(?:\s|\z)//osi) {
422             push(@columns, 'author LIKE ?');
423             push(@values, "%$1%");
424         } elsif ($data =~ s/^\s*note="([^"]*)"(?:\s|\z)//osi or
425                  $data =~ s/^\s*note='([^']*)'(?:\s|\z)//osi or
426                  $data =~ s/^\s*note=(\S+)(?:\s|\z)//osi) {
427             push(@columns, 'note LIKE ?');
428             push(@values, "%$1%");
429         } elsif ($data =~ s/^\s*(\w+)="([^"]*)"(?:\s|\z)//osi or
430                  $data =~ s/^\s*(\w+)='([^']*)'(?:\s|\z)//osi or
431                  $data =~ s/^\s*(\w+)=(\S+)(?:\s|\z)//osi) {
432             $self->say($event, "$event->{from}: I don't know how to search for '$1'. The valid search types are 'author', 'note', and 'text'. See the help entry for 'quote' for more information on the quote searching syntax.");
433             return;
434         } elsif ($data =~ s/^\s*([0-9]+)\s*$//osi) {
435             $skip = $1 - 1;
436         } elsif ($data =~ s/^\s*"([^"]+)"(?:\s|\z)//osi or
437                  $data =~ s/^\s*'([^']+)'(?:\s|\z)//osi or
438                  $data =~ s/^\s*(\S+)(?:\s|\z)//osi) {
439             push(@columns, 'quote LIKE ?');
440             push(@values, "%$1%");
441         } else {
442             # wtf
443             $self->say($event, "$event->{from}: I didn't quite understand what you were looking for ('$data'?). See the help entry for 'quote' for more information on the quote searching syntax.");
444             return;
445         }
446     }
447
448     $self->sanitiseTableName();
449     my($id, $count, $quote, $author, $note);
450     return unless $self->attempt($event, sub {
451         local $" = ' AND ';
452         ($count) = $self->{dbhandle}->selectrow_array("SELECT COUNT(*) FROM $self->{tableName} WHERE @columns", undef, @values);
453         ($id, $quote, $author, $note) = $self->{dbhandle}->selectrow_array("SELECT id, quote, author, note FROM $self->{tableName} WHERE @columns LIMIT $skip,1", undef, @values);
454     }, 'read from the database for some reason', 'search for a quote in');
455     if (defined $quote) {
456         $self->markRead($id);
457         $note = defined $note ? " ($note)" : '';
458         my $n = $skip + 1;
459         $count = "about $n" if $count < $n; # sanitise output in case of race condition
460         my $match = $count == 1 ? 'only match' : "match $n of $count";
461         $self->say($event, "Quote $id ($match): $quote - $author$note");
462     } else {
463         $self->say($event, "$event->{from}: No matching quotes found.");
464     }
465 }
466
467 sub getLastQuote {
468     my $self = shift;
469     my ($event, $data) = @_;
470     if (not $self->{dbhandle}) {
471         $self->say($event, "$event->{from}: I haven't got a connection to a database yet, sorry.");
472         return;
473     }
474     if ($data !~ m/^\s*([0-9]+)?\s*$/os) {
475         $self->say($event, "$event->{from}: The syntax is 'lastquote 2', where 2 is the number of the quote to show (counting from the end). You can omit the number to get the last quote added.");
476         return;
477     }
478     my $skip = ($1 || 1) - 1;
479     $self->sanitiseTableName();
480     my($id, $quote, $author, $note);
481     return unless $self->attempt($event, sub {
482         ($id, $quote, $author, $note) = $self->{dbhandle}->selectrow_array("SELECT id, quote, author, note FROM $self->{tableName} ORDER BY id DESC LIMIT $skip,1", undef);
483     }, 'read from the database for some reason', 'read the last few quotes from the database');
484     if (defined $quote) {
485         $self->markRead($id);
486         $note = defined $note ? " ($note)" : '';
487         $self->say($event, "Quote $id: $quote - $author$note");
488     } else {
489         $self->say($event, "$event->{from}: There are no quotes in the database yet.");
490     }
491 }
492
493 sub addQuote {
494     my $self = shift;
495     my ($event, $data) = @_;
496     if (not $self->canAdd($event)) {
497         $self->say($event, "$event->{from}: You are not allowed to add quotes, sorry.");
498         return;
499     }
500     if (not $self->{dbhandle}) {
501         $self->say($event, "$event->{from}: I haven't got a connection to a database yet, sorry.");
502         return;
503     }
504     # quote - author (note)
505     if ($data =~ m/^ (.+\S)
506                      \s* - \s*
507                      (.+?)
508                      (?:\s+\((.+)\))?
509                    $/osx) {
510         my $quote = $1;
511         my $author = $2;
512         my $note = $3;
513         # insert data
514         $self->sanitiseTableName();
515         return unless $self->attempt($event, sub {
516             $self->{dbhandle}->do("INSERT INTO $self->{tableName} SET
517                                    quote = ?, author = ?, date = NOW(), note = ?",
518                                  undef, $quote, $author, $note);
519             my $id = $self->{dbhandle}->{mysql_insertid};
520             if (not $self->getQuoteById($event, $id, 'inserted')) {
521                 $self->say($event, "$event->{from}: Your quote disappeared after I inserted it into the database. You may wish to speak to the other people who have access to the quotes database about this... :-)");
522             }
523         }, 'seem to add that quote to the database.', 'add a quote to');
524     } else {
525         $self->say($event, "$event->{from}: The syntax for adding a quote is 'quote - author' or 'quote - author (note)'.");
526     }
527 }
528
529 sub deleteQuote {
530     my $self = shift;
531     my ($event, $data) = @_;
532     if (not $self->canDelete($event)) {
533         $self->say($event, "$event->{from}: You are not allowed to delete quotes, sorry.");
534         return;
535     }
536     if (not $self->{dbhandle}) {
537         $self->say($event, "$event->{from}: I haven't got a connection to a database yet, sorry.");
538         return;
539     }
540     if ($data !~ m/^\s*([0-9]+)\s*$/os) {
541         $self->say($event, "$event->{from}: The syntax is 'delquote 5', where 5 is the id of the quote to delete.");
542         return;
543     }
544     my $id = $1;
545     $self->sanitiseTableName();
546     my($quote, $author, $note);
547     return unless $self->attempt($event, sub {
548         ($quote, $author, $note) = $self->{dbhandle}->selectrow_array("SELECT quote, author, note FROM $self->{tableName} WHERE ID=?", undef, $id);
549     }, 'read from the database for some reason', 'read a quote to delete from');
550     if (defined $quote) {
551         return unless $self->attempt($event, sub {
552             $self->{dbhandle}->do("DELETE FROM $self->{tableName} WHERE ID=?", undef, $id);
553         }, 'delete from the database. Maybe I don\'t have enough privileges on the database server', 'delete from');
554         $note = defined $note ? " ($note)" : '';
555         $self->say($event, "Deleted: Quote $id: $quote - $author$note");
556     } else {
557         $self->say($event, "$event->{from}: There is no quote with ID $id as far as I can tell.");
558     }
559 }
560
561 sub editQuote {
562     my $self = shift;
563     my ($event, $data) = @_;
564     if (not $self->canEdit($event)) {
565         $self->say($event, "$event->{from}: You are not allowed to edit quotes, sorry.");
566         return;
567     }
568     if (not $self->{dbhandle}) {
569         $self->say($event, "$event->{from}: I haven't got a connection to a database yet, sorry.");
570         return;
571     }
572     if ($data =~ m/^ ([0-9]+) \s+
573                      (.+\S)
574                      \s* - \s*
575                      (.+?)
576                      (?:\s+\((.+)\))?
577                    $/osx) {
578         my $id = $1;
579         my $quote = $2;
580         my $author = $3;
581         my $note = $4;
582         # insert data
583         $self->sanitiseTableName();
584         return unless $self->attempt($event, sub {
585             $self->{dbhandle}->do("UPDATE $self->{tableName} SET
586                                    quote = ?, author = ?, note = ?
587                                    WHERE id = ?",
588                                  undef, $quote, $author, $note, $id);
589             if (not $self->getQuoteById($event, $id, 'edited')) {
590                 $self->say($event, "$event->{from}: I couldn't find a quote with ID $id.");
591             }
592         }, 'seem to edit that quote', 'edit a quote in');
593     } else {
594         $self->say($event, "$event->{from}: The syntax for editing a quote is 'id quote - author' or 'id quote - author (note)', much like for adding a quote but with the id of the quote to edit at the start.");
595     }
596 }
597
598 sub printStatus {
599     my $self = shift;
600     my ($event) = @_;
601     if (not $self->{dbhandle}) {
602         $self->say($event, "$event->{from}: No connection could be established to the quotes datbase.");
603         return;
604     }
605     $self->sanitiseTableName();
606     my ($quotes, $sources, $shown, $id) = @_;
607     return unless $self->attempt($event, sub {
608         ($quotes, $sources, $shown) = $self->{dbhandle}->selectrow_array("SELECT COUNT(*), COUNT(DISTINCT author), SUM(shown) FROM $self->{tableName}");
609         ($id) = $self->{dbhandle}->selectrow_array("SELECT id, shown/age AS freq FROM $self->{tableName} ORDER BY freq, shown LIMIT 1");
610     }, 'connect to the quotes database', 'obtain statistics of');
611     if ($quotes) {
612         my $s1 = $quotes == 1 ? '' : 's';
613         my $s2 = $sources == 1 ? '' : 's';
614         my $s3 = $shown == 1 ? '' : 's';
615         $self->say($event, "$event->{from}: The database contains $quotes quote$s1 attributed to $sources source$s2. I have shown these quotes $shown time$s3 in total. The most popular quote (relatively speaking) is quote ID $id.");
616     } else {
617         $self->say($event, "$event->{from}: The database contains 0 quotes.");
618     }
619 }
620
621 sub attempt {
622     my $self = shift;
623     my($event, $sub, $action1, $action2) = @_;
624     eval { &$sub };
625     if ($@) {
626         chomp $@;
627         my $error = $@;
628         # A common error is:
629         # "DBD::mysql::db selectrow_array failed: MySQL server has
630         # gone away at (eval 34) line 357."
631         # ...so we try to reconnect and do it again
632         if ($self->dbconnect()) {
633             eval { &$sub };
634             if ($@) {
635                 chomp $@;
636                 $self->say($event, "$event->{from}: I'm sorry, I can't $action1.");
637                 if ($@ eq $error) {
638                     $self->tellAdmin($event, "While trying to $action2 the database, I got '$@'. I tried reconnecting but that didn't help.");
639                 } else {
640                     $self->tellAdmin($event, "While trying to $action2 the database, I got '$error'. Then I tried reconnecting and it worked but when I tried to $action2 the database a second time, it said '$@'.");
641                 }
642                 return 0;
643             }
644         } else {
645             $self->say($event, "$event->{from}: I'm sorry, I can't $action1.");
646             $self->tellAdmin($event, "While trying to $action2 the database, I got '$error', so I tried reconnecting to the database but I got '$self->{dberror}'. Help!");
647             return 0;
648         }
649     }
650     return 1;
651 }