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!
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.
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. |
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.
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.
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.
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
.
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.
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.
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 }
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.
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.
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!
Michael Schilliarbeitet 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. |