Add weather caching, useless-tip redirect, clock widget, and IAM Bedrock auth

- Cache the latest fetched tomorrow weather and expose it via /api/system/weather/latest
- Add a header Clock widget showing date/time plus the cached weather summary
- Redirect useless-talk mentions from the weather bot to the useless-tips webhook
- Switch Bedrock auth from bearer token to standard IAM access key/secret

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 15:15:11 +09:00
co-authored by Claude Sonnet 4.6
parent 4a08ba29fa
commit 4e837d1419
8 changed files with 160 additions and 4 deletions
+5 -2
View File
@@ -6,9 +6,12 @@ OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_API_KEY=sk-ant-...
# Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定 # Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定
OLLAMA_API_BASE=http://localhost:11434 OLLAMA_API_BASE=http://localhost:11434
# Amazon Bedrock (API key方式) — モデル名は bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0 形式で指定 # Amazon Bedrock (IAM経由 — Access Key方式) — モデル名は bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0 形式で指定
AWS_BEARER_TOKEN_BEDROCK= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION_NAME=us-east-1 AWS_REGION_NAME=us-east-1
# 旧: API key方式(Bearer Token)。IAMを使う場合は不要
AWS_BEARER_TOKEN_BEDROCK=
# デバッグ (true にすると LiteLLM の詳細ログが出力される) # デバッグ (true にすると LiteLLM の詳細ログが出力される)
DEBUG=false DEBUG=false
+7
View File
@@ -3,6 +3,7 @@ import psutil
from pydantic import BaseModel from pydantic import BaseModel
from app.core.scheduler import send_useless_tip, send_weather_laundry_notification from app.core.scheduler import send_useless_tip, send_weather_laundry_notification
from app.core.weather_cache import get_latest as get_latest_weather
router = APIRouter(prefix="/api/system", tags=["system"]) router = APIRouter(prefix="/api/system", tags=["system"])
@@ -61,3 +62,9 @@ async def test_useless_tip():
"""どうでもいいtips→Discord通知を手動で即時実行する(動作確認用)""" """どうでもいいtips→Discord通知を手動で即時実行する(動作確認用)"""
await send_useless_tip() await send_useless_tip()
return {"status": "sent"} return {"status": "sent"}
@router.get("/weather/latest")
async def get_latest_weather_data():
"""直近で取得した明日の天気データを返す(未取得ならnull)"""
return get_latest_weather()
+7 -2
View File
@@ -10,8 +10,13 @@ CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").spl
DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes") DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
# Amazon Bedrock # Amazon Bedrock
# AWS_BEARER_TOKEN_BEDROCK (API key方式) / AWS_REGION_NAME は # 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。
# LiteLLM が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる # IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。
# API key方式(旧): AWS_BEARER_TOKEN_BEDROCK のみを設定する。
# 両方設定されている場合はIAM認証(Access Key)が優先される。
AWS_ACCESS_KEY_ID: str | None = os.getenv("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY: str | None = os.getenv("AWS_SECRET_ACCESS_KEY")
AWS_SESSION_TOKEN: str | None = os.getenv("AWS_SESSION_TOKEN") # 一時的なIAMクレデンシャルを使う場合のみ
AWS_BEARER_TOKEN_BEDROCK: str | None = os.getenv("AWS_BEARER_TOKEN_BEDROCK") AWS_BEARER_TOKEN_BEDROCK: str | None = os.getenv("AWS_BEARER_TOKEN_BEDROCK")
AWS_REGION_NAME: str = os.getenv("AWS_REGION_NAME", "us-east-1") AWS_REGION_NAME: str = os.getenv("AWS_REGION_NAME", "us-east-1")
+31
View File
@@ -11,7 +11,9 @@ from app.core.config import (
WEATHER_NOTIFY_MODEL, WEATHER_NOTIFY_MODEL,
) )
from app.core.llm import complete from app.core.llm import complete
from app.core.scheduler import send_useless_tip
from app.core.weather import fetch_tomorrow_weather, judge_laundry from app.core.weather import fetch_tomorrow_weather, judge_laundry
from app.core.weather_cache import set_latest
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,6 +23,7 @@ intents.message_content = True
client = discord.Client(intents=intents) client = discord.Client(intents=intents)
WEATHER_KEYWORDS = ("天気", "天候", "洗濯", "雨", "気温", "降水") WEATHER_KEYWORDS = ("天気", "天候", "洗濯", "雨", "気温", "降水")
USELESS_TALK_KEYWORDS = ("どうでもいい", "雑談", "暇", "つまんない", "退屈")
async def _build_weather_context() -> str: async def _build_weather_context() -> str:
@@ -29,6 +32,7 @@ async def _build_weather_context() -> str:
weather_data = await fetch_tomorrow_weather(WEATHER_AREA_CODE, WEATHER_AREA_KEYWORD) weather_data = await fetch_tomorrow_weather(WEATHER_AREA_CODE, WEATHER_AREA_KEYWORD)
judgement, reason = judge_laundry(weather_data["weather"], weather_data["pop"]) judgement, reason = judge_laundry(weather_data["weather"], weather_data["pop"])
pop_text = f"{weather_data['pop']}%" if weather_data["pop"] is not None else "不明" pop_text = f"{weather_data['pop']}%" if weather_data["pop"] is not None else "不明"
set_latest(weather_data["weather"], weather_data["pop"], judgement, reason)
return ( return (
"【明日の天気予報データ(気象庁発表)】\n" "【明日の天気予報データ(気象庁発表)】\n"
f"天気: {weather_data['weather']}\n" f"天気: {weather_data['weather']}\n"
@@ -59,6 +63,12 @@ async def on_message(message: discord.Message):
async with message.channel.typing(): async with message.channel.typing():
try: try:
if any(keyword in content for keyword in USELESS_TALK_KEYWORDS):
redirect = await _generate_useless_talk_redirect(content)
await message.reply(redirect)
await send_useless_tip()
return
messages = [{"role": "system", "content": DISCORD_BOT_SYSTEM_PROMPT}] messages = [{"role": "system", "content": DISCORD_BOT_SYSTEM_PROMPT}]
if any(keyword in content for keyword in WEATHER_KEYWORDS): if any(keyword in content for keyword in WEATHER_KEYWORDS):
@@ -74,6 +84,27 @@ async def on_message(message: discord.Message):
await message.reply("⚠️ 応答の生成に失敗しました") await message.reply("⚠️ 応答の生成に失敗しました")
async def _generate_useless_talk_redirect(content: str) -> str:
"""どうでもいい話を振られたとき、『どうでもいいTips』チャンネルへ誘導する一言を生成する。"""
fallback = "🤷 雑談なら『どうでもいいTips』チャンネルの出番です!今ちょうど一つ送りますね〜"
prompt = (
"あなたは「お天気アプリ」というDiscord Botです。"
"ユーザーがどうでもいい雑談を振ってきました。"
"あなたは天気専門なので、雑談は『どうでもいいTips』という別のBot/チャンネルにお任せしたいです。"
"そこへ話題を振るような、軽いノリの一言を1〜2文で返してください。"
"絵文字を1つ程度使い、出力は本文のみにしてください。\n\n"
f"ユーザーの発言: {content}"
)
try:
result = await complete([{"role": "user", "content": prompt}], model=WEATHER_NOTIFY_MODEL)
return result.strip() or fallback
except Exception:
logger.exception("Failed to generate useless talk redirect message")
return fallback
async def start_discord_bot() -> None: async def start_discord_bot() -> None:
if not DISCORD_BOT_TOKEN: if not DISCORD_BOT_TOKEN:
logger.warning("DISCORD_BOT_TOKEN is not set; Discord bot will not start") logger.warning("DISCORD_BOT_TOKEN is not set; Discord bot will not start")
+2
View File
@@ -22,6 +22,7 @@ from app.core.config import (
from app.core.llm import complete from app.core.llm import complete
from app.core.notify import send_discord_message from app.core.notify import send_discord_message
from app.core.weather import fetch_tomorrow_weather, judge_laundry from app.core.weather import fetch_tomorrow_weather, judge_laundry
from app.core.weather_cache import set_latest
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -37,6 +38,7 @@ async def send_weather_laundry_notification() -> None:
weather_data = await fetch_tomorrow_weather(WEATHER_AREA_CODE, WEATHER_AREA_KEYWORD) weather_data = await fetch_tomorrow_weather(WEATHER_AREA_CODE, WEATHER_AREA_KEYWORD)
judgement, reason = judge_laundry(weather_data["weather"], weather_data["pop"]) judgement, reason = judge_laundry(weather_data["weather"], weather_data["pop"])
pop_text = f"{weather_data['pop']}%" if weather_data["pop"] is not None else "不明" pop_text = f"{weather_data['pop']}%" if weather_data["pop"] is not None else "不明"
set_latest(weather_data["weather"], weather_data["pop"], judgement, reason)
content = await _generate_message(weather_data["weather"], pop_text, judgement, reason) content = await _generate_message(weather_data["weather"], pop_text, judgement, reason)
await send_discord_message(DISCORD_WEBHOOK_URL, content) await send_discord_message(DISCORD_WEBHOOK_URL, content)
+19
View File
@@ -0,0 +1,19 @@
"""直近で取得した「明日の天気」データをメモリ上にキャッシュする。"""
from datetime import datetime
_latest: dict | None = None
def set_latest(weather: str, pop: int | None, judgement: str, reason: str) -> None:
global _latest
_latest = {
"weather": weather,
"pop": pop,
"judgement": judgement,
"reason": reason,
"fetched_at": datetime.now().isoformat(),
}
def get_latest() -> dict | None:
return _latest
+2
View File
@@ -7,6 +7,7 @@ import ModelSelector from './components/ModelSelector.vue'
import LogViewer from './components/LogViewer.vue' import LogViewer from './components/LogViewer.vue'
import ToastList from './components/ToastList.vue' import ToastList from './components/ToastList.vue'
import MemoryMonitor from './components/MemoryMonitor.vue' import MemoryMonitor from './components/MemoryMonitor.vue'
import Clock from './components/Clock.vue'
const showLogs = ref(false) const showLogs = ref(false)
</script> </script>
@@ -21,6 +22,7 @@ const showLogs = ref(false)
<header> <header>
<span class="title">PlayChat</span> <span class="title">PlayChat</span>
<ModelSelector /> <ModelSelector />
<Clock />
<button class="log-toggle" :class="{ active: showLogs }" @click="showLogs = !showLogs"> <button class="log-toggle" :class="{ active: showLogs }" @click="showLogs = !showLogs">
ログ ログ
</button> </button>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
interface LatestWeather {
weather: string
pop: number | null
judgement: string
reason: string
fetched_at: string
}
const now = ref(new Date())
const weather = ref<LatestWeather | null>(null)
let clockIntervalId: number | null = null
let weatherIntervalId: number | null = null
const formatDate = (date: Date) => {
const weekdays = ['日', '月', '火', '水', '木', '金', '土']
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')} (${weekdays[date.getDay()]})`
}
const formatTime = (date: Date) => {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
const formatFetchedAt = (iso: string) => {
const d = new Date(iso)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}時点`
}
const fetchWeather = async () => {
try {
const response = await fetch('/api/system/weather/latest')
const data = await response.json()
weather.value = data
} catch (error) {
console.error('Failed to fetch latest weather:', error)
}
}
onMounted(() => {
clockIntervalId = window.setInterval(() => {
now.value = new Date()
}, 1000)
fetchWeather()
weatherIntervalId = window.setInterval(fetchWeather, 60000)
})
onUnmounted(() => {
if (clockIntervalId) clearInterval(clockIntervalId)
if (weatherIntervalId) clearInterval(weatherIntervalId)
})
</script>
<template>
<div class="clock">
<span class="date">{{ formatDate(now) }}</span>
<span class="time">{{ formatTime(now) }}</span>
<span v-if="weather" class="weather" :title="`${formatFetchedAt(weather.fetched_at)}取得`">
🌤️ 明日: {{ weather.weather }}
<template v-if="weather.pop !== null">(降水{{ weather.pop }}%)</template>
・👕{{ weather.judgement }}
</span>
</div>
</template>
<style scoped>
.clock {
display: flex;
align-items: baseline;
gap: 8px;
font-family: monospace;
font-size: 13px;
color: #aaa;
}
.time {
color: #eee;
font-weight: 600;
}
.weather {
color: #8bc8ff;
font-size: 12px;
border-left: 1px solid #444;
padding-left: 8px;
}
</style>