ワークフロー / JevModel

Slackメッセージ分類:Events APIとJevでチャンネルのメッセージを振り分ける

小さなSlackアプリでヘルプやフィードバック用チャンネルのメッセージを分類する方法。message.channelsイベントを受け取り、署名を検証し、3秒以内に応答してから、Jevに固定のChoiceと緊急度のNoulを尋ね、リアクション・スレッド返信・転送を行います。再送、重複排除、プライバシー、ノーコードで足りる場面も解説。

最終更新:

要点:先に応答し、分類は後で

  • message.channelsボットイベント(スコープchannels:history)を購読したSlackアプリは、ボットが参加している公開チャンネルのメッセージ1件ごとにHTTP POSTを1回受け取ります。署名シークレットでX-Slack-Signatureヘッダーを検証し、3秒以内にHTTP 200を返します。遅い応答は失敗とみなされ、最大3回再送されます。
  • 応答後、メッセージ本文だけをJevModelの/api/v1/systemoneに送り、自分で定義した2〜12個のラベルのChoice(例:bug、access、how_to、other)と「今ブロックされているか」のNoulを尋ねます。返ってきた確率でリアクション、スレッド返信、転送を決め、不確かなものは人に回します。
  • event_idで重複を除き、ボットのメッセージや編集は無視し、ラベルだけを理由にメッセージを削除しないでください。Zapier、Make、n8n、Power Automate、Pipedreamを使っているなら、それらのSlackトリガーからサーバーなしで同じJev APIを呼べます。

Slackメッセージ分類とは、実際には何をすることか

多くのチームには、種類の違う依頼がたまるチャンネルが少なくとも1つあります。#help、#it-support、#customer-feedback、#sales-questionsなど。分類とは、新しいメッセージを読み、短い固定リストからラベルを1つ付けて、誰かが一日中見張らなくても適切な人に届くようにすることです。ラベルは目に見える動作につながって初めて役立ちます。担当者が絞り込みに使う絵文字リアクション、不足情報を尋ねるスレッド返信、チームチャンネルへの転送、自社システムのチケットなどです。カテゴリと同じくらい大事なのが、もう1つの独立した質問「今、誰かがブロックされているか」です。この2つの判断は分けておきます。優先度の低い使い方の質問もあれば、緊急のアクセス問題もあり、緊急度をカテゴリ一覧に混ぜると両方の答えが悪くなります。本ガイドはボットが参加している公開チャンネルを対象とします。DMや非公開チャンネルは別のスコープと、より強いプライバシー上の理由が必要なので扱いません。

Jev で問い合わせを振り分けるプロダクト/サポート文のインテント分類:ルール、LLM、型付きAPIChoice, Score, and Noul

Slackアプリはどうやってチャンネルのメッセージを受け取るか

Events APIを使います。アプリ設定でEvent Subscriptionsをオンにし、配信方法を選びます。SlackがPOSTするHTTPのRequest URLか、プロセス側からWebSocketを開くので公開URLが不要なSocket Modeです。ボットイベントでmessage.channelsを購読します。これにはchannels:historyスコープが必要で、届くのはボットがメンバーになっている公開チャンネルのメッセージだけなので、分類したいチャンネルごとに/inviteで招待してください。各イベントはJSONで届き、外側の封筒(type event_callback、team_id、全体で一意のevent_id)と、type message、channel、user、text、ts、channel_type channelを持つ内側のイベントで構成されます。ボットへのメンションだけを扱うならapp_mentionを購読します。HTTP配信ではSlackがすべてのリクエストに署名します。署名シークレットで文字列v0:{X-Slack-Request-Timestamp}:{生のボディ}のHMAC-SHA256の16進ダイジェストを計算し、先頭にv0=を付け、定数時間比較でX-Slack-Signatureと照合し、時計と5分以上ずれたタイムスタンプはリプレイ対策として拒否します。Bolt(JavaScript、Python、Java)はこれを自動で行います。新しいRequest URLへの最初のリクエストはurl_verificationチャレンジで、challengeの値をそのまま返せば完了です。

JevModel API:リクエストと応答Private runs and history

なぜ3秒以内にSlackへ応答する必要があるのか

SlackのEvents APIドキュメントは3秒以内にHTTP 2xxを返すよう求めています。そうでなければ配信は失敗扱いとなり、ほぼ即時、1分後、5分後の3回再送され、x-slack-retry-numヘッダー(1、2、3)とhttp_timeoutなどのx-slack-retry-reasonが付きます。60分以内に配信の95%超が失敗すると、Slackはイベント購読を一時的に無効化することがあります(1時間あたり1,000イベント未満のアプリは自動無効化されません)。分類の呼び出しは別サービスへのネットワークリクエストなので、Slackを待たせてはいけません。Slack自身が勧めるとおり、先に応答し、キューやバックグラウンド処理で分類します。さらに3つのルールで二重処理とループを防ぎます。1つ目、処理済みのevent_idを記録して重複を捨てる。再送は同じevent_idで届き、JevModelのAPIには冪等キーがないため、重複を除かないと同じメッセージが2回分類・課金されます。2つ目、bot_idやsubtype(編集、参加通知、自分のボットの返信)を持つメッセージは無視する。そうしないとボットが自分のスレッド返信を分類します。3つ目、量を見積もる。Slackはアプリ・ワークスペースごとに60分あたり30,000イベントまで配信し、分類したメッセージ1件がトークン残高から引かれる通常のJevリクエスト1回です。

JevModel の料金と Jev 無料枠JevModel API:リクエストと応答

比較:ノーコードのSlackトリガー vs 自作Slackアプリ vs CSV書き出し

3つとも同じJevの質問を送れます。違うのは配管を誰が動かすかです。下表はワークフローの形の比較で精度の比較ではなく、どの行もベンチマークではありません。

Zapierテキスト分類:AI by Zapier Classify vs Vertex Classify Text vs HTTP → Jevn8n Text Classifierノード:組み込み分類 vs ワークフローからJevを呼ぶJevModel API によるバッチテキスト分類
比較:ノーコードのSlackトリガー vs 自作Slackアプリ vs CSV書き出し
観点ノーコードツール(Zapier、Make、n8n…)自作Slackアプリ → Events API → Jevチャンネル書き出し → Console → Batch
ホスティングツールのSlackトリガー;サーバー不要自前のエンドポイントかSocket Modeのプロセスブラウザだけ
タイミングツールのポーリング/Webhookの方式次第3秒の応答後、ほぼリアルタイム事後の分析向け
再送と重複排除ツールが処理;実行履歴で確認自分で:event_idを保存し、bot_idとsubtypeを除外該当なし
Jevの出力同じChoice/Noulの回答と確率同上、リアクションと返信も自由に制御書き出しに行ごとの回答(購入前は1バッチ50行、購入後1,000行)

コード例:検証、応答、分類、リアクション(説明用)

スケッチは各手順が見えるよう素のNode 18+のHTTPハンドラーにしています。署名検証と応答はBoltに任せることもできます。ボットにchannels:historyとreactions:writeのスコープを与え、message.channelsを購読し、ボットをチャンネルに招待します。ハンドラーは生のボディで署名を検証し、url_verificationに答え、すぐに200を返してからJevを呼びます。リクエストはJevModelのドキュメントどおりです。認証済みアカウントのBearerキーでPOST /api/v1/systemoneし、stateと名前付きの質問を送ります。ここでは依頼の種類を問うChoiceと緊急度を問うNoulです。成功した応答はcode 0でdata.answersを含みます。本サイトは常にOpenRouter経由でtypesafe/jev-1.13を呼ぶため、modelフィールドはありません。JevModelはJevを開発するTypeSafe AIとは独立しています。確率の低い回答にはeyesリアクションを付けて人が確認し、0以外のcodeならメッセージには触れません。メモリ上のSetはスケッチ用です。複数プロセスなら共有ストアを、応答送信後に処理が止まる基盤なら本物のキューを使ってください。

JevModel API:リクエストと応答Choice, Score, and NoulJev probability, confidence and human-review thresholds
// Illustrative only. Node 18+. Env: SLACK_SIGNING_SECRET, SLACK_BOT_TOKEN, JEVMODEL_API_KEY.
// Bot scopes: channels:history (for message.channels) and reactions:write.
import crypto from "node:crypto";
import http from "node:http";

const handled = new Set(); // event_id values; use a shared store in production

function fromSlack(req, raw) {
  const ts = Number(req.headers["x-slack-request-timestamp"]);
  if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return false; // replay window
  const mine = "v0=" + crypto
    .createHmac("sha256", process.env.SLACK_SIGNING_SECRET)
    .update("v0:" + ts + ":" + raw)
    .digest("hex");
  const theirs = String(req.headers["x-slack-signature"] || "");
  return mine.length === theirs.length &&
    crypto.timingSafeEqual(Buffer.from(mine), Buffer.from(theirs));
}

http.createServer((req, res) => {
  let raw = "";
  req.on("data", (chunk) => (raw += chunk));
  req.on("end", () => {
    if (!fromSlack(req, raw)) return res.writeHead(401).end();
    const body = JSON.parse(raw);
    if (body.type === "url_verification") return res.end(body.challenge);
    res.writeHead(200).end(); // acknowledge within 3 seconds, work afterwards
    const e = body.event;
    if (!e || e.type !== "message" || e.subtype || e.bot_id) return; // skip edits, joins, bots
    if (handled.has(body.event_id)) return; // Slack retries reuse the event_id
    handled.add(body.event_id);
    classify(e).catch(console.error);
  });
}).listen(3000);

async function classify(e) {
  const res = await fetch("https://jevmodel.app/api/v1/systemone", {
    method: "POST",
    headers: {
      Authorization: "Bearer " + process.env.JEVMODEL_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      state: { channel: "help", text: e.text }, // text only, no user profile
      questions: {
        kind: {
          type: "choice",
          instructions: "What kind of request is this help-channel message?",
          criteria: {
            bug: "Something is broken, slow, or returns an error.",
            access: "Asking for an account, a permission, or access to a tool.",
            how_to: "Asking how to do something that already works.",
            other: "Chat, thanks, or anything that fits none of the labels above.",
          },
        },
        urgent: {
          type: "noul",
          instructions: "Is someone blocked right now by what this message describes?",
          criteria: {
            true: "A person or customer cannot work now, or a service is down.",
            false: "It can wait for normal working hours.",
          },
        },
      },
    }),
  });
  const out = await res.json();
  if (out.code !== 0) return; // leave the message unlabeled
  const { kind, urgent } = out.data.answers;
  const p = kind.probabilities ? kind.probabilities[kind.choice] : 0;
  const emoji = { bug: "bug", access: "key", how_to: "question" };
  const names = [p < 0.7 ? "eyes" : emoji[kind.choice]]; // 0.7 is a placeholder
  if (urgent.noul >= 0.8) names.push("rotating_light"); // placeholder threshold
  for (const name of names.filter(Boolean)) {
    await fetch("https://slack.com/api/reactions.add", {
      method: "POST",
      headers: {
        Authorization: "Bearer " + process.env.SLACK_BOT_TOKEN,
        "Content-Type": "application/json; charset=utf-8",
      },
      body: JSON.stringify({ channel: e.channel, timestamp: e.ts, name }),
    });
  }
}

ラベルが付いた後、何をすべきか

取り消せて目に見える動作を優先します。最も軽いのはリアクションで、担当者は絵文字で絞り込め、誤りは誰でも外せます。how_toやbugのラベルに対してスクリーンショットや注文番号を尋ねるスレッド返信は便利ですが、忙しいチャンネルでの誤った自動返信は騒がしいので、検証済みのしきい値を超えたときだけ投稿します。ラベルが信頼できるようになれば、チームチャンネルへのリンク転送や自社システムでのチケット作成も問題ありません。ラベルを理由にメッセージを削除・非表示・移動せず、誤り率を確認するまではNoulだけで当番を呼び出さないでください。送るのは判断に必要なものだけ。通常はメッセージ本文で足り、ユーザー名、メール、プロフィール項目は除きます。有効化する前に、チャンネルの過去メッセージを数百件書き出して人手でラベルを付け、同じ質問を実行してラベルごとに比べ、そのサンプルからしきい値を決めます。例の0.7と0.8は仮の値で推奨値ではありません。otherとeyesリアクションの割合を見て、高ければラベル定義を見直します。

Evaluate a Jev workflowJev probability, confidence and human-review thresholdsPrivate runs and history

どの構成を選ぶべきか

ほかの自動化がすでにノーコードツールで動いていて、1分程度の遅れが許容できるならそれを選びます。Zapier、Make、n8n、Power Automate、Pipedreamの各ガイドで同じJevリクエストを紹介しています。ほぼリアルタイムのリアクション、再送や返信の完全な制御、チャンネルごとに異なるラベルセットが必要なら自作のSlackアプリを。ライブの振り分けではなく、チャンネルが何に使われていたかを一度だけ分析したいなら、チャンネルを書き出してConsole → Batchへ。どれを選んでも確認すること:1)明確なotherを含むラベル定義を書く、2)緊急度はラベルにせず別のNoulにする、3)event_idで重複を除きボットのメッセージを無視する、4)プロフィールではなく本文を送る、5)まずはリアクションだけにし、ラベル付きサンプルで一致率を測ってから返信やチケットを加える。JevModelはテキスト専用でオープンソースではありません。Slack上の画像、ファイル、音声はこの構成では分類されません。1件のメッセージが複数カテゴリに同時に当てはまりうるなら、Choiceを広げる前にmulti-label-text-classificationを読んでください。

マルチラベルテキスト分類:1つのChoice、複数のはい/いいえ、それとも2回目のリクエストかMake.com Categorize Text:AI Toolkit vs HTTP → Jev(型付きラベルと信頼度しきい値)Power Automateテキスト分類:AI Builderカテゴリモデル vs HTTP → JevPipedreamテキスト分類:Hugging Faceアクション vs HTTP → Jev(型付きラベル)Jevはマルチモーダル/ダウンロード可能なモデルか?JevModelの実態GitHub IssueをAIで自動ラベル付け:Jevを呼ぶGitHub ActionsワークフローAIによるメール分類:受信メールにはルール、LLMプロンプト、型付き意思決定APIのどれを使うか

よくある質問

Slackメッセージの分類に公開サーバーは必要ですか?

いいえ。Socket Modeならプロセス側からSlackへWebSocketを開き、公開Request URLなしで同じイベントを受け取れます。Slackトリガーのあるノーコードツールも、自前のサーバーが不要な選択肢です。

同じSlackメッセージが2回分類されたのはなぜ?

たいていは応答が3秒を超え、Slackが同じevent_idで再送したためです。すぐに応答し、処理済みのevent_idを保存して重複を飛ばしてください。JevModelには代わりに処理してくれる冪等キーはありません。

ボットは非公開チャンネルやDMも分類できますか?

その会話タイプ用のメッセージイベントとhistoryスコープを別途追加し、かつボットがメンバーである場合に限ります。本ガイドは公開チャンネルが対象です。非公開の内容はワークスペース管理者の明確な同意を得て、必要最小限のテキストだけを送ってください。

JevでSlackメッセージを分類する費用は?

分類したメッセージ1件が通常のAPIリクエスト1回で、残高からトークン単位で課金されます。認証済みアカウントは100,000トークン無料から始まり、1リクエスト内のChoiceとNoulはまとめて課金されます。呼び出し前にボットのメッセージ、編集、ごく短い雑談を除けば使用量を抑えられます。最新のパックは料金ガイドを参照してください。

JevModel は独立したプロジェクトで、TypeSafe AI とは提携していません。