星期二, 11月 03, 2026

[Line] Flex Message - Hello World

在 [Line] Flex Message - 模擬器 取得 Flex Message json 內容搭配 [GAS] LineBot 傳送訊息,使用 push API 主動發出 Flex Message 發給使用者
function testSendFlexMessageFromGAS() {
  const userId = getUserID();
  const card = SendFlexMessageFromGAS();
  pushFlexMessage(userId, 'altText:Hello Flex Message From GAS', card);
}

function SendFlexMessageFromGAS() {
  return {
    "type": "bubble",
    "body": {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "text",
          "text": "Flex Message Sample",
          "weight": "bold",
          "color": "#1DB446",
          "size": "xs"
        },
        {
          "type": "text",
          "text": "Hello Flex Message",
          "weight": "bold",
          "size": "xl",
          "color": "#1E293B"
        },
        {
          "type": "text",
          "text": "從 Google Apps Script 發出的第一張 Flex Message,這張卡片只用了最基礎的 body 區塊與 vertical 垂直排列容器",
          "size": "sm",
          "color": "#64748B",
          "wrap": true,
          "lineSpacing": "4px"
        }
      ]
    }
  }
}

function pushFlexMessage(to, altText, flexContents) {

  const channelAccessToken = getchannelAccessToken();

  const payload = {
    to,
    messages: [
      {
        type: 'flex',
        altText,
        contents: flexContents
      }
    ]
  };

  const options = {
    method: 'post',
    contentType: 'application/json',
    headers: {
      Authorization: `Bearer ${channelAccessToken}`
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };

  const lineUrl = "https://api.line.me/v2/bot/message/push";
  const response = UrlFetchApp.fetch(lineUrl, options);
  const statusCode = response.getResponseCode();
  const responseText = response.getContentText();

  if (statusCode === 200) {
    Logger.log(`Flex Message 推播成功!"${altText}"`);
    return JSON.parse(responseText || '{}');
  }

  throw new Error(`LINE Push API 發送失敗 (HTTP ${statusCode}):${responseText}`);
}

function getchannelAccessToken() {
  const scriptProperties = PropertiesService.getScriptProperties();
  const rawToken = scriptProperties.getProperty('CHANNEL_ACCESS_TOKEN') || '';

  // 自動去除前後空白、換行或誤加的雙引號/單引號
  const channelAccessToken = rawToken.trim().replace(/^["']+|["']+$/g, '');

  if (!channelAccessToken) {
    throw new Error(
      '尚未設定指令碼屬性 (Script Properties) 內的 CHANNEL_ACCESS_TOKEN'
    );
  }

  return channelAccessToken;
}

function getUserID() {
  const scriptProperties = PropertiesService.getScriptProperties();
  const rawUserId = scriptProperties.getProperty('USER_ID') || '';

  // 自動去除前後空白、換行或誤加的雙引號/單引號
  const userId = rawUserId.trim().replace(/^["']+|["']+$/g, '');

  if (!userId) {
    throw new Error(
      '尚未設定指令碼屬性 (Script Properties) 內的 USER_ID'
    );
  }

  return userId;
}
payload 內的 Message 有兩個重點
  • type 要指定為 flex
  • altText 即為通知訊息,故意在文字前面打個 altText 來辨識
Flex Message josn 有修改部分文字,方便辨識是從 GAS 發出

星期一, 11月 02, 2026

[Line] Flex Message - 模擬器

Line 訊息只能傳送單一類型內容,例如文字、圖片或影片,但 Flex Message 基於 CSS Flexible Box (CSS Flexbox) 能提供更豐富且具互動性的版面配置,官方有提供模擬器,可以製作 Flex Message 並發送測試,該筆記會透過模擬器來發送 Flex Message

Flex Message Simulator 介紹

使用 Line 帳號登錄模擬器後,該畫面是進入預設版面


模擬器 Layout 分為三大區域,由左至右分別為預覽畫面、訊息控件樹狀圖、控件屬性,右上角四個按鈕功能分別為
  • New:建立新的空 Flex Message,只有 bubble 和 carousel 兩個選項
  • Showcase:使用內建 Flex Message 範本
  • Send:將設計好的 Flex Message 傳送至 Line 進行測試,需要先把 Flex Message Simulator 加入好友才能進行發送
  • View as JSON:查看設計 Flex Message json,當 Flex Message 設計到一半時可以先透過該功能把 Flex Message json 複製出來,要繼續編輯時再複製回去並按右下角 Apply 按鈕就可以回復

Flex Message 控件一覽圖

這張 bubble 官方解析圖 簡單明瞭
  1. 容器層 (Container):只有 2 種,單張卡片 (bubble) 或 多張 bubble 左右輪播(carousel)
  2. 區塊層 (Block):每張 bubble 最多有 4 個區塊(由上而下,皆為選填)
    • header:頂部標題列
    • hero :主視覺滿版圖片、影片
    • body :主要內容區(最常使用)
    • footer:底部按鈕區
  3. 排版盒 (Box):Block 內必須放 box,透過 layout 決定排列方向
    • vertical :垂直由上往下堆疊
    • horizontal:水平由左往右並排(常搭配 flex 權重做雙欄表格)
    • baseline :依文字基準線水平對齊(常搭配 icon 做星級評分)
  4. 元件層 (Component):放在 box 的控件,例如:text、image、button、separator、icon

製作並發送 Flex Message

使用 Receipt 範例來建立該 Flex Message,只用上 body 區塊與 vertical 垂直排列
Flex Message json 檔案
{
  "type": "bubble",
  "body": {
    "type": "box",
    "layout": "vertical",
    "contents": [
      {
        "type": "text",
        "text": "Flex Message Simulator",
        "weight": "bold",
        "color": "#1DB446",
        "size": "xs"
      },
      {
        "type": "text",
        "text": "Hello Flex Message",
        "weight": "bold",
        "size": "xl",
        "color": "#1E293B"
      },
      {
        "type": "text",
        "text": "從模擬器發出的第一張 Flex Message,這張卡片只用了最基礎的 body 區塊與 vertical 垂直排列容器",
        "size": "sm",
        "color": "#64748B",
        "wrap": true,
        "lineSpacing": "4px"
      }
    ]
  }
}

Flex Message 限制

同一則 Flex 訊息可能會因接收裝置的環境不同,而呈現不同的顯示效果。裝置的作業系統、LINE 版本、螢幕解析度、語言設定及字型,都可能影響訊息的顯示效果

星期二, 10月 20, 2026

[GAS] 拆活動時段

報名活動使用 Google Forms 時,發現時段是以下列方式呈現,直覺是資料這樣儲存,是要怎麼統計,練習一下在 Google Sheet 內把時段拆開並進行統計

Google Form 上的選單選項
各時段示意圖
設計流程圖
拆解活動時段 gs Code

重點紀錄
  • 該 GAS 是綁定在 Google Sheets 上,而不是 Google Forms
  • gs Code 抓取 Google Sheet 部分,為了方便手動執行測試,會判斷是從 Google Form 還是 GAS 內直接執行的
  • Range.insertCheckboxes() 是指把指定範圍內的 cell 變成 checkbox 並設定勾選值
/**
 * Google Forms 提交後,自動拆解 A、B、C 時段。
 *
 * C 欄:原始報名選項
 * D 欄:A 時段
 * E 欄:B 時段
 * F 欄:C 時段
 */
function onFormSubmit(e) {

  // 從試算表取得工作表
  const sheet = e && e.range
    ? e.range.getSheet()
    : SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Form_Responses");

  if (!sheet) {
    throw new Error("找不到 Form_Responses 工作表。");
  }

  // 表單提交:使用實際提交列
  // 手動執行:使用最後一列
  const row = e && e.range
    ? e.range.getRow()
    : sheet.getLastRow();

  if (row <= 1) {
    throw new Error("目前沒有可處理的報名資料。");
  }

  // 讀取 C 欄報名選項
  const selectedValue = sheet.getRange(row, 3).getDisplayValue();

  // 解析報名選項
  // 例如 A + C (08:00 - 13:00) → ["A", "C"]
  const selectedGroups = selectedValue
    .split("(")[0]
    .split("+")
    .map(group => group.trim());

  // 基本時段
  const groups = ["A", "B", "C"];

  // 產生 CheckBox 勾選狀態
  const checkboxValues = groups.map(group =>
    selectedGroups.includes(group)
  );

  // D、E、F 欄
  const range = sheet.getRange(row, 4, 1, 3);

  // 建立 CheckBox
  range.insertCheckboxes();

  // 寫入勾選狀態
  range.setValues([checkboxValues]);
}
設定觸發條件

活動類型:提取表單時,在 以事件為準的觸發條件 內說明
An installable form submit trigger runs when a user responds to a form,There are two versions of the form-submit trigger
  • one for Google Forms itself
  • one for Sheets if the form submits to a spreadsheet
該筆記是屬於後者,意思是當 Google Sheets 接收到從 Google Forms 送來資料時會觸發,而該 Blog 筆記 - [GAS] 自行定義回覆 Email,則是屬於前者

因為 function 名稱都慣性取名為 onFormSubmit,所以誤會兩件事情
  • 直覺 GAS 是綁定在 Goole Forms 上,但就該筆記並不是
  • 以為跟簡單觸發條件 onOpen、doGet 一樣會自動觸發,不需要手動建立
統計公式設定

時段拆解開後,在 Google Sheets 內另外開一個 Sheet 來當成統計頁面,透過設定公式 CountIf() 來進行統計,小活動用最簡單方式來達成就好
=COUNTIF(Data!D2:D,TRUE)
=COUNTIF(Data!E2:E,TRUE)
=COUNTIF(Data!F2:F,TRUE)


實際執行

星期六, 10月 17, 2026

[SQL] 資料表值參數 - DataTable 參數欄位順序

使用 [SQL] 資料表值參數 (Table Value Parameters) 時拋出字串要轉型為整數 SQL Exception,這才發現原來 DataTable 參數欄位順序要搭配 TVP 參數順序,兩者必須手動維持順序一致,沒有辦法自動對應
使用者定義資料表類型 (User-defined Table Type)
CREATE TYPE [dbo].[UserDefinedTableType] AS TABLE(
	Col1 char(4) NOT NULL,
	Col2 char(16) NOT NULL,
	Col3 int NOT NULL
)
該 case 是使用 FastMember ObjectReader 把 List<T> 轉成 DataTable,所以有兩種處理方式
  1. 事先定義欄位順序:在 class 上指定 FastMember.OrdinalAttribute 定義轉換後欄位順序,詳見 [C#] FastMember-ObjectReader
  2. 事後調整欄位順序:產生 DataTable 後,透過 DataColumn.SetOrdinal() 調整 DataTable 欄位順序
// ToDataTable() 則是封裝好的 FastMember ObjectReader 轉換功能
DataTable dt = List<T>.ToDataTable();
dt.Columns[Col1].SetOrdinal(0);
dt.Columns[Col2].SetOrdinal(1);
dt.Columns[Col3].SetOrdinal(2);

星期五, 10月 16, 2026

[SQL] SQL Profile 側錄 Exception

C# AP 端開發時發生 SQL Exception,在本地端開啟 Profile 側錄語法時沒有看見 SQL Exception 資訊,發現使用標準範本的話要特別去開啟 Errors and Warmings 內相關事件才會記錄

C# AP 端拋出 SQL Excpetion
該 case 在 Profile 內開啟 Exception 和 User Error Message 就行
Profile 側錄結果