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 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589
| // index.js
export default { async fetch(request, env, ctx) { const url = new URL(request.url); // 圖片存取(這是後來想到才加的功能) if (url.pathname.startsWith("/img/")) { return serveImage(request, env); }
if (url.pathname === "/line/webhook" && request.method === "POST") { const signature = request.headers.get("x-line-signature") || ""; const bodyText = await request.text();
try { if (signature && env.LINE_CHANNEL_SECRET) { const ok = await verifyLineSignature( bodyText, signature, env.LINE_CHANNEL_SECRET ); if (!ok) { console.log("WARN: Invalid LINE signature (dev mode, not blocking)"); } } else { console.log("WARN: Missing signature or secret (dev mode)"); } } catch (e) { console.log("Error during signature check:", e); }
let body = {}; try { body = JSON.parse(bodyText || "{}"); } catch (e) { console.log("JSON parse error:", e); }
const events = body.events || []; for (const event of events) { ctx.waitUntil(handleLineEvent(event, env)); }
return new Response("OK", { status: 200 }); }
if (url.pathname === "/health") { return new Response("OK", { status: 200 }); }
return new Response("Not Found", { status: 404 }); },
async scheduled(event, env, ctx) { ctx.waitUntil(runReminderCron(env)); }, };
// 驗證 LINE 簽章 async function verifyLineSignature(bodyText, signature, channelSecret) { if (!channelSecret) return false; const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( "raw", encoder.encode(channelSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"] );
const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(bodyText)); const macBytes = new Uint8Array(mac); let macBase64 = ""; for (const b of macBytes) { macBase64 += String.fromCharCode(b); } macBase64 = btoa(macBase64);
return macBase64 === signature; }
// 處理單一 LINE 事件 async function handleLineEvent(event, env) { if (event.type !== "message" || event.message.type !== "text") return;
const userId = event.source && event.source.userId; if (!userId) return;
const replyToken = event.replyToken;
if (env.OWNER_LINE_USER_ID && userId !== env.OWNER_LINE_USER_ID) { await sendReply( env, replyToken, "自定義回應" ); return; }
const text = event.message.text.trim(); const user = await getOrCreateUser(env, userId);
// 畫圖指令:畫圖:xxxx 或 產圖:xxxx const imgMatch = text.match(/^(畫圖|產圖)[::]\s*(.+)$/); if (imgMatch) { const prompt = imgMatch[2].trim(); const nickname = user.nickname || "自定義";
try { const id = await generateImageAndStore(env, prompt); const base = (env.PUBLIC_BASE_URL || "").replace(/\/+$/, ""); // 去掉最後的斜線 const imageUrl = `${base}/img/${id}`;
await sendReplyMessages(env, replyToken, [ { type: "text", text: `幫你畫好了,${nickname}~\n主題:${prompt}`, }, { type: "image", originalContentUrl: imageUrl, previewImageUrl: imageUrl, }, ]); } catch (e) { console.error("image error", e); await sendReply( env, replyToken, `畫圖的時候出了一點問題QQ` ); } return; }
// 指令:列出提醒 if (text === "列出提醒" || text.toLowerCase() === "/list") { const replyText = await listReminders(env, user); await sendReply(env, replyToken, replyText); return; }
// 指令:取消提醒 <id> if (text.startsWith("取消提醒")) { const match = text.match(/^取消提醒\s+(\d+)/); if (!match) { await sendReply(env, replyToken, "用法:取消提醒 <ID>\n例如:取消提醒 3"); return; } const reminderId = parseInt(match[1], 10); const msg = await cancelReminder(env, user, reminderId); await sendReply(env, replyToken, msg); return; }
// 指令:提醒 YYYY-MM-DD HH:MM 內容 // 或:提醒 HH:MM 內容(日期省略 -> 預設今天,如果時間已過就視為明天) const remindMatch = text.match( /^提醒\s+(\d{4}-\d{2}-\d{2})?\s*(\d{1,2}:\d{2})\s+(.+)$/ ); if (remindMatch) { let [, datePart, timePart, taskText] = remindMatch; taskText = taskText.trim();
if (!datePart) { const now = new Date(); const taipeiNow = new Date(now.getTime() + 8 * 60 * 60 * 1000); const yyyy = taipeiNow.getUTCFullYear(); const mm = String(taipeiNow.getUTCMonth() + 1).padStart(2, "0"); const dd = String(taipeiNow.getUTCDate()).padStart(2, "0"); datePart = `${yyyy}-${mm}-${dd}`; }
const epochSec = computeEpochFromLocal(datePart, timePart); const nowSec = Math.floor(Date.now() / 1000);
let finalEpoch = epochSec; let dateNote = datePart;
if (!text.includes(datePart) && epochSec <= nowSec) { finalEpoch = epochSec + 86400; const d = new Date((finalEpoch + 8 * 3600) * 1000); const yyyy = d.getUTCFullYear(); const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); const dd = String(d.getUTCDate()).padStart(2, "0"); dateNote = `${yyyy}-${mm}-${dd}`; }
const reminderId = await createReminder(env, user, taskText, finalEpoch);
const nickname = user.nickname || "自定義"; const replyText = `好的${nickname}\n已幫你建立提醒(ID: ${reminderId}):\n` + `時間:${dateNote} ${timePart}\n內容:${taskText}`; await sendReply(env, replyToken, replyText); return; }
// 記憶暱稱 const nicknameMatch = text.match(/^我叫(.+)/) || text.match(/^你以後叫我(.+)/) || text.match(/叫我(.+)/); if (nicknameMatch) { const nickname = nicknameMatch[1].trim(); await updateNickname(env, user, nickname); await sendReply(env, replyToken, `好~之後我就叫你 ${nickname} ✨`); return; }
// 其餘訊息 → 丟給 LLM 聊天 + KV 記憶 const aiReply = await chatWithLoyi(env, user, text); await sendReply(env, replyToken, aiReply); }
// 呼叫 Workers AI 產圖,並存到 KV: MEMORY async function generateImageAndStore(env, prompt) { const inputs = { prompt, // 需要固定尺寸可打開 // width: 768, // height: 768, };
const raw = await env.AI.run( "@cf/stabilityai/stable-diffusion-xl-base-1.0", inputs );
// 這邊由於我不確定是哪個所以都寫,可能是 ArrayBuffer / Uint8Array / ReadableStream let arrayBuffer; if (raw instanceof ArrayBuffer) { arrayBuffer = raw; } else if (raw instanceof Uint8Array) { arrayBuffer = raw.buffer; } else { arrayBuffer = await new Response(raw).arrayBuffer(); }
// 基本防呆:避免存到空檔 if (!arrayBuffer || arrayBuffer.byteLength < 32) { throw new Error("Empty/invalid image buffer returned from model"); }
const id = crypto.randomUUID();
await env.MEMORY.put(`img:${id}`, arrayBuffer, { expirationTtl: 60 * 60 * 24 * 7, // 7 天 metadata: { contentType: "image/png", prompt, created_at: new Date().toISOString(), }, });
return id; }
// 讀出 KV 裡的圖片並回傳(從 /img/<id>) async function serveImage(request, env) { const url = new URL(request.url); const id = url.pathname.slice("/img/".length);
if (!id) return new Response("Missing id", { status: 400 });
// 讀二進位 + metadata const got = await env.MEMORY.getWithMetadata(`img:${id}`, "arrayBuffer"); if (!got || !got.value) return new Response("Not found", { status: 404 });
const contentType = (got.metadata && got.metadata.contentType) || "image/png";
return new Response(got.value, { status: 200, headers: { "content-type": contentType, "cache-control": "public, max-age=86400", }, }); }
// base64 helper (之後寫其他功能可能會用到) function arrayBufferToBase64(buffer) { let binary = ""; const bytes = new Uint8Array(buffer); for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); }
function base64ToUint8Array(base64) { const binary = atob(base64); const len = binary.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binary.charCodeAt(i); } return bytes; }
// 將 YYYY-MM-DD + HH:MM (Asia/Taipei) 轉成 Unix 秒(UTC) function computeEpochFromLocal(datePart, timePart) { const iso = `${datePart}T${timePart}:00+08:00`; const ms = Date.parse(iso); return Math.floor(ms / 1000); }
// 建立或取得使用者 async function getOrCreateUser(env, lineUserId) { const selectRes = await env.DB .prepare("SELECT * FROM users WHERE line_user_id = ?") .bind(lineUserId) .first();
if (selectRes) return selectRes;
const insertRes = await env.DB .prepare( "INSERT INTO users (line_user_id, timezone, preferences_json) VALUES (?, ?, ?)" ) .bind(lineUserId, "Asia/Taipei", "{}") .run();
const newId = insertRes.meta.last_row_id;
const newUser = await env.DB .prepare("SELECT * FROM users WHERE id = ?") .bind(newId) .first();
return newUser; }
// 建立提醒 async function createReminder(env, user, text, triggerEpochSec) { const res = await env.DB .prepare( "INSERT INTO reminders (user_id, text, trigger_time, status) VALUES (?, ?, ?, 'ACTIVE')" ) .bind(user.id, text, triggerEpochSec) .run();
return res.meta.last_row_id; }
// 列出提醒 async function listReminders(env, user) { const res = await env.DB .prepare( "SELECT id, text, trigger_time, status FROM reminders WHERE user_id = ? AND status = 'ACTIVE' ORDER BY trigger_time ASC LIMIT 10" ) .bind(user.id) .all();
const rows = res.results || []; if (rows.length === 0) { return "目前沒有啟用中的提醒~可以跟我說:\n「提醒 23:30 關除濕機」"; }
const lines = rows.map((r) => { const localDateTime = epochToTaipeiString(r.trigger_time); return `#${r.id}|${localDateTime}|${r.text}`; });
return "你目前的提醒(最多顯示 10 筆):\n" + lines.join("\n"); }
// 取消提醒 async function cancelReminder(env, user, reminderId) { await env.DB .prepare( "UPDATE reminders SET status = 'CANCELLED', updated_at = datetime('now') WHERE id = ? AND user_id = ? AND status = 'ACTIVE'" ) .bind(reminderId, user.id) .run();
const check = await env.DB .prepare("SELECT id, status FROM reminders WHERE id = ? AND user_id = ?") .bind(reminderId, user.id) .first();
if (!check) { return `找不到 ID 為 ${reminderId} 的提醒,可能已經被刪除或完成。`; }
if (check.status !== "CANCELLED") { return `無法取消這個提醒(ID: ${reminderId}),狀態為 ${check.status}。`; }
return `已幫你取消提醒(ID: ${reminderId})。`; }
// Cron:檢查到期提醒並發送推播 async function runReminderCron(env) { try { const res = await env.DB .prepare( ` SELECT r.id, r.text, r.trigger_time, r.repeat_rule, u.line_user_id, u.nickname FROM reminders r JOIN users u ON u.id = r.user_id WHERE r.status = 'ACTIVE' AND r.trigger_time <= strftime('%s','now') ` ) .all();
let rows = res.results || []; if (rows.length === 0) return;
// 設定 OWNER_LINE_USER_ID,就只推送你的提醒 if (env.OWNER_LINE_USER_ID) { rows = rows.filter((r) => r.line_user_id === env.OWNER_LINE_USER_ID); if (rows.length === 0) return; }
for (const row of rows) { const nickname = row.nickname || "自定義"; const whenStr = epochToTaipeiString(row.trigger_time); const msg = `嗶嗶 ${nickname},到時間囉!\n` + `時間:${whenStr}\n` + `內容:${row.text}`;
await sendPush(env, row.line_user_id, msg);
await env.DB .prepare( "UPDATE reminders SET status = 'DONE', updated_at = datetime('now') WHERE id = ?" ) .bind(row.id) .run(); } } catch (err) { console.error("Cron error:", err); } }
// 把 Unix 秒轉成台北時間字串 function epochToTaipeiString(epochSec) { const ms = (epochSec + 8 * 3600) * 1000; // UTC+8 const d = new Date(ms); const yyyy = d.getUTCFullYear(); const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); const dd = String(d.getUTCDate()).padStart(2, "0"); const hh = String(d.getUTCHours()).padStart(2, "0"); const mi = String(d.getUTCMinutes()).padStart(2, "0"); return `${yyyy}-${mm}-${dd} ${hh}:${mi}`; }
// 更新暱稱 & KV 記憶(給 LLM 用) async function updateNickname(env, user, nickname) { await env.DB .prepare( "UPDATE users SET nickname = ?, updated_at = datetime('now') WHERE id = ?" ) .bind(nickname, user.id) .run();
const key = `memory:${user.line_user_id}`; let memory = {}; const existing = await env.MEMORY.get(key, "json"); if (existing) memory = existing;
memory.nickname = nickname; memory.updated_at = new Date().toISOString();
await env.MEMORY.put(key, JSON.stringify(memory)); }
// LLM 閒聊+簡單記憶 async function chatWithLoyi(env, user, text) { const nickname = user.nickname || "自定義";
if (!env.AI) { return `抱歉 ${nickname},我現在還沒連上腦袋 QQ\n暫時只能幫你做提醒喔。`; }
const key = `memory:${user.line_user_id}`; let memory = (await env.MEMORY.get(key, "json")) || {}; const history = Array.isArray(memory.history) ? memory.history : [];
const systemPrompt = [ "你是一個叫 LINE 聊天與提醒小助手。", "這邊自己寫,自己老婆自己建。" "描述想要AI機器人的個性等等,寫越多可以得到的檔案越豐富。", `稱呼使用者為「${nickname}」。`, "你已經有內建指令:", " - 建立提醒:提醒 23:30 關除濕機", " - 看提醒:列出提醒", " - 刪除提醒:取消提醒 1", "除非使用者問怎麼用,否則不要每句都重複指令說明。", "回答時盡量 1~3 行,不要用程式碼區塊。", ].join("\n");
const messages = [ { role: "system", content: systemPrompt }, ...history.slice(-8), { role: "user", content: text }, ];
try { const result = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", { messages, max_tokens: 256, });
// 依照 Workers AI 常見格式抓回應 const raw = (result && (result.response || result.output || result.result)) || "我好像當機了一下,可以再跟我說一次嗎?";
const reply = typeof raw === "string" ? raw.trim() : JSON.stringify(raw).trim();
const newHistory = [ ...history, { role: "user", content: text }, { role: "assistant", content: reply }, ]; memory.history = newHistory.slice(-10); memory.nickname = nickname; memory.updated_at = new Date().toISOString();
await env.MEMORY.put(key, JSON.stringify(memory));
return reply; } catch (err) { console.error("AI error:", err); return `唔,我腦袋有點當機QQ`; } }
// LINE reply(可傳多個 message,例如文字 + 圖片) async function sendReplyMessages(env, replyToken, messages) { const body = { replyToken, messages, };
await fetch("https://api.line.me/v2/bot/message/reply", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}`, }, body: JSON.stringify(body), }); }
// LINE reply async function sendReply(env, replyToken, text) { const body = { replyToken, messages: [{ type: "text", text }], };
await fetch("https://api.line.me/v2/bot/message/reply", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}`, }, body: JSON.stringify(body), }); }
// LINE push(給 Cron 用) async function sendPush(env, toUserId, text) { const body = { to: toUserId, messages: [{ type: "text", text }], };
await fetch("https://api.line.me/v2/bot/message/push", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}`, }, body: JSON.stringify(body), }); }
|