Flüchtiges für die Ewigkeit (Linux-Magazin, Juni 2007)

Wer nicht nur Emails geordnet und durchsuchbar auf dem IMAP-Server sichern möchte, sondern auch seine Konversationen via Instant Messaging ständig griffbereit braucht, kann dies mit einem Perlskript tun.

Emails auf einem IMAP-Server zu speichern und nicht auf einem lokalen Mail-Client, hat den Vorteil, auch von unterwegs gerade benötigte Informationen abrufen zu können. Fand eine Kommunikation allerdings nicht per Email sondern als Chat via Instant Messaging statt, lösen sich Informationen in Luft auf, sobald die Konversation beendet ist.

Viele Messaging-Clients, wie zum Beispiel der Alleskönner Gaim bieten deshalb Logging an. Wird es einmal angestellt, schreibt der Client alle ausgetauschten Nachrichten auf der Festplatte mit. Aber oft sucht man einen per Chat ausgetauschten URL gerade dann verzweifelt, wenn man gerade an einem anderen Rechner sitzt!

Sicherheitsbedenken

Man könnte die Logdaten in einen öffentlich erreichbaren datenbankgestützten Server einspeisen und diesen mit allerlei Suchfunktionen ausstatten. Allerdings stellt sich die Frage nach der Absicherung der Daten vor öffentlichem Zugriff. Zwar tauscht niemand, der einigermaßen bei Verstand ist, vertrauliche Informationen über ungesicherte Chat-Kanäle aus, aber private Konversationen sollten eben doch privat bleiben. Wenn eine Sicherheitslücke im neuen Server die Chats raussickern lässt, wäre das ähnlich peinlich, wie wenn private Emails ans Licht der Öffentlichkeit gelangten.

Da es für Email bereits einen bewährten und relativ sicheren Aufbewahrungsort, den IMAP-Server, gibt, liegt der Ansatz nahe, die Logdaten des Messaging-Clients einfach in einen sowieso schon für die private Email genutzten IMAP-Server einzuspeisen.

Bitte mitschreiben

Abbildung 1: Gaim wird im Menü "Preferences" zum Mitprotokollieren der Chats konfiguriert.

Über das Menü Preferences->Logging lässt sich Gaim schnell zum Mitschreiben überreden. Als Format wird ``Plain'' gewählt (siehe Abbildung 1), weil ich ein Dinosaurier bin, der noch mit ``Pine'' als Email-Client arbeitet und HTML-Emails für unnütz und gefährlich hält. Die Check-Buttons ``Log all instant messages'' und ``Log all chats'' regeln das Logging für normale Konversationen und Gruppenchats. Nach der Aktivierung legt Gaim selbständig für jede Konversation eine separate Textdatei unter ~/.gaim/logs an, und zwar in den Gaim-Versionen 1.x unter dem Pfad Provider/Sender/Empfänger/*.txt. Konversiert der lokale User am 28.03.2007 um kurz vor 10 Uhr als ``mikeschilli'' über das Yahoo-Messenger-Protokoll mit randomperlhacker, liegt die Logdatei zum Beispiel unter

    ~/.gaim/logs/yahoo/mikeschilli/randomperlhacker/2007-03-28.095243.txt

vor. Die Konversation ist in Abbildung 2, der Inhalt der Logdatei in Abbildung 3 zu sehen.

Abbildung 2: Eine Konversation mit Gaim ...

Abbildung 3: ... und die entsprechende Log-Datei.

Die guten ins Töpfchen

Im Betrieb ruft der vorgestellte Daemon gaim2imap die Funktion update() auf, bearbeitet alle neu gefundenen Log-Dateien und legt sich dann für eine voreingestellte Zeit schlafen. Eine Stunde (3600 Sekunden) sind in der Variablen $sleep voreingestellt.

Statt die Logdateien einfach unbearbeitet als einzelne Emails an den IMAP-Server schicken, werden sie vorher noch mit einigen Meta-Informationen angereichert. Der Absender (From:) wird auf den Namen des Korrespondenzpartners gesetzt und mit einer Pseudo-Domain @gaim versehen, damit weder der IMAP-Server noch der später zum Lesen genutzte Email-Client meckern. Das Datum der Email wird auf den Startzeitpunkt der Konversation eingestellt und mit dem Modul DateTime::Format::Mail korrekt nach RFC822 formatiert. Die ``Subject:''-Zeile der Email soll die wichtigsten Themen der Konversation anzeigen, für den Chat in Abbildung 2 findet es zum Beispiel ``characters, perl, word, split, know, bit''. Richtige ``Topic-Extraction'' ist eine Wissenschaft für sich, aber gaim2imap genügen einige einfache Tricks, um ein zwar nicht perfektes aber dennoch brauchbares Ergebnis zu erzielen.

Stoppwörter

Als erstes versucht die Funktion chat_process, die in der Konversation dominierende Sprache zu ermitteln. Wer international tätig ist, verkehrt in einer Konversation vielleicht in Deutsch oder in Englisch, oder sogar in einer Drittsprache. Das CPAN-Modul Text::Language::Guess errät das recht zuverlässig, wenn man die Optionen auf zwei oder drei Sprachen begrenzt.

Dann werden die sogenannten Stopwords [2] im Text ermittelt. Diese Funktionswörter tragen keine inhaltliche Bedeutung, sind aber zum Verständnis eines Textes notwendig. Artikel (der, die, das), Personalpronomen (ich, du, er) oder Verbindungswörter (und, oder) sind Beispiele für Stopwörter in der deutschen Sprache. Erhält zum Beispiel eine Suchmaschine einen Suchbegriff wie ``wo ist eigentlich san francisco?'', wird sie alles außer der gesuchten Stadt sofort rauswerfen, um dann nur unter ``san francisco'' im Index nachzusehen.

Abbildung 4: Der Email-Client sieht auf dem IMAP-Server alle abgeschlossenen Messaging-Sessions. Die Subject-Zeile der fünften Session zeigt mit *L* an, dass in ihr ein URL ausgetauscht wurde.

Abbildung 5: ... und auch der Text der Session ist verfügbar, wenn der Email-Client den Text der Email aufklappt.

Das Skript gaim2imap eliminiert Stopwords mit dem CPAN-Modul Lingua::StopWords. Man gibt eine Sprache vor und das Modul liefert eine Referenz auf einen Hash zurück, dessen Schlüssel aus allen dem Modul bekannten Stoppwörtern bestehen. Zusätzlich definiert der Hash $im_stopwords in Zeile 19 noch eine Liste mit häufig in Chats vorkommenden Wörtern, die ebenfalls üblicherweise nichts zum Inhalt beitragen und später eliminiert werden.

Um die wichtigsten Themen herauszufiltern, wählt das Skript einen eher hausbackenen Ansatz: Es zählt, wie oft bestimmte Wörter vorkommen, gewichtet die häufigsten und gibt langen Wörtern (über 6 Buchstaben) drei Extrapunkte. Wer möchte, kann ein besseres Verfahren einbauen, unter [4] bietet mein Arbeitgeber Yahoo eine Web-API an, die zurzeit allerdings nur für englische Texte funktioniert.

Links leuchten

Enthält eine IM-Logdatei einen oder mehrere URLs, ist sie besonders wertvoll fuer spätere Suchaufgaben. gaim2imap nutzt das Modul URI::Find vom CPAN, um URLs im Klartext des Chats aufzustöbern. Der Konstruktor erhält eine Callback-Funktion als Argument, die einen leeren String zurückgibt, damit die später mit einer Referenz auf den Nachrichtenstring aufgerufene Methode find die gefundenen URLs für die Textanalyse aus dem Text entfernt. Ist die Anzahl der gefundenen URLs größer 0, wird der Statuszeile ein *L* (für Link) vorangestellt, damit bei mehreren angezeigten Log-Dateien im Email-Client später sofort klar ist, welche die kostbaren Links enthalten. Damit die Logs auch als Email gut lesbar sind, wird der Fließtext der Einzelnachrichten mit dem Modul Text::Wrap und dessen Funktion fill in Zeile 113 auf eine Zeilenlänge von 70 Zeichen in Blocksatz gesetzt. Hierzu muss das Skript $Text::Wrap::columns modifizieren.

Die Funktion chat_process gibt insgesamt drei Werte zurück: Die vorgeschlagene Subject-Zeile der auszusendenden Email, den neu formatierten Text und den Anfangszeitpunkt des Chats in Unix-Sekunden.

Die Funktion imap_add ab Zeile 165 formt daraus einen Mail-Header und fügt die fabrizierte Email mit der Methode append() des CPAN-Moduls IMAP::Client an den Ordner "im_mailbox" des IMAP-Servers an. Der Abschnitt ``Installation'' wird zeigen, wie dieses Verzeichnis auf dem IMAP-Server angelegt wird.

Verbindung herstellen

IMAP::Client wird zu Anfangs mit onfail('ABORT') in den 'RaiseError'-Modus geschaltet, in dem jeder Fehler sofort eine Exception auslöst, die zum sofortigen Abbruch des Skripts führt. So kann man sich die Prüfung der Rückgabewerte der einzelnen Methoden sparen. Wer nicht will, dass der Daemon deswegen aufgibt, kann die Exceptions mit eval abfangen und entsprechend darauf reagieren.

Die Verbindung mit dem IMAP-Server stellt die Methode connect() in Zeile 57 her. Im Skript ist dies ``localhost'', denn auf dem Perlmeister-Rechner wurde der IMAP-Server Dovecot installiert. Statt ``localhost'' kann connect aber mit beliebigen Hosts auf dem Internet Kontakt aufnehmen. Zeile 61 übermittelt mit authenticate() den Usernamen und das vorher verdeckt eingegebene Passwort des Unix-Nutzers, der dem IMAP-Server ebenfalls unter diesen Credentials bekannt ist. Die Passworteingabe steuert die Funktion password_read in Zeile 29 aus dem unerschöpflichen Fundus des CPAN-Moduls Sysadm::Install.

Kein Gefummle

Das Finden und Parsen der Gaim-Logs erledigen die CPAN-Module Gaim::Log::Finder und Gaim::Log::Parser, die dem Anwender lästiges Datei- und Textgefummle abnehmen und Methoden für den Zugriff auf Dateien, Sender, Empfänger, Datum und ausgetauschte Nachrichten anbieten. So iteriert talkefile2subject() mit $parser->next_message() über alle Nachrichtenstücke eines Logfiles und erhält jedesmal ein Objekt vom Typ Gaim::Log::Message. Dieses bietet wiederum mit den Methoden date(), from(), to(), und content() Zugriff auf das Datum, die Gesprächspartner und den Text der Kurznachricht.

Damit gaim2imap nach der Eingabe des Passworts durch den User in den Hintergrund verschwindet, startet Zeile 31 einen Kindprozess mit fork(). Der Vaterprozess verabschiedet sich anschließend unauffällig und der User sieht den Kommandozeilenprompt zurückkehren, während der Kindprozess einfach weiterläuft. Sollte der Daemon später vom Admin mit kill pid abgeschossen werden, versucht er noch mit dbmclose den persistenten Hash zu sichern, um dann mit exit tatsächlich den Abgang zu machen. Der globale Hash %SIG definiert mit dem Eintrag $SIG{TERM} dieses Verhalten.

Wiedergeborene Sessions

Auch wenn sich eine IM-Session über mehrere Stunden hinzieht, hängt Gaim neue Nachrichten stets an ein bestehendes Logfile an. Erst wenn das Kommunikationsfenster geschlossen wird, legt Gaim beim nächsten Nachrichtenaustausch mit dem Gesprächspartner eine neue Datei an. Der Daemon, der aus den Logfiles Emails generiert, legt als Grenze eine Stunde Inaktivität fest.

Ist dies erreicht, wird die Datei in eine Email verwandelt und als 'bearbeitet' gekennzeichnet. War die Session noch aktiv und Gaim hängt später noch eine neue Nachricht an, fällt dem Daemon dies auf, denn für jede bearbeitete Datei merkt er sich deren letzten Modifikationzeitpunkt und speichert ihn in %SEEN, einem mit dbmopen an eine Datei gebundenen permanenten Hash. Damit in diesem eher raren Fall dennoch keine Daten verloren gehen, bearbeitet er sie einfach noch einmal.

Listing 1: gaim2imap

    001 #!/usr/bin/perl -w
    002 use strict;
    003 use Gaim::Log::Parser 0.04;
    004 use Gaim::Log::Finder;
    005 use Sysadm::Install 0.23 qw(:all);
    006 use Lingua::StopWords;
    007 use Text::Language::Guess;
    008 use Log::Log4perl qw(:easy);
    009 use Text::Wrap qw(fill $columns);
    010 use URI::Find;
    011 use IMAP::Client;
    012 use DateTime::Format::Mail;
    013 
    014 my $mailbox = "im_mailbox";
    015 my $tzone   = "America/Los_Angeles";
    016 my $min_age = 3600;
    017 my $sleep   = 3600;
    018 
    019 my %im_stopwords = map { $_ => 1 } qw(
    020 maybe thanks thx doesn hey put already 
    021 said say would can could haha hehe see 
    022 well think like heh now many lol doh );
    023 
    024 Log::Log4perl->easy_init({
    025   level => $DEBUG, category => "main",
    026   file  => ">>$ENV{HOME}/.gaim2imap.log"
    027 });
    028 
    029 my $PW = password_read("password: ");
    030 
    031 my $pid = fork();
    032 die "fork failed" if ! defined $pid;
    033 exit 0 if $pid;
    034 
    035 dbmopen my %SEEN, 
    036         "$ENV{HOME}/.gaim/.seen", 0644 or
    037      LOGDIE "Cannot open dbm file ($!)";
    038 
    039 $SIG{TERM} = sub { INFO "Exiting"; 
    040     dbmclose %SEEN;
    041     exit 0;
    042 };
    043 
    044 while(1) {
    045     update();
    046     INFO "Sleeping $sleep secs";
    047     sleep $sleep;
    048 }
    049 
    050 ###########################################
    051 sub update {
    052 ###########################################
    053   DEBUG "Connecting to IMAP server";
    054 
    055   my $imap = new IMAP::Client();
    056   $imap->onfail('ABORT');
    057   $imap->connect(PeerAddr => 'localhost',
    058       ConnectMethod => 'PLAIN');
    059 
    060   my $u = getpwuid $>;
    061   $imap->authenticate($u, $PW);
    062 
    063   my $finder = Gaim::Log::Finder->new(
    064     callback => sub { 
    065       my($self, $file, $protocol, 
    066          $from, $to) = @_;
    067 
    068       return 1 if $from eq $to;
    069 
    070       my $mtime = (stat $file)[9];
    071       my $age = time() - $mtime;
    072 
    073       return 1 if $SEEN{$file} and
    074                   $SEEN{$file} == $mtime;
    075 
    076       if($age < $min_age) {
    077           INFO "$file: Too recent ($age)";
    078           return 1;
    079       }
    080 
    081       $SEEN{$file} = $mtime;
    082       INFO "Processing log file: $file";
    083       my($subject, $formatted, $epoch) = 
    084                    chat_process($file);
    085 
    086       imap_add($imap, $mailbox, $epoch, 
    087                "$to\@gaim", "", $subject, 
    088                $formatted);
    089   });
    090 
    091   $finder->find();
    092 }
    093 
    094 ###########################################
    095 sub chat_process {
    096 ###########################################
    097   my($file) = @_;
    098 
    099   my $parser = Gaim::Log::Parser->new(
    100     file => $file,
    101   );
    102       # Search+delete URL processor
    103   my $urifind = URI::Find->new(sub {""});
    104 
    105   my $text      = "";
    106   my $formatted = "";
    107   my $urifound;
    108   $Text::Wrap::columns = 70;
    109 
    110   while(my $m = $parser->next_message()) {
    111     my $content = $m->content();
    112     $content =~ s/\n+/ /g;
    113     $formatted .= fill("", "  ", 
    114       nice_time($m->date()) . " " .
    115       $m->from() . ": " . $content) . "\n\n";
    116 
    117     $urifound = 
    118         $urifind->find(\$content);
    119     $text .= " " . $content;
    120   }
    121 
    122   my $guesser = Text::Language::Guess->
    123             new(languages => ['en', 'de']);
    124 
    125   my $lang = 
    126     $guesser->language_guess_string($text);
    127 
    128   $lang = 'en' unless $lang;
    129   DEBUG "Guessed language: $lang\n";
    130 
    131   my $stopwords = 
    132     Lingua::StopWords::getStopWords($lang);
    133 
    134   my %words;
    135 
    136   while($text =~ /\b(\w+)\b/g) {
    137     my $word = lc($1);
    138     next if $stopwords->{$word};
    139     next if $word =~ /^\d+$/;
    140     next if length($word) <= 2;
    141     next if exists $im_stopwords{$word};
    142     $words{$word}++;
    143     $words{$word} += 3 if length $word > 6;
    144   }
    145 
    146   my @weighted_words = sort { 
    147     $words{$b} <=> $words{$a} 
    148   } keys %words;
    149 
    150   my $subj = ($urifound ? '*L*' : "");
    151   my $char = "";
    152 
    153   while(@weighted_words and length($subj) + 
    154       length($char . 
    155 	     $weighted_words[0]) <= 70) {
    156     $subj .= $char .  shift @weighted_words;
    157     $char = ", ";
    158   }
    159 
    160   return($subj, $formatted, 
    161          $parser->{dt}->epoch());
    162 }
    163 
    164 ###########################################
    165 sub imap_add {
    166 ###########################################
    167   my($imap, $mailbox, $date, 
    168      $from, $to, $subject, $text) = @_;
    169 
    170   $date = 
    171    DateTime::Format::Mail->format_datetime(
    172      DateTime->from_epoch(
    173          epoch => $date, 
    174          time_zone => $tzone));
    175 
    176   my $message = "Date: $date\n" .
    177     "From: $from\n" .
    178     "To: $to\n" .
    179     "Subject: $subject\n\n$text";
    180 
    181   my $fl = $imap->buildflaglist();
    182   $imap->append($mailbox, $message, $fl);
    183 }

Unterbrechbares Protokoll

Fast alle modernen Email-Clients unterstützen das IMAP-Protokoll. Wer einen leicht zu installierenden IMAP-Server sucht, dem sei Dovecot empfohlen [3]. Aber egal, ob Cyrus, UW IMAP oder Dovecot zum Einsatz kommen, das Skript mbsetup legt auf jeden Fall eine neue Mailbox für die Chat-Emails auf dem IMAP-Server an.

Listing 2: mbsetup

    01 #!/usr/bin/perl
    02 use strict;
    03 use IMAP::Client;
    04 use Sysadm::Install 0.23 qw(:all);
    05 
    06 my $mailbox   = "im_mailbox";
    07 
    08 my $imap = new IMAP::Client();
    09 $imap->onfail('ABORT');
    10 $imap->errorstyle('STACK');
    11 $imap->debuglevel(0x01);
    12 
    13 $imap->connect(
    14   PeerAddr     => 'localhost',
    15  ConnectMethod => 'PLAIN') or 
    16     die "auth failure " . $imap->error;
    17 
    18 my $u  = getpwuid $>;
    19 my $pw = password_read("passwd: ");
    20 $imap->authenticate($u, $pw);
    21 
    22 $imap->onfail('ERROR');
    23 $imap->delete($mailbox);
    24 $imap->onfail('ABORT');
    25 
    26 $imap->create($mailbox);

Abbildung 6: Client und Server kommunizieren nach den Regeln des IMAP-Protokolls. Jede Anfrage enthält eine eindeutige numerische ID, die der Antwot wieder beiliegt.

Ist der Debug-Level wie in mbsetup auf 0x1 gesetzt, gibt IMAP::Client auch noch aus, welche Kommandos zwischen dem Client und dem Server hin- und herflitzen. So lässt sich das eigenwillige IMAP-Protokoll studieren, das jedem Befehl eine eindeutige Nummer zuordnet, die der Antwort dann wieder beiliegt. So kann sich auch mal der Server unvermittelt zu Wort melden, wenn zum Beispiel auf einer Mailbox, an der der Client interessiert ist, eine Email eingegangen ist. Dank der vorgestellten Nummer kann der Client genau unterscheiden, welche Nachricht vom Server initiiert wurde und welche eine Antwort auf eine Client-Anfrage darstellt.

Installation

Kommuniziert der IMAP-Server über SSL (auf dem offenen Internet ein Muss und auch im Intranet ratsam), muss der Parameter ConnectMethod auf ``SSL'' gesetzt werden. ``PLAIN'' funktioniert hingegen, wenn der IMAP-Server das SSL-Protokoll abgeschaltet hat.

Die in gaim2imap verwendeten CPAN-Module ziehen weitere Abhängigkeiten nach sich, die eine CPAN-Shell aber automatisch auflöst. In gaim2imap sind in Zeile 123 als zu erkennende Sprachen sind Englisch und Deutsch ('en', 'de') eingestellt. Wer statt dessen mit anderen oder weitere Sprachen kommuniziert, ändert einfach den Inhalt dieses anonymen Hashs entsprechend.

Dann wird der Daemon gaim2imap gestartet und anfangs das Passwort eingegeben, damit dieser sich beim IMAP-Server unter der effektiven Benutzer-ID des gerade laufenden Prozesses anmelden kann. In der Logdatei lässt sich dann das Treiben des unermüdlichen Archivierers mitverfolgen.

Läuft alles gut, beginnt sich der Ordner im_mailbox auf dem IMAP-Server nach dem Programmstart mit den bestehenden IM-Konversationen zu füllen. Bleibt der Daemon aktiv, werden nach und nach auch eben noch geführte Chats einbezogen. So füllt sich der Ordner und der User darf mit dem Email-Client und den damit angebotenen Suchfunktionen darin herumstöbern. Damit ist es kein Problem mehr, den Youtube-Link wieder zu finden, den einem der Arbeitskollege am Vormittag zuspielte!

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2007/06/Perl

[2]
http://en.wikipedia.org/wiki/Stopword

[3]
Dovecot, der sichere IMAP-Server http://www.dovecot.org/

[4]
Yahoo Term Extraction API, http://developer.yahoo.com/search/content/V1/termExtraction.html

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.