สร้างระบบอ่านและสรุปสลิปโอนเงินด้วย LINE Bot, OpenAI Vision, และ Google Apps Script
บทแนะนำนี้จะพาคุณสร้างระบบที่สามารถอ่านสลิปโอนเงินที่ส่งผ่าน LINE Bot และสรุปข้อมูลสำคัญ เช่น จำนวนเงิน วันที่ และเลขอ้างอิง โดยใช้เทคโนโลยีต่าง ๆ ดังนี้:
- LINE Messaging API: รับรูปภาพและตอบกลับผู้ใช้
- OpenAI Vision API: อ่านข้อความจากรูปภาพ (OCR)
- Google Apps Script: เชื่อมต่อทุกอย่างเข้าด้วยกัน, ประมวลผลข้อมูล, และบันทึกลง Google Sheets
- Google Drive: เก็บรูปภาพสลิป
- Google Sheets: เก็บข้อมูลการโอนเงิน
ภาพรวมของระบบ:
- ผู้ใช้ส่งรูปสลิปโอนเงินผ่าน LINE Bot
- LINE Bot ส่งรูปไปที่ Google Apps Script
- Google Apps Script บันทึกรูปภาพลง Google Drive และส่งต่อไปยัง OpenAI Vision API เพื่ออ่านข้อความ
- Google Apps Script ประมวลผลข้อความที่ได้จาก OpenAI เพื่อดึงข้อมูลสำคัญ (จำนวนเงิน, วันที่, เลขอ้างอิง)
- Google Apps Script บันทึกข้อมูลลง Google Sheets
- LINE Bot ส่งข้อความยืนยันกลับไปยังผู้ใช้ พร้อมข้อมูลสรุป
- เมื่อผู้ใช้พิมพ์ “สรุป” LINE Bot จะส่ง Flex Message พร้อมปุ่มกดเพื่อดูข้อมูลสรุปทั้งหมด
- เมื่อกดปุ่ม จะเปิดหน้าเว็บแสดงข้อมูลสรุปที่ดึงมาจาก Google Sheets
ดู Flow การทำงาน

ขั้นตอนการดำเนินการ และ Code
สิ่งที่ต้องเตรียม:
- บัญชี LINE และ LINE Official Account
- บัญชี OpenAI และ API Key
- บัญชี Google และ Google Sheet สำหรับเก็บข้อมูล
- ความรู้พื้นฐาน Google Apps Script และ JavaScript
ขั้นตอนการสร้าง:
1. ตั้งค่า LINE Official Account:
- สร้าง LINE Official Account และรับ Channel Access Token
- เปิดใช้งาน Webhook และตั้งค่า Webhook URL ไปยัง Google Apps Script ของคุณ
2. สร้าง Google Sheet:
- สร้าง Google Sheet ใหม่สำหรับเก็บข้อมูลรายการโอนเงิน
- กำหนด header ของแต่ละคอลัมน์ ดังนี้: เลขอ้างอิง, จำนวนเงิน, วันที่, User ID, Token ที่ใช้
3. สร้าง Google Apps Script:
- เปิด Google Sheet ที่สร้างไว้ แล้วไปที่ Tools > Script editor
- คัดลอกโค้ดที่ให้ไว้ใน How to ไปวางใน Script editor
- แก้ไขค่า Configuration ให้ตรงกับของคุณ:
LINE_CHANNEL_ACCESS_TOKEN
: Channel Access Token ของ LINE OAOPENAI_API_KEY
: API Key ของ OpenAISPREADSHEET_ID
: ID ของ Google SheetDRIVE_FOLDER_ID
: ID ของ Google Drive Folder (สำหรับเก็บรูปภาพชั่วคราว)
- Deploy Script เป็น Web App:
- Publish > Deploy as web app
- เลือก “Execute the app as:” เป็น “Me”
- เลือก “Who has access to the app:” เป็น “Anyone, even anonymous”
- คัดลอก Web App URL ไว้ใช้ในภายหลัง
4. ทดสอบ LINE Bot:
- เพิ่ม LINE Bot ที่สร้างไว้เป็นเพื่อน
- ส่งรูปภาพสลิปโอนเงินไปที่ Bot
- Bot จะประมวลผลรูปภาพและบันทึกข้อมูลลง Google Sheet
- Bot จะส่งข้อความตอบกลับพร้อมรายละเอียดการโอนเงิน และปุ่มสำหรับดูสรุปรายการ
- พิมพ์ “สรุป” เพื่อดูรายการโอนเงินทั้งหมดของคุณในรูปแบบตาราง
คำอธิบายโค้ด:
doPost(e)
: รับ Webhook Event จาก LINE เมื่อมีคนส่งข้อความหา BothandleImageMessage(event)
: ประมวลผลรูปภาพสลิปโอนเงิน- ดาวน์โหลดรูปภาพจาก LINE
- อัปโหลดรูปภาพไปยัง Google Drive
- เรียก OpenAI Vision API เพื่อแปลงรูปภาพเป็นข้อความ
- แยกข้อมูลการโอนเงินจากข้อความ
- บันทึกข้อมูลลง Google Sheet
- ส่งข้อความตอบกลับพร้อมรายละเอียดการโอนเงิน
handleSummaryRequest(event)
: สร้าง Flex Message สำหรับแสดงสรุปรายการcreateSecureViewUrl(userId)
: สร้าง URL สำหรับ Web App ที่ปลอดภัยdoGet(e)
: รับคำขอจาก Web AppcreateSummaryHtml(userId)
: สร้างหน้า HTML สำหรับแสดงสรุปรายการ- ฟังก์ชันอื่นๆ: ฟังก์ชันช่วยเหลือ เช่น ดาวน์โหลดรูปภาพ, อัปโหลดไฟล์, เรียก API, parse ข้อมูล
ข้อควรระวัง:
- อย่าลืมเปิดใช้งาน Google Apps Script API ใน Google Cloud Platform Project
- ตั้งค่าสิทธิ์การเข้าถึง Google Drive และ Google Sheet ให้ถูกต้อง
- เก็บรักษา API Key ของ OpenAI และ Channel Access Token ของ LINE OA ให้ปลอดภัย
- Webhook URL ของ LINE Bot ต้องเป็น HTTPS
เพิ่มเติม:
- สามารถปรับแต่ง Flex Message และ HTML ให้สวยงามตามต้องการ
- สามารถเพิ่มฟังก์ชันอื่นๆ ให้ Bot เช่น ค้นหารายการ, ลบรายการ, ส่งแจ้งเตือน
- ศึกษาเพิ่มเติมเกี่ยวกับ LINE Messaging API, OpenAI Vision API, Google Apps Script
// Configuration
const LINE_CHANNEL_ACCESS_TOKEN = '.....';
const OPENAI_API_KEY = '......';
const SPREADSHEET_ID = '.....';
const DRIVE_FOLDER_ID = '.... ';
// Webhook entry point
function doPost(e) {
const event = JSON.parse(e.postData.contents).events[0];
if (event.type === 'message') {
if (event.message.type === 'image') {
handleImageMessage(event);
} else if (event.message.type === 'text' && event.message.text === 'สรุป') {
handleSummaryRequest(event);
}
}
return ContentService.createTextOutput(JSON.stringify({ status: 'ok' }))
.setMimeType(ContentService.MimeType.JSON);
}
// Handle incoming image message
function handleImageMessage(event) {
try {
const imageBytes = getImageFromLine(event.message.id);
const imageFile = saveImageToDrive(imageBytes, event.message.id);
const apiResult = getImageTextFromOpenAI(imageFile.getDownloadUrl());
const paymentDetails = extractPaymentDetails(apiResult.text);
// Save to spreadsheet
saveToSpreadsheet({
...paymentDetails,
userId: event.source.userId,
tokens: apiResult.tokens.total_tokens
});
// Send Flex Message response
replyToUser(event.replyToken, {
referenceNo: paymentDetails.referenceNo,
amount: paymentDetails.amount,
date: paymentDetails.date,
userId: event.source.userId,
tokens: apiResult.tokens.total_tokens
});
} catch (error) {
replyToUser(event.replyToken, 'ขออภัย เกิดข้อผิดพลาดในการประมวลผล: ' + error.toString());
console.error(error);
}
}
// Handle summary request (ส่ง Flex Message พร้อมปุ่มแทนการส่งลิงก์ตรง)
function handleSummaryRequest(event) {
try {
const userId = event.source.userId;
const summaryUrl = createSecureViewUrl(userId);
// แทนที่จะส่งข้อความ + URL ตรง ๆ ให้ส่ง Flex Message ที่มีปุ่มเปิด URL
const flexMessage = {
type: "flex",
altText: "สรุปรายการ",
contents: {
type: "bubble",
body: {
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: "สรุปรายการ",
weight: "bold",
color: "#037bfc",
size: "md"
},
{
type: "text",
text: "กดปุ่มด้านล่างเพื่อดูรายการทั้งหมด",
margin: "md",
wrap: true
}
]
},
footer: {
type: "box",
layout: "vertical",
spacing: "sm",
contents: [
{
type: "button",
style: "primary",
height: "sm",
action: {
type: "uri",
label: "สรุปรายการ",
uri: summaryUrl
},
color: "#037bfc"
}
],
flex: 0
}
}
};
replyToUser(event.replyToken, flexMessage);
} catch (error) {
replyToUser(event.replyToken, 'ขออภัย เกิดข้อผิดพลาดในการสร้างสรุป: ' + error.toString());
console.error(error);
}
}
// Create secure web app URL
function createSecureViewUrl(userId) {
const webAppUrl = ScriptApp.getService().getUrl();
const hash = Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
userId + new Date().toDateString(),
Utilities.Charset.UTF_8
);
const token = Utilities.base64EncodeWebSafe(hash);
return `${webAppUrl}?userId=${userId}&token=${token}`;
}
// Web app entry point (for summary view)
function doGet(e) {
const userId = e.parameter.userId;
const token = e.parameter.token;
// Verify token
const hash = Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
userId + new Date().toDateString(),
Utilities.Charset.UTF_8
);
const expectedToken = Utilities.base64EncodeWebSafe(hash);
if (!userId || !token || token !== expectedToken) {
return HtmlService.createHtmlOutput('Access Denied');
}
// สร้างหน้า HTML โดยส่งพารามิเตอร์ทั้งหมดไป
const html = createSummaryHtml(userId);
return HtmlService.createHtmlOutput(html)
.setTitle('สรุปรายการ')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
// Create HTML summary view
function createSummaryHtml(userId) {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getActiveSheet();
const data = sheet.getDataRange().getValues();
// Filter data for this user
let userData = data.filter(row => row[3] === userId); // Assuming userId is in column D
// Calculate totals
const totals = userData.reduce((acc, row) => {
return {
amount: acc.amount + Number(row[1] || 0),
tokens: acc.tokens + Number(row[4] || 0)
};
}, { amount: 0, tokens: 0 });
// สร้าง Form สำหรับ Filter
let html = `
สรุปรายการ
วันที่
เลขอ้างอิง
จำนวนเงิน
Token ที่ใช้
`;
// Add data rows
userData.forEach(row => {
const amountNum = Number(row[1]) || 0;
const amountText = amountNum.toLocaleString('th-TH', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
html += `
${row[2]}
${row[0]}
${amountText}
${row[4] || 0}
`;
});
// Add total row
html += `
รวมทั้งหมด
${totals.amount.toLocaleString('th-TH', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
${totals.tokens.toLocaleString('th-TH')}
ยอดรวมทั้งหมด: ${totals.amount.toLocaleString('th-TH', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})} บาท
Token ที่ใช้ทั้งหมด: ${totals.tokens.toLocaleString('th-TH')}
`;
return html;
}
/**
* ฟังก์ชันช่วย parse datetime (ถ้าทำได้)
* คืนเป็น Date object หรือ null
*/
function tryParseDateTime(dateStr) {
try {
// ถ้าข้างในมีทั้งวันและเวลา เช่น "2024-01-29 14:30:25" หรือ "29/01/2024 14:30:25"
// ลองแยกกัน และแก้ปีไทย (ถ้ามี 25xx)
let maybeStr = dateStr.trim();
// Replace dash เป็น slash บ้าง ถ้าจะให้ new Date() parse ได้ง่ายขึ้น
maybeStr = maybeStr.replace(/-/g, '/');
// เช็คปีไทย
const yearMatch = maybeStr.match(/(\d{4})/);
if (yearMatch) {
const y = parseInt(yearMatch[1], 10);
if (y > 2400) {
maybeStr = maybeStr.replace(y, (y - 543).toString());
}
}
const dt = new Date(maybeStr);
if (isNaN(dt.getTime())) return null;
return dt;
} catch (err) {
return null;
}
}
// คืน token ที่ใช้ในวันนี้สำหรับ user เดิม (เอาไว้ใส่ hidden form กัน query ผิด)
function getTodayToken(userId) {
const hash = Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
userId + new Date().toDateString(),
Utilities.Charset.UTF_8
);
return Utilities.base64EncodeWebSafe(hash);
}
// Get image content from LINE
function getImageFromLine(messageId) {
const url = `https://api-data.line.me/v2/bot/message/${messageId}/content`;
const response = UrlFetchApp.fetch(url, {
headers: {
'Authorization': 'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN
}
});
return response.getBlob();
}
// Save image to Google Drive
function saveImageToDrive(imageBlob, messageId) {
const folder = DriveApp.getFolderById(DRIVE_FOLDER_ID);
const filename = `slip_${messageId}_${new Date().getTime()}.jpg`;
return folder.createFile(imageBlob.setName(filename));
}
// Get image text using OpenAI Vision API
function getImageTextFromOpenAI(imageUrl) {
const url = 'https://api.openai.com/v1/chat/completions';
const payload = {
model: "gpt-4o-mini",
messages: [
{
role: "user",
content: [
{
type: "text",
text: "Please extract the following information from this Thai bank transfer slip:\n1. Reference number (look for 'รหัสอ้างอิง' or 'เลขที่รายการ')\n2. Amount (in Thai Baht)\n3. Transaction date and time (look for 'วันที่', 'เวลา', 'วันที่-เวลา' - include both date and time if available)\n\nReturn ONLY a JSON object with keys: referenceNo, amount, date, time. Example: {\"referenceNo\":\"REF123456\",\"amount\":\"1000.00\",\"date\":\"2024-01-29\",\"time\":\"14:30:25\"}"
},
{
type: "image_url",
image_url: {
url: imageUrl
}
}
]
}
],
max_tokens: 300
};
const response = UrlFetchApp.fetch(url, {
method: 'post',
headers: {
'Authorization': 'Bearer ' + OPENAI_API_KEY,
'Content-Type': 'application/json'
},
payload: JSON.stringify(payload)
});
const result = JSON.parse(response.getContentText());
return {
text: result.choices[0].message.content,
tokens: result.usage
};
}
// Extract payment details from OCR text
function extractPaymentDetails(apiResponse) {
try {
let content = apiResponse;
if (content.includes('```json')) {
content = content.replace(/```json\n?|```/g, '');
}
const parsedContent = JSON.parse(content);
// Format date and time
let formattedDateTime = parsedContent.date || '';
if (parsedContent.time) {
// Convert Thai Buddhist year to CE if needed
if (formattedDateTime.includes('/')) {
const parts = formattedDateTime.split('/');
if (parts[2] && parseInt(parts[2]) > 2500) {
parts[2] = (parseInt(parts[2]) - 543).toString();
formattedDateTime = parts.join('/');
}
}
// Add time if available
formattedDateTime += ' ' + parsedContent.time;
// Try to parse and format the date-time
try {
const dt = new Date(formattedDateTime);
if (!isNaN(dt.getTime())) {
formattedDateTime = dt.toLocaleString('th-TH', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
} catch (e) {
console.error('Error formatting date-time:', e);
}
}
return {
referenceNo: parsedContent.referenceNo || '',
amount: parsedContent.amount || '',
date: formattedDateTime
};
} catch (error) {
console.error('Error parsing response:', error);
console.log('Raw API Response:', apiResponse);
throw new Error('ไม่สามารถอ่านข้อมูลจากรูปภาพได้ กรุณาลองใหม่อีกครั้ง');
}
}
// Save data to spreadsheet
function saveToSpreadsheet(details) {
const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getActiveSheet();
sheet.appendRow([
details.referenceNo,
details.amount,
details.date,
details.userId,
details.tokens
]);
}
// Reply to user on LINE with Flex Message or Text
function replyToUser(replyToken, messageData) {
const url = 'https://api.line.me/v2/bot/message/reply';
// ถ้าเป็น string ให้ส่งข้อความตัวหนังสือ
if (typeof messageData === 'string') {
const payload = {
replyToken: replyToken,
messages: [{
type: 'text',
text: messageData
}]
};
UrlFetchApp.fetch(url, {
method: 'post',
headers: {
'Authorization': 'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN,
'Content-Type': 'application/json'
},
payload: JSON.stringify(payload)
});
return;
}
// ถ้าเป็น object ให้ส่ง Flex Message
// กรณีที่เป็นการตอบกลับหลังบันทึกภาพ
if (messageData.referenceNo) {
const summaryUrl = createSecureViewUrl(messageData.userId);
const flexMessage = {
type: "flex",
altText: "บันทึกข้อมูลสำเร็จ",
contents: {
type: "bubble",
body: {
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: "บันทึกข้อมูลสำเร็จ:",
weight: "bold",
color: "#037bfc",
size: "md"
},
{
type: "box",
layout: "vertical",
margin: "lg",
spacing: "sm",
contents: [
{
type: "box",
layout: "horizontal",
contents: [
{
type: "text",
text: "เลขอ้างอิง:",
size: "sm",
color: "#666666",
flex: 3
},
{
type: "text",
text: messageData.referenceNo,
size: "sm",
color: "#333333",
flex: 7,
wrap: true
}
]
},
{
type: "box",
layout: "horizontal",
contents: [
{
type: "text",
text: "จำนวนเงิน:",
size: "sm",
color: "#666666",
flex: 3
},
{
type: "text",
text: messageData.amount + " บาท",
size: "sm",
color: "#333333",
flex: 7,
wrap: true
}
]
},
{
type: "box",
layout: "horizontal",
contents: [
{
type: "text",
text: "วันที่:",
size: "sm",
color: "#666666",
flex: 3
},
{
type: "text",
text: messageData.date,
size: "sm",
color: "#333333",
flex: 7,
wrap: true
}
]
},
{
type: "box",
layout: "horizontal",
contents: [
{
type: "text",
text: "Token ที่ใช้:",
size: "sm",
color: "#666666",
flex: 3
},
{
type: "text",
text: messageData.tokens.toString(),
size: "sm",
color: "#333333",
flex: 7,
wrap: true
}
]
}
]
}
]
},
footer: {
type: "box",
layout: "vertical",
spacing: "sm",
contents: [
{
type: "button",
style: "primary",
height: "sm",
action: {
type: "uri",
label: "ดูรายการเดินบัญชี",
uri: summaryUrl
},
color: "#037bfc"
}
],
flex: 0
}
}
};
const payload = {
replyToken: replyToken,
messages: [flexMessage]
};
UrlFetchApp.fetch(url, {
method: 'post',
headers: {
'Authorization': 'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN,
'Content-Type': 'application/json'
},
payload: JSON.stringify(payload)
});
}
// ถ้าเป็น Flex สำหรับสรุปรายการ
else {
const payload = {
replyToken: replyToken,
messages: [messageData] // messageData เป็น flex message อยู่แล้ว
};
UrlFetchApp.fetch(url, {
method: 'post',
headers: {
'Authorization': 'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN,
'Content-Type': 'application/json'
},
payload: JSON.stringify(payload)
});
}
}