diff --git a/MCP_AUTO_RECOVERY_SETUP.md b/MCP_AUTO_RECOVERY_SETUP.md new file mode 100644 index 0000000..afb058e --- /dev/null +++ b/MCP_AUTO_RECOVERY_SETUP.md @@ -0,0 +1,77 @@ +# Codex MCP Auto-Recovery Setup + +PC再起動後も、CodexがMCP接続時に `cloud-run-logging-mcp` を自動起動・復旧するための手順です。 +他プロジェクトへ横展開できるように、共通化前提でまとめています。 + +## 前提 + +- Codexは `~/.codex/config.toml` の `mcp_servers` を参照してMCPを起動する +- 再起動後に接続が切れる主な原因は以下 + - Docker daemon 未起動 + - MCPイメージ未build + - 鍵ファイルパス不整合 + +## 1. ラッパースクリプトを作成 + +各プロジェクトに `scripts/start_cloud_run_logging_mcp.sh` を作成します。 + +```bash +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MCP_REPO_DIR="${MCP_REPO_DIR:-${SCRIPT_DIR}/../../cloud-run-logging-mcp}" +MCP_IMAGE="${MCP_IMAGE:-cloud-run-logging-mcp:local}" +MCP_KEY_PATH="${MCP_KEY_PATH:-/absolute/path/to/sa.json}" + +docker info >/dev/null 2>&1 || { [[ "$(uname)" == "Darwin" ]] && open -ga Docker || true; } +for i in {1..90}; do docker info >/dev/null 2>&1 && break; sleep 2; done +docker info >/dev/null 2>&1 + +docker image inspect "${MCP_IMAGE}" >/dev/null 2>&1 || docker build -t "${MCP_IMAGE}" "${MCP_REPO_DIR}" +[[ -f "${MCP_KEY_PATH}" ]] + +exec docker run --rm -i \ + -e GCP_SA_JSON_PATH=/secrets/key.json \ + -v "${MCP_KEY_PATH}:/secrets/key.json:ro" \ + "${MCP_IMAGE}" +``` + +## 2. 実行権限を付与 + +```bash +chmod +x scripts/start_cloud_run_logging_mcp.sh +``` + +## 3. `~/.codex/config.toml` を設定 + +`mcp_servers.cloud-run-logging-mcp` をラッパー呼び出しに変更します。 + +```toml +personality = "pragmatic" + +[mcp_servers.cloud-run-logging-mcp] +command = "/bin/zsh" +args = ["-lc", "/ABSOLUTE/PATH/TO/PROJECT/scripts/start_cloud_run_logging_mcp.sh"] +enabled = true +``` + +## 4. プロジェクト差分を環境変数で吸収 + +必要に応じて以下を上書きします。 + +- `MCP_KEY_PATH` +- `MCP_REPO_DIR` +- `MCP_IMAGE` + +## 5. 動作確認 + +1. Dockerを停止した状態でCodexを起動 +2. MCPツール(Cloud Runログ取得)を実行 +3. 自動でDocker起動待ち・MCP起動され、ログ取得できることを確認 + +## 運用メモ + +- 複数プロジェクトで同一MCPを使う場合: + - スクリプトを `~/.codex/bin/` などに1本化し、各プロジェクトは同じスクリプトを参照 +- `config.toml` は相対パスより絶対パス運用が安定 diff --git a/scripts/assets/rich_menu_default.png b/scripts/assets/rich_menu_default.png index 1530eb5..3d96d20 100644 Binary files a/scripts/assets/rich_menu_default.png and b/scripts/assets/rich_menu_default.png differ diff --git a/scripts/capture_rich_menu.py b/scripts/capture_rich_menu.py new file mode 100644 index 0000000..758e2fc --- /dev/null +++ b/scripts/capture_rich_menu.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Capture rich_menu_preview.html as a 2500x1686 PNG using Playwright.""" +import sys +from pathlib import Path +from playwright.sync_api import sync_playwright + +HTML_PATH = Path(__file__).parent / "rich_menu_preview.html" +OUTPUT = Path(__file__).parent / "assets" / "rich_menu_default.png" + + +def main(): + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page(viewport={"width": 2500, "height": 1686}) + page.goto(f"file://{HTML_PATH.resolve()}") + # Google Fontsの読み込みを待つ + page.wait_for_load_state("networkidle") + page.screenshot(path=str(OUTPUT), full_page=False) + browser.close() + print(f"Saved: {OUTPUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/rich_menu_preview.html b/scripts/rich_menu_preview.html new file mode 100644 index 0000000..bc677cc --- /dev/null +++ b/scripts/rich_menu_preview.html @@ -0,0 +1,131 @@ + + + + + + + + +
+ +
+
+ format_list_bulleted +
+
一覧
+
+ +
+
+ add_circle +
+
登録
+
+ +
+
+ help +
+
使い方
+
+ +
+
+ language +
+
Web一覧
+
+ +
+
+ calendar_month +
+
カレンダー
+
+ +
+
+ task_alt +
+
習慣タスク
+
+
+ + diff --git a/scripts/setup_line_rich_menu.py b/scripts/setup_line_rich_menu.py index 7ef08e7..0d0be3c 100644 --- a/scripts/setup_line_rich_menu.py +++ b/scripts/setup_line_rich_menu.py @@ -73,7 +73,7 @@ def _build_rich_menu(name: str, chat_bar_text: str, server_url: str) -> RichMenu ), RichMenuArea( bounds=RichMenuBounds(x=833, y=843, width=833, height=843), - action=MessageAction(label="連携", text="アカウント連携"), + action=URIAction(label="カレンダー", uri=habit_url), ), RichMenuArea( bounds=RichMenuBounds(x=1666, y=843, width=834, height=843), diff --git a/src/Domains/Entities/HabitTask.py b/src/Domains/Entities/HabitTask.py index 27d3132..143838a 100644 --- a/src/Domains/Entities/HabitTask.py +++ b/src/Domains/Entities/HabitTask.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import datetime +from typing import Optional @dataclass() @@ -12,6 +13,8 @@ class HabitTask: is_active: bool created_at: datetime updated_at: datetime + notify_day_of_week: Optional[int] # 0=月〜6=日(weekly用) + notify_day_of_month: Optional[int] # 1〜31(monthly用) def __init__( self, @@ -23,6 +26,9 @@ def __init__( is_active: bool = True, created_at: datetime = datetime.now(), updated_at: datetime = datetime.now(), + notify_day_of_week: Optional[int] = None, + notify_day_of_month: Optional[int] = None, + **kwargs, ): self._id = _id self.owner_id = owner_id @@ -32,3 +38,5 @@ def __init__( self.is_active = is_active self.created_at = created_at self.updated_at = updated_at + self.notify_day_of_week = notify_day_of_week + self.notify_day_of_month = notify_day_of_month diff --git a/src/Domains/Entities/Stock.py b/src/Domains/Entities/Stock.py index 01d97a7..965273b 100644 --- a/src/Domains/Entities/Stock.py +++ b/src/Domains/Entities/Stock.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import datetime +from typing import Optional STOCK_STATUS = ['disabled', 'active', 'archived'] @@ -11,7 +12,7 @@ class Stock: owner_id: str expiry_date: datetime status: int - notify_enabled: bool + notify_days_before: Optional[int] created_at: datetime updated_at: datetime @@ -22,15 +23,16 @@ def __init__( owner_id: str = None, expiry_date: datetime = None, status: int = 0, - notify_enabled: bool = False, + notify_days_before: Optional[int] = None, created_at: datetime = datetime.now(), updated_at: datetime = datetime.now(), + **kwargs, ): self._id = _id self.item_name = item_name self.owner_id = owner_id self.expiry_date = expiry_date self.status = status - self.notify_enabled = notify_enabled + self.notify_days_before = notify_days_before self.created_at = created_at self.updated_at = updated_at diff --git a/src/UseCases/Line/AddStockUseCase.py b/src/UseCases/Line/AddStockUseCase.py index cb4342e..47079da 100644 --- a/src/UseCases/Line/AddStockUseCase.py +++ b/src/UseCases/Line/AddStockUseCase.py @@ -70,7 +70,6 @@ def execute(self) -> None: owner_id=self._line_request_service.req_line_user_id, expiry_date=expiry_date, status=1, - notify_enabled=False, ) self._stock_repository.create(new_stock) diff --git a/src/UseCases/Line/CheckExpiredStockUseCase.py b/src/UseCases/Line/CheckExpiredStockUseCase.py index 47ed2f2..e5489a2 100644 --- a/src/UseCases/Line/CheckExpiredStockUseCase.py +++ b/src/UseCases/Line/CheckExpiredStockUseCase.py @@ -1,11 +1,16 @@ from datetime import datetime, timezone -from src import config from src.Domains.IRepositories.IStockRepository import IStockRepository from src.Domains.IRepositories.IWebUserRepository import IWebUserRepository from src.UseCases.Interface.ILineResponseService import ILineResponseService from src.UseCases.Interface.IUseCase import IUseCase -from src.line_rich_messages import add_stock_web_link_button + + +def _should_notify(stock, days_until_expire: int) -> bool: + """notify_days_beforeに基づいて通知すべきかを判定""" + if stock.notify_days_before is None: # 常に通知 + return True + return days_until_expire <= stock.notify_days_before class CheckExpiredStockUseCase(IUseCase): @@ -52,41 +57,40 @@ def execute(self) -> None: "status": 1, } ) - near_due_stocks = [] - notify_on_items = [] + notify_stocks = [] for stock in stocks: - if stock.notify_enabled: - notify_on_items.append(stock.item_name) - if stock.expiry_date is None: continue days_until_expire = (stock.expiry_date.date() - today).days - if days_until_expire < 0 or days_until_expire > 3: + + if not _should_notify(stock, days_until_expire): continue - if days_until_expire == 0: + if days_until_expire < 0: + label = f"{abs(days_until_expire)}日超過" + icon = "🔴" + elif days_until_expire == 0: label = "今日まで" + icon = "🟠" elif days_until_expire == 1: label = "明日まで" + icon = "🟠" + elif days_until_expire <= 3: + label = f"残り{days_until_expire}日" + icon = "🟠" + elif days_until_expire <= 7: + label = f"残り{days_until_expire}日" + icon = "🟡" else: - label = f"あと{days_until_expire}日" - near_due_stocks.append(f"{stock.item_name}: {label}") + label = f"残り{days_until_expire}日" + icon = "🟢" + notify_stocks.append(f"{icon} {stock.item_name}({label})") - if len(near_due_stocks) == 0: + if not notify_stocks: continue self._line_response_service.add_message( - "期限が3日以内のもの:\n" + "\n".join(near_due_stocks) - ) - add_stock_web_link_button( - line_response_service=self._line_response_service, - server_url=config.SERVER_URL, + f"📋 期限通知({len(notify_stocks)}件)\n" + "\n".join(notify_stocks) ) - if len(notify_on_items) == 0: - self._line_response_service.add_message("通知ONのアイテム: なし") - else: - self._line_response_service.add_message( - "通知ONのアイテム:\n" + "\n".join(notify_on_items) - ) self._line_response_service.push(to=schedule.line_user_id) diff --git a/src/UseCases/Line/CheckHabitTaskUseCase.py b/src/UseCases/Line/CheckHabitTaskUseCase.py index 42bb8b0..7567636 100644 --- a/src/UseCases/Line/CheckHabitTaskUseCase.py +++ b/src/UseCases/Line/CheckHabitTaskUseCase.py @@ -29,6 +29,8 @@ def __init__( def execute(self) -> None: now = datetime.now(self._notify_timezone) now_hhmm = now.strftime("%H:%M") + today_weekday = now.weekday() # 0=Monday, 6=Sunday + today_day_of_month = now.day scheduled_date = now.strftime("%Y-%m-%d") line_users = self._line_user_repository.find() @@ -41,14 +43,21 @@ def execute(self) -> None: ) linked_web_user_id = web_users[0]._id if len(web_users) != 0 else "" - tasks = self._habit_task_repository.find( + all_time_tasks = self._habit_task_repository.find( { "owner_id__in": [line_user.line_user_id, linked_web_user_id], "is_active": True, - "frequency": "daily", "notify_time": now_hhmm, } ) + tasks = [] + for task in all_time_tasks: + if task.frequency == "daily": + tasks.append(task) + elif task.frequency == "weekly" and task.notify_day_of_week == today_weekday: + tasks.append(task) + elif task.frequency == "monthly" and task.notify_day_of_month == today_day_of_month: + tasks.append(task) pushed_count = 0 for task in tasks: diff --git a/src/UseCases/Line/HandleIntentOperationUseCase.py b/src/UseCases/Line/HandleIntentOperationUseCase.py index 3410269..dd7aaf4 100644 --- a/src/UseCases/Line/HandleIntentOperationUseCase.py +++ b/src/UseCases/Line/HandleIntentOperationUseCase.py @@ -67,9 +67,11 @@ def execute(self) -> None: "item_name": parsed["item_name"], "expiry_date": parsed["expiry_date"], "exclude_expiry_date": parsed.get("exclude_expiry_date"), - "notify_enabled": parsed.get("notify_enabled", False), + "notify_days_before": parsed.get("notify_days_before"), "frequency": parsed.get("frequency"), "notify_time": parsed.get("notify_time"), + "notify_day_of_week": parsed.get("notify_day_of_week"), + "notify_day_of_month": parsed.get("notify_day_of_month"), "enabled": parsed.get("enabled"), "scheduled_date": parsed.get("scheduled_date"), "result": parsed.get("result"), @@ -83,22 +85,43 @@ def _reply_confirmation(self, parsed): item_name = parsed["item_name"] expiry_date = parsed["expiry_date"] exclude_expiry_date = parsed.get("exclude_expiry_date") - notify_enabled = parsed.get("notify_enabled", False) + notify_days_before = parsed.get("notify_days_before") frequency = parsed.get("frequency") notify_time = parsed.get("notify_time") if intent == "register": + if notify_days_before is None: + notify_suffix = "(常に通知)" + else: + notify_suffix = f"({notify_days_before}日前から通知)" if expiry_date: date_text = datetime.strptime(expiry_date, "%Y-%m-%d").strftime("%Y年%m月%d日") - notify_suffix = "(通知あり)" if notify_enabled else "" message = f'"{item_name}" を期限 {date_text} で登録します{notify_suffix}。よろしいですか?' else: - notify_suffix = "(通知あり)" if notify_enabled else "" message = f'"{item_name}" を登録します{notify_suffix}。よろしいですか?' elif intent == "register_habit": - freq_text = "毎週" if frequency == "weekly" else "毎日" + DOW_NAMES = ["月", "火", "水", "木", "金", "土", "日"] + if frequency == "weekly": + dow = parsed.get("notify_day_of_week") + day_text = f"毎週{DOW_NAMES[dow]}曜日" if dow is not None else "毎週" + elif frequency == "monthly": + dom = parsed.get("notify_day_of_month") + day_text = f"毎月{dom}日" if dom is not None else "毎月" + else: + day_text = "毎日" time_text = notify_time or "12:00" - message = f'習慣タスク "{item_name}" を登録します({freq_text} {time_text} にリマインド)。よろしいですか?' + message = f'習慣タスク "{item_name}" を登録します({day_text} {time_text} にリマインド)。よろしいですか?' + elif intent == "update_habit_frequency": + DOW_NAMES = ["月", "火", "水", "木", "金", "土", "日"] + dow = parsed.get("notify_day_of_week") + dom = parsed.get("notify_day_of_month") + if frequency == "weekly": + label = f"毎週{DOW_NAMES[dow]}曜日" if dow is not None else "毎週" + elif frequency == "monthly": + label = f"毎月{dom}日" if dom is not None else "毎月" + else: + label = "毎日" + message = f'習慣タスク "{item_name}" の頻度を {label} に変更します。よろしいですか?' elif intent == "update": date_text = datetime.strptime(expiry_date, "%Y-%m-%d").strftime("%Y年%m月%d日") message = f'"{item_name}" の期限を {date_text} に更新します。よろしいですか?' @@ -124,8 +147,8 @@ def _reply_confirmation(self, parsed): parts.append(f"時刻={notify_time}") message = f'通知設定を変更します({" / ".join(parts)})。よろしいですか?' elif intent == "update_stock_notify": - notify_enabled = parsed.get("notify_enabled", False) - message = f'"{item_name}" の通知を {"オン" if notify_enabled else "オフ"} にします。よろしいですか?' + label = "常に通知" if notify_days_before is None else f"{notify_days_before}日前から通知" + message = f'"{item_name}" の通知を {label} に設定します。よろしいですか?' elif intent == "update_habit_log": scheduled_date = parsed.get("scheduled_date") result = parsed.get("result") @@ -170,7 +193,7 @@ def _execute_pending(self, line_user_id: str) -> None: owner_id=line_user_id, expiry_date=parsed_expiry_date, status=1, - notify_enabled=operation.get("notify_enabled", False), + notify_days_before=operation.get("notify_days_before"), ) ) if parsed_expiry_date: @@ -240,6 +263,7 @@ def _execute_pending(self, line_user_id: str) -> None: self._line_response_service.add_message("習慣タスク登録は現在利用できません。") self._pending_operation_service.clear(line_user_id) return + DOW_NAMES = ["月", "火", "水", "木", "金", "土", "日"] frequency = operation.get("frequency") or "daily" notify_time = operation.get("notify_time") or "12:00" self._habit_task_repository.create( @@ -248,12 +272,21 @@ def _execute_pending(self, line_user_id: str) -> None: task_name=item_name, frequency=frequency, notify_time=notify_time, + notify_day_of_week=operation.get("notify_day_of_week"), + notify_day_of_month=operation.get("notify_day_of_month"), is_active=True, ) ) - freq_text = "毎週" if frequency == "weekly" else "毎日" + if frequency == "weekly": + dow = operation.get("notify_day_of_week") + day_text = f"毎週{DOW_NAMES[dow]}曜日" if dow is not None else "毎週" + elif frequency == "monthly": + dom = operation.get("notify_day_of_month") + day_text = f"毎月{dom}日" if dom is not None else "毎月" + else: + day_text = "毎日" self._line_response_service.add_message( - f'習慣タスク "{item_name}" を登録しました({freq_text} {notify_time} にリマインド)。' + f'習慣タスク "{item_name}" を登録しました({day_text} {notify_time} にリマインド)。' ) elif intent == "delete_habit": if self._habit_task_repository is None: @@ -303,12 +336,29 @@ def _execute_pending(self, line_user_id: str) -> None: elif intent == "update_stock_notify": count = self._stock_repository.update( query={"owner_id": line_user_id, "item_name": item_name, "status": 1}, - new_values={"notify_enabled": operation.get("notify_enabled", False)}, + new_values={"notify_days_before": operation.get("notify_days_before")}, ) if count > 0: self._line_response_service.add_message(f'"{item_name}" の通知設定を更新しました。') else: self._line_response_service.add_message(f'"{item_name}" が見つかりませんでした。') + elif intent == "update_habit_frequency": + if self._habit_task_repository is None: + self._line_response_service.add_message("習慣タスク操作は現在利用できません。") + self._pending_operation_service.clear(line_user_id) + return + count = self._habit_task_repository.update( + query={"owner_id": line_user_id, "task_name": item_name, "is_active": True}, + new_values={ + "frequency": operation.get("frequency") or "daily", + "notify_day_of_week": operation.get("notify_day_of_week"), + "notify_day_of_month": operation.get("notify_day_of_month"), + }, + ) + if count > 0: + self._line_response_service.add_message(f'習慣タスク "{item_name}" の頻度を変更しました。') + else: + self._line_response_service.add_message(f'"{item_name}" が見つかりませんでした。') elif intent == "update_habit_log": if self._habit_task_repository is None or self._habit_task_log_repository is None: self._line_response_service.add_message("習慣タスク操作は現在利用できません。") diff --git a/src/UseCases/Line/ReplyHelpUseCase.py b/src/UseCases/Line/ReplyHelpUseCase.py index a29a7fe..dd4d7ff 100644 --- a/src/UseCases/Line/ReplyHelpUseCase.py +++ b/src/UseCases/Line/ReplyHelpUseCase.py @@ -1,8 +1,16 @@ +from linebot.models import MessageAction, QuickReply, QuickReplyButton, TextSendMessage + from src import config from src.UseCases.Interface.IUseCase import IUseCase from src.UseCases.Interface.ILineRequestService import ILineRequestService from src.UseCases.Interface.ILineResponseService import ILineResponseService +_QUICK_REPLY_BUTTONS = QuickReply(items=[ + QuickReplyButton(action=MessageAction(label='➕ 登録の使い方', text='使い方 登録')), + QuickReplyButton(action=MessageAction(label='📄 一覧の使い方', text='使い方 一覧')), + QuickReplyButton(action=MessageAction(label='🔗 連携の使い方', text='使い方 アカウント連携')), +]) + class ReplyHelpUseCase(IUseCase): def __init__( @@ -16,44 +24,64 @@ def __init__( def execute(self) -> None: args = self._line_request_service.message.split() keyword = args[1] if len(args) >= 2 else None - messages = self._get_description(keyword) - for message in messages: - self._line_response_service.add_message(message) + text = self._get_description(keyword) + + if keyword is None and hasattr(self._line_response_service, 'buttons'): + self._line_response_service.buttons.append( + TextSendMessage(text=text, quick_reply=_QUICK_REPLY_BUTTONS) + ) + else: + self._line_response_service.add_message(text) - def _get_description(self, keyword): + def _get_description(self, keyword) -> str: if keyword is None: - return [ - '使い方ガイドです', - '例: 「ソファ組み立て」「ライブチケット購入 3/20まで」「打ち合わせ日程調整 2/28まで」', - '「一覧表示」「リスト表示」で一覧、「webで操作」「webで表示」でWebリンクを返します', - '更新例: 「卵の期限を3/22にして」 / 削除例: 「卵を使い切った」', - ] + return ( + '📋 Simple Alert の使い方\n\n' + 'このBotでできること:\n' + '• 期限・締切のあるものを登録して通知を受け取る\n' + '• 期限1週間前から毎日12時にリマインド\n' + '• 習慣タスクの記録・管理\n\n' + '💬 使い方はシンプル。やりたいことを日本語で送るだけ!\n\n' + '【登録】\n「卵 3/15まで」「ライブチケット 3/20まで」\n\n' + '【更新】\n「卵の期限を3/22にして」\n\n' + '【削除】\n「卵を使い切った」\n\n' + '【一覧確認】\n「一覧」と送るか、下のメニューから' + ) elif keyword in ('一覧', '一覧表示', 'リスト表示'): - return [ - '登録したアイテムを一覧で表示します', - '例: 「一覧表示」または「登録済み一覧」', - ] + return ( + '📄 一覧表示\n\n' + '登録中のアイテムをすべて表示します。\n\n' + '「一覧」と送るか、メニューの「一覧」ボタンを押してください。\n' + 'Web画面で確認したい場合はメニューの「Web一覧」から。' + ) elif keyword in ('登録', '追加'): - return [ - '食材・家具などの非食品・チケット購入・日程調整などを管理できます', - '期限が1週間前までに近づいている場合、毎日12時に通知がきます', - '例: 「卵は3/15まで」「ライブチケット購入 3/20まで」「打ち合わせ日程調整 2/28まで」', - '更新は「卵の期限を3/22にして」、削除は「卵を使い切った」と送ってください。', - ] + return ( + '➕ アイテムの登録\n\n' + '登録できるもの:食材・日用品・チケット・締切タスクなど\n\n' + '【送り方の例】\n' + '「卵 3/15まで」\n' + '「ライブチケット購入 3/20まで」\n' + '「レポート提出 2/28まで」\n\n' + '期限1週間前から毎日12時に通知が届きます。\n\n' + '更新:「卵の期限を3/22にして」\n' + '削除:「卵を使い切った」' + ) elif keyword == 'アカウント連携': - return [ - 'Web 上のアカウントと LINE アカウントを紐付けます', - '紐付けが完了すると LINE で登録したストックを Web 上で確認できるようになります', - f'まずは web 上でログインしてください → {config.SERVER_URL}/stock?openExternalBrowser=1', - '次にこのチャットにて\n"アカウント連携 [メールアドレス]"\nと送ってください。', - ] + return ( + '✅ ログインについて\n\n' + 'アカウント連携機能は廃止されました。\n\n' + '現在はLINEアカウントで直接ログインできます。\n' + f'こちらからWebアプリにアクセスしてください👇\n' + f'{config.SERVER_URL}/stock?openExternalBrowser=1' + ) elif keyword in ('URL', 'web', 'Web'): - return [ - 'Web アプリの URL を表示します', - ] + return 'WebアプリのURLを表示します。' else: - return [ - '使い方ガイドです', - '登録はアイテム名をそのまま送信(例: 「卵は3/15まで」)', - '一覧は「一覧表示」、Webは「webで操作」と送ってください。', - ] + return ( + '📋 Simple Alert の使い方\n\n' + 'やりたいことを日本語で送るだけで操作できます。\n\n' + '「卵 3/15まで」→ 登録\n' + '「卵の期限を3/22にして」→ 更新\n' + '「卵を使い切った」→ 削除\n' + '「一覧」→ 一覧表示' + ) diff --git a/src/UseCases/Line/ReplyStockUseCase.py b/src/UseCases/Line/ReplyStockUseCase.py index 7dea972..fd4a3ec 100644 --- a/src/UseCases/Line/ReplyStockUseCase.py +++ b/src/UseCases/Line/ReplyStockUseCase.py @@ -1,11 +1,36 @@ -from src import config from datetime import datetime from src.UseCases.Interface.IUseCase import IUseCase from src.Domains.IRepositories.IStockRepository import IStockRepository from src.Domains.IRepositories.IWebUserRepository import IWebUserRepository from src.UseCases.Interface.ILineRequestService import ILineRequestService from src.UseCases.Interface.ILineResponseService import ILineResponseService -from src.line_rich_messages import add_stock_web_link_button + + +def _days_left(expiry_date: datetime) -> int: + return (expiry_date.date() - datetime.now().date()).days + + +def _expiry_label(expiry_date: datetime) -> str: + days = _days_left(expiry_date) + date_str = expiry_date.strftime("%-m/%-d") + if days < 0: + return f'{date_str}({abs(days)}日超過)' + if days == 0: + return f'{date_str}(今日まで)' + if days == 1: + return f'{date_str}(明日まで)' + return f'{date_str}(残り{days}日)' + + +def _urgency_icon(expiry_date: datetime) -> str: + days = _days_left(expiry_date) + if days < 0: + return '🔴' + if days <= 3: + return '🟠' + if days <= 7: + return '🟡' + return '🟢' class ReplyStockUseCase(IUseCase): @@ -26,8 +51,7 @@ def execute(self) -> None: 'linked_line_user_id': self._line_request_service.req_line_user_id, 'is_linked_line': True, }) - linked_web_user_id = linked_web_users[0]._id if len( - linked_web_users) != 0 else '' + linked_web_user_id = linked_web_users[0]._id if len(linked_web_users) != 0 else '' stocks = self._stock_repository.find({ 'owner_id__in': [ linked_web_user_id, @@ -36,27 +60,30 @@ def execute(self) -> None: 'status': 1, }) - stocks_with_expire_date = [] - stocks_without_expire_date = [] - for stock in stocks: - if stock.expiry_date is not None: - stocks_with_expire_date.append( - f'{stock.item_name}: {stock.expiry_date.strftime("%Y年%m月%d日")}') - else: - elapsed_time = (datetime.now() - stock.created_at).days + 1 - stocks_without_expire_date.append( - f'{stock.item_name}: 登録から{elapsed_time}日目') - - sections = [] - if len(stocks_without_expire_date) != 0: - sections.append('期限未設定:\n' + '\n'.join(stocks_without_expire_date)) - if len(stocks_with_expire_date) != 0: - sections.append('期限あり:\n' + '\n'.join(stocks_with_expire_date)) - if len(sections) == 0: - sections.append('登録中のアイテムはありません。') - - self._line_response_service.add_message('\n\n'.join(sections)) - add_stock_web_link_button( - line_response_service=self._line_response_service, - server_url=config.SERVER_URL, + if not stocks: + self._line_response_service.add_message('登録中のアイテムはありません。') + return + + with_expiry = sorted( + [s for s in stocks if s.expiry_date is not None], + key=lambda s: s.expiry_date, ) + without_expiry = [s for s in stocks if s.expiry_date is None] + + lines = [f'📋 登録中のアイテム({len(stocks)}件)'] + + if with_expiry: + lines.append('') + lines.append('⏰ 期限あり') + for s in with_expiry: + icon = _urgency_icon(s.expiry_date) + lines.append(f'{icon} {s.item_name} {_expiry_label(s.expiry_date)}') + + if without_expiry: + lines.append('') + lines.append('📌 期限なし') + for s in without_expiry: + elapsed = (datetime.now() - s.created_at).days + 1 + lines.append(f'• {s.item_name}(登録{elapsed}日目)') + + self._line_response_service.add_message('\n'.join(lines)) diff --git a/src/UseCases/Line/RequestLinkLineWebUseCase.py b/src/UseCases/Line/RequestLinkLineWebUseCase.py index 1040179..2d3fe69 100644 --- a/src/UseCases/Line/RequestLinkLineWebUseCase.py +++ b/src/UseCases/Line/RequestLinkLineWebUseCase.py @@ -17,40 +17,9 @@ def __init__( self._line_response_service = line_response_service def execute(self) -> None: - args = self._line_request_service.message.split() - - if len(args) != 2: - self._line_response_service.add_message( - 'Web アカウントと紐付けするには "アカウント連携 [メールアドレス]" と送ってください。') - return - - email = args[1] - web_users = self._web_user_repository.find({'web_user_email': email}) - - if len(web_users) == 0: - self._line_response_service.add_message( - f'{email} は登録されていません。一度ブラウザでログインしてください。') - self._line_response_service.add_message( - f'{config.SERVER_URL}/line/approve?openExternalBrowser=1') - return - - if web_users[0].is_linked_line_user: - self._line_response_service.add_message( - f'{email} はすでに LINE アカウントと紐付けされています。') - self._line_response_service.add_message( - f'{config.SERVER_URL}/line/approve?openExternalBrowser=1') - return - - result = self._web_user_repository.update( - {'_id': web_users[0]._id}, - {'linked_line_user_id': self._line_request_service.req_line_user_id}, - ) - - if result == 0: - self._line_response_service.add_message('アカウント連携リクエストに失敗しました。') - return - self._line_response_service.add_message( - 'アカウント連携リクエストを送信しました。ブラウザでログインし、承認してください。') - self._line_response_service.add_message( - f'{config.SERVER_URL}/line/approve?openExternalBrowser=1') + 'アカウント連携機能は廃止されました。\n\n' + '現在はLINEアカウントで直接ログインできます。\n' + f'こちらからWebアプリにアクセスしてください👇\n' + f'{config.SERVER_URL}/stock?openExternalBrowser=1' + ) diff --git a/src/UseCases/Line/tests/test_check_expired_stock_use_case.py b/src/UseCases/Line/tests/test_check_expired_stock_use_case.py index a81e693..5bcbd1a 100644 --- a/src/UseCases/Line/tests/test_check_expired_stock_use_case.py +++ b/src/UseCases/Line/tests/test_check_expired_stock_use_case.py @@ -106,7 +106,6 @@ def now(cls, tz=None): "src.UseCases.Line.CheckExpiredStockUseCase.datetime", FixedDatetime, ) - monkeypatch.setattr(config, "SERVER_URL", "https://example.com") due_schedule = NotificationSchedule( line_user_id="U1", @@ -124,12 +123,18 @@ def now(cls, tz=None): ) stocks = [ - Stock(item_name="no_expiry", owner_id="U1", expiry_date=None, status=1, notify_enabled=True, created_at=fixed_now), + # expiry_date=None → 通知対象外(expiry_dateなしはスキップ) + Stock(item_name="no_expiry", owner_id="U1", expiry_date=None, status=1, created_at=fixed_now), + # 2日超過 → notify_days_before=None(常に通知)なので含まれる Stock(item_name="expired", owner_id="U1", expiry_date=datetime(2025, 1, 8), status=1, created_at=fixed_now), + # 今日まで → 常に通知 Stock(item_name="today", owner_id="U1", expiry_date=datetime(2025, 1, 10), status=1, created_at=fixed_now), + # 明日まで → 常に通知 Stock(item_name="tomorrow", owner_id="U1", expiry_date=datetime(2025, 1, 11), status=1, created_at=fixed_now), - Stock(item_name="three_days", owner_id="U1", expiry_date=datetime(2025, 1, 13), status=1, notify_enabled=True, created_at=fixed_now), - Stock(item_name="future", owner_id="U1", expiry_date=datetime(2025, 1, 20), status=1, notify_enabled=True, created_at=fixed_now), + # 残り3日 → notify_days_before=3 なので3日前から通知 → 含まれる + Stock(item_name="three_days", owner_id="U1", expiry_date=datetime(2025, 1, 13), status=1, notify_days_before=3, created_at=fixed_now), + # 残り10日 → notify_days_before=5 なので5日前から通知 → 10日 > 5日 → 含まれない + Stock(item_name="future", owner_id="U1", expiry_date=datetime(2025, 1, 20), status=1, notify_days_before=5, created_at=fixed_now), ] notification_schedule_repository = DummyNotificationScheduleRepository([due_schedule]) @@ -149,15 +154,14 @@ def now(cls, tz=None): assert notification_schedule_repository.claimed_line_user_ids == ["U1"] assert line_response_service.pushes == ["U1"] joined = "\n".join(line_response_service.messages) - assert "webで一覧を確認" in joined - assert "期限が3日以内のもの" in joined - assert "today: 今日まで" in joined - assert "tomorrow: 明日まで" in joined - assert "three_days: あと3日" in joined - assert "通知ONのアイテム" in joined - assert "no_expiry" in joined - assert "future" in joined - assert "expired" not in joined + assert "期限通知" in joined + assert "今日まで" in joined + assert "明日まで" in joined + assert "three_days" in joined + assert "日超過" in joined # expiredアイテムが含まれる + assert "expired" in joined + assert "no_expiry" not in joined # expiry_dateなしは除外 + assert "future" not in joined # notify_days_before=5 で10日先は除外 def test_check_expired_stock_does_not_push_when_no_active_stocks(monkeypatch): @@ -193,7 +197,9 @@ def now(cls, tz=None): web_user_repository = DummyWebUserRepository([web_user]) stock_repository = DummyStockRepository( [ - Stock(item_name="far", owner_id="U1", expiry_date=datetime(2025, 1, 30), status=1), + # notify_days_before=5 かつ残り20日 → 5日前まで通知しないので対象外 + Stock(item_name="far", owner_id="U1", expiry_date=datetime(2025, 1, 30), status=1, notify_days_before=5), + # expiry_dateなし → 常に通知でも期限なしはスキップ Stock(item_name="none", owner_id="U1", expiry_date=None, status=1), ] ) diff --git a/src/UseCases/Line/tests/test_habit_task_use_cases.py b/src/UseCases/Line/tests/test_habit_task_use_cases.py index 57f5956..9e05698 100644 --- a/src/UseCases/Line/tests/test_habit_task_use_cases.py +++ b/src/UseCases/Line/tests/test_habit_task_use_cases.py @@ -159,6 +159,126 @@ def now(cls, tz=None): assert any("習慣タスク確認" in msg for msg in use_case._line_response_service.messages) +def test_check_habit_weekly_task_sends_on_correct_day(monkeypatch): + # 土曜日(weekday=5)にnotify_day_of_week=5のタスクが通知される + fixed_now = datetime(2026, 3, 7, 9, 0, 0) # 2026-03-07は土曜日 + + class FixedDatetime(datetime): + @classmethod + def now(cls, tz=None): + return fixed_now + + monkeypatch.setattr("src.UseCases.Line.CheckHabitTaskUseCase.datetime", FixedDatetime) + + line_users = [LineUser(line_user_name="u1", line_user_id="U1")] + web_users = [] + tasks = [ + HabitTask(_id="T1", owner_id="U1", task_name="週次筋トレ", + frequency="weekly", notify_time="09:00", notify_day_of_week=5) + ] + + use_case = CheckHabitTaskUseCase( + line_user_repository=DummyLineUserRepository(line_users), + web_user_repository=DummyWebUserRepository(web_users), + habit_task_repository=DummyHabitTaskRepository(tasks), + habit_task_log_repository=DummyHabitTaskLogRepository(), + habit_pending_confirmation_repository=DummyHabitPendingRepository(), + line_response_service=DummyLineResponseService(), + ) + use_case.execute() + assert use_case._line_response_service.pushes == ["U1"] + + +def test_check_habit_weekly_task_not_sent_on_wrong_day(monkeypatch): + # 日曜日(weekday=6)にnotify_day_of_week=5(土)のタスクは通知されない + fixed_now = datetime(2026, 3, 8, 9, 0, 0) # 2026-03-08は日曜日 + + class FixedDatetime(datetime): + @classmethod + def now(cls, tz=None): + return fixed_now + + monkeypatch.setattr("src.UseCases.Line.CheckHabitTaskUseCase.datetime", FixedDatetime) + + line_users = [LineUser(line_user_name="u1", line_user_id="U1")] + web_users = [] + tasks = [ + HabitTask(_id="T1", owner_id="U1", task_name="週次筋トレ", + frequency="weekly", notify_time="09:00", notify_day_of_week=5) + ] + + use_case = CheckHabitTaskUseCase( + line_user_repository=DummyLineUserRepository(line_users), + web_user_repository=DummyWebUserRepository(web_users), + habit_task_repository=DummyHabitTaskRepository(tasks), + habit_task_log_repository=DummyHabitTaskLogRepository(), + habit_pending_confirmation_repository=DummyHabitPendingRepository(), + line_response_service=DummyLineResponseService(), + ) + use_case.execute() + assert use_case._line_response_service.pushes == [] + + +def test_check_habit_monthly_task_sends_on_correct_day(monkeypatch): + # 14日にnotify_day_of_month=14のタスクが通知される + fixed_now = datetime(2026, 3, 14, 12, 0, 0) + + class FixedDatetime(datetime): + @classmethod + def now(cls, tz=None): + return fixed_now + + monkeypatch.setattr("src.UseCases.Line.CheckHabitTaskUseCase.datetime", FixedDatetime) + + line_users = [LineUser(line_user_name="u1", line_user_id="U1")] + web_users = [] + tasks = [ + HabitTask(_id="T1", owner_id="U1", task_name="家計簿", + frequency="monthly", notify_time="12:00", notify_day_of_month=14) + ] + + use_case = CheckHabitTaskUseCase( + line_user_repository=DummyLineUserRepository(line_users), + web_user_repository=DummyWebUserRepository(web_users), + habit_task_repository=DummyHabitTaskRepository(tasks), + habit_task_log_repository=DummyHabitTaskLogRepository(), + habit_pending_confirmation_repository=DummyHabitPendingRepository(), + line_response_service=DummyLineResponseService(), + ) + use_case.execute() + assert use_case._line_response_service.pushes == ["U1"] + + +def test_check_habit_monthly_task_not_sent_on_wrong_day(monkeypatch): + # 15日にnotify_day_of_month=14のタスクは通知されない + fixed_now = datetime(2026, 3, 15, 12, 0, 0) + + class FixedDatetime(datetime): + @classmethod + def now(cls, tz=None): + return fixed_now + + monkeypatch.setattr("src.UseCases.Line.CheckHabitTaskUseCase.datetime", FixedDatetime) + + line_users = [LineUser(line_user_name="u1", line_user_id="U1")] + web_users = [] + tasks = [ + HabitTask(_id="T1", owner_id="U1", task_name="家計簿", + frequency="monthly", notify_time="12:00", notify_day_of_month=14) + ] + + use_case = CheckHabitTaskUseCase( + line_user_repository=DummyLineUserRepository(line_users), + web_user_repository=DummyWebUserRepository(web_users), + habit_task_repository=DummyHabitTaskRepository(tasks), + habit_task_log_repository=DummyHabitTaskLogRepository(), + habit_pending_confirmation_repository=DummyHabitPendingRepository(), + line_response_service=DummyLineResponseService(), + ) + use_case.execute() + assert use_case._line_response_service.pushes == [] + + def test_handle_habit_task_response_other_flow(): pending_repo = DummyHabitPendingRepository() pending_repo.create( diff --git a/src/UseCases/Line/tests/test_handle_intent_operation_use_case.py b/src/UseCases/Line/tests/test_handle_intent_operation_use_case.py index 3c3e081..03023bb 100644 --- a/src/UseCases/Line/tests/test_handle_intent_operation_use_case.py +++ b/src/UseCases/Line/tests/test_handle_intent_operation_use_case.py @@ -305,8 +305,8 @@ def test_followup_expiry_date_updates_recently_registered_item(): assert pending.get("U1") is None -def test_register_with_notify_enabled(): - req = DummyLineRequestService(message="通知ありで確定申告 3/15まで") +def test_register_with_notify_days_before(): + req = DummyLineRequestService(message="7日前から通知で確定申告 3/15まで") res = DummyLineResponseService() repo = DummyStockRepository() parser = DummyIntentParserService( @@ -314,7 +314,7 @@ def test_register_with_notify_enabled(): "intent": "register", "item_name": "確定申告", "expiry_date": "2026-03-15", - "notify_enabled": True, + "notify_days_before": 7, } ) pending = DummyPendingOperationService() @@ -327,13 +327,13 @@ def test_register_with_notify_enabled(): ) use_case.execute() - assert any("通知あり" in m for m in res.messages) + assert any("7日前から通知" in m for m in res.messages) req.message = "はい" use_case.execute() assert repo.created is not None assert repo.created.item_name == "確定申告" - assert repo.created.notify_enabled is True + assert repo.created.notify_days_before == 7 def test_register_habit_creates_habit_task(): @@ -371,6 +371,73 @@ def test_register_habit_creates_habit_task(): assert habit_repo.created.notify_time == "09:00" +def test_register_habit_weekly(): + req = DummyLineRequestService(message="毎週月曜9時に筋トレをリマインドして") + res = DummyLineResponseService() + repo = DummyStockRepository() + parser = DummyIntentParserService( + { + "intent": "register_habit", + "item_name": "筋トレ", + "expiry_date": None, + "frequency": "weekly", + "notify_time": "09:00", + "notify_day_of_week": 0, + "notify_day_of_month": None, + } + ) + pending = DummyPendingOperationService() + habit_repo = DummyHabitTaskRepository() + use_case = HandleIntentOperationUseCase( + stock_repository=repo, + line_request_service=req, + line_response_service=res, + intent_parser_service=parser, + pending_operation_service=pending, + habit_task_repository=habit_repo, + ) + + use_case.execute() + assert any("毎週月曜日" in m for m in res.messages) + + req.message = "はい" + use_case.execute() + assert habit_repo.created is not None + assert habit_repo.created.task_name == "筋トレ" + assert habit_repo.created.frequency == "weekly" + assert habit_repo.created.notify_day_of_week == 0 + assert habit_repo.created.notify_day_of_month is None + + +def test_update_habit_frequency(): + req = DummyLineRequestService(message="筋トレを毎週水曜日に変更して") + res = DummyLineResponseService() + habit_repo = DummyHabitTaskRepository() + parser = DummyIntentParserService( + { + "intent": "update_habit_frequency", + "item_name": "筋トレ", + "expiry_date": None, + "frequency": "weekly", + "notify_day_of_week": 2, + "notify_day_of_month": None, + } + ) + pending = DummyPendingOperationService() + use_case = _make_use_case(req, res, parser, pending, habit_repo=habit_repo) + + use_case.execute() + assert any("毎週水曜日" in m for m in res.messages) + + req.message = "はい" + use_case.execute() + assert habit_repo.updated_query["task_name"] == "筋トレ" + assert habit_repo.updated_values["frequency"] == "weekly" + assert habit_repo.updated_values["notify_day_of_week"] == 2 + assert habit_repo.updated_values["notify_day_of_month"] is None + assert any("頻度を変更しました" in m for m in res.messages) + + def test_delete_with_exclude_expiry_filters_python_side(): req = DummyLineRequestService(message="期限が3/11以外の卵を削除して") res = DummyLineResponseService() @@ -494,22 +561,24 @@ def test_update_notification_setting_off(): assert any("更新しました" in m for m in res.messages) -def test_update_stock_notify_on(): - req = DummyLineRequestService(message="牛乳の通知をオンにして") +def test_update_stock_notify(): + req = DummyLineRequestService(message="牛乳を3日前から通知して") res = DummyLineResponseService() repo = DummyStockRepository() parser = DummyIntentParserService( - {"intent": "update_stock_notify", "item_name": "牛乳", "notify_enabled": True, "expiry_date": None} + {"intent": "update_stock_notify", "item_name": "牛乳", "notify_days_before": 3, "expiry_date": None} ) pending = DummyPendingOperationService() use_case = _make_use_case(req, res, parser, pending, repo=repo) use_case.execute() + assert any("3日前から通知" in m for m in res.messages) + req.message = "はい" use_case.execute() assert repo.updated_query["item_name"] == "牛乳" - assert repo.updated_values["notify_enabled"] is True + assert repo.updated_values["notify_days_before"] == 3 assert any("更新しました" in m for m in res.messages) diff --git a/src/UseCases/Line/tests/test_reply_help_use_case.py b/src/UseCases/Line/tests/test_reply_help_use_case.py index a5ce055..3f75246 100644 --- a/src/UseCases/Line/tests/test_reply_help_use_case.py +++ b/src/UseCases/Line/tests/test_reply_help_use_case.py @@ -41,6 +41,7 @@ def delete_req_info(self) -> None: class DummyLineResponseService(ILineResponseService): def __init__(self): self.messages = [] + self.buttons = [] def add_message(self, text: str) -> None: self.messages.append(text) @@ -71,8 +72,16 @@ def test_reply_help_default_lists_commands(): use_case.execute() - assert any("使い方ガイド" in message for message in line_response_service.messages) - assert any("一覧表示" in message for message in line_response_service.messages) + # キーワードなしはクイックリプライ付きメッセージとして buttons に追加される + assert len(line_response_service.buttons) == 1 + msg = line_response_service.buttons[0] + assert "Simple Alert" in msg.text + assert "一覧" in msg.text + assert msg.quick_reply is not None + labels = [item.action.label for item in msg.quick_reply.items] + assert any("登録" in label for label in labels) + assert any("一覧" in label for label in labels) + assert any("連携" in label for label in labels) def test_reply_help_specific_keyword(): @@ -85,4 +94,4 @@ def test_reply_help_specific_keyword(): use_case.execute() - assert any("期限が1週間前" in message for message in line_response_service.messages) + assert any("期限1週間前" in message for message in line_response_service.messages) diff --git a/src/UseCases/Line/tests/test_reply_stock_use_case.py b/src/UseCases/Line/tests/test_reply_stock_use_case.py index 44aaed8..4389a02 100644 --- a/src/UseCases/Line/tests/test_reply_stock_use_case.py +++ b/src/UseCases/Line/tests/test_reply_stock_use_case.py @@ -137,7 +137,8 @@ def now(cls, tz=None): messages = use_case._line_response_service.messages joined = "\n".join(messages) - assert "期限未設定:" in joined - assert "期限あり:" in joined + assert "期限なし" in joined + assert "期限あり" in joined assert "no_expiry" in joined assert "with_expiry" in joined + assert "残り" in joined # 残り日数が表示される diff --git a/src/UseCases/Line/tests/test_request_link_line_web_use_case.py b/src/UseCases/Line/tests/test_request_link_line_web_use_case.py index 54ed4a0..8d73c00 100644 --- a/src/UseCases/Line/tests/test_request_link_line_web_use_case.py +++ b/src/UseCases/Line/tests/test_request_link_line_web_use_case.py @@ -1,5 +1,5 @@ -from src.Domains.Entities.WebUser import WebUser from src.Domains.IRepositories.IWebUserRepository import IWebUserRepository +from src.Domains.Entities.WebUser import WebUser from src.UseCases.Interface.ILineRequestService import ILineRequestService from src.UseCases.Interface.ILineResponseService import ILineResponseService from src.UseCases.Line.RequestLinkLineWebUseCase import RequestLinkLineWebUseCase @@ -64,104 +64,33 @@ def push_a_message(self, to: str, message: str) -> None: class DummyWebUserRepository(IWebUserRepository): - def __init__(self, find_result: list[WebUser], update_result: int = 1): - self._find_result = find_result - self._update_result = update_result - self.updated = None + def __init__(self, find_result: list = None): + self._find_result = find_result or [] def create(self, new_web_user: WebUser) -> WebUser: return new_web_user def update(self, query, new_web_user) -> int: - self.updated = (query, new_web_user) - return self._update_result + return 0 def delete(self, query) -> int: return 0 - def find(self, query) -> list[WebUser]: + def find(self, query) -> list: return self._find_result -def test_request_link_invalid_args(): - use_case = RequestLinkLineWebUseCase( - web_user_repository=DummyWebUserRepository(find_result=[]), - line_request_service=DummyLineRequestService(message="アカウント連携"), - line_response_service=DummyLineResponseService(), - ) - - use_case.execute() - - assert any("アカウント連携" in message for message in use_case._line_response_service.messages) - - -def test_request_link_email_not_found(): - response = DummyLineResponseService() - use_case = RequestLinkLineWebUseCase( - web_user_repository=DummyWebUserRepository(find_result=[]), - line_request_service=DummyLineRequestService(message="アカウント連携 test@example.com"), - line_response_service=response, - ) - - use_case.execute() - - assert any("登録されていません" in message for message in response.messages) - - -def test_request_link_already_linked(): - response = DummyLineResponseService() - web_user = WebUser( - _id="507f1f77bcf86cd799439011", - web_user_name="dummy", - web_user_email="test@example.com", - is_linked_line_user=True, - ) - use_case = RequestLinkLineWebUseCase( - web_user_repository=DummyWebUserRepository(find_result=[web_user]), - line_request_service=DummyLineRequestService(message="アカウント連携 test@example.com"), - line_response_service=response, - ) - - use_case.execute() - - assert any("すでに LINE アカウントと紐付けされています" in message for message in response.messages) - - -def test_request_link_update_failure(): +def test_request_link_returns_deprecation_message(): + """アカウント連携機能は廃止済みのため、廃止メッセージとWebアプリURLを返す。""" response = DummyLineResponseService() - web_user = WebUser( - _id="507f1f77bcf86cd799439011", - web_user_name="dummy", - web_user_email="test@example.com", - is_linked_line_user=False, - ) use_case = RequestLinkLineWebUseCase( - web_user_repository=DummyWebUserRepository(find_result=[web_user], update_result=0), - line_request_service=DummyLineRequestService(message="アカウント連携 test@example.com"), - line_response_service=response, - ) - - use_case.execute() - - assert any("失敗しました" in message for message in response.messages) - - -def test_request_link_success(): - response = DummyLineResponseService() - web_user = WebUser( - _id="507f1f77bcf86cd799439011", - web_user_name="dummy", - web_user_email="test@example.com", - is_linked_line_user=False, - ) - repo = DummyWebUserRepository(find_result=[web_user], update_result=1) - use_case = RequestLinkLineWebUseCase( - web_user_repository=repo, - line_request_service=DummyLineRequestService(message="アカウント連携 test@example.com"), + web_user_repository=DummyWebUserRepository(), + line_request_service=DummyLineRequestService(message="アカウント連携"), line_response_service=response, ) use_case.execute() - assert repo.updated is not None - assert any("承認してください" in message for message in response.messages) + assert len(response.messages) == 1 + assert "廃止" in response.messages[0] + assert "LINEアカウント" in response.messages[0] diff --git a/src/UseCases/Web/UpdateStockUseCase.py b/src/UseCases/Web/UpdateStockUseCase.py index 87d4008..759d1c0 100644 --- a/src/UseCases/Web/UpdateStockUseCase.py +++ b/src/UseCases/Web/UpdateStockUseCase.py @@ -61,10 +61,17 @@ def _parse_date_value(raw: str) -> datetime: elif key == 'notify_status': if val is None: continue - upper = val.strip().upper() - if upper not in ('ON', 'OFF'): - raise BadRequest('通知は ON または OFF を指定してください。') - new_values['notify_enabled'] = upper == 'ON' + v = val.strip() + if v.upper() in ('ON', '') or v == '常に通知': + new_values['notify_days_before'] = None # 常に通知 + elif v.upper() == 'OFF': + new_values['notify_days_before'] = None # OFFはデフォルト(常に通知)扱い + elif v.rstrip('日前から').isdigit(): + new_values['notify_days_before'] = int(v.rstrip('日前から')) + elif v.isdigit(): + new_values['notify_days_before'] = int(v) + else: + raise BadRequest('通知設定が不正です。') res = self._stock_repository.update( query={ diff --git a/src/models/StockViewModel.py b/src/models/StockViewModel.py index 02079c7..4809d08 100644 --- a/src/models/StockViewModel.py +++ b/src/models/StockViewModel.py @@ -34,7 +34,8 @@ def __init__( ).strftime('%Y/%m/%d') self.str_created_at = '' if stock.created_at is None else stock.created_at.date( ).strftime('%Y/%m/%d') - self.notify_status = 'ON' if stock.notify_enabled else 'OFF' + ndb = stock.notify_days_before + self.notify_status = '常に通知' if ndb is None else f'{ndb}日前から' else: self._id = _id self.item_name = item_name diff --git a/src/routes/handle_line_event.py b/src/routes/handle_line_event.py index ba4cbb3..1670042 100644 --- a/src/routes/handle_line_event.py +++ b/src/routes/handle_line_event.py @@ -194,6 +194,10 @@ def get_use_case_text_message(event: Event): # keep explicit commands direct if keyword in use_case_list['stock_keywords']: if event.source.type == 'user' and keyword == '登録': + # 「登録」単体はアイテム名がないので使い方ガイドを返す + if message == '登録': + line_request_service.message = '使い方 登録' + return help_use_case return HandleIntentOperationUseCase( stock_repository=stock_repository, line_request_service=line_request_service, diff --git a/src/services/LineIntentParserService.py b/src/services/LineIntentParserService.py index 05c1e78..66c37bf 100644 --- a/src/services/LineIntentParserService.py +++ b/src/services/LineIntentParserService.py @@ -21,11 +21,11 @@ "parameters": { "type": "object", "properties": { - "item_name": {"type": "string"}, - "expiry_date": {"type": ["string", "null"]}, - "notify_enabled": {"type": "boolean"}, + "item_name": {"type": "string"}, + "expiry_date": {"type": ["string", "null"]}, + "notify_days_before": {"type": ["integer", "null"], "description": "何日前から通知するか。null = 常に通知、省略や不明もnull。"}, }, - "required": ["item_name", "expiry_date", "notify_enabled"], + "required": ["item_name", "expiry_date", "notify_days_before"], "additionalProperties": False, }, "strict": True, @@ -70,15 +70,36 @@ "type": "function", "function": { "name": "register_habit_task", - "description": "毎日・毎週の習慣タスクを登録する。notify_timeはHH:MM形式、不明ならnull。", + "description": "毎日・毎週・毎月の習慣タスクを登録する。notify_timeはHH:MM形式、不明ならnull。weeklyの場合はnotify_day_of_week(0=月〜6=日)、monthlyの場合はnotify_day_of_month(1〜31)を指定。dailyはどちらもnull。", "parameters": { "type": "object", "properties": { - "item_name": {"type": "string"}, - "frequency": {"type": "string", "enum": ["daily", "weekly"]}, - "notify_time": {"type": ["string", "null"]}, + "item_name": {"type": "string"}, + "frequency": {"type": "string", "enum": ["daily", "weekly", "monthly"]}, + "notify_time": {"type": ["string", "null"]}, + "notify_day_of_week": {"type": ["integer", "null"], "description": "週次の場合の曜日(0=月〜6=日)。daily/monthlyはnull。"}, + "notify_day_of_month": {"type": ["integer", "null"], "description": "月次の場合の日(1〜31)。daily/weeklyはnull。"}, + }, + "required": ["item_name", "frequency", "notify_time", "notify_day_of_week", "notify_day_of_month"], + "additionalProperties": False, + }, + "strict": True, + }, + }, + { + "type": "function", + "function": { + "name": "update_habit_frequency", + "description": "習慣タスクの頻度を変更する。weekly→notify_day_of_week(0〜6)、monthly→notify_day_of_month(1〜31)を指定。", + "parameters": { + "type": "object", + "properties": { + "task_name": {"type": "string"}, + "frequency": {"type": "string", "enum": ["daily", "weekly", "monthly"]}, + "notify_day_of_week": {"type": ["integer", "null"]}, + "notify_day_of_month": {"type": ["integer", "null"]}, }, - "required": ["item_name", "frequency", "notify_time"], + "required": ["task_name", "frequency", "notify_day_of_week", "notify_day_of_month"], "additionalProperties": False, }, "strict": True, @@ -136,14 +157,14 @@ "type": "function", "function": { "name": "update_stock_notify", - "description": "登録済みアイテムの通知設定(notify_enabled)を変更する。", + "description": "登録済みアイテムの通知設定を変更する。何日前から通知するかを指定する。", "parameters": { "type": "object", "properties": { - "item_name": {"type": "string"}, - "notify_enabled": {"type": "boolean"}, + "item_name": {"type": "string"}, + "notify_days_before": {"type": ["integer", "null"], "description": "何日前から通知するか。null = 常に通知、省略や不明もnull。"}, }, - "required": ["item_name", "notify_enabled"], + "required": ["item_name", "notify_days_before"], "additionalProperties": False, }, "strict": True, @@ -177,6 +198,7 @@ "register_habit_task": "register_habit", "delete_habit_task": "delete_habit", "update_habit_notify_time": "update_habit_notify_time", + "update_habit_frequency": "update_habit_frequency", "update_notification_setting": "update_notification", "update_stock_notify": "update_stock_notify", "update_habit_log": "update_habit_log", @@ -259,9 +281,11 @@ def _build_result_from_tool_call(self, tool_name: str, args: dict) -> dict: "item_name": args.get("item_name") or args.get("task_name"), "expiry_date": args.get("expiry_date"), "exclude_expiry_date": args.get("exclude_expiry_date"), - "notify_enabled": bool(args.get("notify_enabled", False)), + "notify_days_before": args.get("notify_days_before"), "frequency": args.get("frequency"), "notify_time": args.get("notify_time"), + "notify_day_of_week": args.get("notify_day_of_week"), + "notify_day_of_month": args.get("notify_day_of_month"), "enabled": args.get("enabled"), "scheduled_date": args.get("scheduled_date"), "result": args.get("result"), @@ -288,9 +312,15 @@ def _check_time(v: Any) -> Optional[str]: return None frequency = parsed.get("frequency") - if frequency not in ("daily", "weekly"): + if frequency not in ("daily", "weekly", "monthly"): frequency = None + dow = parsed.get("notify_day_of_week") + notify_day_of_week = dow if isinstance(dow, int) and 0 <= dow <= 6 else None + + dom = parsed.get("notify_day_of_month") + notify_day_of_month = dom if isinstance(dom, int) and 1 <= dom <= 31 else None + intent = parsed.get("intent", "none") if intent in {"register", "delete"} and not item_name: intent = "none" @@ -298,10 +328,20 @@ def _check_time(v: Any) -> Optional[str]: intent = "none" if intent == "register_habit" and not item_name: intent = "none" + if intent == "register_habit" and frequency == "weekly" and notify_day_of_week is None: + intent = "none" + if intent == "register_habit" and frequency == "monthly" and notify_day_of_month is None: + intent = "none" if intent == "delete_habit" and not item_name: intent = "none" if intent == "update_habit_notify_time" and (not item_name or not _check_time(parsed.get("notify_time"))): intent = "none" + if intent == "update_habit_frequency" and not item_name: + intent = "none" + if intent == "update_habit_frequency" and frequency == "weekly" and notify_day_of_week is None: + intent = "none" + if intent == "update_habit_frequency" and frequency == "monthly" and notify_day_of_month is None: + intent = "none" if intent == "update_notification": if parsed.get("enabled") is None and not _check_time(parsed.get("notify_time")): intent = "none" @@ -311,14 +351,22 @@ def _check_time(v: Any) -> Optional[str]: if not item_name or not _check_date(parsed.get("scheduled_date")) or parsed.get("result") not in ("done", "not_done", "other"): intent = "none" + ndb = parsed.get("notify_days_before") + if isinstance(ndb, int) and ndb >= 0: + notify_days_before = ndb + else: + notify_days_before = None + return { "intent": intent, "item_name": item_name, "expiry_date": _check_date(parsed.get("expiry_date")), "exclude_expiry_date": _check_date(parsed.get("exclude_expiry_date")), - "notify_enabled": bool(parsed.get("notify_enabled", False)), + "notify_days_before": notify_days_before, "frequency": frequency, "notify_time": _check_time(parsed.get("notify_time")), + "notify_day_of_week": notify_day_of_week, + "notify_day_of_month": notify_day_of_month, "enabled": parsed.get("enabled"), "scheduled_date": _check_date(parsed.get("scheduled_date")), "result": parsed.get("result"), @@ -331,9 +379,11 @@ def _none_result(self) -> Dict[str, Any]: "item_name": None, "expiry_date": None, "exclude_expiry_date": None, - "notify_enabled": False, + "notify_days_before": None, "frequency": None, "notify_time": None, + "notify_day_of_week": None, + "notify_day_of_month": None, "enabled": None, "scheduled_date": None, "result": None, diff --git a/src/services/tests/test_line_intent_parser_service.py b/src/services/tests/test_line_intent_parser_service.py index d7e781e..5039e20 100644 --- a/src/services/tests/test_line_intent_parser_service.py +++ b/src/services/tests/test_line_intent_parser_service.py @@ -124,7 +124,7 @@ def teardown_method(self): def test_fc_register_with_date(self): resp = _make_tool_response("register_stock", { - "item_name": "確定申告", "expiry_date": "2026-03-15", "notify_enabled": False + "item_name": "確定申告", "expiry_date": "2026-03-15", "notify_days_before": None }) with _mock_api(resp): result = self.svc.parse("確定申告は3/15まで") @@ -134,7 +134,7 @@ def test_fc_register_with_date(self): def test_fc_register_without_date(self): resp = _make_tool_response("register_stock", { - "item_name": "牛乳", "expiry_date": None, "notify_enabled": False + "item_name": "牛乳", "expiry_date": None, "notify_days_before": None }) with _mock_api(resp): result = self.svc.parse("牛乳買った") @@ -142,14 +142,14 @@ def test_fc_register_without_date(self): assert result["item_name"] == "牛乳" assert result["expiry_date"] is None - def test_fc_register_notify_enabled(self): + def test_fc_register_notify_days_before(self): resp = _make_tool_response("register_stock", { - "item_name": "確定申告", "expiry_date": "2026-03-15", "notify_enabled": True + "item_name": "確定申告", "expiry_date": "2026-03-15", "notify_days_before": 7 }) with _mock_api(resp): - result = self.svc.parse("通知ありで確定申告 3/15まで") + result = self.svc.parse("7日前から通知で確定申告 3/15まで") assert result["intent"] == "register" - assert result["notify_enabled"] is True + assert result["notify_days_before"] == 7 assert result["expiry_date"] == "2026-03-15" def test_fc_update(self): @@ -195,7 +195,8 @@ def test_fc_delete_with_exclude(self): def test_fc_register_habit_with_time(self): resp = _make_tool_response("register_habit_task", { - "item_name": "筋トレ", "frequency": "daily", "notify_time": "09:00" + "item_name": "筋トレ", "frequency": "daily", "notify_time": "09:00", + "notify_day_of_week": None, "notify_day_of_month": None, }) with _mock_api(resp): result = self.svc.parse("毎朝9時に筋トレをリマインドして") @@ -203,10 +204,13 @@ def test_fc_register_habit_with_time(self): assert result["item_name"] == "筋トレ" assert result["frequency"] == "daily" assert result["notify_time"] == "09:00" + assert result["notify_day_of_week"] is None + assert result["notify_day_of_month"] is None def test_fc_register_habit_no_time(self): resp = _make_tool_response("register_habit_task", { - "item_name": "英語学習", "frequency": "daily", "notify_time": None + "item_name": "英語学習", "frequency": "daily", "notify_time": None, + "notify_day_of_week": None, "notify_day_of_month": None, }) with _mock_api(resp): result = self.svc.parse("英語学習を毎日リマインドして") @@ -214,6 +218,45 @@ def test_fc_register_habit_no_time(self): assert result["item_name"] == "英語学習" assert result["notify_time"] is None + def test_fc_register_habit_weekly(self): + resp = _make_tool_response("register_habit_task", { + "item_name": "筋トレ", "frequency": "weekly", "notify_time": "09:00", + "notify_day_of_week": 0, "notify_day_of_month": None, + }) + with _mock_api(resp): + result = self.svc.parse("毎週月曜9時に筋トレをリマインドして") + assert result["intent"] == "register_habit" + assert result["item_name"] == "筋トレ" + assert result["frequency"] == "weekly" + assert result["notify_day_of_week"] == 0 + assert result["notify_day_of_month"] is None + + def test_fc_register_habit_monthly(self): + resp = _make_tool_response("register_habit_task", { + "item_name": "家計簿", "frequency": "monthly", "notify_time": "12:00", + "notify_day_of_week": None, "notify_day_of_month": 1, + }) + with _mock_api(resp): + result = self.svc.parse("毎月1日12時に家計簿をつけるリマインドして") + assert result["intent"] == "register_habit" + assert result["item_name"] == "家計簿" + assert result["frequency"] == "monthly" + assert result["notify_day_of_week"] is None + assert result["notify_day_of_month"] == 1 + + def test_fc_update_habit_frequency(self): + resp = _make_tool_response("update_habit_frequency", { + "task_name": "筋トレ", "frequency": "weekly", + "notify_day_of_week": 2, "notify_day_of_month": None, + }) + with _mock_api(resp): + result = self.svc.parse("筋トレを毎週水曜日に変更して") + assert result["intent"] == "update_habit_frequency" + assert result["item_name"] == "筋トレ" + assert result["frequency"] == "weekly" + assert result["notify_day_of_week"] == 2 + assert result["notify_day_of_month"] is None + def test_fc_delete_habit(self): resp = _make_tool_response("delete_habit_task", {"task_name": "筋トレ"}) with _mock_api(resp): @@ -251,13 +294,13 @@ def test_fc_update_notification_time(self): def test_fc_update_stock_notify(self): resp = _make_tool_response("update_stock_notify", { - "item_name": "牛乳", "notify_enabled": True + "item_name": "牛乳", "notify_days_before": 3 }) with _mock_api(resp): - result = self.svc.parse("牛乳の通知をオンにして") + result = self.svc.parse("牛乳を3日前から通知して") assert result["intent"] == "update_stock_notify" assert result["item_name"] == "牛乳" - assert result["notify_enabled"] is True + assert result["notify_days_before"] == 3 def test_fc_update_habit_log(self): resp = _make_tool_response("update_habit_log", { @@ -319,7 +362,7 @@ def test_invalid_date_format_becomes_none(self): "item_name": "牛乳", "expiry_date": "2026/03/15", "exclude_expiry_date": None, - "notify_enabled": False, + "notify_days_before": None, "frequency": None, "notify_time": None, } @@ -332,7 +375,7 @@ def test_item_name_too_long_becomes_none(self): "item_name": "a" * 101, "expiry_date": None, "exclude_expiry_date": None, - "notify_enabled": False, + "notify_days_before": None, "frequency": None, "notify_time": None, } @@ -346,7 +389,7 @@ def test_item_name_with_newline_becomes_none(self): "item_name": "牛乳\n卵", "expiry_date": None, "exclude_expiry_date": None, - "notify_enabled": False, + "notify_days_before": None, "frequency": None, "notify_time": None, } @@ -360,7 +403,7 @@ def test_invalid_notify_time_becomes_none(self): "item_name": "筋トレ", "expiry_date": None, "exclude_expiry_date": None, - "notify_enabled": False, + "notify_days_before": None, "frequency": "daily", "notify_time": "9:00", } @@ -373,7 +416,7 @@ def test_update_without_expiry_becomes_none(self): "item_name": "牛乳", "expiry_date": None, "exclude_expiry_date": None, - "notify_enabled": False, + "notify_days_before": None, "frequency": None, "notify_time": None, }