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
+
+
一覧
+
+
+
+
+
+
+
+
+
+
+ calendar_month
+
+
カレンダー
+
+
+
+
+
+
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,
}