La partie client appelle le serveur toutes les deux secondes. Elle poste le numéro de canal dans une variable request "chn", ainsi que les textes du chat dans une variable "txt" au format suivant :

FirstName LastName: Message1\n
FirstName2 LastName2: Message2\n
FirstName2 LastName2: Message3
...

Le reste (la région, la clé, l'emplacement de l'intercom...) est envoyé automatiquement par le simulateur dans des entetes bien spécifiques.
Ce qui m'a permis de contribuer à OpenSim, ici et pour qu'il envoie les entêtes corrects lors d'un appel à llHTTPRequest.

Dans la foulée, le script récupère les messages non lus et les InterComs actifs au format suivant :

Region - FirstName LastName: message\n
Region2 - FirstName2 LastName2: autre message\n
Region3 - FirstName2 LastName2: encore un message
...
\n
\n
InterCom1 <position>\n
InterCom2 <position2>\n
InterCom3 <position3>
...

Quand à la partie serveur, elle reçoit les messages des clients, les stocke, puis les retransmet en les marquant comme lus par tel ou tel InterCom. Pour finir, elle purge les données messages et InterComs lorsqu'elles ont atteint dix secondes de durée de vie.

La partie client

C'est juste là, en dessous. Il suffit de la copier coller simplement dans un script :

// InterCom 2.1 - (c) Forest Klaar 2008 (Second Life)

// THIS SOFTWARE IS PROVIDED BY THE DEVELOPERS ``AS IS'' AND ANY EXPRESS OR
// IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE CONTRIBUTORS BE LIABLE FOR ANY
// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
// GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
// IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
// ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

// Adresse du Service Intercom (sur Google AppEngine)
string  URL = "http://slintercom.appspot.com/talk21";

// Par défaut, les données sont échangées avec le service toutes les deux secondes
float   gTimer = 2;

// C'est le channel du chat vers lequel sont dirigées les commandes
// par exemple /100 NumeroCanal pour changer de canal intercom
integer gCommandChannel = 100;

// C'est le canal InterCom par défaut au démarrage de l'application
integer gChannelNo = 0;

list    HTTP_POST_PARAMS = [HTTP_METHOD, "POST", HTTP_MIMETYPE, "application/x-www-form-urlencoded"];

key     gResponseXch;
list    gChatBuffer = [];
integer gCommandListener;
integer gChatListener;

integer gStatus;

string GetStatus(integer status)
{
        return llList2String(["Off", "On"], status);
}

vector GetStatusColor(integer status)
{
        return llList2Vector([<1,0,0>, <0,1,0>], status);
}

string GetDisplay()
{
        if (gStatus == 0)
                return GetStatus(gStatus);
        else
                return GetStatus(gStatus) + " - Channel: " + (string) gChannelNo;
}

InitState(integer status)
{
        gStatus = status;
        llSetText(GetDisplay(), GetStatusColor(gStatus), 1);
        gCommandListener = llListen(gCommandChannel, "", "", "");
}

ShowDialog(key detected)
{
        llDialog(detected,
                "InterCom is "+ GetStatus(gStatus) + "\nCurrent channel is " + (string) gChannelNo
                        + "\nSay \"/" + (string) gCommandChannel + " number\" in the chat to change it",
                ["Switch " + GetStatus(1 - gStatus)], gCommandChannel);
}

integer ProcessDialog(string message)
{
        if (message == "Switch " + GetStatus(1 - gStatus))
                return TRUE;

        gChannelNo = (integer) llStringTrim(message, STRING_TRIM);
        llSetText(GetDisplay(), GetStatusColor(gStatus), 1);
        llSay(0, "Channel set to " + (string) gChannelNo);
        return FALSE;
}

default
{
        state_entry()
        {
                InitState(0);
        }

        touch_start(integer c)
        {
                ShowDialog(llDetectedKey(0));
        }

        listen(integer channel, string name, key id, string message)
        {
                if (channel == gCommandChannel)
                        if (ProcessDialog(message))
                                state Listening;
        }

        on_rez(integer start_param)
        {
                llResetScript();
        }

        state_exit()
        {
                llListenRemove(gCommandListener);
        }
}

state Listening
{
        state_entry()
        {
                InitState(1);

                gChatListener = llListen(0, "", "", "");
                llSetTimerEvent(gTimer);
        }

        touch_start(integer c)
        {
                ShowDialog(llDetectedKey(0));
        }

        listen(integer channel, string name, key id, string message)
        {
                if (channel == gCommandChannel)
                {
                        if (ProcessDialog(message))
                                state default;
                }
                else if (channel == 0)
                        gChatBuffer += name + ": " + message;
        }

        timer()
        {
                string Params = "chn=" + (string) gChannelNo;

                if (llGetListLength(gChatBuffer) != 0)
                        Params += "&txt=" + llDumpList2String(gChatBuffer, "\n");

                gChatBuffer = [];
                gResponseXch = llHTTPRequest(URL, HTTP_POST_PARAMS, Params);
        }

        on_rez(integer start_param)
        {
                llResetScript();
        }

        http_response(key request_id, integer status, list metadata, string body)
        {
                if (status != 200)
                {
                        llSay(0, "Ow... There is a problem with the service: " + (string) status + " - " + body);
                }
                else if (request_id == gResponseXch)
                {
                        list BodySplit = llParseStringKeepNulls(body, ["\n\n"], []);

                        list Chats = llParseString2List(llList2String(BodySplit, 0), ["\n"], []);
                        string Devices = llList2String(BodySplit, 1);

                        integer n = llGetListLength(Chats); integer i;
                        for(i = 0; i < n; i++)
                                llSay(0, llList2String(Chats, i));

                        llSetText(GetDisplay() + "\n" + Devices, GetStatusColor(gStatus), 1);
                }
        }

        state_exit()
        {
                llListenRemove(gChatListener);
                llListenRemove(gCommandListener);
                llSetTimerEvent(0);
        }
}

La partie serveur

Il y'a trois versions : une en .NET 3.5, une en PHP (merci SBach Xue), et une en Python.

En .NET 3.5

C'est pour les hébergeurs de grilles sous Windows. Si ! Il y'en a : j'en ai rencontré...

<%@ WebHandler Language="C#" Class="talk21" %>

/*
(c) Forest Klaar 2008 (Second Life)

THIS SOFTWARE IS PROVIDED BY THE DEVELOPERS ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE CONTRIBUTORS BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/


using System;
using System.Globalization;
using System.Web;
using System.Web.Caching;
using System.Collections.Generic;

public class talk21 : IHttpHandler
{
    /// <summary>
    /// Définit la période d'expiration d'un InterCom ou d'une ligne
    /// dans une conversation
    /// </summary>
    const int EXPIRE = 10;

    /// <summary>
    /// Un Canal contient la liste des InterCom actifs
    /// et le texte d'une conversation
    /// </summary>
    class Channel
    {
        /// <summary>
        /// Le texte de la conversation sur le canal
        /// </summary>
        public Chats ActiveChats;
       
        /// <summary>
        /// Les InterCom actifs sur ce canal
        /// </summary>
        public InterComs ActiveInterComs;

        public Channel()
        {
            ActiveInterComs = new InterComs();
            ActiveChats = new Chats();
        }
    }

    /// <summary>
    /// Un InterCom
    /// </summary>
    class InterCom
    {
        /// <summary>
        /// Id de l'objet qui contient le script InterCom 2.1
        /// </summary>
        public Guid ID;
        /// <summary>
        /// Nom de la région dans laquelle l'InterCom est hébergé
        /// </summary>
        public string Region;

        /// <summary>
        /// Date de dernière activité de cette intercom
        /// </summary>
        public DateTime TimeStamp;
    }

    /// <summary>
    /// Liste des InterComs
    /// </summary>
    class InterComs : List<InterCom>
    {
        /// <summary>
        /// Recupère la liste des InterComs actifs
        /// </summary>
        /// <param name="deviceID">UUID de l'InterCom appelant pour éviter de l'inclure à la liste</param>
        /// <returns>Liste d'InterComs</returns>
        public List<InterCom> GetInterComs(Guid deviceID)
        {
            var Result = this.FindAll(device => device.ID != deviceID);
            Result.Sort(delegate(InterCom device1, InterCom device2) { return device1.Region.CompareTo(device2.Region); });
            return Result;
        }

        /// <summary>
        /// Remet à jour les informations d'un InterCom dans la liste
        /// </summary>
        /// <param name="device">InterCom modifié</param>
        public void Update(InterCom device)
        {
            this.RemoveAll(oneDevice => oneDevice.ID == device.ID);
            this.Add(device);
        }

        /// <summary>
        /// Retire les InterComs périmés
        /// </summary>
        public void CleanUp()
        {
            this.RemoveAll(device => device.TimeStamp.AddSeconds(EXPIRE) < DateTime.Now);
        }
    }

    /// <summary>
    /// Ligne de conversation
    /// </summary>
    class Chat
    {
        /// <summary>
        /// Texte de la conversation sous la forme "FirstName LastName: Text"
        /// </summary>
        public string Text;
       
        /// <summary>
        /// Liste des InterCom ayant reçu la ligne
        /// </summary>
        public List<Guid> HasRead;
       
        /// <summary>
        /// Date à laquelle le texte a été émis
        /// </summary>
        public DateTime TimeStamp;
    }

    /// <summary>
    /// Liste des conversations
    /// </summary>
    class Chats : List<Chat>
    {
        /// <summary>
        /// Retourne une liste des conversations
        /// </summary>
        /// <param name="reader">UUID de l'InterCom qui interroge</param>
        /// <returns>La liste des conversations</returns>
        public List<Chat> GetChats(Guid reader)
        {
            // Evite de ressortir une ligne déja lue
            var Result = this.FindAll(oneChat => !oneChat.HasRead.Contains(reader));
           
            // Tri par TimeStamp
            Result.Sort(delegate(Chat chat1, Chat chat2) { return chat1.TimeStamp.CompareTo(chat2.TimeStamp); });
           
            // Ajoute l'InterCom dans la liste des lecteurs
            Result.ForEach(oneChat => oneChat.HasRead.Add(reader));
            return Result;
        }

        /// <summary>
        /// Retire les conversations périmées
        /// </summary>
        public void CleanUp()
        {
            this.RemoveAll(chat => chat.TimeStamp.AddSeconds(EXPIRE) < DateTime.Now);
        }
    }

    /// <summary>
    /// Traite la demande par llHTTPRequest
    /// </summary>
    /// <param name="context">Contexte HTTP</param>
    public void ProcessRequest(HttpContext context)
    {
        try
        {
            context.Response.ContentType = "text/plain";
           
            // Numéro de canal
            var ChannelNo = context.Request["chn"];

            lock (context.Application)
            {
                // Retire l'objet Channel
                var ActiveChannel = context.Application[ChannelNo] as Channel;

                // On le fabrique s'il n'existe pas
                if (ActiveChannel == null)
                    ActiveChannel = new Channel();

                // UUID de l'objet à la source de la requête
                var rID = new Guid(context.Request.Headers["X-SecondLife-Object-Key"]);
               
                // Region ou se trouve l'objet au format "NomDeLaRegion (positionX, positionY)"
                var rRegion = context.Request.Headers["X-SecondLife-Region"];

                // Position de l'InterCom au format "(0.000000, 0.000000, 0.000000)"
                var rPosition = context.Request.Headers["X-SecondLife-Local-Position"];

                // Nettoie les InterComs inactifs
                ActiveChannel.ActiveInterComs.CleanUp();

                // Cut region location information we dont need
                var DeviceDisplay = rRegion.Substring(0, rRegion.IndexOf('(') - 1).Trim();

                // Transforme "(0.000000, 0.000000, 0.000000) en <0,0,0>
                // Si quelqu'un trouve une regex pour transformer ceci de façon plus propre...
                var PositionDisplay = "<" + string.Join(",",
                    new List<string>(rPosition.Replace("(", "").Replace(")", "").Split(','))
                        .ConvertAll<string>(s => ((int)float.Parse(s.Trim(), CultureInfo.InvariantCulture)).ToString()).ToArray())
                    + ">";

                // Marque l'InterCom à l'origine de la requête comme étant actif
                ActiveChannel.ActiveInterComs.Update(new InterCom()
                    {
                        ID = rID,
                        Region = DeviceDisplay + " " + PositionDisplay,
                        TimeStamp = DateTime.Now
                    });

                // Récupère la conversation
                var ChatList = ActiveChannel
                    .ActiveChats.GetChats(rID)
                    .ConvertAll<string>(chat => chat.Text);

                // Récupère les InterComs actifs
                var DeviceList = ActiveChannel
                    .ActiveInterComs.GetInterComs(rID)
                    .ConvertAll<string>(device => device.Region);

                // Renvoie la conversation, et la liste des InterComs en utilisant \n
                // pour séparer les éléments, et \n\n pour séparer les deux listes
                context.Response.Write(string.Join("\n", ChatList.ToArray())
                    + "\n\n" + string.Join("\n", DeviceList.ToArray()));

                // Si une conversation a été envoyée, on l'intègre à la liste
                var rText = context.Request["txt"];
                if (rText != null)
                    ActiveChannel.ActiveChats.Add(new Chat()
                        {
                            Text = DeviceDisplay + " - " + rText,
                            TimeStamp = DateTime.Now,
                            HasRead = new List<Guid>() { rID }
                        });

                // Nettoie les conversations inactives
                ActiveChannel.ActiveChats.CleanUp();

                // Persiste le Channel en mémoire
                context.Application[ChannelNo] = ActiveChannel;
            }
        }
        catch (Exception ex)
        {
            // Si quelque chose ne se passe pas bien
            context.Response.StatusCode = 499;
            context.Response.StatusDescription = ex.Message;
        }
    }

    public bool IsReusable { get { return true; }}
}

En PHP

Merci à SBach pour le portage ! Il y'a deux partie : le script en PHP, et une autre en SQL pour installer les tables.

<?php

/*
 * Script serveur pour l'intercom 2.1 et 2.0.
 * Par Stan Bach de la FrancoGrid.
 *
 * THIS SOFTWARE IS PROVIDED BY THE DEVELOPERS ``AS IS'' AND ANY EXPRESS OR
 * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE CONTRIBUTORS BE LIABLE FOR ANY
 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
 * GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
 * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
 * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */


$configuration_base_de_donnees = Array(
                                          // On entre ses identifiants mysql
                                          "serveur"       => "localhost",
                                          "utilisateur"   => "root",
                                          "mot_de_passe"  => "",
                                          "nom_bdd"           => "intercom",
);

mysql_connect($configuration_base_de_donnees["serveur"], $configuration_base_de_donnees["utilisateur"], $configuration_base_de_donnees["mot_de_passe"]);
mysql_select_db($configuration_base_de_donnees["nom_bdd"]);
mysql_query("SET NAMES latin2");

$channel = $_POST['chn'];
$txt = $_POST['txt'];

$objet_id = $_SERVER['HTTP_X_SECONDLIFE_OBJECT_KEY'];
$objet_nom = $_SERVER['HTTP_X_SECONDLIFE_OBJECT_NAME'];
$objet_region = $_SERVER['HTTP_X_SECONDLIFE_REGION'];

if(!empty($txt)){

  $sql = 'insert into messages(message,channel,objets_recu,date_heure) values("'.$txt.'","'.$channel.'","1|'.$objet_id.'|","'.time().'")';
  $req = mysql_query($sql) or die('Erreur SQL !<br>'.$sql.'<br>'.mysql_error());
  $sql = "ALTER TABLE messages ORDER BY id DESC";
  $req = mysql_query($sql) or die('Erreur SQL !<br>'.$sql.'<br>'.mysql_error());
 
}else{

  $date_heure_intervalle = time() - 120;
 
  $sql = "select * from messages where channel='".$channel."' and date_heure >".$date_heure_intervalle."";
  $req = mysql_query($sql);

  while ($row = mysql_fetch_array($req, MYSQL_ASSOC)) {

    $array_objets_recu = explode("|", $row['objets_recu']);
    $recherche = array_search($objet_id, $array_objets_recu, true);
 
    if($recherche=="0"){
 
      echo $row['message']."\n";
   
      $sql1 = "update messages set objets_recu = '".$row['objets_recu']."".$objet_id."|' where id='".$row['id']."'";
      $req2 = mysql_query($sql1) ;
      $sql2 = "ALTER TABLE messages ORDER BY id DESC";
      $req2 = mysql_query($sql2) ;

    }else{
 
    }
  }
}


$sql3 = "select * from objets_allumes where channel='".$channel."' and id_objet ='".$objet_id."'";
$req3 = mysql_query($sql3);
$data3 = mysql_fetch_assoc($req3);

if (!empty($data3)){

  $sql4 = "update objets_allumes set mise_a_jour = '".time()."' where channel='".$channel."' and id_objet ='".$objet_id."'";
  $req4 = mysql_query($sql4);

}else{

  $sql5 = 'insert into objets_allumes(id_objet,channel,region,mise_a_jour) values("'.$objet_id.'","'.$channel.'","'.$objet_region.'","'.time().'")';
  $req5 = mysql_query($sql5) or die('Erreur SQL !<br>'.$sql6.'<br>'.mysql_error());
}


$mise_a_jour_intervalle = time() - 120;

$sql6 = "delete from objets_allumes where mise_a_jour<'".$mise_a_jour_intervalle."'";
$req6 = mysql_query($sql6);


echo "\n\n";

$sql7 = "select * from objets_allumes where channel='".$channel."'";
$req7 = mysql_query($sql7);

while ($row = mysql_fetch_array($req7, MYSQL_ASSOC)) {

  echo $row['region']."\n";

}
   
?>
CREATE TABLE `messages` (
  `id` INT(111) NOT NULL AUTO_INCREMENT,
  `channel` INT(111) NOT NULL,
  `message` TEXT NOT NULL,
  `objets_recu` TEXT NOT NULL,
  `date_heure` INT(111) NOT NULL,
  PRIMARY KEY  (`id`)
) ENGINE=MyISAM  DEFAULT CHARSET=latin2 AUTO_INCREMENT=144 ;

CREATE TABLE `objets_allumes` (
  `id` INT(111) NOT NULL AUTO_INCREMENT,
  `id_objet` TEXT NOT NULL,
  `channel` INT(111) NOT NULL,
  `region` TEXT NOT NULL,
  `mise_a_jour` INT(111) NOT NULL,
  PRIMARY KEY  (`id`)
) ENGINE=MyISAM  DEFAULT CHARSET=latin2 AUTO_INCREMENT=261 ;

En Python

Soyez intransigeants : c'est mon premier truc en Python. Il est destiné à mouliner sur l'AppEngine de Google.
J'ai besoin d'un avis éclairé sur ce code :-}

J'adore Python ! C'est simple et élégant à la fois.

# coding=UTF-8

# (c) Forest Klaar 2008 (Second Life)

# THIS SOFTWARE IS PROVIDED BY THE DEVELOPERS ``AS IS'' AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE CONTRIBUTORS BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
# GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

import logging
import cgi
import wsgiref.handlers
import string

from google.appengine.ext import db
from google.appengine.ext import webapp
from datetime import datetime, timedelta

# Durée de vie d'un intercom et des messages
PurgeMessages = 10
PurgeInterComs = 10

class Channel(db.Model):
  def __init__(self, *args, **kw):
    kw["key_name"] = kw["Number"]
    db.Model.__init__(self, *args, **kw)

  Number = db.StringProperty()

class InterCom(db.Model):
  def __init__(self, *args, **kw):
    kw["key_name"] = kw["ID"]
    db.Model.__init__(self, *args, **kw)

  ID = db.StringProperty()
  Region = db.StringProperty()
  Location = db.StringProperty()
  TimeStamp = db.DateTimeProperty()

class Message(db.Model):
  Name = db.StringProperty()
  From = db.StringProperty()
  Text = db.StringProperty(multiline = True)
  Originator = db.StringProperty()
  HasRead = db.StringListProperty()
  TimeStamp = db.DateTimeProperty()

class Talk21(webapp.RequestHandler):
  def get(self):
    key = "K" + self.request.get("key")
    reg = "Region (X,Y)"
    loc = "(0.000000, 0.000000, 0.000000)"
    self.process(key, reg, loc)

  def post(self):
    # UUID de l'objet à la source de la requête
    key = "K" + self.request.headers.get("X-SecondLife-Object-Key")

    # Region ou se trouve l'objet au format "NomDeLaRegion (positionX, positionY)"
    reg = self.request.headers.get("X-SecondLife-Region")
    # Position de l'InterCom au format "(0.000000, 0.000000, 0.000000)"
    loc = self.request.headers.get("X-SecondLife-Local-Position")
    self.process(key, reg, loc)

  def process(self, key, reg, loc):
    #Canal du Message
    chn = "K" + self.request.get("chn")
    # Récupère uniquement le nom de la région en tronquant sa position
    Region = reg[0:reg.find("(")].strip()
    # Transforme "(23.000000,12.000000, 34.000000) en <23,12,34>
    Location = "<" + string.join(map(lambda o: o[0:o.find(".")].strip(),
                                 loc.strip()[1:-1].split(",")), ",") + ">"

    # Récupère ou crée le canal demandé
    ActiveChannel = Channel.get_by_key_name(chn)
    if ActiveChannel is None:
      ActiveChannel = Channel(Number = chn)
      ActiveChannel.put()

    # Recupère les message courants, non lus
    q = filter(lambda o: key not in o.HasRead,
                         list(db.GqlQuery("SELECT * FROM Message " +
                                          "WHERE ANCESTOR IS :1 " +
                                          "AND Originator != :2",
                                          ActiveChannel, key)))

    def MarkAsRead(q):
      for o in q:
        o.HasRead.append(key)
        o.put()

    # Marque tout comme lu dans une transaction, pour éviter les accès concurrentiels
    if len(q) > 0:
      db.run_in_transaction(MarkAsRead, q)

    MessageListDisplay = map(lambda o:
                             o.Name + ": " + o.From + " - " + o.Text, q)
    DeviceListDisplay = map(lambda o:
                            o.Region + " " + o.Location,
                            list(db.GqlQuery("SELECT * FROM InterCom " +
                                             "WHERE ANCESTOR IS :1 " +
                                             "AND ID != :2",
                                             ActiveChannel, key)))

    InterCom(ID = key,
             Region = Region,
             Location = Location,
             TimeStamp = datetime.now(),
             parent = ActiveChannel).put()

    txt = self.request.get("txt").strip()
    if txt != "":
      Name = txt[0:txt.index(": ")]
      for t in txt.replace(Name + ": ", "").splitlines():
        Message(Name = Name,
             From = Region,
             Text = t,
             Originator = key,
             TimeStamp = datetime.now(),
             parent = ActiveChannel).put()

    # Renvoie la conversation, et la liste des InterComs en utilisant \n
    # pour séparer les éléments, et \n\n pour séparer les deux listes
    self.response.headers["Content-Type"] = "text/plain; charset=utf-8"
    self.response.out.write(string.join(MessageListDisplay, "\n") + '\n\n' +
                            string.join(DeviceListDisplay, "\n"))

    # Nettoie les InterComs et les messages expirés
    d = list(db.GqlQuery("SELECT * FROM Message " +
                         "WHERE ANCESTOR IS :1 AND TimeStamp < :2",
                         ActiveChannel,
                         datetime.now() - timedelta(seconds = PurgeMessages)))

    if len(d) > 0: db.delete(d)

    d = list(db.GqlQuery("SELECT * FROM InterCom " +
                         "WHERE ANCESTOR IS :1 AND TimeStamp < :2",
                         ActiveChannel,
                         datetime.now() - timedelta(seconds = PurgeInterComs)))

    if len(d) > 0: db.delete(d)

def main():
  application = webapp.WSGIApplication([('/talk21', Talk21)], debug=True)
  wsgiref.handlers.CGIHandler().run(application)

if __name__ == "__main__":
  main()

Et bien sûr, le fichier des index qui va bien

indexes:

# AUTOGENERATED

# This index.yaml is automatically updated whenever the dev_appserver
# detects that a new type of query is run.  If you want to manage the
# index.yaml file manually, remove the above marker line (the line
# saying "# AUTOGENERATED").  If you want to manage some indexes
# manually, move them above the marker line.  The index.yaml file is
# automatically uploaded to the admin console when you next deploy
# your application using appcfg.py.

# Unused in query history -- copied from input.
- kind: Chat
  ancestor: yes
  properties:
  - name: Originator

# Unused in query history -- copied from input.
- kind: Chat
  ancestor: yes
  properties:
  - name: TimeStamp

# Unused in query history -- copied from input.
- kind: InterCom
  ancestor: yes
  properties:
  - name: ID

# Unused in query history -- copied from input.
- kind: InterCom
  ancestor: yes
  properties:
  - name: TimeStamp

# Unused in query history -- copied from input.
- kind: Message
  ancestor: yes
  properties:
  - name: Originator

# Unused in query history -- copied from input.
- kind: Message
  ancestor: yes
  properties:
  - name: TimeStamp

Ne pas hésiter à proposer vos modifs dans les commentaires !