แจกฟรี! ระบบ AI Chatbot จัดการ Google Calendar ผ่าน Line

อยากมีเลขาที่คอยช่วยจัดการตารางงานของเรา แบบที่เราสามารถสั่งงานผ่านทาง Line ได้เลย!
.
โพสนี้ผมจะมาแจก script ที่สามารถทำให้เราเชื่อมต่อ google calendar กับ line และ AI ได้แบบอัตโนมัติ
.
ซึ่ง AI จะทำหน้าที่เหมือนกับสมองของน้องเลขา ที่สามารถรับคำสั่งต่างๆจากเรา ไม่ว่าจะเป็นการสร้างกิจกรรม ลบกิจกรรม หรือ แสดงรายการกิจกรรมให้กับเรา โดยสามารถใช้คำพูดปกติในการสั่ง chatbot ได้อย่างอัตโนมัติ
.
โดยระบบนี้เราสามารถนำไปลองใช้งานได้เลยโดยไม่มีค่าใช้จ่าย
.
สามารถลองทำกันได้เลยครับ!
.

สิ่งที่ต้องเตรียมก่อนเริ่มต้นปรับแต่ง Script

  1. Calendar ของ Google Calendar ที่ต้องการจะเชื่อมกับระบบใน Script
  2. บัญชี LINE Official Account (LINE OA)

หลักจากที่เตรียมทุกอย่างพร้อมแล้วให้เราทำการสร้าง Script ใหม่ขึ้นมาใน Google App Script โดยการพิมพ์ script.new ที่ url ของ browser ก็จะพาทุกคนมาที่หน้านี้

Screenshot

หลักจากนั้นให้ทำการ copy script ด้านล่างนี้ไปวางที่หน้า Google App Script ของเราได้ทันที
โดย Script นี้จะเป็น script ที่ช่วยให้เราสามารถทำการเพิ่ม ลบ Event ในปฏิทิน Google Calendar ของเราผ่านทางไลน์

// === CONFIGURATION ===
// --- LINE ---
const LINE_CHANNEL_ACCESS_TOKEN = 'YOUR LINE CHANNEL ACCESS TOKEN'; // ใส่ Access Token ของ LINE Bot ของคุณ

// --- Google Calendar ---
const CALENDAR_ID = 'YOUR CALENDAR ID'; // Calendar ID ที่ต้องการใช้งาน (ใช้ 'primary' สำหรับปฏิทินหลัก)

// --- Google Gemini AI ---
const GEMINI_API_KEY = 'YOUR GEMINI API KEY'; // ใส่ API Key ของ Gemini ที่คุณสร้างขึ้น

// === MAIN FUNCTION (LINE Webhook) ===
/**
 * ฟังก์ชันหลักที่ทำงานเมื่อ LINE ส่งข้อมูลมาให้ (Webhook)
 * @param {object} e - Event object จาก Google Apps Script
 */
function doPost(e) {
  try {
    const data = JSON.parse(e.postData.contents);
    const userMessage = data.events[0].message.text.trim();
    const replyToken = data.events[0].replyToken;

    // ✅ 1. ตรวจสอบว่าข้อความขึ้นต้นด้วย "gm " หรือไม่ (ไม่สนตัวพิมพ์เล็ก/ใหญ่)
    if (!userMessage.toLowerCase().startsWith('gm ')) {
      return; // ถ้าไม่ขึ้นต้นด้วย "gm " ให้ออกจากฟังก์ชันทันที
    }

    // ตัดคำว่า "gm " ออกจากข้อความก่อนส่งไปวิเคราะห์
    const commandText = userMessage.substring(3).trim();
    if (!commandText) {
        replyToLine(replyToken, "กรุณาพิมพ์คำสั่งหลังคำว่า gm ด้วยนะคะ เช่น 'gm สร้างนัดพรุ่งนี้' หรือ 'gm วันนี้มีอะไรบ้าง'");
        return;
    }

    // 2. ส่งข้อความของผู้ใช้ไปให้ Gemini วิเคราะห์
    const structuredInfo = callGemini(commandText);

    // ✅ 2.1 แก้ไขเงื่อนไข: ตรวจสอบ action ก่อน และเช็ค title เฉพาะ action ที่จำเป็นต้องมี
    if (!structuredInfo || structuredInfo.action === 'unknown' || (!structuredInfo.title && (structuredInfo.action === 'create' || structuredInfo.action === 'delete'))) {
      replyToLine(replyToken, "ขออภัยค่ะ ฉันไม่เข้าใจคำสั่ง ลองระบุให้ชัดเจนขึ้นอีกนิดนะคะ เช่น 'สร้างนัดประชุมพรุ่งนี้ 9 โมงเช้า' หรือ 'ลบอีเวนต์ไปส่งของ'");
      return;
    }

    const calendar = CalendarApp.getCalendarById(CALENDAR_ID);

    // 3. จัดการ Event ตามที่ Gemini วิเคราะห์ได้
    switch (structuredInfo.action) {
      case 'create':
        handleCreateEvent(calendar, replyToken, structuredInfo);
        break;
      case 'delete':
        handleDeleteEvent(calendar, replyToken, structuredInfo);
        break;
      case 'list':
        // ส่งข้อความต้นฉบับไปด้วยเพื่อเช็คว่าผู้ใช้ถามว่า "ว่างไหม"
        handleListEvents(calendar, replyToken, structuredInfo, commandText);
        break;
      default:
        replyToLine(replyToken, `ฉันเข้าใจว่าคุณต้องการจะ "${structuredInfo.title}" แต่ไม่แน่ใจว่าให้ทำอะไร (สร้าง, ลบ, หรือแสดงรายการ) กรุณาลองอีกครั้งค่ะ`);
    }
  } catch (error) {
    console.error(`Error in doPost: ${error.toString()}`);
    try {
        const data = JSON.parse(e.postData.contents);
        const replyToken = data.events[0].replyToken;
        replyToLine(replyToken, "เกิดข้อผิดพลาดบางอย่างในระบบ ขออภัยในความไม่สะดวกค่ะ");
    } catch (e) {
        // Ignore if we can't even parse the initial request
    }
  }
}

// === EVENT HANDLERS ===

/**
 * จัดการการสร้าง Event
 */
function handleCreateEvent(calendar, replyToken, info) {
  const { title, startTime, endTime } = info;

  if (!startTime) {
    // ถ้าไม่มีเวลาเริ่มต้น ให้สร้างเป็น Event ทั้งวันสำหรับ "วันนี้"
    const today = new Date();
    calendar.createAllDayEvent(title, today);
    replyToLine(replyToken, `✅ สร้าง Event "${title}" แบบทั้งวันสำหรับวันนี้ (${formatDate(today)}) เรียบร้อยแล้วค่ะ`);
    return;
  }

  const startDate = new Date(startTime);
  if (isNaN(startDate.getTime())) {
    replyToLine(replyToken, `⚠️ ขออภัยค่ะ รูปแบบวันที่/เวลาไม่ถูกต้อง (${startTime})`);
    return;
  }

  if (endTime) {
    const endDate = new Date(endTime);
    if (isNaN(endDate.getTime())) {
      replyToLine(replyToken, `⚠️ ขออภัยค่ะ รูปแบบวันที่/เวลาสิ้นสุดไม่ถูกต้อง (${endTime})`);
      return;
    }
    calendar.createEvent(title, startDate, endDate);
    replyToLine(replyToken, `✅ สร้าง Event "${title}"\n🗓️ วันที่: ${formatDate(startDate)}\n⏰ เวลา: ${formatTime(startDate)} - ${formatTime(endDate)}\nเรียบร้อยแล้วค่ะ`);
  } else {
    // ถ้ามีแต่เวลาเริ่ม ไม่มีเวลาจบ ให้สร้างเป็น Event ทั้งวันสำหรับวันที่ระบุ
    calendar.createAllDayEvent(title, startDate);
    replyToLine(replyToken, `✅ สร้าง Event "${title}" แบบทั้งวันในวันที่ ${formatDate(startDate)} เรียบร้อยแล้วค่ะ`);
  }
}

/**
 * จัดการการลบ Event
 */
function handleDeleteEvent(calendar, replyToken, info) {
  const { title } = info;
  // ค้นหา Event ในอนาคต 365 วัน (เพิ่มจาก 30 วัน)
  const today = new Date();
  const nextYear = new Date();
  nextYear.setDate(today.getDate() + 365);

  const events = calendar.getEvents(today, nextYear);
  // ทำให้การค้นหาแม่นยำขึ้น โดยอาจจะหาคำที่ตรงกันทั้งหมดก่อน
  const exactMatchEvents = events.filter(e => e.getTitle().toLowerCase() === title.toLowerCase());
  const eventsToDelete = exactMatchEvents.length > 0 ? exactMatchEvents : events.filter(e => e.getTitle().toLowerCase().includes(title.toLowerCase()));


  if (eventsToDelete.length > 0) {
    const deletedTitles = [];
    eventsToDelete.forEach(event => {
        deletedTitles.push(event.getTitle());
        event.deleteEvent();
    });
    replyToLine(replyToken, `🗑️ ลบ Event ที่มีชื่อตรงกับ "${title}" จำนวน ${eventsToDelete.length} รายการเรียบร้อยแล้วค่ะ:\n- ${deletedTitles.join('\n- ')}`);
  } else {
    replyToLine(replyToken, `⚠️ ไม่พบ Event ที่มีชื่อใกล้เคียงกับ "${title}" ใน 365 วันข้างหน้าค่ะ`);
  }
}

/**
 * จัดการการแสดงรายการ Event
 */
function handleListEvents(calendar, replyToken, info, originalText) {
  let targetDate;
  if (info.startTime) {
    targetDate = new Date(info.startTime);
  } else {
    targetDate = new Date(); // ถ้าไม่ระบุวัน ให้เป็นวันนี้
  }
  
  if (isNaN(targetDate.getTime())) {
      replyToLine(replyToken, `⚠️ ขออภัยค่ะ รูปแบบวันที่ไม่ถูกต้อง (${info.startTime})`);
      return;
  }

  // กำหนดช่วงเวลาเป็น 00:00:00 ถึง 23:59:59 ของวันที่ต้องการ
  const startTime = new Date(targetDate);
  startTime.setHours(0, 0, 0, 0);

  const endTime = new Date(targetDate);
  endTime.setHours(23, 59, 59, 999);

  const events = calendar.getEvents(startTime, endTime);

  // ✅ 3.1 ปรับปรุงการตอบกลับ
  const isAskingIfFree = originalText.includes("ว่างไหม") || originalText.includes("ว่างมั้ย");

  if (events.length === 0) {
    replyToLine(replyToken, `👍 ในวันที่ ${formatDate(targetDate)} คุณไม่มีตารางงานค่ะ (ว่าง)`);
    return;
  }

  let message = `🗓️ ตารางงานสำหรับวันที่ ${formatDate(targetDate)}:\n\n`;
  events.forEach(event => {
    if (event.isAllDayEvent()) {
      message += `▫️ ทั้งวัน: ${event.getTitle()}\n`;
    } else {
      message += `⏰ ${formatTime(event.getStartTime())} - ${formatTime(event.getEndTime())}: ${event.getTitle()}\n`;
    }
  });

  if (isAskingIfFree) {
      message += "\nดังนั้นคุณอาจจะไม่ว่างในช่วงเวลาดังกล่าวค่ะ";
  }

  replyToLine(replyToken, message.trim());
}


// === GEMINI AI INTEGRATION ===

/**
 * เรียกใช้ Gemini API เพื่อวิเคราะห์ข้อความ
 * @param {string} text - ข้อความจากผู้ใช้
 * @returns {object|null} - Object ที่มีข้อมูลที่ผ่านการวิเคราะห์แล้ว
 */
function callGemini(text) {
  const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key=${GEMINI_API_KEY}`;

  // ✅ 1.1 แก้ไข Prompt ให้ฉลาดขึ้น
  const prompt = `
    Your task is to analyze user text in Thai to extract information for managing Google Calendar events.
    You must respond ONLY with a JSON object. Do not add markdown formatting like \`\`\`json.

    The current date is: ${new Date().toISOString()}

    Analyze the user's intent and extract the following details:
    1.  "action": Must be one of "create", "delete", "list", or "unknown".
        - If the user asks if they are "free" or "available" (e.g., "ว่างไหม", "ว่างมั้ย"), the action should be "list".
    2.  "title": The name of the event. For "list" action, this can be null.
    3.  "startTime": The start date/time in ISO 8601 format (YYYY-MM-DDTHH:mm:ss). For all-day events or list queries, provide only the date part (YYYY-MM-DD). If no date is specified, assume today. Use Thai buddhist year to calculate correctly if user mentions it.
    4.  "endTime": The end date/time in ISO 8601 format. Optional.

    Examples:
    - User: "สร้าง event ไปกินข้าวกับเพื่อนพรุ่งนี้ตอนเที่ยงถึงบ่ายสอง" -> {"action": "create", "title": "ไปกินข้าวกับเพื่อน", "startTime": "[tomorrow's date]T12:00:00", "endTime": "[tomorrow's date]T14:00:00"}
    - User: "ลบ event ประชุมโปรเจค" -> {"action": "delete", "title": "ประชุมโปรเจค", "startTime": null, "endTime": null}
    - User: "พรุ่งนี้มีงานอะไรบ้าง" -> {"action": "list", "title": null, "startTime": "[tomorrow's date]", "endTime": null}
    - User: "ขอดูตารางงานวันที่ 25 ธันวาคม" -> {"action": "list", "title": null, "startTime": "[this year]-12-25", "endTime": null}
    - User: "วันนี้มีนัดอะไรไหม" -> {"action": "list", "title": null, "startTime": "[today's date]", "endTime": null}
    - User: "พรุ่งนี้ฉันว่างไหม" -> {"action": "list", "title": null, "startTime": "[tomorrow's date]", "endTime": null}
    - User: "นัดกินข้าวเย็น 1 ทุ่ม" -> {"action": "create", "title": "นัดกินข้าวเย็น", "startTime": "[today's date]T19:00:00", "endTime": null}
    - User: "ไปเที่ยวทะเล 3 วัน เริ่มศุกร์นี้" -> {"action": "create", "title": "ไปเที่ยวทะเล", "startTime": "[this Friday's date]", "endTime": "[this Sunday's date]"}

    User's message to analyze: "${text}"
  `;

  const payload = {
    contents: [{
      parts: [{ text: prompt }]
    }],
    // เพิ่ม safety settings เพื่อลดการถูกบล็อค
    "safetySettings": [
        {
            "category": "HARM_CATEGORY_HARASSMENT",
            "threshold": "BLOCK_NONE"
        },
        {
            "category": "HARM_CATEGORY_HATE_SPEECH",
            "threshold": "BLOCK_NONE"
        },
        {
            "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
            "threshold": "BLOCK_NONE"
        },
        {
            "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
            "threshold": "BLOCK_NONE"
        }
    ]
  };

  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };

  try {
    const response = UrlFetchApp.fetch(apiUrl, options);
    const responseCode = response.getResponseCode();
    const resultText = response.getContentText();

    if (responseCode === 200) {
      const result = JSON.parse(resultText);
      if (result.candidates && result.candidates[0].content.parts[0].text) {
          // Gemini อาจจะยังคงตอบกลับมาพร้อม markdown ให้เราตัดออก
          const innerJsonText = result.candidates[0].content.parts[0].text.replace(/```json\n?/, '').replace(/\n?```/, '');
          return JSON.parse(innerJsonText);
      }
      console.error("Gemini response is valid, but no content text found.");
      return null;
    } else {
      console.error(`Gemini API Error: ${responseCode} - ${resultText}`);
      return null;
    }
  } catch (error) {
    console.error(`Failed to call Gemini API: ${error.toString()}`);
    return null;
  }
}


// === UTILITY FUNCTIONS ===

function replyToLine(replyToken, msg) {
  const url = 'https://api.line.me/v2/bot/message/reply';
  const payload = {
    replyToken: replyToken,
    messages: [{ type: 'text', text: msg }]
  };

  UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    headers: {
      Authorization: 'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN
    },
    payload: JSON.stringify(payload)
  });
}

function formatTime(date) {
  return Utilities.formatDate(date, "Asia/Bangkok", "HH:mm");
}

function formatDate(date) {
  return Utilities.formatDate(date, "Asia/Bangkok", "d MMMM yyyy");
}

หลังจากนั้นเราจำเป็นที่จะต้องเปลี่ยนค่า 3 ค่าด้านบนสุดก่อนดังต่อไปนี้

  1. ค่า LINE_TOKEN ให้เราใส่ค่า Channel Access token จากหน้า Line Developer
  2. ค่า CALENDAR_ID ให้เราไปที่ Google Calendar ที่เพิ่งสร้าง คลิกจุด 3 จุดข้างชื่อปฏิทิน เลือก “การตั้งค่าและการแชร์” เลื่อนลงมาจะเจอ “รหัสปฏิทิน”
  3. ค่า GEMINI API Key: หากยังไม่มี ให้ไปที่ https://aistudio.google.com/ เพื่อสร้างคีย์ใหม่

หลังจากที่เราเปลี่ยนค่าเรียบร้อยแล้วให้คลิกที่ “การทำให้ใช้งานได้” และเลือก “การทำให้ใช้งานได้รายการใหม่” เพื่อทำการสร้าง link สำหรับนำไปผูกใน webhook ในหน้า Line Developer ได้เลย

แค่นี้ระบบของเราก็สามารถใช้งานได้แล้ว โดยเคสในการใช้งานสามารถสั่งงานผ่านไลน์ของเรา โดยถ้าต้องการจะสั่ง AI Chatbot ของเราก็สามารถทำได้โดยการพิมพ์คำว่า ‘gm’ และตามด้วยคำสั่งเกี่ยวกับ calendar ของเราได้เลย

Screenshot

หวังว่าโพสต์นี้จะเป็นประโยชน์นะครับ ลองนำไปตั้งค่าและใช้งานกันดู!

โดยถ้าต้องการดูวิธีการทำระบบจัดการ Google Calendar ผ่าน Line แบบ step by step ก็สามารถดูได้ที่คลิปดังต่อไปนี้เลยครับ

💳 ชอบคลิปที่ช่วยเพิ่มประสิทธิภาพการทำงานแบบนี้สามารถสมัครสมาชิกช่องได้ที่ 
https://www.youtube.com/channel/UChxmhkD8uSSzUOkfMO_p5oQ/join

🎥 อุปกรณ์ที่ผมใช้

กล้อง Sony ZV-E10 kit 16-50mm
Mouse Logitech MX Master 3s
MacBook Air M2
ไมค์ wireless Saramonic Blink 500
เก้าอี้ Anda Seat X-Air Pro Ergonomic Gaming Chair
แขนจับจอ Anda Seat Stealth A6L Ergonomic Monitor Arm
ไมโครโฟน AKG Lyra
ไฟส่องหน้าจอ Xiaomi Light Bar

Leave a Reply

Your email address will not be published. Required fields are marked *