]> git.donarmstrong.com Git - debbugs.git/blob - bin/debbugs-spam
b172e97e36508ebd70b35e3b28fa2ae826ff314c
[debbugs.git] / bin / debbugs-spam
1 #! /usr/bin/perl
2 # debbugs-spam is part of debbugs, and is released
3 # under the terms of the GPL version 2, or any later version, at your
4 # option. See the file README and COPYING for more information.
5 # Copyright 2012 by Don Armstrong <don@donarmstrong.com>.
6
7
8 use warnings;
9 use strict;
10
11 use Getopt::Long qw(:config no_ignore_case);
12 use Pod::Usage;
13
14 =head1 NAME
15
16 debbugs-spam -- Scan log files for spam and populate nnn.log.spam
17
18 =head1 SYNOPSIS
19
20 debbugs-spam [options] bugnumber [[bugnumber2]..]
21
22  Options:
23   --spool-dir debbugs spool directory
24   --debug, -d debugging level (Default 0)
25   --help, -h display this help
26   --man, -m display manual
27
28 =head1 OPTIONS
29
30 =over
31
32 =item B<--spool-dir>
33
34 Debbugs spool directory; defaults to the value configured in the
35 debbugs configuration file.
36
37 =item B<--debug, -d>
38
39 Debug verbosity.
40
41 =item B<--help, -h>
42
43 Display brief useage information.
44
45 =item B<--man, -m>
46
47 Display this manual.
48
49 =back
50
51 =head1 SUBCOMMANDS
52
53 =over
54
55 =item B<auto-scan>
56
57 Automatically scan messages using spamassassin and mark messages as
58 spam which hit the threshold, and those that are highly negative as
59 ham.
60
61 =item B<score>
62
63 Output the score of all of the messages in a bug
64
65 =over
66
67 =item B<--skip-seen> Skip messages which have previously been classified
68
69 =back
70
71 =item B<mark-spam>
72
73 Mark messages as spam if there is a regex match to subject or message
74 id
75
76 =item B<mark-ham>
77
78 Mark messages as ham if there is a regex match to subject or message
79 id
80
81 =back
82
83
84 =head1 EXAMPLES
85
86
87 =cut
88
89
90 use vars qw($DEBUG);
91
92 use Debbugs::Log qw(record_regex);
93 use Debbugs::Log::Spam;
94 use Debbugs::Config qw(:config);
95 use Debbugs::Command qw(:all);
96 use IPC::Open3 qw(open3);
97 use Carp;
98
99 my %options =
100     (debug   => 0,
101      help    => 0,
102      man     => 0,
103      verbose => 0,
104      quiet   => 0,
105      quick   => 0,
106      spamc   => 'spamc',
107      spamc_opts => [],
108     );
109
110 handle_main_arguments(\%options,
111                       'quick|q',
112                       'service|s',
113                       'sysconfdir|c',
114                       'spamc=s' => 0,
115                       'spamc_opts|spamc-opts=s@' => 0,
116                       'spool_dir|spool-dir=s',
117                       'debug|d+','help|h|?','man|m');
118
119 my %subcommands =
120     ('auto-scan' => {function => \&auto_spamscan,
121                      arguments => {'ham_threshold|ham-threshold=s' => 0,
122                                   },
123                      defaults => {ham_threshold => -5},
124                     },
125      'score' => {function => \&score_bug,
126                  arguments => {'skip_seen|skip-seen!' => 0
127                               },
128                 },
129      'mark-spam' => {function => \&mark_spam,
130                     },
131      'mark-ham' => {function => \&mark_ham,
132                    },
133      'help' => {function => sub {pod2usage({verbose => 2});}}
134     );
135
136 pod2usage() if $options{help};
137 pod2usage({verbose=>2}) if $options{man};
138
139 $DEBUG = $options{debug};
140
141 my @USAGE_ERRORS;
142 $options{verbose} = $options{verbose} - $options{quiet};
143
144 pod2usage(join("\n",@USAGE_ERRORS)) if @USAGE_ERRORS;
145
146 my ($subcommand) = shift @ARGV;
147 if (not defined $subcommand) {
148     $subcommand = 'help';
149     print STDERR "You must provide a subcommand; displaying usage.\n";
150     pod2usage();
151 } elsif (not exists $subcommands{$subcommand}) {
152     print STDERR "$subcommand is not a valid subcommand; displaying usage.\n";
153     pod2usage();
154 }
155
156 if (exists $options{spool_dir} and defined $options{spool_dir}) {
157     $config{spool_dir} = $options{spool_dir};
158 }
159 if ($subcommand ne 'help') {
160     chdir($config{spool_dir}) or die "chdir $config{spool_dir} failed: $!";
161 }
162 my $opts =
163     handle_subcommand_arguments(\@ARGV,
164                                 $subcommands{$subcommand}{arguments},
165                                 $subcommands{$subcommand}{defaults},
166                                );
167 $subcommands{$subcommand}{function}->(\%options,$opts,\%config,\@ARGV);
168
169
170 sub mark_ham {
171     mark_it('ham',@_);
172 }
173
174 sub mark_spam {
175     mark_it('spam',@_);
176 }
177
178 sub mark_it {
179     my ($spam_ham,$options,$opts,$config,$argv) = @_;
180     my $regex = shift @{$argv};
181     for my $bug_num (@{$argv}) {
182         my $spam = Debbugs::Log::Spam->new(bug_num => $bug_num) or
183             die "Unable to open bug log spam for $bug_num";
184         foreachmsg(sub {
185                        my ($bn,$rec,$mid) = @_;
186                        my $body = $rec->{text};
187                        my ($subject) = $body =~ /^Subject: *(.+)$/mi;
188                        my $is_match = 0;
189                        if ($subject =~ /\Q$regex\E/) {
190                            $is_match = 1;
191                        }
192                        if ($mid =~ /\Q$regex\E/) {
193                            $is_match = 1;
194                        }
195                        if ($is_match) {
196                            print STDERR "it's a match" if $DEBUG;
197                            if ($spam_ham eq 'spam') {
198                                $spam->add_spam($mid);
199                            } else {
200                                $spam->add_ham($mid);
201                            }
202                        }
203                    },
204                    $bug_num
205                   );
206         $spam->save();
207     }
208 }
209
210
211 sub score_bug {
212     my ($options,$opts,$config,$argv) = @_;
213     for my $bug_num (@{$argv}) {
214         my @bug_score =
215             spam_score_bug($bug_num,
216                            $options->{spamc},
217                            $options->{spamc_opts},
218                            $opts->{skip_seen},
219                           );
220         print "$_->{score} $_->{message_id} $_->{subject}\n"
221             foreach @bug_score;
222     }
223 }
224
225 sub auto_spamscan {
226     my ($options,$opts,$config,$argv) = @_;
227
228     for my $bug_num (@{$argv}) {
229         my $spam = Debbugs::Log::Spam->new(bug_num => $bug_num) or
230             die "Unable to open bug log spam for $bug_num";
231         foreachmsg(sub {
232                        my ($bn,$rec,$mid) = @_;
233                        if ($spam->is_spam($mid)) {
234                            print STDERR "already spam\n" if $DEBUG;
235                            return;
236                        }
237                        if ($spam->is_ham($mid)) {
238                            print STDERR "already ham\n" if $DEBUG;
239                            return;
240                        }
241                        my ($score,$is_spam,$report,$threshold) =
242                            spam_score($rec,
243                                       $options->{spamc},
244                                       $options->{spamc_opts},
245                                      );
246                        if ($is_spam) {
247                            print STDERR "it's spam ($score)\n" if $DEBUG;
248                            $spam->add_spam($mid);
249                        } elsif ($score < $opts->{ham_threshold}) {
250                            print STDERR "it's really ham ($score)\n" if $DEBUG;
251                            $spam->add_ham($mid);
252                        }
253                        else {
254                            print STDERR "it's ham ($score)\n" if $DEBUG;
255                        }
256                    },
257                    $bug_num,
258                   );
259         $spam->save();
260     }
261 }
262
263 sub spam_score_bug {
264     my ($bug,$spamc,$spamc_opts,$skip_seen) = @_;
265
266     my $spam;
267     if ($skip_seen) {
268         $spam = Debbugs::Log::Spam->new(bug_num => $bug) or
269             die "Unable to open bug log spam for $bug";
270     }
271     my @records;
272     foreachmsg(sub {
273                    my ($bn,$rec,$mid) = @_;
274                    my $score;
275                    if ($skip_seen) {
276                        if ($spam->is_spam($mid)) {
277                            $score = 999;
278                        } elsif ($spam->is_ham($mid)) {
279                            $score = -999;
280                        }
281                    }
282                    $score //=
283                        spam_score($rec,$spamc,$spamc_opts);
284                    my ($subject) = $rec->{text} =~ /^Subject: *(.+)$/mi;
285                    push @records,
286                       {message_id => $mid,
287                        score => $score,
288                        subject => $subject,
289                       };
290                },
291                $bug
292               );
293     return @records;
294 }
295
296 sub spam_score {
297     my ($record,$spamc,$spamc_opts) = @_;
298     my ($score,$threshold,$report);
299     my $is_spam = 0;
300     eval {
301         my ($spamc_in,$spamc_out);
302         my $old_sig = $SIG{"PIPE"};
303         $SIG{"PIPE"} = sub {
304             die "SIGPIPE in child for some reason";
305         };
306         my $childpid =
307             open3($spamc_in,$spamc_out,0,
308                   $spamc,'-E',@{$spamc_opts}) or
309                       die "Unable to fork spamc: $!";
310         if (not $childpid) {
311             die "Unable to fork spamc";
312         }
313         print {$spamc_in} $record->{text};
314         close($spamc_in) or die "Unable to close spamc_in: $!";
315         waitpid($childpid,0);
316         if ($? >> 8) {
317             $is_spam = 1;
318         }
319         my ($first_line,@report) = <$spamc_out>;
320         if ($DEBUG) {
321             print STDERR "[$?;".($? >> 8)."] ";
322             print STDERR $first_line,@report;
323             print STDERR " ";
324         }
325         if (defined $first_line) {
326             chomp $first_line;
327             ($score,$threshold) = $first_line =~ m{^(-?[\d\.]+)/(-?[\d\.]+)$};
328             $report = join('',@report);
329         }
330         close($spamc_out);
331         $SIG{"PIPE"} = $old_sig;
332     };
333     if ($@) {
334         carp "processing of message failed [$@]\n";
335         return undef;
336     }
337     return wantarray?($score,$is_spam,$report):$score;
338 }
339
340 sub foreachmsg {
341     my ($sub,$bug_num) = @_;
342     my $log = Debbugs::Log->new(bug_num => $bug_num) or
343         die "Unable to open bug log for $bug_num";
344     my %seen_msgids;
345     while (my $record = $log->read_record()) {
346         next if $record->{type} eq 'html';
347         next if $record->{type} eq 'autocheck';
348         my ($msg_id) = record_regex($record,
349                                     qr/^Message-Id:\s+<(.+)>/mi);
350         next unless defined $msg_id;
351         print STDERR "examining $msg_id: " if $DEBUG;
352         if ($msg_id =~ /$config{email_domain}$/) {
353             print STDERR "skipping\n" if $DEBUG;
354             next;
355         }
356         if ($seen_msgids{$msg_id}) {
357             print STDERR "already seen\n" if $DEBUG;
358             next;
359         }
360         $seen_msgids{$msg_id}=1;
361         $sub->($bug_num,$record,$msg_id);
362         print STDERR "\n" if $DEBUG;
363     }
364 }
365
366
367 __END__
368
369 # Local Variables:
370 # cperl-indent-level: 4
371 # indent-tabs-mode: nil
372 # End: