From 1de4c6616d075d609db4a4697b612bc299232d7e Mon Sep 17 00:00:00 2001 From: shibadogcap Date: Sat, 2 May 2026 10:11:01 +0900 Subject: [PATCH] inital commit --- .env.example | 6 + .gitignore | 12 + .python-version | 1 + Dockerfile | 31 +++ README.md | 0 bot.py | 255 ++++++++++++++++++ database.py | 99 +++++++ docker-compose.yml | 14 + main.py | 27 ++ pyproject.toml | 13 + tasks.db | Bin 0 -> 12288 bytes uv.lock | 630 +++++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 1088 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bot.py create mode 100644 database.py create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 tasks.db create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..91b22fc --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +DISCORD_TOKEN=your_bot_token_here +NOTIFICATION_CHANNEL_ID=your_channel_id_here +GUILD_ID=your_guild_id_here_for_instant_sync +# Optional: Notion Integration +NOTION_TOKEN=your_notion_token_here +NOTION_DATABASE_ID=your_notion_database_id_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..898e654 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +.env \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..079eddf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Use a Python image with uv pre-installed +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim + +# Install dependencies +WORKDIR /app + +# Set timezone +ENV TZ=Asia/Tokyo +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Enable bytecode compilation +ENV UV_COMPILE_BYTECODE=1 + +# Copy from the cache instead of linking since it's a container +ENV UV_LINK_MODE=copy + +# Install the project's dependencies using the lockfile and pyproject.toml +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --no-install-project --no-dev + +# Copy the rest of the application +COPY . /app + +# Final sync to include the project itself +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev + +# Run the bot +CMD ["uv", "run", "main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..5300b5c --- /dev/null +++ b/bot.py @@ -0,0 +1,255 @@ +import discord +from discord import app_commands +from discord.ext import commands, tasks +import datetime +import os +import asyncio +from typing import Optional, List, Dict, Any +from database import Database +from dotenv import load_dotenv + +load_dotenv() + +# --- Views & Modals --- + +class TaskModal(discord.ui.Modal, title="新規タスク登録"): + task_title = discord.ui.TextInput(label="タイトル", placeholder="タスク名", required=True, max_length=100) + priority = discord.ui.TextInput(label="優先順位 (SSS-D)", placeholder="S", default="A", min_length=1, max_length=3) + deadline = discord.ui.TextInput(label="締め切り (YYYY-MM-DD HH:MM)", placeholder="2024-12-31 23:59", required=True) + details = discord.ui.TextInput(label="詳細", style=discord.TextStyle.long, required=False, max_length=1000) + + def __init__(self, db: Database): + super().__init__() + self.db = db + + async def on_submit(self, interaction: discord.Interaction): + prio = self.priority.value.upper() + if prio not in ["SSS", "SS", "S", "A", "B", "C", "D"]: + await interaction.response.send_message(f"❌ 優先順位が無効です。", ephemeral=True) + return + + try: + deadline_dt = datetime.datetime.strptime(self.deadline.value, "%Y-%m-%d %H:%M") + except ValueError: + await interaction.response.send_message("❌ 日時の形式が正しくありません。", ephemeral=True) + return + + tid = await self.db.add_task( + user_id=interaction.user.id, + user_name=interaction.user.display_name, + guild_id=interaction.guild_id, + channel_id=interaction.channel_id, + title=self.task_title.value, + description=self.details.value, + priority=prio, + deadline=deadline_dt.isoformat() + ) + await interaction.response.send_message(f"✅ タスク『{self.task_title.value}』(ID: {tid}) を登録しました!", ephemeral=True) + +class EditTaskModal(discord.ui.Modal): + def __init__(self, db: Database, task: dict): + super().__init__(title=f"タスク編集 (ID: {task['id']})") + self.db = db + self.task_id = task['id'] + deadline_dt = datetime.datetime.fromisoformat(task['deadline']) + + self.task_title = discord.ui.TextInput(label="タイトル", default=task['title'], required=True) + self.priority = discord.ui.TextInput(label="優先順位 (SSS-D)", default=task['priority'], min_length=1, max_length=3) + self.deadline = discord.ui.TextInput(label="締め切り (YYYY-MM-DD HH:MM)", default=deadline_dt.strftime("%Y-%m-%d %H:%M"), required=True) + self.details = discord.ui.TextInput(label="詳細", default=task['description'] or "", style=discord.TextStyle.long, required=False) + + self.add_item(self.task_title) + self.add_item(self.priority) + self.add_item(self.deadline) + self.add_item(self.details) + + async def on_submit(self, interaction: discord.Interaction): + prio = self.priority.value.upper() + if prio not in ["SSS", "SS", "S", "A", "B", "C", "D"]: + await interaction.response.send_message("❌ 優先順位が無効です。", ephemeral=True) + return + try: + deadline_dt = datetime.datetime.strptime(self.deadline.value, "%Y-%m-%d %H:%M") + except ValueError: + await interaction.response.send_message("❌ 日時の形式が正しくありません。", ephemeral=True) + return + + await self.db.update_task(self.task_id, self.task_title.value, self.details.value, prio, deadline_dt.isoformat()) + await interaction.response.send_message(f"✨ タスクを更新しました!", ephemeral=True) + +class TaskActionView(discord.ui.View): + """通知やリストに表示する「完了」ボタン""" + def __init__(self, db: Database, task_id: str): + super().__init__(timeout=None) + self.db = db + self.task_id = task_id + + @discord.ui.button(label="✅ 完了にする", style=discord.ButtonStyle.success) + async def complete(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.db.complete_task(self.task_id) + # メッセージを「完了済み」に更新 + embed = interaction.message.embeds[0] + embed.title = f"🏁 【完了】 {embed.title}" + embed.color = discord.Color.light_grey() + await interaction.response.edit_message(content="お疲れ様でした!", embed=embed, view=None) + +class ControlPanel(discord.ui.View): + def __init__(self, db: Database): + super().__init__(timeout=None) + self.db = db + + @discord.ui.button(label="➕ タスク追加", style=discord.ButtonStyle.success, custom_id="panel_add") + async def add_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_modal(TaskModal(self.db)) + + @discord.ui.button(label="📋 タスク一覧", style=discord.ButtonStyle.primary, custom_id="panel_list") + async def list_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + tasks_list = await self.db.get_user_tasks(interaction.user.id, interaction.channel_id) + if not tasks_list: + await interaction.response.send_message("📋 未完了のタスクはありません。", ephemeral=True) + return + + await interaction.response.defer(ephemeral=True) + for t in tasks_list: + deadline_fmt = datetime.datetime.fromisoformat(t['deadline']).strftime("%m/%d %H:%M") + embed = discord.Embed(title=t['title'], color=discord.Color.green()) + embed.add_field(name="優先度", value=t['priority'], inline=True) + embed.add_field(name="締切", value=deadline_fmt, inline=True) + if t['description']: + embed.description = t['description'] + await interaction.followup.send(embed=embed, view=TaskActionView(self.db, t['id']), ephemeral=True) + +# --- Bot Core --- + +class TaskBot(commands.Bot): + def __init__(self, db: Database): + intents = discord.Intents.default() + intents.message_content = True + intents.members = True + super().__init__(command_prefix="!", intents=intents) + self.db = db + + async def setup_hook(self): + await self.db.initialize() + self.daily_notification.start() + self.deadline_monitor.start() + await self.add_cog(TaskCommands(self)) + print("Setup hook completed.") + + async def on_ready(self): + print(f"Logged in as {self.user} (ID: {self.user.id})") + + async def on_message(self, message: discord.Message): + if message.author.bot: return + if self.user.mentioned_in(message): + await message.reply("📋 **タスク管理パネル**", view=ControlPanel(self.db)) + await self.process_commands(message) + + @tasks.loop(time=datetime.time(hour=7, minute=0)) + async def daily_notification(self): + print("Running daily notification...") + tasks_list = await self.db.get_all_active_tasks() + now = datetime.datetime.now() + for t in tasks_list: + deadline = datetime.datetime.fromisoformat(t["deadline"]) + diff = deadline - now + new_prio = t["priority"] + if diff.days <= 1: new_prio = "SSS" + elif diff.days <= 3: new_prio = "SS" + elif diff.days <= 7: new_prio = "S" + if new_prio != t["priority"]: + await self.db.update_priority(t["id"], new_prio) + + # チャンネルごとの通知 + channel_tasks = {} + for t in tasks_list: + cid = t["channel_id"] + if cid not in channel_tasks: channel_tasks[cid] = {} + uid = t["user_id"] + if uid not in channel_tasks[cid]: channel_tasks[cid][uid] = [] + channel_tasks[cid][uid].append(t) + + for cid, u_dict in channel_tasks.items(): + channel = self.get_channel(cid) + if not channel: continue + for uid, t_list in u_dict.items(): + embed = discord.Embed(title="☀️ 本日のタスクリマインド", color=discord.Color.gold()) + desc = "\n".join([f"• **[{t['priority']}] {t['title']}** ({datetime.datetime.fromisoformat(t['deadline']).strftime('%m/%d %H:%M')})" for t in t_list]) + embed.description = desc + await channel.send(content=f"<@{uid}> さん、今日の予定です!", embed=embed) + + @tasks.loop(minutes=1) + async def deadline_monitor(self): + now = datetime.datetime.now() + tasks_list = await self.db.get_all_active_tasks() + for t in tasks_list: + deadline = datetime.datetime.fromisoformat(t["deadline"]) + diff = deadline - now + seconds_left = diff.total_seconds() + + channel = self.get_channel(t["channel_id"]) + if not channel: continue + + # 1時間前 + if 0 < seconds_left <= 3600 and t['last_reminded_type'] != '1h': + embed = discord.Embed(title=f"⏳ 締切1時間前: {t['title']}", color=discord.Color.orange()) + await channel.send(content=f"<@{t['user_id']}> 準備はいいですか?あと1時間です!", embed=embed, view=TaskActionView(self.db, t['id'])) + await self.db.update_reminded_type(t['id'], '1h') + + # 締切 + elif seconds_left <= 0 and t['last_reminded_type'] != '0h': + embed = discord.Embed(title=f"⏰ 締切時刻です!: {t['title']}", color=discord.Color.red()) + await channel.send(content=f"<@{t['user_id']}> 締切時間になりました!完了しましたか?", embed=embed, view=TaskActionView(self.db, t['id'])) + await self.db.update_reminded_type(t['id'], '0h') + +class TaskCommands(commands.Cog): + def __init__(self, bot: TaskBot): + self.bot = bot + self.db = bot.db + + async def task_autocomplete(self, interaction: discord.Interaction, current: str): + tasks = await self.db.get_user_tasks(interaction.user.id, interaction.channel_id) + return [app_commands.Choice(name=f"[{t['priority']}] {t['title']}", value=t['id']) for t in tasks if current.lower() in t['title'].lower()][:25] + + @commands.command(name="sync") + @commands.is_owner() + async def sync_commands(self, ctx: commands.Context, guild_only: bool = True): + try: + if guild_only: + self.bot.tree.copy_global_to(guild=ctx.guild) + await self.bot.tree.sync(guild=ctx.guild) + await ctx.send("✅ 同期完了(サーバー)") + else: + await self.bot.tree.sync() + await ctx.send("✅ 同期完了(グローバル)") + except Exception as e: await ctx.send(f"❌ エラー: {e}") + + @app_commands.command(name="add") + async def add_slash(self, interaction: discord.Interaction): + await interaction.response.send_modal(TaskModal(self.db)) + + @app_commands.command(name="list") + async def list_tasks_slash(self, interaction: discord.Interaction): + tasks_list = await self.db.get_user_tasks(interaction.user.id, interaction.channel_id) + if not tasks_list: + await interaction.response.send_message("📋 未完了のタスクはありません。", ephemeral=True) + return + await interaction.response.defer(ephemeral=True) + for t in tasks_list: + deadline_fmt = datetime.datetime.fromisoformat(t['deadline']).strftime("%m/%d %H:%M") + embed = discord.Embed(title=f"[{t['priority']}] {t['title']}", color=discord.Color.green()) + embed.description = f"📅 締切: {deadline_fmt}\n{t['description'] or ''}" + await interaction.followup.send(embed=embed, view=TaskActionView(self.db, t['id']), ephemeral=True) + + @app_commands.command(name="done") + @app_commands.autocomplete(task_id=task_autocomplete) + async def done_slash(self, interaction: discord.Interaction, task_id: str): + await self.db.complete_task(task_id) + await interaction.response.send_message(f"✅ 完了にしました!", ephemeral=True) + + @app_commands.command(name="edit") + @app_commands.autocomplete(task_id=task_autocomplete) + async def edit_slash(self, interaction: discord.Interaction, task_id: str): + task = await self.db.get_task_by_id(task_id) + if task: await interaction.response.send_modal(EditTaskModal(self.db, task)) + else: await interaction.response.send_message("❌ 見つかりません。", ephemeral=True) diff --git a/database.py b/database.py new file mode 100644 index 0000000..9d5ad13 --- /dev/null +++ b/database.py @@ -0,0 +1,99 @@ +import aiosqlite +import datetime +import uuid +from typing import List, Optional, Dict, Any + +class Database: + def __init__(self, db_path: str = "tasks.db"): + self.db_path = db_path + + async def initialize(self): + async with aiosqlite.connect(self.db_path) as db: + await db.execute(""" + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + user_name TEXT, + guild_id INTEGER, + channel_id INTEGER, + title TEXT NOT NULL, + description TEXT, + priority TEXT NOT NULL, + deadline TEXT NOT NULL, + is_completed INTEGER DEFAULT 0, + notion_page_id TEXT, + created_at TEXT DEFAULT (DATETIME('now', 'localtime')), + last_reminded_type TEXT DEFAULT '' + ) + """) + await db.commit() + + async def add_task(self, user_id: int, user_name: str, guild_id: Optional[int], + channel_id: int, title: str, description: str, + priority: str, deadline: str) -> str: + task_id = str(uuid.uuid4())[:8] # 短めのUUID(8文字)を使用 + async with aiosqlite.connect(self.db_path) as db: + await db.execute(""" + INSERT INTO tasks (id, user_id, user_name, guild_id, channel_id, title, description, priority, deadline) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (task_id, user_id, user_name, guild_id, channel_id, title, description, priority, deadline)) + await db.commit() + return task_id + + async def get_user_tasks(self, user_id: int, channel_id: Optional[int] = None) -> List[Dict[str, Any]]: + async with aiosqlite.connect(self.db_path) as db: + db.row_factory = aiosqlite.Row + if channel_id: + query = "SELECT * FROM tasks WHERE user_id = ? AND channel_id = ? AND is_completed = 0 ORDER BY deadline ASC" + params = (user_id, channel_id) + else: + query = "SELECT * FROM tasks WHERE user_id = ? AND is_completed = 0 ORDER BY deadline ASC" + params = (user_id,) + + async with db.execute(query, params) as cursor: + rows = await cursor.fetchall() + return [dict(row) for row in rows] + + async def get_all_active_tasks(self) -> List[Dict[str, Any]]: + async with aiosqlite.connect(self.db_path) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM tasks WHERE is_completed = 0") as cursor: + rows = await cursor.fetchall() + return [dict(row) for row in rows] + + async def get_task_by_id(self, task_id: str) -> Optional[Dict[str, Any]]: + async with aiosqlite.connect(self.db_path) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)) as cursor: + row = await cursor.fetchone() + return dict(row) if row else None + + async def update_priority(self, task_id: str, new_priority: str): + async with aiosqlite.connect(self.db_path) as db: + await db.execute("UPDATE tasks SET priority = ? WHERE id = ?", (new_priority, task_id)) + await db.commit() + + async def update_task(self, task_id: str, title: str, description: str, + priority: str, deadline: str): + async with aiosqlite.connect(self.db_path) as db: + await db.execute(""" + UPDATE tasks + SET title = ?, description = ?, priority = ?, deadline = ? + WHERE id = ? + """, (title, description, priority, deadline, task_id)) + await db.commit() + + async def update_reminded_type(self, task_id: str, reminded_type: str): + async with aiosqlite.connect(self.db_path) as db: + await db.execute("UPDATE tasks SET last_reminded_type = ? WHERE id = ?", (reminded_type, task_id)) + await db.commit() + + async def complete_task(self, task_id: str): + async with aiosqlite.connect(self.db_path) as db: + await db.execute("UPDATE tasks SET is_completed = 1 WHERE id = ?", (task_id,)) + await db.commit() + + async def delete_task(self, task_id: str): + async with aiosqlite.connect(self.db_path) as db: + await db.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) + await db.commit() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4d016bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + bot: + build: . + volumes: + - ./tasks.db:/app/tasks.db + - ./.env:/app/.env + restart: always + environment: + - PYTHONUNBUFFERED=1 + env_file: + - .env + +volumes: + tasks_db: diff --git a/main.py b/main.py new file mode 100644 index 0000000..945a9ee --- /dev/null +++ b/main.py @@ -0,0 +1,27 @@ +import os +import asyncio +import discord +from bot import TaskBot +from database import Database +from dotenv import load_dotenv + +async def main(): + load_dotenv() + discord.utils.setup_logging() + + token = os.getenv("DISCORD_TOKEN") + if not token: + print("Error: DISCORD_TOKEN not found in environment variables.") + return + + db = Database() + bot = TaskBot(db) + + async with bot: + await bot.start(token) + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3b63ca3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "ai-hades" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "aiosqlite>=0.22.1", + "apscheduler>=3.11.2", + "discord-py>=2.7.1", + "notion-client>=3.0.0", + "python-dotenv>=1.2.2", +] diff --git a/tasks.db b/tasks.db new file mode 100644 index 0000000000000000000000000000000000000000..75ba89e7fdf3ca14ae86796c5b00726b274e499f GIT binary patch literal 12288 zcmeI$&ui0A902f_RO(u7Gj}N-iti$6VUwh#Ybg}0HjZIewPxZ#2x*eG57VS3Dca*& zJnS#f|6vCY;=z-!!*2c!9s>Fgco6j9OKLZnq4gq6_&&(*Z@%w+KY2MM4<9s%XW*{w z3{(#n(JEpXbQ5EQP@FC?y389xcP`8i=r%ZB*5c@f#-zA!h)vZ{s>Xd>b^}}o0T2KI z5C8!X009sH0T2Lzb1CpL#azr}GR(o)Q#+>NsqRxZT(P&?QeBp?T))+naCi#mk~pFf z9m~>V8Q*Vj@6_9S_^!0K9vL`t4M(BT+bvnzlG?a+PsXj?W^+Nbr4EcRdvr{1L`?m+ zl+jUIU$rd5TpH&Q&z$wPWN5nKY7QBC#I_a;Zs-u(A>MeVv$$B@P<4}7f7Ls270n(D z>BBNkEgRCE`fgLkLNtM82ZL3HYR?E5g69<-s5yp8^D6X9I((?}ytxLQ4D$All;bV? zIlqp1)7DheBLjoa=c99(s_Q9^F(8&sGkW9Uukz+OdH#=SlFzg8OfAEp;GP%mGn0PV zE9%Iz!(%0!hKd+&g75m3;GIfwB4X3WD1FSmK9}zp3W^{21=>0!Mf6gD@6(uPowMX@SWs)BHO3X4KjC|AXz*r$E=PWt_KJ6(2G(KTAp z3av=f#d4`z=`5@0gFkuiPu`p