Aus Linux-Magazin 06/2007

Perl-Skript sichert Chat-Protokolle auf IMAP-Server

© Frankenmarco, photocase.com

Wer nicht nur E-Mails auf dem IMAP-Server sichern, ordnen und durchsuchen möchte, sondern auch Konversationen via Instant Messaging, der tut dies am besten mit dem hier vorgestellten Perl-Skript.

E-Mails auf einem IMAP-Server statt über einen POP-Mailclient auf dem lokalen PC zu speichern, hat den Vorteil, auch von unterwegs Post mit gerade benötigten Informationen abrufen zu können. Wer allerdings diese Informationen nicht per E-Mail, sondern via Chat austauscht, der verliert sie, sobald die Konversation beendet ist.

Viele Messaging-Clients, zum Beispiel der Alleskönner Gaim bieten deshalb Logging an. Damit schreibt der Client alle Nachrichten auf der Festplatte mit. Aber oft genug sucht der Korrespondent eine per Chat ausgetauschten URL gerade dann verzweifelt, wenn er an einem anderen Rechner sitzt.

Sicherheitsbedenken

Hemmungslose Praktiker legen die Logdaten der Chats auf einem öffentlich erreichbaren Server ab und statten diesen mit allerlei Suchfunktionen aus. Dann allerdings stellt sich die Frage nach der Sicherheit der Daten vor unbefugtem Zugriff. Zwar tauscht niemand, der einigermaßen bei Verstand ist, vertrauliche Informationen über ungesicherte Chat-Kanäle aus, aber auch die private Konversation sollte privat bleiben. Wenn jedoch eine Sicherheitslücke im frei zugänglichen Server die Chats raussickern ließe, wäre das ähnlich peinlich, als wenn private E-Mails ans Licht der Öffentlichkeit gelangten.

Da es für E-Mail bereits einen bewährten und relativ sicheren Aufbewahrungsort gibt, nämlich den IMAP-Server, liegt es nahe, die Logdaten des Messaging-Clients ebenfalls dort einzuspeisen. Mit Hilfe des Menüs »Preferences | Logging« lässt sich Gaim schnell zum Mitschreiben überreden. Als Format wähle ich »Plain« (siehe Abbildung 1), weil ich ein Dinosaurier bin, der noch mit Pine als E-Mail-Client arbeitet und HTML-E-Mails 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.

Abbildung 1: Gaim wird im Menü »Preferences« zum Mitprotokollieren der Chats konfiguriert. EInzelgespräche und Gruppenchats lassen sich mitschreiben.

Abbildung 1: Gaim wird im Menü »Preferences« zum Mitprotokollieren der Chats konfiguriert. EInzelgespräche und Gruppenchats lassen sich mitschreiben.

Gut sortierte Ablage

Nach der Aktivierung legt Gaim selbststä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«. Unterhalte ich mich als lokaler User zum Beispiel am 28. März 2007 kurz vor 10 Uhr unter dem Benutzernamen »mikeschilli« über das Yahoo-Messenger-Protokoll mit dem Partner »randomperlhacker«, liegt die Logdatei entsprechend unter »~/.gaim/logs/yahoo/mikeschilli/randomperlhacker/2007-03-28.095243.txt« vor. Die Konversation ist in Abbildung 2, der Inhalt der zugehörigen Logdatei dagegen in Abbildung 3 zu sehen.

Abbildung 2: Eine Konversation mit Gaim ...

Abbildung 2: Eine Konversation mit Gaim …

Abbildung 3: ... erzeugt die entsprechende Logdatei.

Abbildung 3: … erzeugt die entsprechende Logdatei.

Im Betrieb ruft der Daemon »gaim2imap« (Listing 1) die Funktion »update()« auf, bearbeitet alle neu gefundenen Logdateien und legt sich für eine voreingestellte Zeit schlafen. Eine Stunde (3600 Sekunden) ist dafür in der Variablen »$sleep« definiert.

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) . "nn";
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: $langn";
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: $daten" .
177   "From: $fromn" .
178   "To: $ton" .
179   "Subject: $subjectnn$text";
180 
181  my $fl = $imap->buildflaglist();
182  $imap->append($mailbox, $message, $fl);
183 }

Angereichert

Statt die Logdateien einfach unbearbeitet als einzelne E-Mails an den IMAP-Server zu schicken, reichert der Daemon sie vorher noch mit einigen Meta-Informationen an. 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 E-Mail-Client etwas zu meckern haben.

Das Datum der E-Mail bekommt noch den Startzeitpunkt der Konversation verpasst und das Modul DateTime::Format::Mail formatiert die Post anschließend korrekt nach RFC822.

Die Subject-Zeile der E-Mail soll die wichtigsten Themen der Konversation anzeigen, für den Chat in Abbildung 2 findet das Skript 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 sich mit internationalen Partnern austauscht, konversiert vielleicht in Deutsch oder in Englisch oder noch einer anderen Sprache. Das CPAN-Modul Text::Language::Guess errät das recht zuverlässig, wenn sich die Optionen auf zwei oder drei Sprachen begrenzen lassen. Danach versucht »chat_process()« so genannte Stopwords [2] im Text zu finden.

Diese Wörter tragen keine inhaltliche Bedeutung, sind aber zum Verständnis eines Textes notwendig. Artikel (der, die, das), Personalpronomen (ich, du, er) oder Verbindungswörter (Konjunktionen: und, weil, oder …) sind Beispiele für Stoppwörter in der deutschen Sprache. Erhält zum Beispiel eine Suchmaschine eine Anfrage 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.

Um die wichtigsten Themen herauszufiltern, wählt das Skript einen eher hausbackenen Weg: Es zählt, wie oft bestimmte Wörter im Text vorkommen, gewichtet die häufigsten und gibt langen Wörtern (mit mehr als sechs Buchstaben) drei Extrapunkte. Wer möchte, kann ein besseres Verfahren einbauen, mein Arbeitgeber Yahoo bietet beispielsweise ein Web-API an, das zurzeit allerdings nur für englische Texte funktioniert.

Links leuchten

Enthält eine Messenger-Logdatei eine oder mehrere URLs, ist sie besonders wertvoll für spätere Suchaufgaben. Listing 1 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 als null, bekommt die Subject-Zeile ein »*L*« (für Link) vorangestellt (siehe Abbildung 4). Auf diese Weise ist bei mehreren angezeigten Logdateien im E-Mail-Client später sofort klar, welche die kostbaren Links enthalten. Damit die Logs auch als E-Mail gut lesbar sind, setzen das Modul Text::Wrap und dessen Funktion »fill()« in Zeile 113 den Fließtext der Einzelnachrichten auf eine Zeilenlänge von 70 Zeichen in Blocksatz fest. Hierzu muss das Skript »$Text::Wrap::columns« modifizieren.

Abbildung 4: Der E-Mail-Client sieht auf dem IMAP-Server alle abgeschlossenen Messaging-Sessions. Die Subject-Zeile der fünften Session zeigt mit »*L*« den Austausch einer URL an.

Abbildung 4: Der E-Mail-Client sieht auf dem IMAP-Server alle abgeschlossenen Messaging-Sessions. Die Subject-Zeile der fünften Session zeigt mit »*L*« den Austausch einer URL an.

Abbildung 5: Auch der Text der Session ist verfügbar, wenn der Email-Client die E-Mail aufklappt. Überhaupt lassen sich die gespeicherten Konversationen auf diese Weise wie Mails behandeln.

Abbildung 5: Auch der Text der Session ist verfügbar, wenn der Email-Client die E-Mail aufklappt. Überhaupt lassen sich die gespeicherten Konversationen auf diese Weise wie Mails behandeln.

Die Funktion »chat_process()« gibt insgesamt drei Werte zurück: die vorgeschlagene Subject-Zeile der auszusendenden E-Mail, den neu formatierten Text und den Anfangszeitpunkt des Chats in Unix-Sekunden. Die Funktion »imap_add()« ab Zeile 165 formt daraus einen Mailheader und fügt die fabrizierte E-Mail mit der Methode »append()« des CPAN-Moduls IMAP::Client an den Ordner »im_mailbox« des IMAP-Servers an. Der Abschnitt “Installation” am Schluss des Artikels beschreibt später, wie dieses Verzeichnis auf dem IMAP-Server angelegt wird.

Zu Anfang schaltet der IMAP::Client mit »onfail(\’ABORT\’)« in den Raise-Error-Modus, in dem jeder Fehler eine Exception auslöst, die zum sofortigen Abbruch des Skripts führt. So spare ich mir die Prüfung der Rückgabewerte der einzelnen Methoden. Wer nicht will, dass der Daemon deswegen schon aufgibt, kann die Exceptions mit »eval« abfangen und entsprechend darauf reagieren.

IMAP-Server kontaktieren

Die Verbindung mit dem IMAP-Server stellt die Methode »connect()« in Zeile 57 her. Im Skript ist »localhost« der Server, denn auf dem Perlmeister-Rechner läuft der IMAP-Server Dovecot. Genau so ist aber auch eine Verbindung mit jedem anderen Mailserver im Internet möglich. Zeile 61 übermittelt mit »authenticate()« den Usernamen und das 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 beiden 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 »chat_process()« mit »$parser->next_message()« über alle Nachrichtenstücke eines Logfile und erhält jedes Mal 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.

Weiter im Hintergrund

Damit »gaim2imap« nach der Eingabe des Passworts durch den User in den Hintergrund verschwindet, startet Zeile 31 einen Kindprozess mit »fork()«. Schießt der Admin den Daemon später mit dem Kommando »kill PID« ab, dann versucht der noch mit »dbmclose()« den persistenten Hash zu sichern, um sich dann mit »exit()« zu beenden. Der globale Hash »%SIG« definiert durch den Eintrag »$SIG{TERM}« dies 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 der Anwender das Kommunikationsfenster schließt, legt Gaim beim nächsten Nachrichtenaustausch mit dem Partner eine neue Datei an. Der Daemon, der aus den Logfiles E-Mails generiert, legt als Grenze eine Stunde Inaktivität fest.

Ist die erreicht, verwandelt der Daemon die Datei in eine E-Mail und kennzeichnet sie als bearbeitet. War die Session noch aktiv und Gaim hängt später noch eine neue Nachricht an, fällt dies dem Daemon auf, denn für jede bearbeitete Datei merkt er sich deren letzten Modifikationszeitpunkt und speichert ihn in »%SEEN«, einem permanenten Hash, der mit »dbmopen« an eine Datei gebundenen ist. Damit in diesem Fall dennoch keine Daten verloren gehen, bearbeitet er sie einfach noch einmal.

Fast alle modernen E-Mail-Clients unterstützen das IMAP-Protokoll. Wer einen leicht zu installierenden IMAP-Server sucht, dem sei Dovecot empfohlen. Aber egal, ob Cyrus, UW IMAP oder Dovecot zum Einsatz kommen, das Skript »mbsetup« (Listing 2) legt auf jeden Fall eine neue Mailbox für die Chat-E-Mails auf dem IMAP-Server an.

Listing 2:
»mbsetup«

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

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

Der Server ergreift das Wort

Ist der Debug-Level wie in Zeile 14 von Listing 2 auf »0x1« gesetzt, gibt IMAP::Client auch aus, welche Kommandos zwischen dem Client und dem Server hin und her flitzen. So lässt sich das eigenwillige IMAP-Protokoll studieren, das jedem Befehl eine eindeutige Nummer zuordnet, die der Antwort dann wieder beiliegt (siehe den Grundlagen-Artikel im Schwerpunkt dieses Magazins). So kann sich auch mal der Server unvermittelt zu Wort melden, wenn zum Beispiel auf einer Mailbox, an der der Client interessiert ist, eine E-Mail eingegangen ist. Dank der vorgestellten Nummer kann der Client unterscheiden, welche Nachricht der Server initiierte und welche auf eine Anfrage des Clients antwortet.

Installation

Kommuniziert der IMAP-Server über SSL (im Internet ein Muss und auch sonst ratsam), muss der Parameter »ConnectMethod« den String »SSL« enthalten. Der Eintrag »PLAIN« funktioniert hingegen, wenn der IMAP-Server das SSL-Protokoll abgeschaltet hat.

Die verwendeten CPAN-Module sind ihrerseits von anderen abhängig, die eine CPAN-Shell aber automatisch mit installiert. In »gaim2imap« sind in Zeile 123 als Sprachen Englisch und Deutsch eingestellt. Der anonyme Hash lässt sich leicht erweitern.

Startet der Anwender den Daemon »gaim2imap«, muss er zuerst das Passwort eingegeben, mit dem sich dieser 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 Archivars mitverfolgen.

Läuft alles gut, beginnt sich nun der Ordner »im_mailbox« auf dem IMAP-Server nach dem Programmstart mit IM-Konversationen zu füllen. Bleibt der Daemon aktiv, bezieht er alle während seiner Laufzeit geführten Chats ein und der User darf mit dem E-Mail-Client und dessen Suchfunktionen darin herumstöbern. Damit ist es kein Problem mehr, den Youtube-Link wiederzufinden, den einem der Arbeitskollege am Vormittag zugespielt hat. (jcb)

Infos

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

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

Der Autor


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.

LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben