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. |