ให้ LINE ถ่ายและเก็บ ข้อมูลสลิปโอนเงิน หรือเอกสาร ด้วย ChatGPT API ใน Google Sheets

สร้างระบบอ่านและสรุปสลิปโอนเงินด้วย 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: เก็บข้อมูลการโอนเงิน

ภาพรวมของระบบ:

  1. ผู้ใช้ส่งรูปสลิปโอนเงินผ่าน LINE Bot
  2. LINE Bot ส่งรูปไปที่ Google Apps Script
  3. Google Apps Script บันทึกรูปภาพลง Google Drive และส่งต่อไปยัง OpenAI Vision API เพื่ออ่านข้อความ
  4. Google Apps Script ประมวลผลข้อความที่ได้จาก OpenAI เพื่อดึงข้อมูลสำคัญ (จำนวนเงิน, วันที่, เลขอ้างอิง)
  5. Google Apps Script บันทึกข้อมูลลง Google Sheets
  6. LINE Bot ส่งข้อความยืนยันกลับไปยังผู้ใช้ พร้อมข้อมูลสรุป
  7. เมื่อผู้ใช้พิมพ์ “สรุป” LINE Bot จะส่ง Flex Message พร้อมปุ่มกดเพื่อดูข้อมูลสรุปทั้งหมด
  8. เมื่อกดปุ่ม จะเปิดหน้าเว็บแสดงข้อมูลสรุปที่ดึงมาจาก Google Sheets

ดู Flow การทำงาน

Chat GPT Flow-B

ขั้นตอนการดำเนินการ และ Code

สิ่งที่ต้องเตรียม:

  1. บัญชี LINE และ LINE Official Account
  2. บัญชี OpenAI และ API Key
  3. บัญชี Google และ Google Sheet สำหรับเก็บข้อมูล
  4. ความรู้พื้นฐาน 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 OA
    • OPENAI_API_KEY: API Key ของ OpenAI
    • SPREADSHEET_ID: ID ของ Google Sheet
    • DRIVE_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 เมื่อมีคนส่งข้อความหา Bot
  • handleImageMessage(event): ประมวลผลรูปภาพสลิปโอนเงิน
    • ดาวน์โหลดรูปภาพจาก LINE
    • อัปโหลดรูปภาพไปยัง Google Drive
    • เรียก OpenAI Vision API เพื่อแปลงรูปภาพเป็นข้อความ
    • แยกข้อมูลการโอนเงินจากข้อความ
    • บันทึกข้อมูลลง Google Sheet
    • ส่งข้อความตอบกลับพร้อมรายละเอียดการโอนเงิน
  • handleSummaryRequest(event): สร้าง Flex Message สำหรับแสดงสรุปรายการ
  • createSecureViewUrl(userId): สร้าง URL สำหรับ Web App ที่ปลอดภัย
  • doGet(e): รับคำขอจาก Web App
  • createSummaryHtml(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 = `
    <!DOCTYPE html>
    <html>
    <head>
      <base target="_top">
      <meta charset="UTF-8">
      <link data-minify="1" rel="stylesheet" type="text/css" href="//insights.nconnect.asia/wp-content/cache/min/1/1.11.3/css/jquery.dataTables.css?ver=1738170223">
      <script data-minify="1" type="text/javascript" charset="utf8" src="//insights.nconnect.asia/wp-content/cache/min/1/jquery-3.5.1.js?ver=1738170223"></script>
      <script data-minify="1" type="text/javascript" charset="utf8" src="//insights.nconnect.asia/wp-content/cache/min/1/1.11.3/js/jquery.dataTables.js?ver=1738170223"></script>
      <style>
        body { font-family: Arial, sans-serif; margin: 20px; }
        table { border-collapse: collapse; width: 100%; }
        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
        th { background-color: #f5f5f5; }
        .total-row { font-weight: bold; background-color: #f8f9fa; }
        .amount { text-align: right; }
      </style>
      <script>
        $(document).ready(function() {
          $('#summaryTable').DataTable({
            "pageLength": 10 // จำนวนรายการต่อหน้า
          });
        });
      </script>
    </head>
    <body>
      <h2>สรุปรายการ</h2>
      
      <table id="summaryTable">
        <thead>
          <tr>
            <th>วันที่</th>
            <th>เลขอ้างอิง</th>
            <th>จำนวนเงิน</th>
            <th>Token ที่ใช้</th>
          </tr>
        </thead>
        <tbody>`;
  
  // Add data rows
  userData.forEach(row => {
    const amountNum = Number(row[1]) || 0;
    const amountText = amountNum.toLocaleString('th-TH', {
      minimumFractionDigits: 2, 
      maximumFractionDigits: 2
    });
    html += `
          <tr>
            <td>${row[2]}</td>
            <td>${row[0]}</td>
            <td class="amount">${amountText}</td>
            <td class="amount">${row[4] || 0}</td>
          </tr>`;
  });
  
  // Add total row
  html += `
          <tr class="total-row">
            <td colspan="2">รวมทั้งหมด</td>
            <td class="amount">${totals.amount.toLocaleString('th-TH', {
              minimumFractionDigits: 2, 
              maximumFractionDigits: 2
            })}</td>
            <td class="amount">${totals.tokens.toLocaleString('th-TH')}</td>
          </tr>
        </tbody>
      </table>
      <h3>ยอดรวมทั้งหมด: ${totals.amount.toLocaleString('th-TH', {
        minimumFractionDigits: 2, 
        maximumFractionDigits: 2
      })} บาท</h3>
      <h3>Token ที่ใช้ทั้งหมด: ${totals.tokens.toLocaleString('th-TH')}</h3>
    </body>
    </html>`;
  
  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)
    });
  }
}