1 # -*- Mode: perl; tab-width: 4; indent-tabs-mode: nil; -*-
2 ################################
4 ################################
5 # Based on a request from Nortis http://www.blomstereng.org/
7 # XXX need to support multiple quote servers:
10 package BotModules::Quotes;
12 @ISA = qw(BotModules);
17 # This uses a number of MySQL-specific features.
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.',
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.';
36 if ($self->canDelete($event)) {
37 $help->{'delquote'} = 'Delete a quote from the database. The format is \'delquote id\'.';
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\'.';
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\'.';
49 # RegisterConfig - Called when initialised, should call registerVariables
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, []],
68 # call this at the top of any function that uses tableName
69 sub sanitiseTableName {
71 $self->{tableName} =~ s/[^a-zA-Z]//gos;
72 if (length($self->{tableName}) < 1) {
73 $self->{tableName} = 'quotes';
80 return $self->checkRights('Add', @_);
85 return $self->checkRights('Delete', @_);
90 return $self->checkRights('Edit', @_);
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};
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.');
110 $self->SUPER::Schedule($event);
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});
121 if (not $self->{dbhandle}) {
122 $self->{dberror} = $@;
123 $self->debug("Failed to connect to quotes database: $self->{dberror}");
132 if ($self->{dbhandle}) {
133 $self->{dbhandle}->disconnect();
134 $self->{dbhandle} = undef;
141 $self->dbdisconnect($event);
148 $self->sanitiseTableName();
151 my $tables = $self->{dbhandle}->selectall_arrayref('SHOW TABLES');
152 my $wantedTable = undef;
153 $tables = [] unless defined $tables;
158 # if only one, assume that's the one we want to use
159 $wantedTable = $tables->[0];
161 # otherwise, assume the name is 'quotes'
162 $wantedTable = $self->{tableName} || 'quotes';
166 $self->{dbtables} = $tables;
167 foreach my $table (@$tables) {
168 if (lc $table eq lc $wantedTable) {
169 $self->{tableName} = $table;
181 $self->sanitiseTableName();
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)
197 $self->{dberror} = $@;
198 $self->debug("Failed to create quotes table: $self->{dberror}");
204 sub verifyConnection {
207 if ($self->dbconnect()) {
208 if (not $self->dbcheckconfig($event)) {
209 if (@{$self->{dbtables}}) {
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).");
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'.");
217 $self->say($event, "Connected (using table '$self->{tableName}').");
220 $self->say($event, "Failed to connect to quotes database: $self->{dberror}");
226 my ($event, $message) = @_;
227 if ($message =~ /^\s*set\s*up\s*quotes?(?:\s+(.*?))?\s*$/osi and $self->isAdmin($event)) {
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;
237 $self->say($event, "Ok, trying to connect...");
238 $self->verifyConnection($event);
239 } elsif ($data =~ m/^tables?(?:\s+(\S+))?$/osi) {
240 if ($self->{dbhandle}) {
242 $self->{tableName} = $1;
243 $self->sanitiseTableName();
245 if ($self->dbcreatetables($event)) {
246 $self->say($event, "Connected (using table '$self->{tableName}').");
248 $self->say($event, "Failed to create the table ('$self->{dberror}') -- make sure you have the right permissions set up.");
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\'');
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}}) {
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).");
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'.");
266 $self->say($event, "Connected (using table '$self->{tableName}').");
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\'');
272 } elsif ($data =~ m/^\s*$/osi) {
273 $self->dbdisconnect($event);
274 $self->say($event, "Checking connection...");
275 $self->verifyConnection($event);
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.');
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(@_);
294 return 0; # we've dealt with it, no need to do anything else.
299 if ($self->checkBangCommands(@_)) {
300 return $self->SUPER::Heard(@_);
302 return 0; # we've dealt with it, no need to do anything else.
305 sub checkBangCommands {
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);
321 return 0; # we've dealt with it, no need to do anything else.
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");
331 # ignore errors (don't have to worry about timeouts, this is only
332 # ever done after recent db access)
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.");
343 if ($data =~ m/^\s*([0-9]+)\s*$/os) {
344 $self->getQuoteById($event, $1);
346 $self->searchQuote($event, $data);
349 $self->randomQuote($event);
353 sub randomQuoteInternal {
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");
364 return 1; # try again
370 $self->sanitiseTableName();
371 if ($self->randomQuoteInternal($event)) {
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.");
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}'!");
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) {
402 $self->say($event, "$event->{from}: There is no quote with ID $id as far as I can tell.");
409 my ($event, $data) = @_;
410 # [author=""] [text=""] [note=""] [text] [n]
411 my (@columns, @values);
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.");
434 } elsif ($data =~ s/^\s*([0-9]+)\s*$//osi) {
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%");
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.");
448 $self->sanitiseTableName();
449 my($id, $count, $quote, $author, $note);
450 return unless $self->attempt($event, sub {
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)" : '';
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");
463 $self->say($event, "$event->{from}: No matching quotes found.");
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.");
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.");
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");
489 $self->say($event, "$event->{from}: There are no quotes in the database yet.");
495 my ($event, $data) = @_;
496 if (not $self->canAdd($event)) {
497 $self->say($event, "$event->{from}: You are not allowed to add quotes, sorry.");
500 if (not $self->{dbhandle}) {
501 $self->say($event, "$event->{from}: I haven't got a connection to a database yet, sorry.");
504 # quote - author (note)
505 if ($data =~ m/^ (.+\S)
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... :-)");
523 }, 'seem to add that quote to the database.', 'add a quote to');
525 $self->say($event, "$event->{from}: The syntax for adding a quote is 'quote - author' or 'quote - author (note)'.");
531 my ($event, $data) = @_;
532 if (not $self->canDelete($event)) {
533 $self->say($event, "$event->{from}: You are not allowed to delete quotes, sorry.");
536 if (not $self->{dbhandle}) {
537 $self->say($event, "$event->{from}: I haven't got a connection to a database yet, sorry.");
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.");
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");
557 $self->say($event, "$event->{from}: There is no quote with ID $id as far as I can tell.");
563 my ($event, $data) = @_;
564 if (not $self->canEdit($event)) {
565 $self->say($event, "$event->{from}: You are not allowed to edit quotes, sorry.");
568 if (not $self->{dbhandle}) {
569 $self->say($event, "$event->{from}: I haven't got a connection to a database yet, sorry.");
572 if ($data =~ m/^ ([0-9]+) \s+
583 $self->sanitiseTableName();
584 return unless $self->attempt($event, sub {
585 $self->{dbhandle}->do("UPDATE $self->{tableName} SET
586 quote = ?, author = ?, note = ?
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.");
592 }, 'seem to edit that quote', 'edit a quote in');
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.");
601 if (not $self->{dbhandle}) {
602 $self->say($event, "$event->{from}: No connection could be established to the quotes datbase.");
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');
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.");
617 $self->say($event, "$event->{from}: The database contains 0 quotes.");
623 my($event, $sub, $action1, $action2) = @_;
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()) {
636 $self->say($event, "$event->{from}: I'm sorry, I can't $action1.");
638 $self->tellAdmin($event, "While trying to $action2 the database, I got '$@'. I tried reconnecting but that didn't help.");
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 '$@'.");
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!");