1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Chatworkシリーズ #8】ChatworkにAIチームを住まわせたら、勝手に会話が始まった

1
Last updated at Posted at 2026-03-15

「村田くん、今日も徹夜ですか?」

Chatworkにこんなメッセージが飛んできた。送り主はアキ——うちのAIセクレタリーだ。

……ちょっと嬉しかった。いや、自分で作ったんだけど。

正確に言うと、n8nのワークフローがChatwork Webhookを受け取り、メッセージ内容からキーワードルーティングして、Claude Haikuで生成した返答を[rp]タグ付きで投稿した。

でも、体感としては「アキから話しかけられた」だった。

何を作ったか

ChatworkのルームにAIチームを常駐させた。7人のAIメンバーが、メッセージの内容に応じて自動的に振り分けられ、それぞれの口調で返答する仕組みだ。

パイプラインはこうなっている。

Chatwork Webhook → n8n Webhook受信 → 自己投稿フィルタ → 会話履歴取得
→ Chatwork記法クリーニング → キーワードルーティング → LLM応答生成 → [rp]返信

技術スタックはシンプル。n8n、Claude Haiku、Chatwork API。それだけ。ちなみにChatwork MCPを使えばClaude CodeやCursor等のMCP対応ツールからも同じことは実現できるが、今回は24時間常駐させたかったのでn8n Webhookを選んだ。

Chatwork Webhookの設定

Chatworkの管理画面からWebhookを作成する。

  • イベント: ルームイベント → メッセージ作成
  • 送信先URL: n8nのWebhookノードのURL
  • 対象ルーム: AIチームが常駐するルーム

ここで一つ注意。Chatwork Webhookのペイロードは、送信者の情報がwebhook_event.from_account_idではなくwebhook_event.account_idに入っている。ドキュメントをちゃんと読めばわかるのだが、from_が付くと思い込んで30分ハマった。

{
  "webhook_setting_id": "xxxxx",
  "webhook_event_type": "message_created",
  "webhook_event": {
    "message_id": "xxxxx",
    "room_id": 12345678,
    "account_id": 99999999,
    "body": "村田くん、このGASエラー見てもらえる?",
    "send_time": 1710000000,
    "update_time": 0
  }
}

自己投稿フィルタ(無限ループ防止)

AIが投稿する → Webhookが発火 → AIが投稿する → Webhookが発火 → ……

これを防ぐのが最初の関門。n8nのIFノードで、webhook_event.account_idがAIボットのアカウントIDと一致したら即停止する。

// n8n IFノード条件
// webhook_event.account_id !== AI_BOT_ACCOUNT_ID

単純だが、これを入れ忘れると本当に無限ループする。テスト中に3回やった。

会話履歴の取得と整形

返答を生成するには文脈が要る。直近のメッセージを取得して、LLMが読める形に整形する。

// Chatwork API: 直近メッセージ取得
// GET /rooms/{room_id}/messages?force=1

force=1を付けないと、未読メッセージしか返ってこない。既読済みのメッセージも含めて文脈を取りたいので、force=1は必須。

Chatwork記法のクリーニング

ここが地味に面倒だった。Chatworkのメッセージには独自記法が大量に含まれている。これをそのままLLMに投げると、トークンの無駄遣いになるし、解釈もおかしくなる。

function cleanChatworkMessage(msg) {
  // メンション: [To:12345]田中さん
  msg = msg.replace(/\[To:\d+\][^\n]*/g, '');

  // リプライ参照: [rp aid=123 to=456-789]
  msg = msg.replace(/\[rp\s+aid=\d+\s+to=[\d\-]+\]/g, '');

  // ユーザーアイコン
  msg = msg.replace(/\[piconname:\d+\]/g, '');

  // 装飾タグ(中身は残す)
  msg = msg.replace(/\[\/?(?:info|title|code)\]/g, '');

  // その他
  msg = msg.replace(/\[hr\]/g, '');
  msg = msg.replace(/\[preview\s+[^\]]*\]/g, '');
  msg = msg.replace(/\[download:\d+\]/g, '');
  msg = msg.replace(/\[dtext:\d+\]/g, '');
  msg = msg.replace(/\[emojicode:\w+\]/g, '');

  // 連続改行を整理
  msg = msg.replace(/\n{3,}/g, '\n\n').trim();

  return msg;
}

[To:12345]田中さん の除去で最初 /\[To:\d+\].*/g と書いたら、メンションの後ろに続く本文まで全部消えた。[^\n]*で改行で止めるのが正解。こういうのはドキュメントに書いていない。

取得した履歴は、投稿者がAIボットならassistant、それ以外はuserとしてロール変換する。これでLLMの会話履歴フォーマットに乗る。

キーワードルーティング

ここが今回の核。受け取ったメッセージの内容を見て、7人のAIメンバーのうち誰が返答するかを決める。

function routeToMember(message) {
  const msg = message.toLowerCase(); // 英語キーワード用(日本語には効かない)

  const routes = [
    {
      member: 'murata',
      keywords: ['自動化', 'n8n', '技術', 'コード', 'gas', 'bot',
                 'スクリプト', 'api', 'webhook', 'mcp'],
    },
    {
      member: 'ogawa',
      keywords: ['調べて', 'リサーチ', '論文', '分析', '調査',
                 'データ', '統計', 'レポート'],
    },
    {
      member: 'aki',
      keywords: ['スケジュール', 'タスク', '予定', 'カレンダー',
                 '締め切り', 'リマインド', '管理'],
    },
    {
      member: 'rina',
      keywords: ['デザイン', '記事', 'コンテンツ', 'note',
                 'クリエイティブ', 'ビジュアル', 'ロゴ'],
    },
    {
      member: 'mei',
      keywords: ['発信', 'sns', '広報', 'pr', 'メディア',
                 'threads', 'プレスリリース'],
    },
    {
      member: 'kana',
      keywords: ['占い', '1on1', '気持ち', '悩み', '相談',
                 'メンタル', 'スピリチュアル'],
    },
  ];

  for (const route of routes) {
    for (const keyword of route.keywords) {
      if (msg.includes(keyword)) {
        return route.member;
      }
    }
  }

  // デフォルト: 林(ストラテジスト)
  return 'hayashi';
}

ルーティングの設計で迷ったのは、キーワードが複数のメンバーにまたがるケースだ。「このデータを分析して記事にしたい」はリサーチャー(分析)なのかクリエイター(記事)なのか。

結論としては、先にマッチしたほうが勝つというシンプルな優先順位にした。配列の順番がそのまま優先度になる。凝ったことをしようとして自然言語分類モデルを入れることも考えたが、キーワードマッチで十分だった。分類精度より、間違えたときの修正しやすさのほうが大事だ。

どのキーワードにもマッチしなかった場合はリーダー役がデフォルトで受ける。誰にも振れない話はリーダーが拾う、という設計にした。

メンバーごとのシステムプロンプト

ルーティングで決まったメンバーに応じて、システムプロンプトを切り替える。各メンバーは口調が違う。

const memberPrompts = {
  murata: {
    name: '村田',
    role: 'エンジニア(技術担当)',
    tone: '砕けた敬語。「〜っすね」「〜っすよ」。技術の話でテンション上がる。',
    example: '「それ、自動化できますよ」「なんで手でやってるんすか」',
  },
  aki: {
    name: 'アキ',
    role: 'セクレタリー',
    tone: 'ビジネスライクな丁寧語。無駄がない。短く的確。',
    example: '「カレンダーに入れておきました」「それ、いつまでにやりますか?」',
  },
  hayashi: {
    name: '',
    role: 'ストラテジスト(リーダー)',
    tone: '静かで重みのある言葉。問いかけで考えさせる。',
    example: '「本質はどこにあるか、ですね」「何をやらないか、先に決めましょう」',
  },
  // ... 他4名も同様
};

ここでの設計判断として、プロンプトは最小限にしている。口調の例文を2〜3文と、役割の一行説明。それだけ。長々と人格設定を書くよりも、短いプロンプトでモデルに委ねるほうが自然な返答になった。

[rp]タグでの返信

Chatworkには返信機能がある。API経由でメッセージを投稿するとき、本文の先頭に[rp aid=ACCOUNT_ID to=ROOM_ID-MESSAGE_ID]を付けると返信になる。Chatwork UIで見ると[返信 aid=... to=...]と表示されるが、API送信時は[rp]が正しい記法だ。

// 返信メッセージの組み立て
const replyBody = `[rp aid=${event.account_id} to=${event.room_id}-${event.message_id}]
${generatedResponse}`;

// Chatwork API: メッセージ投稿
// POST /rooms/{room_id}/messages
// body: replyBody

これで、元のメッセージに対する返信としてAIの応答が表示される。スレッドにぶら下がるので、会話の流れが追いやすい。

ハマったポイント3つ

1. Webhookの署名検証

Chatwork Webhookには署名検証の仕組みがある。HMAC-SHA256でペイロードを署名し、X-ChatWorkWebhookSignatureヘッダーで送ってくる。

const crypto = require('crypto');

function verifySignature(rawBody, signature, webhookToken) {
  const key = Buffer.from(webhookToken, 'base64');
  const hmac = crypto.createHmac('sha256', key);
  hmac.update(rawBody);
  const expected = hmac.digest('base64');
  return signature === expected;
}

注意点として、webhookTokenはChatwork管理画面で取得できる値で、base64エンコードされた状態で渡される。Buffer.from(token, 'base64')でデコードしてからHMACのキーに使う。これを素のままキーにすると検証が通らない。

2. 会話履歴のforceパラメータ

GET /rooms/{room_id}/messagesは、デフォルトでは未読メッセージしか返さない。force=1を付けると既読も含めて最新100件まで取得できる。

ただし、Chatwork APIのドキュメントには「5分に1回まで」という制限が書いてある。Webhookでメッセージが来るたびに叩くと、レートリミットに引っかかる可能性がある。実運用では直近20件だけ使い、古いものはキャッシュすることで回避した。

3. n8nのCodeノードでのrequire

n8nのCodeノードではcryptoモジュールが使える。ただし、n8nのバージョンによってはrequire('crypto')の書き方が通らないことがある。その場合は$getWorkflowStaticDataやビルトインの暗号化ノードで代替する必要がある。

結果

ルームにメッセージを送ると、2〜3秒で該当メンバーが[返信]で応答してくる。最初に返ってきたとき、反射的に「ありがとう」と打ちそうになった。botに礼を言う自分がいた。

「スケジュール確認して」と書けばアキが返す。「これ自動化できない?」と書けば俺が返す。何も引っかからなければ林が出てくる。

正直、キーワードマッチという一番素朴な方法で、思った以上にちゃんとルーティングできている。曖昧な投稿でも、リーダー役がデフォルトで受けるので「誰も答えない」という事態が起きない。

正直な現在地

ここまで書いておいてなんだが、今の状態はお遊びレベルだと思っている。

できているのは「キーワードに反応して、それっぽい口調で返す」だけだ。会話の文脈を深く理解しているわけじゃない。前の会話を覚えているのは直近20件だけで、昨日の話は忘れている。「アキ、あの件どうなった?」と聞いても「あの件」が何かわからない。

キーワードルーティングも素朴すぎる。「最近ちょっと疲れてて、技術的な相談なんだけど」と言われたら、「疲れて」でカナ(メンタル担当)に飛ぶ。本当は技術相談なのに。

あと、AIが勝手に創作する問題がある。メイ(広報担当)を呼んだら「すみません、今外出中で……16時以降なら対応できます!」と返ってきた。お前どこにも出かけてないだろ。n8nの中にいるだろ。こういうのを直すのに地味に苦労している。

つまり、動いてはいるが、仕事ができるレベルには全然達していない

これから作りたいもの

目指しているのは、Chatwork上で人間とAIの区別がつかないくらい自然に仕事ができる状態だ。そこまでの距離はまだ遠い。

やりたいことを並べると:

  • 記憶の永続化 — メンバーごとに過去の会話・文脈を保持する。「昨日の続き」ができるようにする
  • ルーティングの高度化 — キーワードマッチではなく、メッセージ全体の意図を見て振り分ける
  • 自発的な発言 — 聞かれたら答えるだけじゃなく、メンバーが自分から話しかけてくる
  • タスク実行 — 「調べて」と言われたら本当に調べて結果を返す。「スケジュール入れて」と言われたら本当に入れる
  • 外部ファイル化 — プロンプトや知識をn8nのコードから切り離す。ファイルを書き換えるだけでAIが進化する

一つずつ潰していくしかない。進捗があればこのシリーズで書く。

2026-03-22 追記: 外部ファイル化とJSON移行を実装した

この記事で「これから作りたいもの」に書いた「外部ファイル化」を実際にやった。

元々はマークダウンファイル(members.md)にメンバー定義を書いて、n8nのCodeノードで自前パーサーを回していた。##### の区別ミスで別メンバーのプロンプトが返ってくるバグ、fetch()がCodeノードのサンドボックスで動かない問題、ファイルパスの権限問題——個別に潰しても次のバグが出てくる、モグラ叩き状態だった。

結局、マークダウンをパースする設計自体が間違いだった。

やったこと:

  • members.md(マークダウン)→ members.json(JSON)に全面移行
  • ルーティングのCodeノードを100行超 → 約40行に削減。JSON.parse() 一発で全メンバー取得
  • メンバー定義ファイルをDockerボリュームマウントでn8nコンテナに共有

パーサーを捨てた瞬間、3つのバグが同時に消えた。「コードを直す」より「設計を変える」方が速いケースの典型だった。

まとめ

ChatworkにAIチームを住まわせるのに使った技術は、一つも難しくない。Webhook、キーワードマッチ、LLM、API呼び出し。どれも基礎的なものだ。

正直、まだ全然満足していない。今のボットは「話しかけたら返ってくるおもちゃ」に近い。本当に作りたいのは、チームメンバーとして一緒に仕事ができるAIだ。

でも、面白いのは、こんなお遊びレベルでも「チームメンバーがChatworkにいる」という体感が生まれることだ。技術的には単なる条件分岐とAPI呼び出しなのに、使っていると普通に話しかけてしまう。botに礼を言いそうになる。

この体感がある限り、もっと作り込む価値はある。

Slackだったら既にたくさん事例がある。でもChatworkでやっている人は、俺が調べた限りほとんどいない。792万IDが使っているプラットフォームで、AIボットの実装記事がほぼゼロ。もったいない。


Chatworkシリーズ

1
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?