📝

SlackからOpenAI(assistantAPI)を使用する

に公開

はじめに

SlackとOpenAIを連携させるという記事は探せばたくさん見つかりますが
ニッチな依頼というか最近流行りのvibe codingを取り入れた開発体験について
記事にしてみました。

情シス、コーポレートITの文脈で参考になればと思います。

はじまり

「GPTsをSlackから使用したい実装して。」

渋々調べたところ、ChatGPTはGUIベース、API連携するにはOpenAIのAPIを利用しないといけないのは
分っていたのですが、「GPTsは使えないよ、AssistantAPIを利用すれば同じ事が可能だが従量課金である!」

暫くは上の回答で引き下がってくれたのですが「従量課金でもよい!実装して!!」と再度食いついてきたので渋々実装しました。

環境

サーバ環境:Vercel
言語:TypeScript

なんでサーバーレスじゃないの?というのは凄く深い事情(落とし穴)があって辞めました。

丁度同じ事象でハマっていた方の記事が6/6・・・1日前にドはまりしたので
翌週に知っていれば、3時間ほど無駄にならなくて済んだのに(たられば)

Cloud Runでハマったslack-bolt + flaskをデプロイした際のエラー対処法

OpenAIのassistant設定

APIKeyは既に発行済みのものを使用
Assistantを作成します。後ほどassistant_idが必要となるのでidは控えておきましょう。

Slack App作成

既に作成している似たようなappからapp manifestをコピーして作成しました。

開発

初回はプロジェクトごとClaude Desktopで生成します。
Claude DesktopにはファイルシステムにアクセスできるMCPサーバーを設定済み

Filesystem MCP Server

プロンプトを打ち込んで生成

出来た物をGithubにプッシュします。

Vercel側でGithub連携を行います。

デプロイエラーが起きたのでエラーログをそのままClaudeに投げると
修正してくれました。

で、Claudeはコレでお役目終了。

動作確認

フロントはAPIエンドポイントのみ、バックエンドAPIでVercelからOpenAIのAPIを使います。

APIエンドポイント
https://your-vercel-app.vercel.app/api/slack/events

Slack Appを任意のチャンネルに追加(招待)
Botにメンションしてプロンプトを添えて投稿するとしばらくして回答が返ってきます。

現状スレッドIDなどを指定したりする機能は未実装ですが
スレッドIDによるスレッド管理やセキュリティの強化にはCodexでサクッと実装する予定です。

vibe開発続き

動作はするものの、Slackあるあるの同じ3秒ルールによりリクエストを再送信するという仕様があるので追加で実装を変更します。今度はGithubリポジトリ連携済みのCodexを使いました。

実装2分程
デプロイまで1分程の合計3分ほどでタスク完了です。

コード

import { App } from '@slack/bolt';
import OpenAI from 'openai';
import { NextRequest, NextResponse } from 'next/server';

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
});

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

const ASSISTANT_ID = process.env.OPENAI_ASSISTANT_ID;

// 処理済みイベントのIDを保持するマップ(5分で期限切れ)
const processedEventIds = new Map<string, number>();

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const type = body.type;

    // イベントの重複チェック
    const eventId: string | undefined = body.event_id;
    if (eventId) {
      const now = Date.now();
      // 5分以上前のIDは削除
      for (const [id, timestamp] of processedEventIds) {
        if (now - timestamp > 5 * 60 * 1000) {
          processedEventIds.delete(id);
        }
      }
      if (processedEventIds.has(eventId)) {
        console.log(`Duplicate event ${eventId} ignored`);
        return NextResponse.json({ status: 'duplicate' });
      }
      processedEventIds.set(eventId, now);
    }

    // Slack URL verification (初回設定時に必要)
    if (type === 'url_verification') {
      console.log('URL verification challenge received:', body.challenge);
      return NextResponse.json({ challenge: body.challenge });
    }

    // イベントコールバック処理
    if (type === 'event_callback') {
      const event = body.event;

      // Bot mentionの場合のみ処理
      if (event.type === 'app_mention' && event.text && !event.bot_id) {
        console.log('Bot mention received:', event.text);
        
        // Botのメンションを除去してメッセージを取得
        const message = event.text.replace(/<@[A-Z0-9]+>/g, '').trim();
        
        if (message) {
          // OpenAI Assistant APIで回答を生成
          try {
            // スレッドを作成
            const thread = await openai.beta.threads.create();
            
            // メッセージを追加
            await openai.beta.threads.messages.create(thread.id, {
              role: 'user',
              content: message,
            });
            
            // アシスタントを実行
            const run = await openai.beta.threads.runs.create(thread.id, {
              assistant_id: ASSISTANT_ID!,
            });
            
            // 実行完了を待機
            let runStatus = await openai.beta.threads.runs.retrieve(run.id, {
              thread_id: thread.id,
            });
            while (runStatus.status === 'in_progress' || runStatus.status === 'queued') {
              await new Promise(resolve => setTimeout(resolve, 1000));
              runStatus = await openai.beta.threads.runs.retrieve(run.id, {
                thread_id: thread.id,
              });
            }
            
            if (runStatus.status === 'completed') {
              // アシスタントの回答を取得
              const messages = await openai.beta.threads.messages.list(thread.id);
              const assistantMessage = messages.data.find(
                msg => msg.role === 'assistant'
              );
              
              if (assistantMessage && assistantMessage.content[0].type === 'text') {
                const reply = assistantMessage.content[0].text.value;
                
                // Slackスレッドに返信
                await app.client.chat.postMessage({
                  channel: event.channel,
                  thread_ts: event.ts,
                  text: reply,
                });
                
                console.log('Reply sent successfully');
              }
            } else {
              console.error('Assistant run failed:', runStatus.status);
              
              // エラー時の返信
              await app.client.chat.postMessage({
                channel: event.channel,
                thread_ts: event.ts,
                text: '申し訳ございません。回答の生成中にエラーが発生しました。',
              });
            }
          } catch (openaiError) {
            console.error('OpenAI API error:', openaiError);
            
            // エラー時の返信
            await app.client.chat.postMessage({
              channel: event.channel,
              thread_ts: event.ts,
              text: '申し訳ございません。AI処理中にエラーが発生しました。',
            });
          }
        }
      }
    }

    return NextResponse.json({ status: 'ok' });
  } catch (error) {
    console.error('Error processing request:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

// Slack Events APIはPOSTのみ使用
export async function GET() {
  return NextResponse.json({ message: 'Slack Events API endpoint' });
}

Discussion