Spaces:
Sleeping
Sleeping
File size: 10,552 Bytes
52833f4 d422181 352fd07 d422181 352fd07 52833f4 99ecfd2 52833f4 1527b5e 52833f4 e9e401c 52833f4 d422181 7b108b2 a8eda7b 352fd07 1527b5e 7b108b2 a8eda7b 52833f4 352fd07 d422181 52833f4 7b108b2 352fd07 d761501 3387e96 352fd07 3387e96 d422181 352fd07 d422181 352fd07 d761501 a8eda7b 52833f4 7b108b2 352fd07 7b108b2 352fd07 93b40f2 352fd07 93b40f2 352fd07 1527b5e 352fd07 d422181 352fd07 d422181 93b40f2 352fd07 52833f4 352fd07 52833f4 352fd07 c175e0c 352fd07 52833f4 352fd07 52833f4 352fd07 1527b5e 352fd07 d422181 352fd07 1527b5e 352fd07 d422181 352fd07 d422181 1527b5e d422181 352fd07 3387e96 1527b5e 352fd07 d422181 3387e96 352fd07 a2c5df6 352fd07 d422181 352fd07 1527b5e 352fd07 d422181 1527b5e 352fd07 52833f4 352fd07 1527b5e 352fd07 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 |
import os
import uuid
from datetime import datetime, timedelta, timezone
from flask import Flask, request, abort, send_from_directory
from linebot.v3 import WebhookHandler
from linebot.v3.exceptions import InvalidSignatureError
from linebot.v3.messaging import (
Configuration, ApiClient, MessagingApi,
ReplyMessageRequest, TextMessage
)
# ✅ LINE v3: use ImageMessage (not ImageSendMessage)
from linebot.v3.messaging.models import ImageMessage
from linebot.v3.webhooks import MessageEvent, TextMessageContent
import requests
import pandas as pd
import plotly.express as px
# --- Environment Variables ---
CHANNEL_ACCESS_TOKEN = os.getenv("CHANNEL_ACCESS_TOKEN")
CHANNEL_SECRET = os.getenv("CHANNEL_SECRET")
HF_SPACE_URL = os.getenv("SPACEURL") # optional: https://<space-name>.hf.space
# --- Flask & LINE Bot Initialization ---
app = Flask(__name__)
os.makedirs("static", exist_ok=True)
configuration = Configuration(access_token=CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(CHANNEL_SECRET)
# --- Welcome & Health ---
@app.route("/", methods=["GET"])
def home():
return """
<html>
<head>
<title>LINE Bot Server</title>
<style>
body{font-family:Arial,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;background:#f0f2f5;margin:0;}
.container{max-width:720px;text-align:center;padding:40px;background:#fff;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.08)}
h1{color:#1dcd00;margin:0 0 8px}
p{color:#333;font-size:1.05rem;margin:.4rem 0}
.status{font-weight:700;color:#28a745}
</style>
</head>
<body>
<div class="container">
<h1>✓ LINE Bot Server is Running</h1>
<p>This is the backend service for the Earthquake Alert Bot.</p>
<p>The service is <span class="status">active</span> and listening for webhook events from LINE.</p>
</div>
</body>
</html>
"""
@app.route("/healthz")
def healthz():
return "ok"
@app.route("/static/<path:filename>")
def serve_static(filename):
return send_from_directory("static", filename)
# --- Earthquake Query Logic ---
USGS_API_BASE_URL = "https://earthquake.usgs.gov/fdsnws/event/1/query"
def _iso(dt: datetime) -> str:
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
def fetch_global_last24h_text(min_mag=5.0, limit=10) -> str:
now_utc = datetime.now(timezone.utc)
since = now_utc - timedelta(hours=24)
params = {
"format": "geojson",
"starttime": _iso(since),
"endtime": _iso(now_utc),
"minmagnitude": float(min_mag),
"limit": int(limit),
"orderby": "time",
}
try:
r = requests.get(USGS_API_BASE_URL, params=params, timeout=15)
r.raise_for_status()
features = r.json().get("features", [])
if not features:
return "✅ 過去 24 小時內,全球無規模 5.0 以上的顯著地震。"
lines = ["🚨 近 24 小時全球顯著地震 (M≥5.0):", "-" * 20]
for f in features:
p = f["properties"]
t_utc = datetime.fromtimestamp(p["time"] / 1000, tz=timezone.utc)
lines.append(
f"震級: {p['mag']:.1f} | 時間: {t_utc.strftime('%H:%M')} (UTC)\n地點: {p.get('place','')}"
)
return "\n\n".join(lines)
except Exception as e:
return f"❌ 查詢失敗: {e}"
def fetch_taiwan_df_this_year(min_mag=5.0) -> pd.DataFrame | str:
now_utc = datetime.now(timezone.utc)
start_of_year_utc = datetime(now_utc.year, 1, 1, tzinfo=timezone.utc)
params = {
"format": "geojson",
"starttime": _iso(start_of_year_utc),
"endtime": _iso(now_utc),
"minmagnitude": float(min_mag),
"minlatitude": 21,
"maxlatitude": 26,
"minlongitude": 119,
"maxlongitude": 123,
"limit": 250,
"orderby": "time",
}
try:
r = requests.get(USGS_API_BASE_URL, params=params, timeout=20)
r.raise_for_status()
features = r.json().get("features", [])
if not features:
return f"✅ 今年 ({now_utc.year} 年) 以來,台灣區域無 M≥{min_mag:.1f} 的顯著地震。"
rows = []
for f in features:
p = f["properties"]
lon, lat, *_ = f["geometry"]["coordinates"]
rows.append(
{
"latitude": lat,
"longitude": lon,
"magnitude": p["mag"],
"place": p.get("place", ""),
"time_utc": datetime.fromtimestamp(p["time"] / 1000, tz=timezone.utc),
}
)
return pd.DataFrame(rows)
except Exception as e:
return f"❌ 查詢失敗: {e}"
# --- Offline-friendly Map (PNG) ---
def create_and_save_map(df: pd.DataFrame) -> str:
# scatter_geo → renders via kaleido without external tiles
fig = px.scatter_geo(
df,
lat="latitude",
lon="longitude",
size="magnitude",
color="magnitude",
hover_name="place",
hover_data={"magnitude": ":.1f", "time_utc": "|%Y-%m-%d %H:%M UTC"},
size_max=24,
color_continuous_scale=px.colors.sequential.YlOrRd,
projection="natural earth",
)
fig.update_layout(
title=f"<b>今年 ({datetime.now(timezone.utc).year}) 台灣區域顯著地震 (M≥5.0)</b>",
margin=dict(r=0, t=40, l=0, b=0),
)
fig.update_geos(
lonaxis_range=[118.5, 123.5],
lataxis_range=[20.5, 26.8],
showcountries=True,
showland=True,
landcolor="#f8f8f8",
countrycolor="#aaa",
)
filename = f"map_{uuid.uuid4().hex}.png"
filepath = os.path.join("static", filename)
# Requires `kaleido` in requirements.txt
fig.write_image(filepath, scale=2, width=900, height=600)
return filename
def _base_url_for_images() -> str:
if HF_SPACE_URL:
return HF_SPACE_URL.rstrip("/")
return request.url_root.rstrip("/")
# --- LINE Webhook ---
@app.route("/callback", methods=["POST"])
def callback():
signature = request.headers.get("X-Line-Signature")
body = request.get_data(as_text=True)
try:
handler.handle(body, signature)
except InvalidSignatureError:
abort(400)
return "OK"
# --- Message Handler ---
@handler.add(MessageEvent, message=TextMessageContent)
def handle_message(event):
user_message = (event.message.text or "").strip().lower()
with ApiClient(configuration) as api_client:
line_bot_api = MessagingApi(api_client)
# Taiwan map command
if ("臺灣地震畫圖" in user_message) or ("台灣地震畫圖" in user_message):
result = fetch_taiwan_df_this_year()
if isinstance(result, pd.DataFrame):
filename = create_and_save_map(result)
image_url = f"{_base_url_for_images()}/static/{filename}"
reply = ReplyMessageRequest(
reply_token=event.reply_token,
messages=[
TextMessage(text="🗺️ 已為您繪製今年台灣區域 M≥5.0 地震分佈圖(UTC)。"),
ImageMessage(
original_content_url=image_url,
preview_image_url=image_url,
),
],
)
else:
reply = ReplyMessageRequest(
reply_token=event.reply_token,
messages=[TextMessage(text=result)],
)
line_bot_api.reply_message_with_http_info(reply)
return
# Help
if user_message == "/help":
text = (
"📖 地震預警 dayichen 指令說明\n\n"
"➡️ /help\n 說明:顯示此幫助訊息。\n\n"
"➡️ 地震\n 說明:查詢全球最近 24 小時內,M≥5.0 的顯著地震。\n\n"
"➡️ 臺灣地震 / 台灣地震\n 說明:查詢今年以來台灣區域 (21–26°N, 119–123°E) M≥5.0 地震。\n\n"
"➡️ 臺灣地震畫圖 / 台灣地震畫圖\n 說明:繪製今年台灣區域 M≥5.0 地震分佈圖並回傳圖片。\n\n"
"➡️ 你好\n 說明:顯示歡迎訊息。"
)
line_bot_api.reply_message_with_http_info(
ReplyMessageRequest(reply_token=event.reply_token, messages=[TextMessage(text=text)])
)
return
# Taiwan list
if ("臺灣地震" in user_message) or ("台灣地震" in user_message):
result = fetch_taiwan_df_this_year()
if isinstance(result, pd.DataFrame):
count = len(result)
lines = [f"🇹🇼 今年 ({datetime.now(timezone.utc).year} 年) 台灣區域顯著地震 (M≥5.0),共 {count} 筆:", "-" * 20]
for _, row in result.head(15).iterrows():
t = row["time_utc"].strftime("%Y-%m-%d %H:%M")
lines.append(f"震級: {row['magnitude']:.1f} | 時間: {t} (UTC)\n地點: {row['place']}")
if count > 15:
lines.append(f"... (還有 {count - 15} 筆,可用「臺灣地震畫圖」查看全部)")
reply_text = "\n\n".join(lines)
else:
reply_text = result
line_bot_api.reply_message_with_http_info(
ReplyMessageRequest(reply_token=event.reply_token, messages=[TextMessage(text=reply_text)])
)
return
# Global last 24h
if ("地震" in user_message) or ("quake" in user_message):
reply_text = fetch_global_last24h_text()
line_bot_api.reply_message_with_http_info(
ReplyMessageRequest(reply_token=event.reply_token, messages=[TextMessage(text=reply_text)])
)
return
# Greeting
if ("你好" in user_message) or ("hi" in user_message):
line_bot_api.reply_message_with_http_info(
ReplyMessageRequest(
reply_token=event.reply_token,
messages=[TextMessage(text="👋 你好!我是地震查詢機器人。\n\n輸入 /help 查看所有指令。")]
)
)
return
# No-op on unmatched text (keeps reply_token free for other handlers)
return |