Cisco Jabberのチャット履歴を覗いてみる


Cisco Jabberとは

Jabberというと、XMPPの方が一般的かもしれません。
しかし、今回のターゲットはCisco Jabberの方です。企業で導入しているところもあると思います。

Cisco Jabberは、インスタントメッセージ、音声/ビデオ通話、ボイスメッセージ、デスクトップ共有や会議などが出来る、とても優れたツールです。

ですが、1点だけ腑に落ちないところがあります。
それはチャット履歴がデフォルトでは自動保存されないところです。
(チャット履歴を個別に手動保存する機能はあります)

過去のやり取りをさかのぼって調べたいということはよくあります。
一般的なメッセンジャーではよくある機能がなぜ標準で無いのかはわかりません。
Cisco Jabberにもその機能は有るのですが、電話機能とセットになった追加オプションで月額別料金が別途必要だそうです。

チャット履歴の保存場所

Cisco Jabberは、チャットウィンドウを表示したときに過去の会話を表示させるために、1人毎に100件ほど過去の履歴を持っています。
保存場所は"C:\Users\[ユーザー名]\AppData\Local\Cisco\Unified Communications\Jabber\CSF\History\[アカウント名].db"です。
保存形式はSQLite3です。(テキストエディタで開いてみると、先頭が「SQLite format 3」となっているのでわかります)

2019/07/18追記

Jabber バージョン12 (11.9.X?) からこのファイルは暗号化されているようです。
中身を見る方法は現在判明していません。

保存ファイルの形式

中を覗いてみると、以下のテーブルがあることが判りました。(バージョンによっては違うかも)

  • filter_matches
  • filter_view
  • history_item
  • history_message
  • history_participant
  • im_label

メッセージ履歴は、history_messageにあります。
SQLite3が普通に使える人ならば、この状態でも十分かもしれませんね。

history_messageからは、誰がいつ何を送ったという情報が取得できるのですが、誰に送ったのかの情報はありません。
自分以外の発言ならば自分へ送っているので良いですが、自分の発言が誰に送ったのかはわかりません。
そこでhistory_participantを使います。ここには誰から誰への紐づきがあるのでこれで取得できます。

送信者と受信者
SELECT DATE
      ,SENDER
      ,JID AS RECEIVER
      ,PAYLOAD
  FROM HISTORY_MESSAGE A
 INNER JOIN HISTORY_PARTICIPANT B ON A.ITEM = B.ITEM
   AND A.SENDER <> B.JID

チャット履歴を参照するクライアントアプリ

というわけで、作ってみました。(自分用なので汚いです)
JavaScriptで書きたかったので、Electronで作っています。

package.json
{
  "name": "jabberhistory",
  "version": "1.0.0",
  "description": "Cisco Jabber Chat History",
  "private": true,
  "main": "index.js",
  "scripts": {
    "start": "electron ."
  },
  "keywords": [],
  "author": "KAJIKEN <[email protected]> (http://kajiken.jp)",
  "license": "MIT",
  "dependencies": {
    "electron-search-dialog": "^0.2.2",
    "sqlite3": "^4.0.6"
  },
  "devDependencies": {
    "electron-rebuild": "^1.8.4"
  }
}
index.js
const electron = require("electron");
const sqlite = require("sqlite3");
const fs = require("fs");
const app = electron.app;
const ipc = electron.ipcMain;
const BrowserWindow = electron.BrowserWindow;

// DBファイル取得
const basedir = `${ app.getPath("home") }\\AppData\\Local\\Cisco\\Unified Communications\\Jabber\\CSF\\History`;
const config = {};

let mainWindow = null;

app.on("window-all-closed", () => {
  if (process.platform !== "darwin") {
    app.quit();
  }
});

app.on("ready", () => {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    resizable: false,
    useContentSize: true
  });
  mainWindow.loadURL(`file://${ __dirname }/index.html`);
  mainWindow.on("closed", () => mainWindow = null);
});

// データベースアクセスサービス
const das = {
  "connect": id => new sqlite.Database(config.dbfile),
  "execute": async (obj, connection) => {
    const db = connection || das.connect();
    const exec = (obj, conn) => new Promise((resolve, reject) => conn.serialize(() => conn.all(obj.sql, obj.bind || {}, (err, res) => err ? reject(err) : resolve(res))));

    try {
      return await exec(obj, db);
    } finally {
      if (!connection) {
        db.close();
      }
    }
  }
};

// 受信メソッド
const method = {
  // データベース一覧取得
  "get_database_list": async (e, obj) => e.sender.send("get_database_list", fs.readdirSync(basedir).filter(val => /\.db$/.test(val))),

  // データベース選択
  "select_database": async (e, obj) => {
    config.dbfile = `${ basedir }\\${ obj }`;
    config.myaccount = obj.replace(/\.db$/, "");
    method.get_chatlist(e, obj);
  },

  // チャット履歴対象者の一覧を取得
  "get_chatlist": async (e, obj) => e.sender.send("get_chatlist", await das.execute({
    "sql": "SELECT SENDER FROM HISTORY_MESSAGE WHERE SENDER <> ? GROUP BY SENDER ORDER BY SENDER",
    "bind": [ config.myaccount ]
  })),

  // チャット履歴選択
  "select_chatlist": async (e, obj) => e.sender.send("get_payload", {
    "data": await das.execute({
      "sql": "SELECT DATE, SENDER, JID AS RECEIVER, PAYLOAD FROM HISTORY_MESSAGE A INNER JOIN HISTORY_PARTICIPANT B ON A.ITEM = B.ITEM AND A.SENDER <> B.JID AND (A.SENDER = ? OR B.JID = ?)",
      "bind": [ obj, obj ]
    }),
    "user": obj,
    "my": config.myaccount
  }),

  "chat_search": async (e, obj) => obj ? e.sender.send("get_payload", {
    "data": await das.execute({
      "sql": "SELECT DATE, SENDER, PAYLOAD FROM HISTORY_MESSAGE WHERE PAYLOAD LIKE ?;",
      "bind": [ `%${ obj }%` ]
    }),
    "user": obj,
    "my": config.myaccount
  }) : undefined,
};

// イベントバインド
for (let key of Object.keys(method)) {
  ipc.on(key, method[key]);
}
index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>Cisco Jabber Chat History</title>
    <script>
    if (typeof module !== 'undefined') {
      window.__tempModuleExports__ = module.exports;
      module.exports = false;
    }
    </script>
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"></script>
    <script>
      if (window.__tempModuleExports__) {
        module.exports = window.__tempModuleExports__;
        window.__tempModuleExports__ = null;
        delete window.__tempModuleExports__;
      }
    </script>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.0/css/bootstrap.min.css">
    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.1/css/all.css">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.0/js/bootstrap.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.1.2/handlebars.min.js"></script>
    <style>
      .balloon-right::before{
        content: '';
        position: absolute;
        display: block;
        width: 0;
        height: 0;
        right: -15px;
        top: 20px;
        border-left: 15px solid #d4edda;
        border-top: 15px solid transparent;
        border-bottom: 15px solid transparent;
      }

      .balloon-left::before{
        content: '';
        position: absolute;
        display: block;
        width: 0;
        height: 0;
        left: -15px;
        top: 20px;
        border-right: 15px solid #e2e3e5;
        border-top: 15px solid transparent;
        border-bottom: 15px solid transparent;
      }
    </style>

    <!-- プロファイル選択画面 -->
    <script id="profile" type='text/x-handlebars-template'>
      <div class="row my-3">
        <div class="col text-center">データベースを選択してください</div>
      </div>
      <div class="row">
        <div class="col">
          <div class="list-group">
            {{#each .}}
            <button type="button" class="list-group-item list-group-item-action" onclick="ipcRenderer.send('select_database', '{{ . }}')">{{ . }}</button>
            {{/each}}
          </div>
        </div>
      </div>
    </script>

    <!-- 履歴選択画面 -->
    <script id="chatlist" type='text/x-handlebars-template'>
      <div class="row my-3">
        <div class="col-1">
          <button type="button" class="btn btn-sm" onclick="ipcRenderer.send('get_database_list')"><i class="fas fa-arrow-left"></i></button>
        </div>
        <div class="col-10 text-center">履歴を表示するユーザーを選択するかキーワードを入力してください</div>
      </div>
      <div class="row">
        <div class="col">
          <form onsubmit="ipcRenderer.send('chat_search', $('#search').val()); return false;">
            <div class="form-row align-items-center">
              <div class="col my-1">
                <input type="text" class="form-control" id="search" placeholder="検索">
              </div>
            </div>
          </form>
        </div>
      </div>
      <div class="row">
        <div class="col">
          <div class="list-group">
            {{#each .}}
            <button type="button" class="list-group-item list-group-item-action" onclick="ipcRenderer.send('select_chatlist', '{{ sender }}')">{{ sender }}</button>
            {{/each}}
          </div>
        </div>
      </div>
    </script>

    <!-- チャット内容表示画面 -->
    <script id="payload" type='text/x-handlebars-template'>
      <div class="row my-3">
        <div class="col-1">
          <button type="button" class="btn btn-sm" onclick="ipcRenderer.send('get_chatlist')"><i class="fas fa-arrow-left"></i></button>
        </div>
        <div class="col-10 text-center">{{ user }}</div>
        <div class="col-1">
          <button type="button" class="btn btn-sm" onclick="dialog.openDialog()"><i class="fas fa-search"></i></button>
        </div>
      </div>
      <div class="row">
        <div class="col">
          {{#each data}}
          <div class="alert alert-{{#isMyChat sender ../my}}{{/isMyChat}} balloon-{{#isMyChat2 sender ../my}}{{/isMyChat2}} text-wrap text-break" role="alert">
            {{ sender }}:<br>
            {{{ payload }}}
            <small>{{ get_date date }}</small>
          </div>
        {{/each}}
      </div>
    </script>

    <script type="text/javascript">
      const electron = require("electron");
      const SearchDialog = require("electron-search-dialog").default;
      const dialog = new SearchDialog(electron.remote.getCurrentWindow());
      dialog.width = 470;
      dialog.height = 170;
      const ipcRenderer = electron.ipcRenderer;

      // 受信メソッド
      const method = {
        // データベース一覧受信
        "get_database_list": (e, obj) => $("#main").html(Handlebars.compile($('#profile').html())(obj)),

        // チャット履歴一覧受信
        "get_chatlist": (e, obj) => $("#main").html(Handlebars.compile($('#chatlist').html())(obj)),

        // チャット内容受信
        "get_payload": (e, obj) => $("#main").html(Handlebars.compile($('#payload').html())(obj))
      };

      // イベントバインド
      for (let key of Object.keys(method)) {
        ipcRenderer.on(key, method[key]);
      }

      // onReady
      $(() => {
        // 日付フォーマット関数
        Handlebars.registerHelper("get_date", val => {
          const date = new Date(val / 1000);
          return `${ date.getFullYear() }/${ ("0" + (date.getMonth() + 1)).slice(-2) }/${ ("0" + date.getDate()).slice(-2) } ${ ("0" + date.getHours()).slice(-2) }:${ ("0" + date.getMinutes()).slice(-2) }:${ ("0" + date.getSeconds()).slice(-2) }`;
        });

        // 自分の発言かチェック
        Handlebars.registerHelper("isMyChat", (sender, my) => sender == my ? 'success' : 'secondary');
        Handlebars.registerHelper("isMyChat2", (sender, my) => sender == my ? 'right' : 'left');

        // DB一覧取得
        ipcRenderer.send("get_database_list");
      });
    </script>
  </head>
  <body>
    <div class="container my-2">
      <div id="main"></div>
    </div>
  </body>
</html>

参考

作ってから見つけましたが、DBファイルから内容を読み取ってHTMLに保存するスクリプトを作成された方がいますので、単純に保存したいだけならば、これを定期的に動かしておけば良さそう。
https://github.com/imrandomizer/jabbersync