プロジェクト

全般

プロフィール

機能 #14

未完了

Chatworkのルーム投稿するとニュースとして反映される

Redmine Admin さんが4日前に追加. 約10時間前に更新.

ステータス:
解決
優先度:
通常
担当者:
-
開始日:
2025-05-10
期日:
進捗率:

0%

予定工数:

説明

Chatworkのルーム投稿するとニュースとして反映される

参考ニュースを共有するチャットワークが指定のルームで投稿されると、
ニュースをRedmineに取り込み、カテゴリとタグを設定する。
カテゴリは2つまで、タグは5個まで登録できる。

チャットワークスのAPとWebhookと連携する。

Redmine Admin さんが4日前に更新

comment1.txt

Redmine Admin さんが4日前に更新

comment2.txt

Redmine Admin さんが4日前に更新

comment3.txt

Redmine Admin さんが4日前に更新

comment4.txt

Redmine Admin さんが4日前に更新

comment5.txt

Redmine Admin さんが4日前に更新

chatwork_news_plugin_report.txt

Redmine Admin さんが4日前に更新

ChatworkのWebhookからRedmineニュースに連携するプラグイン開発

開発概要

Chatworkのメッセージを受け取り、Redmineのニュースとして自動登録するプラグインを開発しました。このプラグインを使うことで、Chatworkで共有された情報を、カテゴリとタグを付けてRedmineのニュースとして整理できます。

調査結果

  1. 既存のプラグイン調査

    • Redmine-Chatworkの連携プラグインはいくつか存在するが、チケット通知が主な用途
    • ニュース登録に特化したプラグインは見つからなかった
  2. Redmine News API

    • Redmineはニュース作成用のAPIを提供している(POST /projects/:project_id/news.json)
    • カスタムフィールドを使ってカテゴリ設定が可能
  3. タグ機能

    • 複数のタグプラグインが存在、Additional Tagsが最も活発にメンテナンスされている
    • タグ検索・フィルタリング機能を実装可能
  4. Chatwork Webhook

    • メッセージ作成イベントをWebhookとして受け取り可能
    • 署名検証による安全なデータ受信が可能

実装機能

  1. 基本機能

    • Chatworkメッセージを受信し、ニュースとして保存
    • メッセージ形式: 最初の行をタイトル、残りを本文として処理
    • カテゴリとタグの自動抽出(例:[カテゴリ:お知らせ] [タグ:重要])
  2. 設定機能

    • Webhookトークン設定
    • ルーム-プロジェクトマッピング
    • デフォルトカテゴリ/タグ設定
  3. UI/UX

    • ニュース表示時のカテゴリ/タグ表示
    • タグによるニュースのフィルタリング

プラグイン構成

ファイル構造

redmine_chatwork_news/
├── app/
│   ├── controllers/
│   │   ├── chatwork_webhook_controller.rb  # Webhook受信処理
│   │   └── chatwork_news_settings_controller.rb  # 設定画面
│   ├── models/
│   │   └── chatwork_news_room_mapping.rb  # ルームマッピングモデル
│   └── views/
│       ├── chatwork_news_settings/  # 設定画面ビュー
│       ├── hooks/
│       │   └── redmine_chatwork_news/
│       │       └── _view_news_show_bottom.html.erb  # ニュース表示拡張
│       └── settings/
│           └── _chatwork_news_settings.html.erb  # プラグイン設定画面
├── assets/
│   ├── stylesheets/  # CSS
│   └── javascripts/  # JavaScript
├── config/
│   ├── locales/  # 翻訳ファイル
│   └── routes.rb  # ルーティング
├── db/
│   └── migrate/  # マイグレーションファイル
├── lib/
│   ├── redmine_chatwork_news.rb  # コア機能
│   └── redmine_chatwork_news/
│       └── hooks.rb  # フック実装
├── test/  # テストファイル
├── init.rb  # プラグイン初期化
└── README.md

データベース構造

  • chatwork_news_room_mappings: ChatworkルームとRedmineプロジェクトの対応関係
  • NewsCustomField: カテゴリ保存用のカスタムフィールド

主要コード

init.rb(プラグイン登録)

require 'redmine'
require_dependency 'redmine_chatwork_news'

Redmine::Plugin.register :redmine_chatwork_news do
  name 'Redmine Chatwork News Plugin'
  author 'Claude'
  description 'A plugin that creates news from Chatwork messages via Webhook'
  version '0.1.0'
  url 'https://github.com/jdmnt1999/redmine_chatwork_news'
  author_url 'https://github.com/jdmnt1999'
  
  # 設定メニューを追加
  settings default: {
    webhook_token: '',
    chatwork_room_ids: '',
    default_project_id: '',
    default_categories: '',
    max_categories: 2,
    max_tags: 5
  }, partial: 'settings/chatwork_news_settings'
  
  # プロジェクトモジュールとして追加
  project_module :chatwork_news do
    permission :manage_chatwork_news_settings, {
      chatwork_news_settings: [:index, :edit, :update]
    }, require: :member
  end
  
  # メニューに追加
  menu :admin_menu, :chatwork_news, 
    { controller: 'chatwork_news_settings', action: 'index' },
    caption: :label_chatwork_news,
    html: { class: 'icon icon-news' }
end

# 登録後の処理
Rails.application.config.to_prepare do
  # モデルの拡張
  News.send(:include, RedmineChatworkNews::NewsPatch)
  
  # フックを登録
  require_dependency 'redmine_chatwork_news/hooks'
end

chatwork_webhook_controller.rb(Webhook受信処理)

class ChatworkWebhookController < ApplicationController
  skip_before_action :verify_authenticity_token, only: [:create]
  accept_api_auth :create
  
  # Chatworkからのwebhookを受け取るアクション
  def create
    # リクエストボディを取得
    request_body = request.raw_post
    signature = request.headers['X-ChatWorkWebhookSignature']
    
    # 署名検証
    unless RedmineChatworkNews.valid_signature?(signature, request_body, webhook_token)
      logger.warn "Invalid webhook signature received from #{request.remote_ip}"
      render json: { error: 'Invalid signature' }, status: :unauthorized
      return
    end
    
    # JSONをパース
    begin
      data = JSON.parse(request_body)
    rescue JSON::ParserError => e
      logger.error "Failed to parse webhook JSON: #{e.message}"
      render json: { error: 'Invalid JSON payload' }, status: :bad_request
      return
    end
    
    # メッセージを処理
    result = process_webhook(data)
    
    if result[:success]
      render json: { status: 'success', news_id: result[:news_id] }, status: :ok
    else
      render json: { error: result[:error] }, status: :unprocessable_entity
    end
  end
  
  private
  
  # Chatworkからのwebhookを処理
  def process_webhook(data)
    # メッセージをパース
    message = RedmineChatworkNews.parse_message(data)
    
    unless message
      return { success: false, error: 'Invalid message format' }
    end
    
    # 対象ルームIDかチェック
    room_id = message[:room_id].to_s
    unless target_room_ids.include?(room_id)
      return { success: false, error: 'Room not configured for news' }
    end
    
    # プロジェクトを特定
    project = find_project_for_room(room_id)
    unless project
      return { success: false, error: 'Project not found' }
    end
    
    # 著者を特定(管理者をデフォルトにする)
    author = User.where(admin: true).first
    unless author
      # 管理者が見つからない場合は最初のアクティブユーザー
      author = User.active.first
    end
    
    # ニュースのタイトルと説明を抽出
    title = RedmineChatworkNews.extract_title(message[:body])
    description = RedmineChatworkNews.extract_description(message[:body])
    
    # カテゴリとタグを抽出
    categories = RedmineChatworkNews.extract_categories(
      message[:body], 
      RedmineChatworkNews.settings['max_categories'].to_i
    )
    
    tags = RedmineChatworkNews.extract_tags(
      message[:body],
      RedmineChatworkNews.settings['max_tags'].to_i
    )
    
    # ニュースを作成
    news = RedmineChatworkNews.create_news(
      project, title, description, author, categories, tags
    )
    
    if news
      return { success: true, news_id: news.id }
    else
      return { success: false, error: 'Failed to create news' }
    end
  end
  
  # ルームIDに対応するプロジェクトを取得
  def find_project_for_room(room_id)
    # 設定からプロジェクトマッピングを取得
    mapping = ChatworkNewsRoomMapping.find_by(chatwork_room_id: room_id)
    
    if mapping && mapping.project_id.present?
      return Project.find_by(id: mapping.project_id)
    end
    
    # マッピングがない場合はデフォルトプロジェクトを使用
    default_project_id = RedmineChatworkNews.settings['default_project_id']
    if default_project_id.present?
      return Project.find_by(id: default_project_id)
    end
    
    nil
  end
  
  # Webhook検証用のトークンを取得
  def webhook_token
    RedmineChatworkNews.settings['webhook_token']
  end
  
  # 対象のChatworkルームID一覧を取得
  def target_room_ids
    room_ids = RedmineChatworkNews.settings['chatwork_room_ids']
    return [] if room_ids.blank?
    
    room_ids.split(',').map(&:strip)
  end
end

コアモジュール(redmine_chatwork_news.rb)

module RedmineChatworkNews
  class << self
    def settings
      Setting.plugin_redmine_chatwork_news
    end
    
    # Chatworkからのメッセージをパース
    def parse_message(webhook_event)
      return nil unless webhook_event && webhook_event['webhook_event_type'] == 'message_created'
      
      message_data = webhook_event['webhook_event']
      return nil unless message_data
      
      {
        room_id: message_data['room_id'],
        message_id: message_data['message_id'],
        account_id: message_data['account_id'],
        body: message_data['body'],
        send_time: Time.at(message_data['send_time'])
      }
    end
    
    # メッセージからタイトル抽出(最初の行)
    def extract_title(body)
      return '' if body.blank?
      body.lines.first.strip
    end
    
    # メッセージから本文を抽出(2行目以降)
    def extract_description(body)
      return '' if body.blank?
      lines = body.lines
      return '' if lines.size <= 1
      lines[1..-1].join
    end
    
    # メッセージからカテゴリを抽出
    def extract_categories(body, max_categories = 2)
      categories = []
      return categories if body.blank?
      
      # カテゴリタグを探す(例: [カテゴリ:技術])
      body.scan(/\[カテゴリ:([^\]]+)\]/).flatten.each do |category|
        categories << category.strip
        break if categories.size >= max_categories
      end
      
      categories
    end
    
    # メッセージからタグを抽出
    def extract_tags(body, max_tags = 5)
      tags = []
      return tags if body.blank?
      
      # タグを探す(例: [タグ:Ruby])
      body.scan(/\[タグ:([^\]]+)\]/).flatten.each do |tag|
        tags << tag.strip
        break if tags.size >= max_tags
      end
      
      tags
    end
    
    # 検証用のメソッド(Chatworkからのリクエストが正当かチェック)
    def valid_signature?(signature, body, token)
      return false if signature.blank? || body.blank? || token.blank?
      
      # HMACによる署名検証
      require 'base64'
      require 'openssl'
      
      hmac = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), Base64.decode64(token), body)
      expected_signature = Base64.encode64(hmac).strip
      
      ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
    end
    
    # ニュース記事を作成
    def create_news(project, title, description, author, categories = [], tags = [])
      return nil if project.nil? || title.blank? || author.nil?
      
      news = News.new(
        project: project,
        title: title,
        description: description,
        author: author
      )
      
      if news.save
        # カテゴリを設定(カスタムフィールドとして)
        set_news_categories(news, categories) if categories.any?
        
        # タグを設定(AdditionalTagsプラグインが利用可能な場合)
        set_news_tags(news, tags) if tags.any? && additional_tags_available?
        
        return news
      end
      
      nil
    end
    
    # ニュースにカテゴリを設定
    def set_news_categories(news, categories)
      # カテゴリをカスタムフィールドとして設定
      category_field = CustomField.where(type: 'NewsCustomField', name: 'Categories').first
      
      if category_field
        category_value = categories.join(', ')
        custom_value = news.custom_values.where(custom_field_id: category_field.id).first_or_initialize
        custom_value.value = category_value
        custom_value.save
      end
    end
    
    # ニュースにタグを設定
    def set_news_tags(news, tags)
      return unless additional_tags_available?
      
      # AdditionalTagsプラグインのAPIを使用
      if news.respond_to?(:tag_list=)
        news.tag_list = tags
        news.save
      end
    end
    
    # AdditionalTagsプラグインが利用可能か確認
    def additional_tags_available?
      Redmine::Plugin.installed?(:additional_tags) && 
      defined?(AdditionalTags) && 
      News.included_modules.include?(AdditionalTags::NewsTagging)
    end
  end
end

実装のポイント

1. セキュリティ対策

  • HMAC SHA-256署名検証でWebhookが正当なChatworkからの送信であることを検証
  • クロスサイトリクエストフォージェリ(CSRF)対策

2. データ処理フロー

  1. Chatworkからのwebhookを受信
  2. メッセージをパースしてタイトル、本文、カテゴリ、タグを抽出
  3. 対応するプロジェクトを特定
  4. Redmineのニュースとして登録
  5. カテゴリとタグを設定

3. UX改善

  • フロントエンドではニュース表示時にカテゴリとタグを視覚的に表示
  • タグによるフィルタリング機能でニュースの整理を容易に

4. 拡張性

  • 複数のルームとプロジェクトのマッピングに対応
  • Additional Tagsプラグインとの連携(オプション)
  • 将来的な拡張のための柔軟なアーキテクチャ設計

マイグレーション

class CreateChatworkNewsRoomMappings < ActiveRecord::Migration[5.2]
  def change
    create_table :chatwork_news_room_mappings do |t|
      t.string :chatwork_room_id, null: false
      t.integer :project_id, null: false
      t.string :category1
      t.string :category2
      t.string :default_tags
      t.timestamps
    end
    
    add_index :chatwork_news_room_mappings, :chatwork_room_id, unique: true
    add_index :chatwork_news_room_mappings, :project_id
    
    # ニュースカテゴリ用のカスタムフィールドを作成
    unless CustomField.where(type: 'NewsCustomField', name: 'Categories').exists?
      CustomField.create!(
        type: 'NewsCustomField',
        name: 'Categories',
        field_format: 'string',
        is_required: false,
        is_for_all: true,
        searchable: true
      )
    end
  end
end

テスト

テストは以下の観点で実装:

  • ユニットテスト: メッセージ解析、カテゴリ/タグ抽出、HMAC検証など
  • コントローラーテスト: Webhook受信、エラー処理など
  • 統合テスト: エンドツーエンドのフロー確認

今後の開発計画

短期計画(v0.2.0)

  1. メッセージフィルタリング機能
    • 特定のプレフィックスが付いたメッセージのみをニュースとして登録
  2. 添付ファイル対応
    • Chatworkの添付ファイルをニュースの添付ファイルとして登録
  3. メッセージ編集への対応
    • メッセージ編集時にニュースも更新

中期計画(v0.3.0)

  1. 複数ルーム対応の強化
  2. ニュース作成通知機能
  3. 管理画面の機能強化
  4. アクセス権限の詳細設定

長期計画(v1.0.0)

  1. 他のチャットツールへの対応(Slack、Microsoft Teams)
  2. テンプレート機能
  3. 高度なフィルタリング機能
  4. APIの拡張

GitHub リポジトリ

https://github.com/jdmnt1999/redmine_chatwork_news

Redmine Admin さんが約10時間前に更新

  • ステータス新規 から 解決 に変更
  • 進捗率0 から 0 に変更
  • 担当者 を削除 ()

他の形式にエクスポート: Atom PDF