Atom
LINE AI機器人

LINE AI機器人

在群組看到有人分享line的聊天機器人,有點好玩所以我也做了一個。

全部都使用免費資源,cloudflare我真的越來越離不開你了。

平台:Cloudflare(Workers / Pages Functions)
聊天入口:LINE Messaging API Bot
模型:Cloudflare Workers AI
(如果你財大氣粗也可以租雲端主機跑自己的模型或是建地端模型然後同樣用cloudflare tunnel)
支援功能:
使用者對話
主動提醒(到時間用 LINE push)

cloudflare的workers ai有免費額度,自己一個人用的話基本不會超過。

架構圖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[LINE 使用者]

│ 1. 聊天 / 加好友 / 指令

[LINE Messaging API]
│ Webhook (HTTPS POST)

[Cloudflare Worker:Loy Webhook]
│ ├ 解析訊息 / 驗證簽章
│ ├ 存使用者資料 & 提醒設定 到 KV/D1
│ └ 呼叫 「大腦 API」

├──► [LLM 後端(Workers AI)]
│ └ 回傳回答文字

└──► 回覆 LINE Reply API / Push API


[Cloudflare Worker:Scheduler(Scheduled Trigger)]
│ 每 1 分鐘 / 5 分鐘觸發一次

從 KV/D1 讀「到期提醒」

└──► 呼叫 LINE Push API 發送提醒訊息

Cloudflare 組件規格

1.Cloudflare Worker 專案

結構:
/src/index.ts:主 Worker(Webhook + API)
使用 wrangler.toml 管理環境、KV、Secrets、Scheduled Triggers

2.雲端儲存KV / D1

users 表、reminders 表、memory(使用KV)

3.定時觸發:Scheduled Triggers

使用Cloudflare Workers支援的Scheduled events

4. Secrets / Env

與LINE連線用的資訊

由於是免費額度,所以這邊將動作腳本分成兩塊:處理固定邏輯聊天
這樣就能只有聊天會用到LLM


wrangler.toml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
name = "自定義"
main = "src/index.js"
compatibility_date = "依照你的日期"

[vars] # 可以使用wrangler secret put 來設定
# LINE_CHANNEL_ACCESS_TOKEN = ""
# LINE_CHANNEL_SECRET = ""

[triggers]
# 每分鐘跑一次 Cron,檢查到期提醒
crons = ["*/1 * * * *"]

[[d1_databases]]
binding = "DB"
database_name = "自定義"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # D1 的 ID

[[kv_namespaces]]
binding = "MEMORY"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # KV Namespace 的 ID

schema.sql

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- 使用者基本資料
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
line_user_id TEXT NOT NULL UNIQUE,
display_name TEXT,
nickname TEXT,
timezone TEXT DEFAULT 'Asia/Taipei',
preferences_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);

-- 提醒列表
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
text TEXT NOT NULL,
trigger_time INTEGER NOT NULL, -- Unix timestamp (秒,UTC)
repeat_rule TEXT, -- 先保留欄位,之後可以擴充每天/每週
status TEXT DEFAULT 'ACTIVE', -- ACTIVE / DONE / CANCELLED
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);

package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "自定義",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"migrate": "wrangler d1 execute 自定義 --file=./schema.sql"
},
"devDependencies": {
"wrangler": "^3.100.0"
}
}

src/index.js

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),
});
}

Cloudflare 端設定步驟

  1. 安裝 wrangler

    1
    npm install
  2. 建立 D1 資料庫
    進 Cloudflare Dashboard建立資料庫
    將建立好的的資料庫database_id & database_name帶回到前面的wrangler.toml

  3. 建立 KV Namespace
    在 Dashboard 裡選 Workers & Pages → KV
    同樣將ID放回wrangler.toml

  4. 將schema 匯入到 D1

    1
    npx wrangler d1 execute 自定義 --remote --file=./schema.sql
  5. 設定 Secrets(LINE 金鑰)
    在LINE申請官方帳號後查看LINE console的金鑰

    1
    2
    npx wrangler secret put LINE_CHANNEL_ACCESS_TOKEN
    npx wrangler secret put LINE_CHANNEL_SECRET

檢查用指令:

1
npx wrangler secret list
  1. 部署到 Cloudflare
    1
    npx wrangler deploy

將生成後的Webhook URL貼至LINE官方帳號的Webhook中即可。
記得要把LINE中的自動回應訊息關掉,不然只會回預設的固定文字。

在部屬後可以使用來監控除錯

1
npx wrangler tail loyi-reminder-bot

可以去LINE官方帳號那邊開始試著聊天確認是否有設置正確了。
確定可以聊天再繼續往下↓

  1. 設定個人ID
    因為LINE的官方帳號是沒法禁止其他人家好友的,雖然你不宣傳幾乎不會有人家,不過為了避免有人誤加到所以有寫一個限制只對自己會使用AI回復的功能。

到 Cloudflare 後台 → D1 Database → 選 自定義 → Console (因為資料不多所以你也可以直接看)

1
SELECT id, line_user_id, nickname FROM users;

查看你的ID
然後將這個ID放到secret裡面

1
npx wrangler secret put OWNER_LINE_USER_ID

重新部屬

1
npx wrangler deploy

替代文字

由於圖片功能使用的額度比較高,雖然寫了但還是不要用的好。
目前只有聊天跟提醒功能,可以依照自己的需求去改寫成自己要的機器人。

來自朋友帳號的截圖:
替代文字

本文作者:Atom
本文鏈接:https://d0ngd.github.io/2026/01/20/LINE AI機器人/
版權聲明:本文採用 CC BY-NC-SA 3.0 CN 協議進行許可