cwadayi commited on
Commit
352fd07
·
verified ·
1 Parent(s): 99ecfd2

Update app.py

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