読者です 読者をやめる 読者になる 読者になる

notebook

都内でWEB系エンジニアやってます。

botkitでサーバに対して任意のコマンドを実行できるようにする

javascript bot

出先でサーバに対してコマンド打てないのがストレスだったのでslackから本番サーバにコマンド打てるようにしました

今回はbotkitを使っています

hubotは有名ですが今更cofeescriptを覚える気にならなかったので他のものを探していたところちょうど手頃な感じのbotkitを見つけたので使ってみました

事前にslackのintegrationページでbotを作成しtokenを用意します

要件

  • 任意のコマンドが実行できる
    • ボットが起動しているサーバから各サーバにSSHが出来る前提
  • 特定ユーザのみにコマンドの実行権限を与える

install

npmでインストールします。簡単

npm install --save botkit

スクリプトの用意

適当なサンプルを取ってきて用意します

const Botkit = require('botkit');

if (!process.env.SLACK_TOKEN) {
  console.log('Error: Specify SLACK_TOKEN in environment');
  process.exit(1);
}

controller = Botkit.slackbot({
  ¦ debug: false,
});

controller.spawn({
    token: process.env.SLACK_TOKEN
}).startRTM(function(err){
    if (err) {
        throw new Error(err);
    }
});

controller.hears('hi', ['direct_message','direct_mention','mention'],(bot,message) => {
  bot.reply(message,"hi!");
});

起動

事前に用意したtokenをここで使います

SLACK_TOKEN=${api_token} node bot.js

簡単ですね

特定の発言に反応させる

基本はhearsで発言を拾ってきて処理をする

返答はreplyで行います

上記のサンプルをコードの最後の3行のように実装することでメンションかダイレクトメッセージ+hiという文言に反応してリプライしてくれるようになります

f:id:swfz:20161222020439p:plain

正規表現によるマッチ

特定文言以外にも正規表現を使ってマッチしたものを使うことが出来ます

controller.hears('hi Im (.*)',['direct_message','direct_mention','mention'], (bot,message) => {
  bot.reply(message,`hi! ${message.match[1]}.`);
});

hi Imが先頭にあってメンションかダイレクトメッセージを受けているときに反応します

正規表現でマッチしてる箇所は名前を想定

f:id:swfz:20161222020456p:plain

コマンド実行

nodeのExecでshellコマンドを実行できます

これと組み合わせることでサーバに任意のコードを実行できるようになります

Execの引数にsshなどを使えばssh可能なサーバ全てに対してコマンドが実行できそうですね

const Exec = require('child_process').exec;
controller.hears('cmd (.*)',['direct_message','direct_mention','mention'],(bot,message) => {
  Exec(message.match[1], (err,stdout,stderr) => {
    if ( err ) {
      bot.reply(message,`Error!!!\n${err}`);
    }
    else {
      bot.reply(message,`\`\`\`${stdout}\`\`\``);
    }
  });
});

f:id:swfz:20161222020512p:plain

  • sshしてみた

f:id:swfz:20161222020529p:plain

データの保存

botkitにはユーザー、チャンネル、チームなどの単位でデータを保存することができます

データの保存先はredisなどのデータストアを指定することもできますが今回は一番簡なファイルに保存します

controllerを宣言する際にオプションを指定してあげるだけで簡単なデータを扱えるようになります

controller = Botkit.slackbot({
    debug: false,
    json_file_store: './storage'
});

保存するときはidをプロパティにもつハッシュ(他は自由)を渡してあげるだけです

idにはmessage.userで取得した発言者のユーザーIDを使っています

ファイルもユーザーIDをベースに作られるようです

controller.hears('my name is (.*)',['direct_message','direct_mention','mention'], (bot,message) => {
  controller.storage.users.save({id: message.user, name: message.match[1], role: 'staff' }, (err)=>{controller.log(err)});
  bot.reply(message,`hi! ${message.match[1]}. user_id: ${message.user}`);
});
cat storage/users/Uxxxxxxx.json
{"id":"Uxxxxxxx","name":"hoge","role":"staff"}

getの他にもsave,allメソッドもあるので簡単な操作であればサクッと実装できますね

詳しい使い方などは下記エントリから参照できます

  • 参考

さすがBotkit……!Slack botにデータを持たせる方法 - Storage -

toach.click

ユーザー管理

コマンド実行はすることができるようになりました。だたこのままだと誰でもサーバに対してコマンドを実行できてしまうので危険ですね

そこで簡単ではありますがユーザーごとに権限を持たせてボットに話しかけるユーザーによってコマンドを実行できるか判定して制御するようにします

発言者のユーザーIDからデータを取得しコマンド実行時はユーザーのroleがadminでないと実行できないようにしました(roleは独自に設定した項目です)

const Exec = require('child_process').exec;
controller.hears('cmd (.*)',['direct_message','direct_mention','mention'],(bot,message) => {
  controller.storage.users.get(message.user,(err,data) => {
    if (err){console.log(err);return}
    else if ( !data.role || data.role != 'admin' ) {
      bot.reply(message,'permission denied.');
      return;
    }
    Exec(message.match[1], (err,stdout,stderr) => {
      controller.log(`${message.user} : ${message.match[1]} \n STDERR: ${err} \n STDOUT: ${stdout}`);

      if ( err ) {
        bot.reply(message,`Error!!!\n${err}`);
      }
      else {
        bot.reply(message,`\`\`\`${stdout}\`\`\``);
      }
    });
  });
});

ファイルの分割

ここまででも良かったのですが、コマンドが増えてきたときに対応するためにscript以下のjsファイルを自動で読み込めるようにします

ほぼ下記のエントリーをそのまま使わせていただきました

BotkitでHubotみたいにscriptsを読み込む - Qiita

qiita.com

  • app.js
const Fs = require('fs');
const Path = require('path');

// load commands in scripts/*
const load = (path, file) => {
  const ext  = Path.extname(file);
  const full = Path.join(path, Path.basename(file, ext));

  try {
    const script = require(full);
    if (typeof script === 'function') {
      script(this);
    }
  } catch(error) {
    console.log(error);
    process.exit(1);
  }
};

const path = Path.resolve('.', 'scripts')

loadFiles = Fs.readdirSync(path).filter((file)=>{return !file.match(/\..*.swp/)})
loadFiles.sort().forEach((file) =>{
  load(path, file);
});
  • script/useradd.js
controller.hears('my name is (.*)',['direct_message','direct_mention','mention'], (bot,message) => {
  controller.storage.users.get(message.user, (err,data) => {
    if (err) {
        controller.storage.users.save({id: message.user, name: message.match[1], role: 'staff' }, (err)=>{controller.log(err)});
        bot.reply(message,`hi! ${message.match[1]}. user_id: ${message.user}`);
    }
    else {
      if ( data ) {
        controller.storage.users.save({id: message.user, name: message.match[1], role: data.role }, (err)=>{controller.log(err)});
        bot.reply(message,`hi! ${message.match[1]}. user_id: ${data.id}`);
      }
    }
  });
});

まとめ

最終的にできたのが下記

swfz/slackbot: slackbot

github.com

tokenさえ用意すれば簡単に実行できると思います

おまけとしてdocker-composeでの起動と、supervisorによるデーモンまで構築できるansibleのplaybookも作っておきました

構築にあたってはsupervisor3の実行ユーザだったり、環境変数だったりでちょっとつまづいたくらいでかなり簡単に実装することができました

これでslackからでもコマンドを打つことが出来るのでPCもってない時でもある程度対応できそうですね

だたコマンドを実行でき危険なので使い道は社内などの限られたチーム内になりそうですが...

botkit自体はslack特化のようですが簡単なボットであれば十分な気がします

これから色々機能を追加していく予定