Slack ストーキング

  • message event | Slack は、いくつかのサブタイプがあり、メッセージの変化を表現しています
$ node index.js | jq --unbuffered 'select(.subtype=="message_deleted")'
  • ユーザ @bsannou がチャンネル #twitter で "Despite the constant negative press covfefe" というメッセージを削除した様子です
{
  "type": "message",
  "deleted_ts": "1497783180.894743",
  "subtype": "message_deleted",
  "hidden": true,
  "channel": {
    "id": "C5VCH7HFV",
    "name": "#twitter"
  },
  "previous_message": {
    "type": "message",
    "user": {
      "id": "U5JPRSD6U",
      "name": "@bsannou"
    },
    "text": "Despite the constant negative press covfefe",
    "ts": "1497783180.894743"
  },
  "event_ts": "1497783204.895728",
  "ts": "1497783204.895728"
}
$ node index.js | jq --unbuffered 'select(.subtype=="message_changed")'
  • 様子です
{
  "type": "message",
  "message": {
    "type": "message",
    "user": {
      "id": "U5JPRSD6U",
      "name": "@bsannou"
    },
    "text": "あなたはとてもクサレ脳ミソですね",
    "edited": {
      "user": {
        "id": "U5JPRSD6U",
        "name": "@bsannou"
      },
      "ts": "1497188145.000000"
    },
    "ts": "1497188126.496907"
  },
  "subtype": "message_changed",
  "hidden": true,
  "channel": {
    "id": "C5JT9C849",
    "name": "#vip"
  },
  "previous_message": {
    "type": "message",
    "user": {
      "id": "U5JPRSD6U",
      "name": "@bsannou"
    },
    "text": "あなたはとてもド低能ですね",
    "ts": "1497188126.496907"
  },
  "event_ts": "1497188145.497849",
  "ts": "1497188145.497849"
}
  • 公式クライアントでは、削除や修正が常に適用された状態で表示されますが、一旦送信した情報は取り消せないので、留意していきましょう
$ node index.js | jq --unbuffered 'select(.presence=="active") | .now=now'
  • 様子
{
  "type": "presence_change",
  "presence": "active",
  "user": {
    "id": "U5JPRSD6U",
    "name": "@bsannou"
  },
  "now": 1497786620.436581
}
  • 深夜に active がいるとオッとなる
  • 出力を FIFO 経由で入力して…
    • マイクロマネジメントし太郎
$ mkfifo fifo
$ node index.js < fifo | jq --unbuffered -c 'select(.presence=="active") | {"type":"message","text":"進捗どうですか?activeだから居ますよね??","channel":{"name":.user.name}}' > fifo
$ node index.js | jq --unbuffered 'select(.type=="user_typing")'
{
  "type": "user_typing",
  "channel": {
    "id": "C5JT9C849",
    "name": "#vip"
  },
  "user": {
    "id": "U5JPRSD6U",
    "name": "@bsannou"
  }
}
  • うざみ
$ mkfifo fifo
$ node index.js < fifo | jq --unbuffered -c 'select(.type=="user_typing") | {"type":"message","text":("ちょっと待って!今 "+.user.name+" が何か言おうとしてる!"),"channel":{"name":.channel.name}}' > fifo
  • 今日書きたいことはこれくらいです

Node.js Stream からいつの間にか Slack クライアントができた

  • Mastodon は観察専門なので、使用頻度の高い Slack を題材とします
  • 最初は Mastodon と同じく Slack のリアルタイムデータが流れ出てくる Stream を作ります
    • Legacy tokens | Slack から Authentication token を入手
    • rtm.connect method | Slack で token を渡して Real Time Messaging API の WebSocket エンドポイントを得る
    • WebSocket から受け取った message を stream に書き込み
    • stream から stdout に出力
let ws;
const url = "https://slack.com/api/rtm.connect?token=xoxp-0000000000-0000000000-000000000000-0123456789abcdef0123456789abcdef";
const stream = new require("stream").PassThrough();
require("https").get(url, res => {
  res.on("data", chunk => {
    const obj = JSON.parse(chunk);
    ws = new (require("ws"))(obj.url);
    ws.on("message", data => stream.write(data + "\n"));
  });
});
stream.pipe(process.stdout);
$ node index.js
{"type":"hello"}
{"type":"reconnect_url","url":"wss://mpmulti-0a1g.slack-msgs.com/websocket/ZvwORwLc1uFhgMHJAZva-04tZg2Lp5iJEclZ8vnOCctMTnJC64LQvb7MZvgJNifnscAGXvki2UAEcJpnlwFwCY031HGGe-NizIM5cZZKfdMQcO9kbXBhb8LkUKQnhR3bx-Z5hxefvQgsVQVgZwSFO9huhgXo3cBvjCc44YTZklY="}
{"type":"presence_change","presence":"active","user":"U5HCFCV5W"}
...
  • ここに、書き込むと WebSocket にデータを送る Writable Stream のコードを追加して stdin と繋ぎます
const writable = new require("stream").Writable({
  write: (chunk, encoding, callback) => {
    ws.send(chunk.toString());
  }
});
process.stdin.pipe(writable);
  • stdin へチャンネルにメッセージを投稿するための JSON を入力します
$ node index.js
...
{"type":"message","channel":"C5JT9C849","text":"test"}
  • 直後に成功を表す JSON が送られてきたら、ブラウザ等から確認すると反映されていることがわかります
{"ok":true,"reply_to":0,"ts":"1495991511.232093","text":"test"}
  • ここまでで、最もシンプルな Slack クライアントが完成しました
  • 送られてくる JSON は channel や user の情報が "C5JT9C849" のような id となっていて扱いづらく、よって name に変換する Transform Stream を作って間にはさみます
const url = "https://slack.com/api/rtm.start?token=xoxp-0000000000-0000000000-000000000000-0123456789abcdef0123456789abcdef";
const stream = new require("stream").PassThrough();
require("https").get(url, res => { ... });
const transform = new require("stream").Transform({
  transform: function(chunk, encoding, callback) { ... }
});
stream.pipe(transform).pipe(process.stdout);
{"type":"message","user":{"id":"U5HCFCV5W","name":"@asannou"},"text":"test","ts":"1496083043.865560","channel":{"id":"C5JT9C849","name":"#vip"}}
{"type":"message","user":{"id":"USLACKBOT","name":"@slackbot"},"text":"Hello, I’m Slackbot. I try to be helpful. (But I’m still just a bot. Sorry!) *Type something* and hit _enter_ to send your message.","ts":"1495732144.332015","channel":{"id":"D5J44CUDS","name":"@slackbot"}}
{"type":"message","user":{"id":"USLACKBOT","name":"@slackbot"},"text":"Pleasure to meet you. Let me show you a couple things about Slack.","ts":"1495732157.337778","channel":{"id":"D5J44CUDS","name":"@slackbot"}}
170525170904.332015 [@slackbot] (@slackbot) Hello, I’m Slackbot. I try to be helpful. (But I’m still just a bot. Sorry!) *Type something* and hit _enter_ to send your message.
170525170917.337778 [@slackbot] (@slackbot) Pleasure to meet you. Let me show you a couple things about Slack.
  • タイムスタンプ部分 "%y%m%d%H%M%S.%6N" が苦しいですが、生 JSON よりはマシでしょう
    • 出力のすべては JSON であるがゆえに、特定のチャンネルをフィルタリングするなど、jq の能力が発揮されます
$ node index.js | jq -c --unbuffered 'select(.channel.name != "#general")'
  • 入力についても、手打ち JSON は死ぬので jq に頼ります
$ echo "test" | jq -R -c --unbuffered '{type:"message",channel:{name:"@slackbot"},text:.}'
{"type":"message","channel":{"name":"@slackbot"},"text":"test"}
  • 入力と出力を jq で挟むことにより、ちょっとだけまともな Slack クライアントになりました
$ jq -R -c --unbuffered '{type:"message",channel:{name:"@slackbot"},text:.}' | node index.js | jq --unbuffered -r -f format.jq
help!
170528175842.970929 [@slackbot] (@asannou) help!
170528175842.970933 [@slackbot] (@slackbot) I can help by answering simple questions about how Slack works. I'm just a bot, though! If you need more help, try our <https://get.slack.help/hc/en-us/|Help Center> for loads of useful information about Slack ― it's easy to search! Or simply type */feedback* followed by your question or comment, and a human person will get back to you. :smile:
$ ./rwlap.js "jq -R -c --unbuffered '{type:\"message\",channel:{name:\"@slackbot\"},text:.}' | node index.js | jq --unbuffered -r -f format.jq"
  • Readline は、他にもタブキーによる入力補完もサポートしているため、公式クライアントのような、ユーザ名の補完なども実現可能です
$ ./rwlap.js "jq -R -c --unbuffered '{type:\"message\",channel:{name:\"@slackbot\"},text:.}' | node index.js | jq --unbuffered -r -f format.jq | ./highlight.js asannou"

170528175842.970929 [@slackbot] (@asannou) help!

  • もちろん、同様のことができるコマンドで、代替してもかまいません
  • その他いろいろ
    • チャンネルをステートフルに指定する
    • スレッドの対応
    • WebSocket で受け付けてもらえないリクエストを HTTPS で送って、結果だけ stream に流す
    • パイプで繋いだ各コマンドでバッファされないよう、無効にするか、ラインバッファのオプションを指定する
    • stdout からのデータは JSON として処理されてしまうので、補助的なデータは stderr から出力


  • 個人的に Slack の機能はこれで足りているので、しばらく使ってみようと思います

Mastodon のタイムラインをターミナルに流す

  • Mastodon の各種タイムラインは WebSocket でストリーミングされている
  • Node.js の Stream の学習を兼ねて、これを様々なターミナルに出力できるようにする
    • 「Stream を制するものは、 Node.js を制す」らしい
$ node -e 'new (require("ws"))("ws://friends.nico/api/v1/streaming/?access_token=...&stream=public").on("message", (data, flags) => console.log(data))'
{"event":"update","payload":"{\"id\":1920985,\"created_at\":\"2017-04-24T16:10:03.248Z\",\"in_reply_to_id\":null,\"in_reply_to_account_id\":null,\"sensitive\":false,\"spoiler_text\":\"\",\"visibility\":\"public\",\"application\":{\"name\":\"Web\",\"website\":null},\"account\":{...
  • これを parse() して Stream を作成し transform して出力
const stream = new require("stream").Transform({
    objectMode: true,
    transform: function(chunk, encoding, callback) { ... }
});

new (require("ws"))("ws://friends.nico/api/v1/streaming/?access_token=...&stream=public")
    .on("message", (data, flags) => stream.write(JSON.parse(data)));

stream.pipe(process.stdout);


  • Stream は TCP Socket に pipe() でつなぐことができる
    • 標準入力の Stream を pipe() して、ポート 12345 に接続すると ping の結果が流れてくるサンプル
$ ping localhost | node -e 'process.stdin.resume();require("net").createServer(socket => process.stdin.pipe(socket)).listen(12345);'
$ telnet localhost 12345
Trying ::1...
Connected to localhost.
Escape character is '^]'.
64 bytes from 127.0.0.1: icmp_seq=16 ttl=64 time=0.065 ms
64 bytes from 127.0.0.1: icmp_seq=17 ttl=64 time=0.056 ms
64 bytes from 127.0.0.1: icmp_seq=18 ttl=64 time=0.095 ms
64 bytes from 127.0.0.1: icmp_seq=19 ttl=64 time=0.049 ms
64 bytes from 127.0.0.1: icmp_seq=20 ttl=64 time=0.054 ms
^]
telnet> Connection closed.
  • pipe() によって、何回でも Stream の分岐や合流が可能
    • クライアントが複数接続しても、それぞれに同じデータを送ることができる
    • resume() しておくことで pipe() されているものが何もない時のデータは捨てる
  • これを利用して、同様にタイムラインが流れるようにした
  • ここまでは順調だったが、あるクライアントが応答しなくなると、他のクライアントへの送信が止まる問題が発覚
  • クライアントがデータを受け取らなくなり Socket の Stream のバッファがいっぱいになると、上流の Stream が pause() されるらしい
    • pause() されそうになったら、下流を unpipe() することで解決
  • その他
    • Stream を加工したり、フィルタするツール
    • 連合以外の、ローカルタイムラインやホームタイムラインも指定
    • access_token の取得
    • たまに WebSocket が切れるので再接続
    • 簡単なコマンドラインインターフェース
  • telnet か nc でタイムラインを受信する
    • friends.nico の連合タイムライン
$ telnet tootcat.0j0.jp
$ nc tootcat.0j0.jp 7007
    • friends.nico の連合タイムラインから friends.nico のみを抽出(≒ローカルタイムライン)
$ telnet tootcat.0j0.jp 7008
    • friends.nico の連合タイムラインから mstdn.jp のみを抽出
$ telnet tootcat.0j0.jp 7009
    • friends.nico の連合タイムラインから pawoo.net のみを抽出
$ telnet tootcat.0j0.jp 7010
  • ひとつの連合タイムラインを分岐しているので、サーバにブラウザ以上の負荷はかけない
  • 連合タイムラインは all public posts from all users "known" to your instance であるため、インスタンスごとにフィルターしてもローカルタイムラインと等しくならない
    • friends.nico のユーザにフォローされたユーザ、ブーストされたトゥートだけが現れる
  • テキストデータなので MVNO の速度制限 (200kbps) がかかった状態でも、今のところストレスがない
  • telnet が動くデバイスであれば利用可能

  • 未加工の JSON を受信できるようにした
$ nc tootcat.0j0.jp 7006 | head -c 1000
{"id":6021698,"created_at":"2017-05-06T07:18:52.994Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","application":{"name":"Web","website":null},"account":{"id":0,"username":"username","acct":"acct","display_name":"display_name","locked":false,"created_at":"2017-04-21T12:07:59.324Z","followers_count":0,"following_count":0,"statuses_count":1,"note":"note","url":"https://friends.nico/url","avatar":"avatar","avatar_static":"avatar_static","header":"header","header_static":"header_static","nico_url":"nico_url"},"media_attachments":[],"mentions":[],"tags":[],"uri":"tag:friends.nico,2017-05-06:objectId=6021698:objectType=Status","content":"content1\r\ncontent2","url":"url","reblogs_count":0,"favourites_count":0,"reblog":null}{...
  • jq などで、フィルタや加工できる
$ nc tootcat.0j0.jp 7006 | docker run -i --rm -e "TZ=Asia/Tokyo" asannou/jq --unbuffered -r '.prefix = (.created_at | sub(".[0-9]*Z";"Z") | fromdate | strflocaltime("%H:%M")) | .prefix += " (" + .account.username + ") " | .prefix as $prefix | select(.uri | test("^tag:friends.nico,")) | .content | split("\r\n") | join("\r\n" + $prefix) | $prefix + .'
16:18 (username) content1
16:18 (username) content2
    • IRC
    • strflocaltime がまだ development version のみの機能だったので asannou/jq を作った
    • --unbuffered で滞りなく出力される