🎙️

RailsでCirclebackのWebhookを受け取って会議データを保存する

に公開

はじめに

Circlebackは、オンライン会議の録音・転写・要約を自動化してくれるAIサービスです。会議終了後に議事録やアクションアイテムを自動生成してくれるため、多くの企業で導入が進んでいます。

この記事では、CirclebackのWebhook機能を使って、Railsアプリケーションで会議データを受け取り、データベースに保存する実装方法を解説します。

Circlebackとは

Circlebackは、Zoom、Google Meet、Microsoft TeamsなどのWeb会議ツールと連携し、以下の機能を提供します:

  • 自動録音・転写: 会議内容の自動録音と音声のテキスト化
  • AI要約: 会議内容の要点をAIが自動で要約
  • アクションアイテム抽出: 会議中に決まったタスクを自動抽出
  • インサイト生成: カスタマイズ可能な項目での情報抽出

Webhook機能を使うことで、会議終了後にこれらのデータを自動的に外部システムに送信できます。

テーブル設計

Circlebackから送信されるWebhookデータを保存するためのテーブル設計を考えてみましょう。

meetings テーブル

# 会議の基本情報を保存
create_table :meetings do |t|
  t.bigint :circleback_id, null: false, index: { unique: true }
  t.string :name, null: false
  t.datetime :meeting_created_at, null: false
  t.integer :duration_seconds
  t.string :meeting_url
  t.string :recording_url
  t.string :ical_uid
  t.text :notes
  t.json :raw_webhook_data # 元のWebhookデータを念のため保存
  t.timestamps
end

meeting_attendees テーブル

# 参加者情報を保存
create_table :meeting_attendees do |t|
  t.references :meeting, null: false, foreign_key: true
  t.string :name
  t.string :email
  t.timestamps
end

meeting_tags テーブル

# タグ情報を保存(多対多の関係)
create_table :meeting_tags do |t|
  t.string :name, null: false, index: { unique: true }
  t.timestamps
end

create_table :meeting_taggings do |t|
  t.references :meeting, null: false, foreign_key: true
  t.references :meeting_tag, null: false, foreign_key: true
  t.timestamps
end

action_items テーブル

# アクションアイテムを保存
create_table :action_items do |t|
  t.references :meeting, null: false, foreign_key: true
  t.bigint :circleback_id, null: false
  t.string :title, null: false
  t.text :description
  t.string :assignee_name
  t.string :assignee_email
  t.string :status, default: 'PENDING'
  t.timestamps
end

transcript_segments テーブル

# 転写データを保存
create_table :transcript_segments do |t|
  t.references :meeting, null: false, foreign_key: true
  t.string :speaker, null: false
  t.text :text, null: false
  t.decimal :timestamp_seconds, precision: 10, scale: 2
  t.timestamps
end

meeting_insights テーブル

# インサイトデータを保存
create_table :meeting_insights do |t|
  t.references :meeting, null: false, foreign_key: true
  t.string :insight_name, null: false
  t.json :insight_data
  t.string :speaker
  t.decimal :timestamp_seconds, precision: 10, scale: 2
  t.timestamps
end

CirclebackでのWebhook設定手順

RailsアプリケーションでWebhookを受け取る準備ができたら、Circleback側でWebhookの設定を行います。

設定手順

  1. Automationsページを開く: https://app.circleback.ai/automations にアクセスします

  2. Webhook設定を追加: 既存のAutomationにWebhookステップを追加するか、新しいAutomationを作成してWebhookステップを追加します

  3. エンドポイントURLの設定:

    • Webhook endpoint URLに、先ほど作成したRailsアプリケーションのエンドポイントを入力します
    • 例:https://yourdomain.com/api/v1/circleback/webhook
    • Webhook送信に含めたくないデータがある場合は、該当項目をオフに切り替えます
  4. 設定の保存: Done をクリックしてから Save をクリックしてAutomationを保存します

注意事項

  • HTTPS必須: CirclebackのWebhookはHTTPS接続が必要です
  • 署名検証: 本番環境では必ず署名検証を有効にしてください
  • タイムアウト設定: Webhookエンドポイントは30秒以内にレスポンスを返す必要があります

Railsアプリケーションでの実装

モデルの定義

まず、各モデルのリレーションを定義します:

# app/models/meeting.rb
class Meeting < ApplicationRecord
  has_many :meeting_attendees, dependent: :destroy
  has_many :meeting_taggings, dependent: :destroy
  has_many :meeting_tags, through: :meeting_taggings
  has_many :action_items, dependent: :destroy
  has_many :transcript_segments, dependent: :destroy
  has_many :meeting_insights, dependent: :destroy

  validates :circleback_id, presence: true, uniqueness: true
  validates :name, presence: true
end

# app/models/meeting_attendee.rb
class MeetingAttendee < ApplicationRecord
  belongs_to :meeting
end

# app/models/meeting_tag.rb
class MeetingTag < ApplicationRecord
  has_many :meeting_taggings, dependent: :destroy
  has_many :meetings, through: :meeting_taggings

  validates :name, presence: true, uniqueness: true
end

# app/models/meeting_tagging.rb
class MeetingTagging < ApplicationRecord
  belongs_to :meeting
  belongs_to :meeting_tag
end

# app/models/action_item.rb
class ActionItem < ApplicationRecord
  belongs_to :meeting

  validates :circleback_id, presence: true
  validates :title, presence: true
  validates :status, inclusion: { in: %w[PENDING DONE] }
end

# app/models/transcript_segment.rb
class TranscriptSegment < ApplicationRecord
  belongs_to :meeting

  validates :speaker, presence: true
  validates :text, presence: true
end

# app/models/meeting_insight.rb
class MeetingInsight < ApplicationRecord
  belongs_to :meeting

  validates :insight_name, presence: true
end

Webhookコントローラーの実装

# app/controllers/api/v1/circleback_webhooks_controller.rb
class Api::V1::CirclebackWebhooksController < ApplicationController
  protect_from_forgery with: :null_session
  before_action :verify_webhook_signature

  def create
    webhook_service = CirclebackWebhookService.new(webhook_params)
    result = webhook_service.process

    if result[:success]
      Rails.logger.info "Circleback webhook processed successfully for meeting ID: #{result[:meeting].circleback_id}"
      render json: { status: 'success' }, status: :ok
    else
      Rails.logger.error "Failed to process Circleback webhook: #{result[:error]}"
      render json: { error: result[:error] }, status: :unprocessable_entity
    end
  rescue => e
    Rails.logger.error "Unexpected error processing Circleback webhook: #{e.message}"
    Rails.logger.error e.backtrace.join("\n")
    render json: { error: 'Internal server error' }, status: :internal_server_error
  end

  private

  def webhook_params
    params.permit!.to_h
  end

  def verify_webhook_signature
    return unless Rails.env.production? # 開発環境では署名検証をスキップ

    signing_secret = Rails.application.credentials.circleback_signing_secret
    signature = request.headers['X-Signature']
    request_body = request.raw_post

    unless CirclebackSignatureVerifier.verify(request_body, signature, signing_secret)
      Rails.logger.warn "Invalid Circleback webhook signature"
      render json: { error: 'Invalid signature' }, status: :unauthorized
    end
  end
end

Webhookデータ処理サービスの実装

# app/services/circleback_webhook_service.rb
class CirclebackWebhookService
  def initialize(webhook_data)
    @webhook_data = webhook_data
  end

  def process
    ActiveRecord::Base.transaction do
      meeting = create_or_update_meeting
      create_attendees(meeting)
      create_tags(meeting)
      create_action_items(meeting)
      create_transcript_segments(meeting)
      create_insights(meeting)

      { success: true, meeting: meeting }
    end
  rescue => e
    Rails.logger.error "Error processing Circleback webhook: #{e.message}"
    { success: false, error: e.message }
  end

  private

  def create_or_update_meeting
    meeting = Meeting.find_or_initialize_by(circleback_id: @webhook_data['id'])
    
    meeting.assign_attributes(
      name: @webhook_data['name'],
      meeting_created_at: Time.parse(@webhook_data['createdAt']),
      duration_seconds: @webhook_data['duration']&.to_i,
      meeting_url: @webhook_data['url'],
      recording_url: @webhook_data['recordingUrl'],
      ical_uid: @webhook_data['icalUid'],
      notes: @webhook_data['notes'],
      raw_webhook_data: @webhook_data
    )
    
    meeting.save!
    meeting
  end

  def create_attendees(meeting)
    # 既存の参加者を削除してから再作成
    meeting.meeting_attendees.destroy_all
    
    return unless @webhook_data['attendees'].present?

    @webhook_data['attendees'].each do |attendee_data|
      meeting.meeting_attendees.create!(
        name: attendee_data['name'],
        email: attendee_data['email']
      )
    end
  end

  def create_tags(meeting)
    # 既存のタグ関連付けを削除
    meeting.meeting_taggings.destroy_all
    
    return unless @webhook_data['tags'].present?

    @webhook_data['tags'].each do |tag_name|
      tag = MeetingTag.find_or_create_by(name: tag_name)
      meeting.meeting_taggings.create!(meeting_tag: tag)
    end
  end

  def create_action_items(meeting)
    # 既存のアクションアイテムを削除してから再作成
    meeting.action_items.destroy_all
    
    return unless @webhook_data['actionItems'].present?

    @webhook_data['actionItems'].each do |action_item_data|
      assignee = action_item_data['assignee']
      
      meeting.action_items.create!(
        circleback_id: action_item_data['id'],
        title: action_item_data['title'],
        description: action_item_data['description'],
        assignee_name: assignee&.dig('name'),
        assignee_email: assignee&.dig('email'),
        status: action_item_data['status']
      )
    end
  end

  def create_transcript_segments(meeting)
    # 既存の転写データを削除してから再作成
    meeting.transcript_segments.destroy_all
    
    return unless @webhook_data['transcript'].present?

    @webhook_data['transcript'].each do |segment_data|
      meeting.transcript_segments.create!(
        speaker: segment_data['speaker'],
        text: segment_data['text'],
        timestamp_seconds: segment_data['timestamp']
      )
    end
  end

  def create_insights(meeting)
    # 既存のインサイトを削除してから再作成
    meeting.meeting_insights.destroy_all
    
    return unless @webhook_data['insights'].present?

    @webhook_data['insights'].each do |insight_name, insight_data_array|
      next unless insight_data_array.is_a?(Array)

      insight_data_array.each do |insight_item|
        meeting.meeting_insights.create!(
          insight_name: insight_name,
          insight_data: insight_item['insight'],
          speaker: insight_item['speaker'],
          timestamp_seconds: insight_item['timestamp']
        )
      end
    end
  end
end

署名検証の実装

セキュリティのため、Webhookの署名検証を実装します:

# app/services/circleback_signature_verifier.rb
require 'openssl'

class CirclebackSignatureVerifier
  def self.verify(request_body, signature, signing_secret)
    return false if signature.blank? || signing_secret.blank?

    expected_signature = OpenSSL::HMAC.hexdigest(
      OpenSSL::Digest.new('sha256'),
      signing_secret,
      request_body
    )

    # タイミング攻撃を防ぐためのセキュアな比較
    ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
  end
end

エラーハンドリングとロギング

カスタムログフォーマットの設定

# config/application.rb
class Application < Rails::Application
  # Webhookのログ設定
  config.webhook_logger = ActiveSupport::Logger.new(Rails.root.join('log', 'webhooks.log'))
  config.webhook_logger.formatter = proc do |severity, datetime, progname, msg|
    "[#{datetime}] #{severity}: #{msg}\n"
  end
end

ログ記録用のサービス追加

# app/services/webhook_logger_service.rb
class WebhookLoggerService
  def self.log_webhook_received(webhook_data)
    Rails.application.config.webhook_logger.info(
      "Circleback webhook received - Meeting ID: #{webhook_data['id']}, Name: #{webhook_data['name']}"
    )
  end

  def self.log_processing_error(error, webhook_data)
    Rails.application.config.webhook_logger.error(
      "Circleback webhook processing failed - Meeting ID: #{webhook_data&.dig('id')}, Error: #{error.message}"
    )
  end

  def self.log_signature_verification_failed(request_headers)
    Rails.application.config.webhook_logger.warn(
      "Circleback webhook signature verification failed - User-Agent: #{request_headers['User-Agent']}"
    )
  end
end

改良されたコントローラー

# app/controllers/api/v1/circleback_webhooks_controller.rb(改良版)
class Api::V1::CirclebackWebhooksController < ApplicationController
  protect_from_forgery with: :null_session
  before_action :verify_webhook_signature

  def create
    WebhookLoggerService.log_webhook_received(webhook_params)
    
    webhook_service = CirclebackWebhookService.new(webhook_params)
    result = webhook_service.process

    if result[:success]
      Rails.logger.info "Circleback webhook processed successfully for meeting ID: #{result[:meeting].circleback_id}"
      render json: { status: 'success', meeting_id: result[:meeting].id }, status: :ok
    else
      WebhookLoggerService.log_processing_error(StandardError.new(result[:error]), webhook_params)
      render json: { error: result[:error] }, status: :unprocessable_entity
    end
  rescue => e
    WebhookLoggerService.log_processing_error(e, webhook_params)
    render json: { error: 'Internal server error' }, status: :internal_server_error
  end

  private

  def webhook_params
    params.permit!.to_h
  end

  def verify_webhook_signature
    return unless Rails.env.production?

    signing_secret = Rails.application.credentials.circleback_signing_secret
    signature = request.headers['X-Signature']
    request_body = request.raw_post

    unless CirclebackSignatureVerifier.verify(request_body, signature, signing_secret)
      WebhookLoggerService.log_signature_verification_failed(request.headers)
      render json: { error: 'Invalid signature' }, status: :unauthorized
    end
  end
end

ルーティング設定

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      post 'circleback/webhook', to: 'circleback_webhooks#create'
    end
  end
end

設定ファイルの追加

credentials.yml.enc の設定

rails credentials:edit
# config/credentials.yml.enc
circleback_signing_secret: your_webhook_signing_secret_here

環境変数の設定例

# config/environments/production.rb
Rails.application.configure do
  # Webhook用のログレベル設定
  config.log_level = :info
  
  # セキュリティ設定
  config.force_ssl = true
end

まとめ

この記事では、RailsアプリケーションでCirclebackのWebhookを受け取り、会議データを効率的に保存する実装方法を解説しました。

実装のポイント

  1. 正規化されたテーブル設計: 会議データの各要素を適切なテーブルに分割して保存
  2. 署名検証: セキュリティを確保するためのWebhook署名検証
  3. トランザクション: データの整合性を保つためのトランザクション処理
  4. エラーハンドリング: 適切なログ記録と例外処理
  5. 冪等性: 同じWebhookが複数回送信されても安全な処理

今後の拡張案

  • 非同期処理: Sidekiqなどを使ったバックグラウンド処理
  • 重複排除: Webhook IDを使った重複処理の防止
  • リトライ機能: 失敗時の自動リトライ機能
  • Webhook配信履歴: 配信状況の記録と監視機能

Circlebackとの連携により、会議の生産性向上と情報の一元管理が実現できます。ぜひこの実装を参考に、自社のワークフローに合わせてカスタマイズしてみてください。

Discussion