目次
- Botでできること
- 前提・環境準備
- ディレクトリ構成と依存ライブラリ
- 環境変数の設定 (.env)
-
主要モジュール解説
- load/save 周り(JSON・YAML)
- 要約機能 (
summarize_article
) - パーソナライズ推薦 (
get_personalized_recommendations
) - 日次レコメンドタスク (
daily_recommendations
) - Discord イベントハンドラ
- コマンド一覧
- デプロイと運用
- おわりに
- コード全文
この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チャンネル内でのリプライをコンテキストとして要約・推薦
-
各ハンドラ内で共通して以下を実施:
- URL抽出(メッセージ本文・Embed含む)
- ログ記録(reaction_history.json)
- 履歴更新(news_user_history.json)
- 要約&推薦生成 → DM
コマンド一覧
コマンド | 概要 |
---|---|
!recommend-news-add <カテゴリ> <RSS URL> |
RSSフィードをカテゴリに追加 |
!recommend-news-delete <カテゴリ> <RSS URL> |
RSSフィードをカテゴリから削除 |
!recommend-news-list [カテゴリ] |
登録中のRSSフィード一覧を表示 |
!recommend-history [@ユーザー] |
指定ユーザー(自身)の履歴をDM送信 |
!reaction-history |
リアクション履歴全体を表示(権限要) |
デプロイと運用
-
VPSへの配置
-
bot.py
/.env
/news-config.yaml
/JSONファイル群 を同一ディレクトリに配置
-
-
永続化とプロセスマネージャ
-
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
-
運用ポイント
- 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)