0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ニュース記事の要約・推薦を自動化するBotの設計と実装方法

Posted at

ChatGPT Image 2025年4月30日 12_28_22.png

目次

  1. Botでできること
  2. 前提・環境準備
  3. ディレクトリ構成と依存ライブラリ
  4. 環境変数の設定 (.env)
  5. 主要モジュール解説
    • load/save 周り(JSON・YAML)
    • 要約機能 (summarize_article)
    • パーソナライズ推薦 (get_personalized_recommendations)
    • 日次レコメンドタスク (daily_recommendations)
    • Discord イベントハンドラ
  6. コマンド一覧
  7. デプロイと運用
  8. おわりに
  9. コード全文

このBotでできること

  • 記事の自動要約
    ユーザーがリアクションを付けたりリプライした記事URLをAzure OpenAIで日本語要約
  • ユーザー履歴の管理
    反応・コメント履歴をJSONファイルで永続化し、TF-IDFで個人向け推薦を実施
  • RSSフィードの動的追加/削除
    !recommend-news-add!recommend-news-delete などでカテゴリ単位にRSSを管理
  • 日次レコメンド配信
    毎日決まった時刻にDMでその日のおすすめ記事を自動配信
  • 権限管理
    ロールベースでコマンド使用可否を制御

前提・環境準備

  • Python 3.8+
  • Discord Bot アカウント(トークン取得済み)
  • Azure OpenAI サービス利用権限(APIキー・エンドポイント取得済み)
  • サーバー or VPS などの常時稼働環境
# 必要ライブラリのインストール例
pip install discord.py feedparser openai pyyaml python-dotenv scikit-learn requests beautifulsoup4

ディレクトリ構成と依存ライブラリ

your-bot/
├─ bot.py                  # メインコード(以下サンプルと同等)
├─ news-config.yaml        # RSSフィード設定 (YAML)
├─ news_user_history.json  # ユーザー閲覧履歴 (JSON)
├─ reaction_history.json   # リアクション・コメント履歴 (JSON)
└─ .env                    # 環境変数
  • feedparser:RSS/Atomフィードの取得
  • openai (Azure):Chat API 呼び出し
  • yaml:RSS設定管理
  • sklearn:TF-IDF + コサイン類似度
  • dotenv.env からトークン・APIキー読み込み
  • requests + BeautifulSoup4:外部記事のHTML解析

環境変数の設定 (.env)

# Discord Bot
DISCORD_BOT_TOKEN=あなたのDiscordトークン

# Azure OpenAI
AZURE_OPENAI_API_KEY=あなたのAPIキー
AZURE_OPENAI_ENDPOINT=https://<あなたのリソース名>.openai.azure.com/

主要モジュール解説

1. load/save 周り(JSON・YAML)

# ユーザーニュース履歴をJSONから読み書き
def load_user_history():
    # USER_HISTORY_FILE → news_user_history.json
    

def save_user_history():
    

# RSS設定をYAMLで管理
def load_news_config() -> Dict:
    # NEWS_CONFIG_FILE → news-config.yaml
    

def save_news_config(config: Dict) -> bool:
    
  • ユーザーごとにリアクション・コメントした記事URLを配列で保持。
  • YAMLにはカテゴリ単位で rss_feeds: { tech: [url1, url2], … } を記述可能。

2. 要約機能 (summarize_article)

async def summarize_article(url, user_request=None):
    client = openai.AzureOpenAI(
        api_key=AZURE_OPENAI_API_KEY,
        api_version="2023-05-15",
        azure_endpoint=AZURE_OPENAI_ENDPOINT
    )
    system_prompt = "あなたはニュース記事を要約するアシスタントです。要約は必ず日本語で出力してください。"
    user_prompt = f"以下のニュース記事を日本語で要約してください。\n記事URL: {url}"
    if user_request:
        user_prompt += f"\nユーザーのリクエスト: 「{user_request}」を考慮してください。"
    else:
        user_prompt += "\n簡潔に要約してください。"

    response = client.chat.completions.create(
        model="gpt-4o", messages=[
            {"role":"system","content":system_prompt},
            {"role":"user","content":user_prompt}
        ],
        max_tokens=250, temperature=0.5
    )
    return response.choices[0].message.content.strip()
  • ポイント:system/user プロンプトを分けることで、要約時の出力トーンを安定させる。

3. パーソナライズ推薦 (get_personalized_recommendations)

async def get_personalized_recommendations(user_id, top_n=3):
    history = user_news_history.get(user_id, [])
    # 履歴が2件以上ない場合は推薦なし
    if len(history) < 2: return []

    # 全記事をRSSから収集
    all_articles = []
    config = load_news_config()
    for feeds in config['rss_feeds'].values():
        for url in feeds:
            feed = feedparser.parse(url)
            for e in feed.entries:
                if e.link not in history:
                    all_articles.append({
                        'title': e.title, 'link': e.link,
                        'description': getattr(e, 'description', ''),
                        'published': getattr(e, 'published', '')
                    })

    # TF-IDF行列を生成し、ユーザープロファイルと類似度計算
    corpus = history + [a['link'] + a['title'] + a['description'] for a in all_articles]
    vect = TfidfVectorizer(stop_words='english')
    mat = vect.fit_transform(corpus)
    user_vec = mat[:len(history)].mean(axis=0)
    art_vecs = mat[len(history):]
    scores = cosine_similarity(user_vec, art_vecs)[0]
    ranked = sorted(zip(all_articles, scores), key=lambda x: x[1], reverse=True)
    return [a for a, _ in ranked[:top_n]]
  • ユーザ履歴と全記事を同一TF-IDF空間にマッピングし、平均ベクトルとのコサイン類似度で上位を抽出。

4. 日次レコメンドタスク (daily_recommendations)

async def daily_recommendations():
    await asyncio.sleep(3600)  # 起動直後は1時間待機
    while True:
        for user_id in user_news_history:
            if len(user_news_history[user_id]) < 2: continue
            user = await client.fetch_user(user_id)
            recs = await get_personalized_recommendations(user_id, top_n=5)
            if recs:
                embed = discord.Embed(
                    title="📰 今日のニュース推薦",
                    description="あなたの閲覧履歴に基づくおすすめ記事",
                    color=discord.Color.blue()
                )
                for i, art in enumerate(recs, 1):
                    info = f"{i}. {art['title']} ({art['published']})"
                    embed.add_field(name=info, value=f"[読む]({art['link']})", inline=False)
                await user.send(embed=embed)
        await asyncio.sleep(24 * 3600)
  • Bot起動から1時間後に最初の配信、その後24時間ごとに自動実行。

5. Discord イベントハンドラ

  • on_raw_reaction_add:指定チャンネルへのリアクションを検出し、ログ保存 → 要約 & 推薦 → DM
  • on_message
    • !recommend-news-add!recommend-news-delete 等のコマンド
    • Techチャンネル内でのリプライをコンテキストとして要約・推薦

各ハンドラ内で共通して以下を実施:

  1. URL抽出(メッセージ本文・Embed含む)
  2. ログ記録(reaction_history.json)
  3. 履歴更新(news_user_history.json)
  4. 要約&推薦生成 → DM

コマンド一覧

コマンド 概要
!recommend-news-add <カテゴリ> <RSS URL> RSSフィードをカテゴリに追加
!recommend-news-delete <カテゴリ> <RSS URL> RSSフィードをカテゴリから削除
!recommend-news-list [カテゴリ] 登録中のRSSフィード一覧を表示
!recommend-history [@ユーザー] 指定ユーザー(自身)の履歴をDM送信
!reaction-history リアクション履歴全体を表示(権限要)

デプロイと運用

  1. VPSへの配置
    • bot.py.envnews-config.yaml/JSONファイル群 を同一ディレクトリに配置
  2. 永続化とプロセスマネージャ
    • systemd もしくは pm2 で常時稼働
# /etc/systemd/system/news-bot.service の例
[Unit]
Description=Discord News Recommendation Bot

[Service]
Type=simple
WorkingDirectory=/home/youruser/your-bot
ExecStart=/usr/bin/python3 bot.py
Restart=always

[Install]
WantedBy=multi-user.target
  1. 運用ポイント
    • RSS設定変更後はBot再起動不要(YAMLのreload処理あり)
    • JSONファイルのバックアップを定期実行
    • Botトークン・APIキー漏洩時は即座に再発行

おわりに

以上、DiscordとAzure OpenAI、TF-IDFベースの推薦システムを組み合わせたニュースBotの全体像と実装ポイントをご紹介しました。Noteでの公開にあわせ、必要なファイル構成や環境設定の手順、主要な関数解説をMarkdown形式でまとめています。皆さんのプロジェクトの参考になれば幸いです。

コード全文

import discord
import feedparser
import openai
import yaml
import os
import json
import datetime
from datetime import timezone
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from dotenv import load_dotenv
import asyncio
from typing import Dict, List
import requests
from bs4 import BeautifulSoup
import re

# Get the directory of this script
script_dir = os.path.dirname(os.path.abspath(__file__))

# Load environment variables
dotenv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
load_dotenv(dotenv_path=dotenv_path)

# Discord bot token
DISCORD_TOKEN = os.getenv("DISCORD_BOT_TOKEN","MTM0NDEyMzg2Njc1NDMxODQzOA.GUYnR_.lHmrq05qW82YyYM4uEQ4s7MFQwdv9r_wHgqWzY")
if not DISCORD_TOKEN:
    print("Error: DISCORD_BOT_TOKEN not found in environment variables. Please set it.")
    exit()

# Azure OpenAI API key and endpoint
AZURE_OPENAI_API_KEY = os.getenv("AZURE_OPENAI_API_KEY")
AZURE_OPENAI_ENDPOINT = os.getenv("AZURE_OPENAI_ENDPOINT")

if not AZURE_OPENAI_API_KEY or not AZURE_OPENAI_ENDPOINT:
    print("Error: AZURE_OPENAI_API_KEY or AZURE_OPENAI_ENDPOINT not found in environment variables. Please set them.")
    exit()

openai.api_type = "azure"
openai.api_version = "2023-05-15"
openai.api_base = AZURE_OPENAI_ENDPOINT
openai.api_key = AZURE_OPENAI_API_KEY

# Discord client
intents = discord.Intents.default()
intents.message_content = True
intents.reactions = True
client = discord.Client(intents=intents)

# File to store user news history - using a separate file to avoid conflicts
USER_HISTORY_FILE = os.path.join(script_dir, "news_user_history.json")

# User news history and reaction history files
user_news_history = {}
REACTION_HISTORY_FILE = os.path.join(script_dir, "reaction_history.json")

def load_reaction_history():
    """リアクション履歴をJSONファイルから読み込む"""
    if os.path.exists(REACTION_HISTORY_FILE):
        try:
            with open(REACTION_HISTORY_FILE, 'r', encoding='utf-8') as f:
                data = json.load(f)
                if not isinstance(data, dict) or "reactions" not in data:
                    print("Invalid reaction history format, initializing with empty history")
                    return {"reactions": []}
                return data
        except Exception as e:
            print(f"Error loading reaction history: {e}")
            return {"reactions": []}
    else:
        print("No reaction history file found, starting with empty history")
        return {"reactions": []} # Ensure 'reactions' key exists

def save_reaction_history(reaction_data):
    """リアクション履歴をJSONファイルに保存"""
    # Ensure the main key exists before saving
    if "reactions" not in reaction_data:
        reaction_data = {"reactions": []}
        
    try:
        with open(REACTION_HISTORY_FILE, 'w', encoding='utf-8') as f:
            json.dump(reaction_data, f, ensure_ascii=False, indent=2)
        # Check if 'reactions' key exists before accessing its length
        num_entries = len(reaction_data.get("reactions", []))
        print(f"Saved reaction history with {num_entries} entries")
        return True
    except Exception as e:
        print(f"Error saving reaction history: {e}")
        return False

def load_user_history():
    """ユーザー履歴をJSONファイルから読み込む"""
    global user_news_history
    if os.path.exists(USER_HISTORY_FILE):
        try:
            with open(USER_HISTORY_FILE, 'r', encoding='utf-8') as f:
                # JSON形式(キーは文字列)からPython形式(キーは整数)に変換
                data = json.load(f)
                user_news_history = {int(user_id): urls for user_id, urls in data.items()}
            print(f"Loaded user history for {len(user_news_history)} users")
        except Exception as e:
            print(f"Error loading user history: {e}")
            user_news_history = {}
    else:
        print("No user history file found, starting with empty history")
        user_news_history = {}

def save_user_history():
    """ユーザー履歴をJSONファイルに保存"""
    try:
        with open(USER_HISTORY_FILE, 'w', encoding='utf-8') as f:
            json.dump(user_news_history, f, ensure_ascii=False, indent=2)
        print(f"Saved user history for {len(user_news_history)} users")
        return True
    except Exception as e:
        print(f"Error saving user history: {e}")
        return False

# Configuration file - using absolute path with script directory
NEWS_CONFIG_FILE = os.path.join(script_dir, "news-config.yaml")

# Technology news channel ID
# Ensure this ID is correct for the channel you want to monitor
TECH_CHANNEL_ID = 856702707838484511

# Allowed roles
ALLOWED_ROLES = {
    "news": ["G.state"], # Keep role check for commands
}

# Helper functions
def load_news_config() -> Dict:
    """ニュース設定ファイル (YAML) を読み込む"""
    if not os.path.exists(NEWS_CONFIG_FILE):
        return {'rss_feeds': {}}
    try:
        with open(NEWS_CONFIG_FILE, 'r', encoding='utf-8') as f:
            # Use safe_load to prevent arbitrary code execution
            config = yaml.safe_load(f)
            # Ensure the base structure exists
            if config is None:
                return {'rss_feeds': {}}
            if 'rss_feeds' not in config or not isinstance(config['rss_feeds'], dict):
                config['rss_feeds'] = {}
            return config
    except yaml.YAMLError as e:
        print(f"ニュース設定ファイル ({NEWS_CONFIG_FILE}) のYAMLパースエラー: {e}")
        return {'rss_feeds': {}}
    except IOError as e:
        print(f"ニュース設定ファイル ({NEWS_CONFIG_FILE}) の読み込みエラー: {e}")
        return {'rss_feeds': {}}

def save_news_config(config: Dict) -> bool:
    """ニュース設定ファイル (YAML) を保存する"""
    try:
        with open(NEWS_CONFIG_FILE, 'w', encoding='utf-8') as f:
            # allow_unicode=True ensures Japanese characters are saved correctly
            yaml.dump(config, f, allow_unicode=True, default_flow_style=False)
        return True
    except IOError as e:
        print(f"ニュース設定ファイル ({NEWS_CONFIG_FILE}) の書き込みエラー: {e}")
        return False
    except yaml.YAMLError as e:
        print(f"ニュース設定ファイル ({NEWS_CONFIG_FILE}) のYAML書き込みエラー: {e}")
        return False

def has_required_roles(member, command_type: str) -> bool:
    """ユーザーが指定されたコマンドタイプに必要なロールを持っているか確認"""
    # コマンドタイプの設定が存在しない場合は誰でも利用可能
    if command_type not in ALLOWED_ROLES:
        return True

    # 許可ロールが空の場合は誰でも利用可能
    if not ALLOWED_ROLES[command_type]:
        return True

    # DMの場合(memberがdiscord.Memberではない場合)、許可ロールが空なら許可
    if not isinstance(member, discord.Member):
        return not ALLOWED_ROLES[command_type]  # 空リストならTrue、そうでなければFalse

    # ユーザーのロールを確認
    user_roles = [role.name for role in member.roles]

    # 必要なロールのいずれかを持っているか確認
    for allowed_role in ALLOWED_ROLES[command_type]:
        if allowed_role in user_roles:
            return True

    # どのロールも持っていない場合は権限なし
    return False

async def summarize_article(url, user_request=None):
    """記事の内容を要約する。ユーザーのリクエストがあれば考慮し、日本語で出力する。"""
    try:
        # Use new OpenAI API format (v1.0+)
        client = openai.AzureOpenAI(
            api_key=AZURE_OPENAI_API_KEY,
            api_version="2023-05-15",
            azure_endpoint=AZURE_OPENAI_ENDPOINT
        )

        # Construct the prompt
        system_prompt = "あなたはニュース記事を要約するアシスタントです。要約は必ず日本語で出力してください。"
        user_prompt = f"以下のニュース記事を日本語で要約してください。\n記事URL: {url}"
        if user_request:
            user_prompt += f"\n\nユーザーのリクエスト: 「{user_request}\nこのリクエストを考慮して要約してください。"
        else:
             user_prompt += "\n簡潔に要約してください。" # Default request if none provided

        print(f"--- Summarizer Prompt ---\nSystem: {system_prompt}\nUser: {user_prompt}\n-------------------------")

        response = client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt}
            ],
            max_tokens=250, # Increased slightly for potentially more detail
            temperature=0.5, # Slightly lower temp for more factual summary
        )
        
        # Extract the summary from the response
        summary = response.choices[0].message.content.strip()
        return summary
    except Exception as e:
        print(f"Error summarizing article: {e}")
        return None

def log_interaction(user_id, username, article_url, interaction_details, log_type='reaction'):
    """リアクションまたはコメント情報をログに記録"""
    history_data = load_reaction_history()
    
    # Ensure the 'reactions' key exists
    if "reactions" not in history_data:
        history_data["reactions"] = []

    timestamp = datetime.datetime.now(timezone.utc).isoformat()
    log_entry = {
        "user_id": user_id,
        "username": username,
        "article_url": article_url, # URL of the original message/article
        "type": log_type, # 'reaction' or 'comment'
        "timestamp": timestamp
    }

    if log_type == 'reaction':
        log_entry["emoji"] = interaction_details # The emoji string
        log_message = f"[REACTION LOG] {timestamp}: {username} ({user_id}) reacted with {interaction_details} to {article_url}"
    elif log_type == 'comment':
        log_entry["comment"] = interaction_details # The comment text
        log_message = f"[COMMENT LOG] {timestamp}: {username} ({user_id}) commented on {article_url}: \"{interaction_details[:50]}...\""
    else:
        log_message = f"[UNKNOWN LOG] {timestamp}: {username} ({user_id}) interacted with {article_url}"

    history_data["reactions"].append(log_entry)
    save_reaction_history(history_data)
    
    # Print to terminal
    print(log_message)
    return log_entry


def print_reaction_history():
    """リアクションとコメントの履歴をターミナルに表示"""
    reaction_data = load_reaction_history()
    
    print("\n===== INTERACTION HISTORY =====")
    # Use .get() to safely access 'reactions', defaulting to an empty list
    interactions = reaction_data.get("reactions", [])
    
    if not interactions:
        print("No interactions recorded yet.")
    else:
        for idx, entry in enumerate(interactions, 1):
            print(f"{idx}. User: {entry.get('username', 'N/A')} ({entry.get('user_id', 'N/A')})")
            log_type = entry.get('type', 'unknown')
            print(f"   Type: {log_type.capitalize()}")
            if log_type == 'reaction':
                print(f"   Emoji: {entry.get('emoji', 'N/A')}")
            elif log_type == 'comment':
                print(f"   Comment: {entry.get('comment', 'N/A')[:100]}...") # Show preview
            print(f"   URL/Item: {entry.get('article_url', 'N/A')}")
            print(f"   Time: {entry.get('timestamp', 'N/A')}")
            print("---")
    print("============================\n")

async def extract_article_content(url):
    """URLから記事のテキストコンテンツを抽出する"""
    try:
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36'
        }
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()
        
        soup = BeautifulSoup(response.text, 'html.parser')
        
        # Remove script and style elements
        for script in soup(["script", "style"]):
            script.extract()
        
        # Get text and clean it
        text = soup.get_text()
        lines = (line.strip() for line in text.splitlines())
        chunks = (phrase.strip() for line in lines for phrase in line.split("  "))
        text = '\n'.join(chunk for chunk in chunks if chunk)
        
        # Limit to first 3000 characters for API efficiency
        return text[:3000]
    except Exception as e:
        print(f"Error extracting content from {url}: {e}")
        return f"コンテンツの抽出に失敗しました: {e}"

async def get_personalized_recommendations(user_id, top_n=3):
    """ユーザーの履歴に基づいてパーソナライズされたニュース記事を推薦する"""
    if user_id not in user_news_history or len(user_news_history[user_id]) < 2:
        return []
    
    # ユーザーの履歴を取得
    user_history = user_news_history[user_id]
    
    # すべての記事を取得
    all_articles = []
    config = load_news_config()
    for category, feeds in config.get("rss_feeds", {}).items():
        for feed_url in feeds:
            try:
                feed = feedparser.parse(feed_url)
                for entry in feed.entries:
                    if hasattr(entry, 'link') and entry.link not in user_history:
                        title = entry.title if hasattr(entry, 'title') else "無題"
                        description = entry.description if hasattr(entry, 'description') else ""
                        pub_date = entry.published if hasattr(entry, 'published') else ""
                        all_articles.append({
                            'title': title,
                            'link': entry.link,
                            'description': description,
                            'published': pub_date
                        })
            except Exception as e:
                print(f"Error parsing feed {feed_url}: {e}")
    
    if not all_articles:
        return []
    
    # TF-IDFベクトルを作成
    try:
        # ユーザー履歴とすべての記事からコーパスを作成
        corpus = user_history + [a['link'] + ' ' + a['title'] + ' ' + a['description'] for a in all_articles]
        
        tfidf_vectorizer = TfidfVectorizer(stop_words='english')
        tfidf_matrix = tfidf_vectorizer.fit_transform(corpus)
        
        # ユーザー履歴のベクトルと記事ベクトル間のコサイン類似度を計算
        user_profile = tfidf_matrix[:len(user_history)].mean(axis=0)
        article_vectors = tfidf_matrix[len(user_history):]
        
        # 各記事の類似度スコアを計算
        similarity_scores = cosine_similarity(user_profile, article_vectors)[0]
        
        # 類似度に基づいて記事をランク付け
        ranked_articles = [(all_articles[i], similarity_scores[i]) 
                           for i in range(len(all_articles))]
        ranked_articles.sort(key=lambda x: x[1], reverse=True)
        
        # 上位N件を返す
        return [article for article, score in ranked_articles[:top_n]]
    except Exception as e:
        print(f"Error in recommendation algorithm: {e}")
        return []

async def daily_recommendations():
    """一日一回、ニュース推薦を配信するタスク"""
    try:
        # 初回は起動時すぐに実行せず、次の指定時刻まで待機
        await asyncio.sleep(3600)  # 1時間待機(初期化時間)
        
        while True:
            # ユーザー履歴がある全ユーザーに推薦を送信
            for user_id, history in user_news_history.items():
                if len(history) < 2:  # 履歴が少なすぎる場合はスキップ
                    continue
                
                try:
                    # ユーザーオブジェクトを取得
                    user = await client.fetch_user(user_id)
                    
                    # パーソナライズされた推薦記事を取得
                    recommendations = await get_personalized_recommendations(user_id, top_n=5)
                    
                    if recommendations:
                        # DMを送信
                        embed = discord.Embed(
                            title="📰 今日のニュース推薦",
                            description="あなたの閲覧履歴に基づいたおすすめ記事です。",
                            color=discord.Color.blue()
                        )
                        
                        for i, article in enumerate(recommendations, 1):
                            title = article['title']
                            link = article['link']
                            pub_date = article.get('published', '')
                            pub_info = f" ({pub_date})" if pub_date else ""
                            
                            embed.add_field(
                                name=f"{i}. {title}{pub_info}",
                                value=f"[記事を読む]({link})",
                                inline=False
                            )
                        
                        try:
                            await user.send(embed=embed)
                            print(f"Sent daily recommendations to {user.name} ({user_id})")
                        except Exception as e:
                            print(f"Failed to send daily recommendations to {user_id}: {e}")
                    
                except Exception as e:
                    print(f"Error processing daily recommendations for user {user_id}: {e}")
            
            # 24時間待機
            await asyncio.sleep(24 * 3600)
    except asyncio.CancelledError:
        print("Daily recommendations task was cancelled")
    except Exception as e:
        print(f"Error in daily recommendations task: {e}")

@client.event
async def on_ready():
    print(f"We have logged in as {client.user}")
    # Load user history from file
    load_user_history()
    # Print current reaction history
    print_reaction_history()
    # Start the daily recommendations task
    client.loop.create_task(daily_recommendations())

@client.event
async def on_message(message):
    if message.author == client.user:
        return
        
    # Add command to display reaction history
    if message.content.startswith("!reaction-history"):
        if not has_required_roles(message.author, "news"):
            await message.channel.send("⚠️ リアクション履歴表示機能を使用する権限がありません。")
            return
            
        # Print to terminal
        print_reaction_history()
        
        # Send to channel
        reaction_data = load_reaction_history()
        
        if not reaction_data["reactions"]:
            await message.channel.send("リアクション履歴はまだありません。")
            return
            
        embed = discord.Embed(
            title="👍 リアクション履歴",
            description="チャンネル内の投稿へのリアクション履歴",
            color=discord.Color.blue()
        )
        
        # Group by URL for cleaner display
        url_reactions = {}
        for entry in reaction_data["reactions"]:
            url = entry["article_url"]
            if url not in url_reactions:
                url_reactions[url] = []
            url_reactions[url].append(entry)
        
        # Add fields to embed (limited to avoid Discord's message size limits)
        count = 0
        for url, entries in list(url_reactions.items())[:10]:  # Limit to 10 URLs
            usernames = ", ".join([f"{e['username']}" for e in entries[:5]])
            if len(entries) > 5:
                usernames += f" and {len(entries)-5} more"
                
            embed.add_field(
                name=f"記事: {url[:75]}{'...' if len(url) > 75 else ''}",
                value=f"リアクション: {len(entries)}\nユーザー: {usernames}",
                inline=False
            )
            count += 1
            
        embed.set_footer(text=f"合計: {len(reaction_data['reactions'])}件のリアクション")
        
        await message.channel.send(embed=embed)
        return

    # --- Reply Handling in Tech Channel ---
    # Check if it's a reply and in the tech channel BEFORE checking commands
    if message.reference and message.channel.id == TECH_CHANNEL_ID:
        print(f"--- Reply detected in Tech Channel from {message.author.name} ---")
        try:
            # Fetch the original message being replied to
            original_message = await message.channel.fetch_message(message.reference.message_id)
            print(f"Original message fetched (ID: {original_message.id})")

            # Extract URL from the original message (using the same logic as on_reaction_add)
            original_content = original_message.content
            article_url = None
            url_pattern = re.compile(r'https?://\S+')

            if "リンク:" in original_content:
                article_url = original_content.split("リンク:", 1)[1].strip()
                print(f"Extracted URL from original message 'リンク:': {article_url}")
            else:
                urls = url_pattern.findall(original_content)
                if urls:
                    article_url = urls[0]
                    print(f"Found URL in original message content: {article_url}")
                else:
                    for embed in original_message.embeds:
                        if embed.url and embed.url != "https://discord.com":
                            article_url = embed.url
                            print(f"Found URL in original message embed: {article_url}")
                            break
                        if article_url is None:
                            for field in embed.fields:
                                if field.value:
                                    field_urls = url_pattern.findall(field.value)
                                    if field_urls:
                                        article_url = field_urls[0]
                                        print(f"Found URL in original message embed field: {article_url}")
                                        break
                        if article_url:
                            break
                    
                    if not article_url:
                         # Fallback identifier for the original message
                        embed_identifier = None
                        if original_message.embeds:
                            embed = original_message.embeds[0]
                            if embed.title:
                                embed_identifier = f"Embed Title: {embed.title[:80]}"
                            elif embed.description:
                                embed_identifier = f"Embed Desc: {embed.description[:80]}"
                        
                        if embed_identifier:
                            article_url = embed_identifier
                            print(f"No URL found in original, using embed identifier: {article_url}")
                        elif original_content:
                            article_url = f"Message Text: {original_content[:80]}"
                            print(f"No URL found in original, using message text: {article_url}")
                        else:
                            article_url = f"Message ID: {original_message.id}"
                            print(f"No URL/text in original, using message ID: {article_url}")

            # Log the comment interaction
            user_id = message.author.id
            username = message.author.display_name or message.author.name
            comment_text = message.content
            log_interaction(user_id, username, article_url, comment_text, log_type='comment')

            # Update user history with the URL from the original message
            is_likely_url = article_url and article_url.startswith("http")
            if is_likely_url:
                if user_id not in user_news_history:
                    user_news_history[user_id] = []
                if article_url not in user_news_history[user_id]:
                    user_news_history[user_id].append(article_url)
                    save_user_history()
                    print(f"Added URL from replied message to user history: {article_url}")

            # --- Send DM with comment acknowledgement, summary, and recommendations ---
            summary = None
            if is_likely_url:
                try:
                    print(f"Generating summary for original article: {article_url} with request: {comment_text}")
                    # Pass the user's comment as the request
                    summary = await summarize_article(article_url, user_request=comment_text) 
                    if summary: print(f"Generated summary (Japanese requested): {summary[:100]}...")
                    else: print("Summary generation returned None.")
                except Exception as e:
                    print(f"Error summarizing article from reply context: {e}")

            recommendations = await get_personalized_recommendations(user_id, top_n=5)
            if recommendations: print(f"Generated {len(recommendations)} recommendations for commenter")
            else: print("No recommendations generated for commenter")

            try:
                dm_text = f"👋 こんにちは、{username}さん!\n\n✅ あなたのコメントを記録しました:\n> {comment_text}\n\n関連アイテム: {article_url}"
                if summary:
                    dm_text += f"\n\n**元の記事の要約:**\n{summary}"
                elif is_likely_url:
                    dm_text += "\n\n元の記事の要約生成に失敗しました。"

                if recommendations:
                    embed = discord.Embed(
                        title="📰 あなたにおすすめの記事",
                        description="あなたの閲覧履歴に基づいておすすめします。",
                        color=discord.Color.blue()
                    )
                    for i, article in enumerate(recommendations, 1):
                        title = article['title']
                        link = article['link']
                        pub_date = article.get('published', '')
                        pub_info = f" ({pub_date})" if pub_date else ""
                        embed.add_field(name=f"{i}. {title}{pub_info}", value=f"[記事を読む]({link})", inline=False)
                    
                    history_count = len(user_news_history.get(user_id, []))
                    embed.set_footer(text=f"あなたの履歴: {history_count}")
                    
                    await message.author.send(dm_text)
                    await message.author.send(embed=embed)
                else:
                    await message.author.send(dm_text + "\n\n推薦記事を生成するにはもう少し閲覧履歴が必要です。")
                
                print(f"Successfully sent comment acknowledgement DM to {username} (ID: {user_id})")

            except discord.errors.Forbidden:
                print(f"Error sending DM to user {user_id} (reply context): Bot is blocked or DMs are disabled.")
            except Exception as e:
                print(f"Error sending DM to user {user_id} (reply context): {e}")

        except discord.NotFound:
            print(f"Could not fetch original message (ID: {message.reference.message_id}). It might have been deleted.")
        except Exception as e:
            print(f"Error processing reply message: {e}")
        
        # Return here to prevent processing the reply as a command
        return 
        
    # --- End Reply Handling ---


    # News management commands (ensure these are not triggered by replies handled above)
    if message.content.startswith("!recommend-news-add"):
        if not has_required_roles(message.author, "news"):
            await message.channel.send("⚠️ ニュースフィード管理機能を使用する権限がありません。")
            return

        parts = message.content.split(maxsplit=2)
        if len(parts) < 3:
            await message.channel.send("正しい形式: `!recommend-news-add <tech(チャンネル名)> <RSSフィードURL>`")
            return

        category = parts[1]
        url = parts[2].strip('<>')  # Remove potential markdown <> around URL

        # Basic URL validation (optional, can be more robust)
        if not url.startswith("http://") and not url.startswith("https://"):
            await message.channel.send("⚠️ 無効なURL形式です。http:// または https:// で始まる必要があります。")
            return

        config = load_news_config()
        if 'rss_feeds' not in config:
            config['rss_feeds'] = {}

        if category not in config['rss_feeds']:
            config['rss_feeds'][category] = []
            await message.channel.send(f"ℹ️ 新しいカテゴリ '{category}' を作成しました。")

        if url in config['rss_feeds'][category]:
            await message.channel.send(f"⚠️ URL '{url}' はカテゴリ '{category}' に既に存在します。")
        else:
            config['rss_feeds'][category].append(url)
            if save_news_config(config):
                await message.channel.send(f"✅ URL '{url}' をカテゴリ '{category}' に追加しました。")
            else:
                await message.channel.send("❌ 設定ファイルの保存中にエラーが発生しました。")
        return
        
    elif message.content.startswith("!recommend-history"):
        # Check if user has proper permissions
        if not has_required_roles(message.author, "news"):
            await message.channel.send("⚠️ 履歴表示機能を使用する権限がありません。")
            return
            
        # Get target user - use message author by default
        target_user = message.author
        
        # Acknowledge receipt of command in channel
        await message.channel.send(f"📰 {message.author.mention} 履歴情報をDMで送信します。")
        
        # Check if an admin is requesting someone else's history
        parts = message.content.split()
        if len(parts) > 1 and message.author.guild_permissions.administrator:
            # Try to find user by mention
            if len(message.mentions) > 0:
                target_user = message.mentions[0]
            else:
                # Try to find by username
                username = parts[1]
                found = False
                for member in message.guild.members:
                    if member.name.lower() == username.lower() or str(member.id) == username:
                        target_user = member
                        found = True
                        break
                
                if not found:
                    await message.author.send(f"⚠️ ユーザー '{username}' が見つかりません。")
                    return
        
        # Get the user's history
        user_id = target_user.id
        if user_id not in user_news_history or not user_news_history[user_id]:
            await message.author.send(f"📰 {target_user.display_name} の履歴はありません。")
            return
            
        # Create embed with history
        embed = discord.Embed(
            title=f"📰 {target_user.display_name} のニュース履歴",
            color=discord.Color.blue()
        )
        
        # Add history items (limited to 25 most recent to avoid hitting Discord's limits)
        history = user_news_history[user_id][-25:]
        history_text = []
        
        for i, url in enumerate(history):
            history_text.append(f"{i+1}. {url}")
        
        # Split into multiple fields if needed (Discord has 1024 char limit per field)
        chunks = []
        current_chunk = []
        current_length = 0
        
        for item in history_text:
            if current_length + len(item) + 1 > 1000:  # +1 for newline
                chunks.append("\n".join(current_chunk))
                current_chunk = [item]
                current_length = len(item)
            else:
                current_chunk.append(item)
                current_length += len(item) + 1
        
        if current_chunk:
            chunks.append("\n".join(current_chunk))
        
        # Add fields to embed
        for i, chunk in enumerate(chunks):
            embed.add_field(
                name=f"最近の履歴 {i+1}/{len(chunks)}" if len(chunks) > 1 else "最近の履歴",
                value=f"```\n{chunk}\n```",
                inline=False
            )
        
        # Add total count
        embed.set_footer(text=f"合計: {len(user_news_history[user_id])}")
        
        # Send the embed as a DM
        try:
            await message.author.send(embed=embed)
        except Exception as e:
            print(f"Error sending DM to user: {e}")
            await message.channel.send("⚠️ DMの送信中にエラーが発生しました。プライバシー設定をご確認ください。")
        return

    elif message.content.startswith("!recommend-news-list"):
        if not has_required_roles(message.author, "news"):
            await message.channel.send("⚠️ ニュースフィード管理機能を使用する権限がありません。")
            return

        parts = message.content.split(maxsplit=1)
        target_category = parts[1] if len(parts) > 1 else None

        config = load_news_config()
        feeds = config.get('rss_feeds', {})

        if not feeds:
            await message.channel.send("📰 現在登録されているRSSフィードはありません。")
            return

        embed = discord.Embed(title="📰 RSSフィードリスト", color=discord.Color.blue())

        if target_category:
            if target_category in feeds:
                urls = feeds[target_category]
                if urls:
                    # Use numbered list within code block for better readability
                    url_list = "\n".join([f"{i + 1}. {url}" for i, url in enumerate(urls)])
                    embed.add_field(name=f"カテゴリ: {target_category}", value=f"```\n{url_list}\n```",
                                    inline=False)
                else:
                    embed.add_field(name=f"カテゴリ: {target_category}", value="このカテゴリにはURLが登録されていません。",
                                    inline=False)
            else:
                embed.description = f"❓ カテゴリ '{target_category}' は見つかりませんでした。"
        else:
            # List all categories and their URLs
            if not feeds:
                embed.description = "現在登録されているRSSフィードはありません。"
            else:
                for category, urls in feeds.items():
                    if urls:
                        # Limit URL display slightly for overview
                        url_list = "\n".join([f"- {url}" for url in urls[:5]])  # Show first 5
                        if len(urls) > 5:
                            url_list += f"\n- ...他{len(urls) - 5}"
                        embed.add_field(name=f"カテゴリ: {category} ({len(urls)}件)", value=f"```{url_list}```",
                                        inline=False)
                    else:
                        embed.add_field(name=f"カテゴリ: {category}", value="登録URLなし", inline=False)

        await message.channel.send(embed=embed)
        return

    elif message.content.startswith("!recommend-news-delete"):
        if not has_required_roles(message.author, "news"):
            await message.channel.send("⚠️ ニュースフィード管理機能を使用する権限がありません。")
            return

        parts = message.content.split(maxsplit=2)
        if len(parts) < 3:
            await message.channel.send("正しい形式: `!recommend-news-delete <カテゴリ名> <RSSフィードURL>`")
            return

        category = parts[1]
        url_to_delete = parts[2].strip('<>')

        config = load_news_config()
        feeds = config.get('rss_feeds', {})

        if category not in feeds:
            await message.channel.send(f"❓ カテゴリ '{category}' が見つかりません。")
            return

        if url_to_delete not in feeds[category]:
            await message.channel.send(f"❓ URL '{url_to_delete}' はカテゴリ '{category}' に見つかりません。")
            return

        feeds[category].remove(url_to_delete)
        # Option: Remove category if it becomes empty
        # if not feeds[category]:
        #     del feeds[category]
        #     await message.channel.send(f"ℹ️ カテゴリ '{category}' が空になったため削除しました。")

        if save_news_config(config):
            await message.channel.send(f"✅ URL '{url_to_delete}' をカテゴリ '{category}' から削除しました。")
        else:
            await message.channel.send("❌ 設定ファイルの保存中にエラーが発生しました。")
        return

# @client.event
# async def on_reaction_add(reaction, user):
#     """ (Disabled - Using on_raw_reaction_add instead for reliability) """
#     pass

@client.event
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent):
    """Handles reaction additions using raw events for reliability."""
    print(f"--- Raw reaction detected: User {payload.user_id}, Emoji {payload.emoji}, Msg {payload.message_id}, Chan {payload.channel_id} ---")

    # Ignore reactions from the bot itself or not in the target channel
    if payload.user_id == client.user.id or payload.channel_id != TECH_CHANNEL_ID:
        print(f"--- Skipping raw reaction (Bot user or wrong channel: {payload.channel_id}) ---")
        return

    print(f"--- Processing raw reaction in TECH_CHANNEL_ID ({TECH_CHANNEL_ID}) ---")
    try:
        # Fetch necessary objects
        channel = client.get_channel(payload.channel_id)
        if not channel:
             print(f"--- ERROR: Could not find channel {payload.channel_id} ---")
             return
        message = await channel.fetch_message(payload.message_id)
        user = await client.fetch_user(payload.user_id)
        if not user:
            print(f"--- ERROR: Could not fetch user {payload.user_id} ---")
            return

        print(f"--- Fetched objects: User={user.name}, Message={message.id} ---")

        emoji_str = str(payload.emoji)
        username = user.display_name or user.name

        # --- Extract URL from the reacted message ---
        message_content = message.content
        article_url = None
        url_pattern = re.compile(r'https?://\S+')

        if "リンク:" in message_content:
            article_url = message_content.split("リンク:", 1)[1].strip()
            print(f"Extracted URL from reacted message 'リンク:': {article_url}")
        else:
            urls = url_pattern.findall(message_content)
            if urls:
                article_url = urls[0]
                print(f"Found URL in reacted message content: {article_url}")
            else:
                for embed in message.embeds:
                    if embed.url and embed.url != "https://discord.com":
                        article_url = embed.url
                        print(f"Found URL in reacted message embed: {article_url}")
                        break
                    if article_url is None:
                        for field in embed.fields:
                            if field.value:
                                field_urls = url_pattern.findall(field.value)
                                if field_urls:
                                    article_url = field_urls[0]
                                    print(f"Found URL in reacted message embed field: {article_url}")
                                    break
                    if article_url:
                        break
                
                if not article_url:
                    embed_identifier = None
                    if message.embeds:
                        embed = message.embeds[0]
                        if embed.title: embed_identifier = f"Embed Title: {embed.title[:80]}"
                        elif embed.description: embed_identifier = f"Embed Desc: {embed.description[:80]}"
                    
                    if embed_identifier: article_url = embed_identifier
                    elif message_content: article_url = f"Message Text: {message_content[:80]}"
                    else: article_url = f"Message ID: {message.id}"
                    print(f"No URL found in reacted message, using identifier: {article_url}")
        # --- End URL Extraction ---

        # Log the interaction
        log_interaction(user.id, username, article_url, emoji_str, log_type='reaction')
        print(f"--- Logged reaction interaction for {username} ---")

        # Update user history
        is_likely_url = article_url and article_url.startswith("http")
        if is_likely_url:
            if user.id not in user_news_history: user_news_history[user.id] = []
            if article_url not in user_news_history[user.id]:
                user_news_history[user.id].append(article_url)
                save_user_history()
                print(f"Added URL from reaction to user history: {article_url}")

        # --- Send DM with summary and recommendations ---
        summary = None
        if is_likely_url:
            try:
                print(f"Generating Japanese summary for reacted article: {article_url}")
                # Request default Japanese summary for reactions
                summary = await summarize_article(article_url) 
                if summary: print(f"Generated summary (Japanese requested): {summary[:100]}...")
                else: print("Summary generation returned None.")
            except Exception as e:
                print(f"Error summarizing article from reaction context: {e}")

        recommendations = await get_personalized_recommendations(user.id, top_n=5)
        if recommendations: print(f"Generated {len(recommendations)} recommendations for reactor")
        else: print("No recommendations generated for reactor")

        try:
            dm_text = f"👋 こんにちは、{username}さん!\n\n✅ あなたの反応を記録しました: {emoji_str}{article_url}"
            if summary:
                dm_text += f"\n\n**記事の要約 (日本語):**\n{summary}"
            elif is_likely_url:
                dm_text += "\n\n記事の要約生成に失敗しました。"

            if recommendations:
                embed = discord.Embed(
                    title="📰 あなたにおすすめの記事",
                    description="あなたの閲覧履歴に基づいておすすめします。",
                    color=discord.Color.blue()
                )
                for i, article in enumerate(recommendations, 1):
                    title = article['title']
                    link = article['link']
                    pub_date = article.get('published', '')
                    pub_info = f" ({pub_date})" if pub_date else ""
                    embed.add_field(name=f"{i}. {title}{pub_info}", value=f"[記事を読む]({link})", inline=False)
                
                history_count = len(user_news_history.get(user.id, []))
                embed.set_footer(text=f"あなたの履歴: {history_count}")
                
                await user.send(dm_text)
                await user.send(embed=embed)
            else:
                await user.send(dm_text + "\n\n推薦記事を生成するにはもう少し閲覧履歴が必要です。")
            
            print(f"Successfully sent reaction acknowledgement DM to {username} (ID: {user.id})")

        except discord.errors.Forbidden:
            print(f"Error sending DM to user {user.id} (reaction context): Bot is blocked or DMs are disabled.")
        except Exception as e:
            print(f"Error sending DM to user {user.id} (reaction context): {e}")

    except discord.NotFound:
         print(f"--- ERROR: Could not fetch message {payload.message_id} or user {payload.user_id} (NotFound) ---")
    except Exception as e:
        print(f"--- ERROR: Unhandled exception in on_raw_reaction_add: {e} ---")
        import traceback
        traceback.print_exc() # Print full traceback for debugging


# Run the Discord client
if __name__ == "__main__":
    print("Starting news recommendation bot...")
    client.run(DISCORD_TOKEN)

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?