AWS Lambda と Typetalk Streaming API でサイトキャプチャをとるボットを作る

これはWeb API Advent Calendar 2014、12日目のエントリです。

AWS Lambda と Typetalk Streaming API を使って、Typetalk のポストに URL が含まれていたら、そのサイトの画面キャプチャを取得して返信する以下のようなボットを作ってみました。

aws_lambda_sample

アーキテクチャ 〜Typetalk Streaming API から AWS Lambda へ〜

全体の処理フローは以下のようになります。今回は Streaming API に接続してポスト内に URL があれば Kinesis に PUT する部分と、AWS Lambda から実行される JavaScript を記述しました。サイトのキャプチャには wkhtmltoimage を利用しています。

 

Typetalk Streaming API の使いかた

 Streaming API の Python のサンプル の中で受け取ったメッセージを処理する on_message 関数を以下のように修正して利用します。Typetalk 上で発生するイベントのうち、メッセージの投稿を表す postMessage のうち、TARGET_TOPIC_ID のトピックのもののみを対象に処理しています。Kinesis のストリームの名前は次のステップで作成するものを利用します。

# チェック対象のトピックID
TARGET_TOPIC_ID = 1111
# Kinesis のリージョン
KINESIS_REGION = 'your-region'
# Kinesis の Stream 名
KINESIS_STREAME_NAME = 'your-stream-name'

conn = boto.kinesis.connect_to_region(KINESIS_REGION)
pattern = re.compile('http[s]?:\/\/[A-Za-z0-9-_]+\.[A-Za-z0-9-_:%&\?\/.=()\@\+\$,#\^]+')


def put_kinesis(postId, urls):

    if len(urls) == 0:
        return

    for url in urls:
        data = {
            'postId': postId,
            'url': url
        }
        conn.put_record(KINESIS_STREAME_NAME, json.dumps(data), 'partitionKey')

def on_message(ws, message):
    data = json.loads(message)
    if data['type'] == 'postMessage':
        post = data['data']['post']
        if post['topicId'] == TARGET_TOPIC_ID:
            urls = pattern.findall(post['message'])
            put_kinesis(post['id'], urls)

URL が見つかったときに Kinesis へは、{postId:1111, url:'https://nulab.com/'} のような形式で登録します。ここで登録した値を後述の Lambda 関数の中でイベントから取り出すことになります。

Amazon Kinesis ストリームの作成

以下のように任意の名前でストリームを作成します。今回は特に流量も想定していないので shard-count は 1 に設定しています。

aws kinesis create-stream --stream-name="your-stream-name" --shard-count=1

AWS Lambda 関数の定義

そして Kinesis から受け取った URL をキャプチャし、Typetalk の元のトピックに添付ファイルとして返信するのが以下のようなコードになります。大きく以下の3つのステップで構成されています。

  1. wkhtmltoimage を S3 からダウンロード
  2. wkhtmltoimage の実行
  3. 2 で生成されたキャプチャを Typetalk にアップロードし、メッセージをポスト
var aws = require('aws-sdk');
var fs = require('fs');
var https = require('https');
var querystring = require('querystring');

var WK_BUCKET_REGION = 'your-bucket-region';
var WK_BUCKET_NAME = 'your-bucket-name';
var WK_BUCKET_PATH = 'your-bucket-path';

var TYPETALK_TOKEN = 'your-typetalk-bot-token';
var TARGET_TOPIC_ID = 111; // 先述の python で指定したものと同じ TOPIC_ID

exports.handler = function(event, context) {

  var encodedPayload = event.Records[0].kinesis.data;
  var data = JSON.parse(new Buffer(encodedPayload, 'base64').toString('ascii'));

  var path = '/tmp/wkhtmltoimage';

  // (1) install wkhtmltoimage
  var install_binary = function(callback){

    new aws.S3({
      region: WK_BUCKET_REGION
    }).getObject({
      Bucket : WK_BUCKET_NAME,
      Key : WK_BUCKET_PATH
    }, function(err, data) {
      if (err) throw err;

      fs.writeFile(path, data.Body, {
        mode: 0755
      }, callback);
    });
  };

  // (2) run wkhtmltoimage
  var capture_image = function(callback){
    var exec = require('child_process').exec;
    var output = '/tmp/output.png';
    var cmd = path + ' -q --quality 50 ' + data["url"] + ' ' + output;
    var child = exec(cmd, function(err, stdout, stderr) {
      if (err) throw err;

      callback(output);
    });
  };

  var call_typetalk_api = function(path, contentType, callback){

    var options = {
      host: 'typetalk.in',
      port: 443,
      path: path,
      method: 'POST',
      headers: {
        'X-Typetalk-Token': TYPETALK_TOKEN,
        'Content-Type': contentType
      }
    };

    var req = https.request(options, function(res){
      res.setEncoding('utf8');
      res.on('data', callback);
    });

    req.on('error', function(err){
      throw err;
    });

    return req;
  };

  // (3) post typetalk
  var post_typetalk = function(img){

    var boundaryKey = Math.random().toString(16);

    // upload attachment
    var req1 = call_typetalk_api('/api/v1/topics/' + TARGET_TOPIC_ID + '/attachments', 'multipart/form-data; boundary="' + boundaryKey + '"', function(chunk){

      // post message
      var req2 = call_typetalk_api('/api/v1/topics/' + TARGET_TOPIC_ID, 'application/x-www-form-urlencoded', function(data){
        console.log(data);
        context.done();
      });
      var param = {
        message: 'captured',
        replyTo: data["postId"],
        "fileKeys[0]": JSON.parse(chunk).fileKey
      };
      req2.write(querystring.stringify(param));
      req2.end();
    });

    var buf = [
      '--' + boundaryKey,
      'Content-Type: application/octet-stream',
      'Content-Disposition: form-data; name="file"; filename="capture.png"',
      'Content-Transfer-Encoding: binary\r\n\r\n'
    ];
    req1.write(buf.join('\r\n'));
    fs.createReadStream(img, { bufferSize: 4 * 1024 }).on('end', function(){
      req1.end('\r\n--' + boundaryKey + '--');
    }).pipe(req1, {end: false});
  };

  install_binary(function(err){
    if (err) throw err;
    capture_image(post_typetalk);
  });

};

TYPETALK_TOKEN には TARGET_TOPIC_ID に設定されているボットアカウントの Typetalk Token を指定します。( 参考 )

イベントソースの設定 (Kinesis ストリームと Lambda 関数の紐付け)

最後に、先に作成した Kinesis ストリームと Lambda 関数の紐付けを行います。作業にあたって、まず Kinesis のストリームから Lambda を呼び出す際に利用する IAM ロール ( Invocation Role ) を作成する必要があります。Lambda のドキュメント を参考にまず IAM ロールを作成してください。

次に、Lambda 関数に Kinesis をイベントソースとして設定するのですが、現時点(2014年12月) ではマネージメントコンソール上での設定ができません。ですので、以下のように CLI で設定をします。

# Kinesis ストリームの ARN を調べる
aws kinesis describe-stream --stream-name="ストリーム名" | jq '.StreamDescription.StreamARN'

# Invocation Role の ARN を調べる
aws iam get-role --role-name="ロール名" | jq '.Role.Arn'

# Lambda 関数にイベントソースを設定する
aws lambda add-event-source \
--event-source="上記の Kinesis の ARN"
--role="上記の IAM の ARN"
--function-name="作成した Lambda 関数の名前"

ここまでできたら、あとは URL を含んだメッセージを Typetalk にポストするだけです!

まとめ

今回のボット作成では、Typetalk 側には一切手を加えず Streaming API と昨日リリースした Bot API のみを利用しています。Typetalk は API を設計のコアにおいていて、Web やモバイルで提供しているほぼ全ての機能が API でも利用できますので、是非色々と触ってもらいたいと思います。開発者サイトには各言語のサンプルも充実しておりますので、ご覧ください!

AWS Lambda はまだプレビューですが、今回のような負荷が高くかつ単機能な処理には向いてるように感じます。アプリケーションロジックを AWS に依存したくないという考え方もありますが、単純なものであれば実装そのものは大変ではないですし、そもそも疎結合になるので、後から構造的に切り替えることは難しくありません。

また、この処理フローをみると AWS Lambda 使わずに、ポストの内容を処理しているプログラムから直接 wkhtmlimage を実行しても良さそうに見えます。しかし、AWS Lambda を使うことで、今回の例では 「URL と Typetalk のポストの ID を投げればキャプチャをとって返信してくれる」というサービスを簡単に作ることが出来ます。これにより、別のボットでも Kinesis に流すだけで同じ機能を使うことが出来るようになるというところは、大きなポイントだと思っています。

参考

前者のエントリで割と普通の Linux 環境であることがわかったので、キャプチャをとるのに wkhtmltoimage を採用しました。wkhtmltoimage は依存関係の多くを静的に組み込むようビルドされており、執筆現在 (2014年12月) の Lambda の実行環境では動きました。ただプラットフォーム依存の部分もあるので、今後動かなくなる可能性もあります。そこを考えると Java や Go といった丸っと置くだけで使えるようなものを使うのがプロダクションでは良さそうです。

また後者のエントリで紹介されているのと同じように、S3 から wkhtmltoimage のバイナリをダウンロードして使っています。ダウンロードするサイズが大きくなると Lambda Function の実行時間に影響しますので、自分が指定したイメージを使えるようになったり、もしくは起動時に高速にバイナリをダウンロードする仕組みなど、今後の AWS Lambda の機能拡張にも期待したいところです。


 12/16(火) Typetalk Hack 開催!!

12/16(火)に渋谷の SmartNews 様のオフィスにて、Typetalk API をハックするハッカソン Typetalk Hack Tokyoを行います。Streaming API などの最近のアップデートや、Swift による Typetalk API クライアントの実装の話などもお届けします。私もこのエントリで書いた内容を整えてソースを公開出来る状態にしたいと思いますので、是非遊びに来てください!