在 Day 6 中,我們透過整合 OpenWeatherMap API
與座標位置訊息
學習了如何結合第三方服務。今天將探索 Cloudinary 雲端服務,解決 LINE 圖片資源會過期的問題,學習如何透過 LINE Message API
取得使用者傳送的圖片並儲存至雲端。
本日程式碼的範例連結
在 Day4 介紹的 6+1 種 LINE 使用者訊息基礎類型中,一對一聊天情境可接收 Image 類型的靜態圖片。然而,LINE 圖片具有時效性限制,超過保存期限後會顯示過期狀態,無法正常開啟檢視。
首先我們需要取得使用者上傳的靜態檔案資訊,包含圖片(Image)
、音訊(Audio)
、影片(Video)
及檔案(File)
等類型。透過訊息 ID 可以取得對應的靜態檔案實體資訊,但需要特別注意的是,此功能僅適用於靜態資源來源為 LINE 平台的情況。
靜態資源傳送來源區分為兩種類型:
line(LINE 平台)
和external(LIFF 發送的情形)
。
官方 Get content API 說明
LINE SDK
在處理上分為 MessagingApiClient
及 MessagingApiBlobClient
兩個部分,前面我們主要使用 MessagingApiClient
處理 Webhook Event
、Message Event
並根據不同類型進行回覆。現在需要使用 BlobClient
來透過 Message Content API
取得圖片內容。
line-webhook/line-webhook.service.ts
export class LineWebhookService {
// 略
private readonly blobClient: messagingApi.MessagingApiBlobClient;
constructor(// 略 ) {
this.blobClient = new messagingApi.MessagingApiBlobClient({
channelAccessToken: this.lineConfig.channelAccessToken,
});
}
}
主要用於檢測檔案
magic number
判斷靜態檔案類型
由於 NestJS 預設使用 CommonJS
模組系統,而 file-type
套件在後續版本更新中僅支援 ESM 模組格式,因此我們需要透過 load-esm
的方式來動態引入 file-type
套件。
pnpm i file-type
pnpm i load-esm
透過 file-type
套件檢測從 LINE 串流中取得的靜態檔案資源,以獲取其副檔名
及 MIME 類型
。在進行檢測前,需要先將檔案資料轉換為 Buffer
格式。
line-webhook/line-webhook.service.ts
private async handleMessageEvent(event: MessageEvent): Promise<void> {
const messageEventHandlerMap = {
// 略
image: async (message) => {
const { id, contentProvider } = message;
const provideType = contentProvider.type;
const defaultMsg = `🖼️ 收到圖片訊息 => 訊息編號:${id}-圖片來源:${provideType}`;
// 檢查來源必須來自 Line 平台
if (provideType !== 'line') return defaultMsg;
// 原始型別 Readable Stream
const stream = await this.blobClient.getMessageContent(id);
// 將 Stream 轉換成 Buffer 的格式
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
// 取得 Buffer 資訊了解檔案大小(KB)
const sizeInBytes = buffer.length;
const sizeInKB = (sizeInBytes / 1024).toFixed(2);
// 處理 NestJS 中 CommonJS 的匯入方式(編碼檢查套件)
const { fileTypeFromBuffer } =
await loadEsm<typeof import('file-type')>('file-type');
// 取得靜態檔案的副檔名及 MINE 類型
const fileType = await fileTypeFromBuffer(buffer);
const { ext, mime } = fileType;
// 組合檔案資訊訊息
const fileMsg = `📙 檔案大小:${sizeInKB} KB | 副檔名:${ext} | MINE 類型:${mime}`;
return `${defaultMsg}\n\n${fileMsg}`;
},
}
圖片上傳至 LINE 平台後,會由平台內部進行處理與轉換,例如壓縮等優化作業。
我們使用一張原始檔案大小為 3 MB
的 PNG 格式圖片上傳進行測試,驗證 LINE 平台處理後的實際效果。
從結果可以明顯看出,不論是副檔名或檔案大小都與原始檔案不同!!
fs
及path
為 Node.js 內建模組,NestJS 可直接使用,不需要另外安裝
在組合回傳 fileMsg
之前,先將讀取到的檔案內容儲存至本地端(使用 fs
及 path
模組)。由於無法取得使用者原始上傳的檔案名稱,這裡使用訊息 ID
作為檔案索引名稱,你也可以根據需求自定義檔案命名格式。
line-webhook/line-webhook.service.ts:
import * as fs from 'fs/promises';
import * as path from 'path';
// 略
image: async (message) => {
// 略
try {
const uploadDir = 'src/line-webhook';
const filename = `${id}.${ext}`;
const filePath = path.join(uploadDir, filename);
await fs.writeFile(filePath, buffer);
} catch (error) {
console.error(`儲存圖片時發生錯誤:${error}`);
}
return `${defaultMsg}\n\n${fileMsg}`;
}
上傳後就可以在
line-webhook
資料夾內看到使用者傳到LINE Bot
的圖片囉 🎉🎉🎉
看到這裡可能會覺得流程有些複雜,但其實如果直接串接 Cloudinary API
,讓 Cloudinary 協助檢查檔案格式,可以省略 file-type
和本地儲存的步驟。接下來讓我們實際動手整合 Cloudinary 雲端服務吧!
前置作業:需要先在
Cloudinary
進行註冊,才可以取得其API Token
。
Cloudinary
提供圖床服務,讓使用者能夠將靜態資源儲存在雲端平台上,並提供多種裁切與調整功能,可以有效整合到網站中呈現。接下來我們將透過這個服務,在接收到 Line Bot 傳送的圖片訊息後,將其透過 Cloudinary API
上傳至 Cloudinary
。
這部分的資訊可以在 Cloudinary 官網的設定中找到
.env
CLOUDINARY_CLOUD_NAME=YOUR_CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY=YOUR_CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET=YOUR_CLOUDINARY_API_SECRET
nest g module cloudinary
nest g service cloudinary --no-spec
將 Cloudinary 的設定都參照 Day 7 的方式,透過 Configuration 模組集中管理環境變數,並在應用程式啟動時進行驗證
config/configuration.ts
const configSchema = Joi.object({
// 略
cloudinary: Joi.object({
cloudName: Joi.string().required(), // Cloudinary Cloud Name (字串且必填)
apiKey: Joi.string().required(), // Cloudinary API Key (字串且必填)
apiSecret: Joi.string().required(), // Cloudinary API Secret (字串且必填)
}).required(), // 必填
});
export default () => {
const config = {
// 略
cloudinary: {
cloudName: process.env.CLOUDINARY_CLOUD_NAME,
apiKey: process.env.CLOUDINARY_API_KEY,
apiSecret: process.env.CLOUDINARY_API_SECRET,
},
};
// 略
};
這部分的設計概念與 Day 6 天氣服務相同,透過環境變數動態獲取第三方服務的配置參數來建立服務實例。這樣的架構設計有助於提升系統的靈活性和可維護性,當需要更改配置時,只需修改環境變數即可,不會直接影響到核心服務的運行。
透過使用 NestJS 的依賴注入機制,我們可以確保 Cloudinary 服務在應用程式啟動時正確初始化,並且可以在整個應用程式中安全地使用。
cloudinary/cloudinary.provider.ts
import { ConfigOptions, v2 as cloudinary } from 'cloudinary';
import { ConfigService } from '@nestjs/config';
import { Provider } from '@nestjs/common';
export const CLOUDINARY = 'Cloudinary';
const cloudinaryConfig = (configService: ConfigService) => {
const config: ConfigOptions = {
cloud_name: configService.get<string>('cloudinary.cloudName'),
api_key: configService.get<string>('cloudinary.apiKey'),
api_secret: configService.get<string>('cloudinary.apiSecret'),
};
cloudinary.config(config);
return cloudinary;
};
export const CloudinaryProvider: Provider = {
provide: CLOUDINARY,
useFactory: (configService: ConfigService) => cloudinaryConfig(configService),
inject: [ConfigService],
};
建立 Cloudinary 模組並註冊相關的 Provider 和 Service。記得要將
CloudinaryService
加到exports
中,否則其他模組無法使用這個服務。
cloudinary/cloudinary.module.ts
import { Module } from '@nestjs/common';
import { CloudinaryProvider } from './cloudinary.provider';
import { CloudinaryService } from './cloudinary.service';
@Module({
providers: [CloudinaryService, CloudinaryProvider],
exports: [CloudinaryService], // 讓其他匯入此模組的模組可以使用 CloudinaryService
})
export class CloudinaryModule {}
當函數參數超過兩個時,我習慣使用物件傳遞參數,這樣不用擔心參數順序問題,未來要新增或修改參數時也比較靈活
這裡使用 Stream 來處理檔案上傳,透過串流的方式可以處理大檔案,不會把整個檔案一次載入記憶體。
cloudinary/cloudinary.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ImageUploadPayload } from './cloudinary.types';
import {
UploadApiOptions,
UploadApiResponse,
v2 as cloudinary,
} from 'cloudinary';
@Injectable()
export class CloudinaryService {
constructor(
@Inject('Cloudinary')
private readonly cloudinaryInstance: typeof cloudinary,
) {}
async uploadImage(
imageUploadPayload: Partial<UploadApiOptions> & { stream: Readable },
): Promise<UploadApiResponse> {
const folderName = '2025 IT 鐵人賽'; // 指定儲存的資料夾名稱!!
const { stream, folder = folderName, public_id } = imageUploadPayload;
return new Promise((resolve, reject) => {
const uploadStream = this.cloudinaryInstance.uploader.upload_stream(
{ folder, public_id },
(error, result) => {
if (result) return resolve(result);
return reject(new Error(error.message));
},
);
stream.pipe(uploadStream);
});
}
}
呼叫 uploadImage
方法時,傳遞從 LINE 平台獲取的圖片串流資料,並使用訊息 ID 作為檔案名稱,確保每個檔案都有唯一識別碼。
line-webhook/line-webhook.service.ts
image: async (message) => {
const { id, contentProvider } = message;
const provideType = contentProvider.type;
const defaultMsg = `🖼️ 收到圖片訊息 => 訊息編號:${id}-圖片來源:${provideType}`;
// 檢查來源必須來自 Line 平台
if (provideType !== 'line') return defaultMsg;
// 原始型別 Readable Stream
const stream = await this.blobClient.getMessageContent(id);
// 上傳所需的參數
const cloudinaryPayload = {
stream,
public_id: id,
};
// 處理 cloudinary 上傳的部分
const cloudinarySuccessResult =
await this.cloudinaryService.uploadImage(cloudinaryPayload);
const { bytes, format, resource_type, url } = cloudinarySuccessResult;
// 組合檔案資訊訊息
const sizeInKB = (bytes / 1024).toFixed(2);
const fileMsg = `📙 檔案大小:${sizeInKB} KB | 副檔名:${format} | 資源類型:${resource_type} | url:${url}`;
return `${defaultMsg}\n\n${fileMsg}`;
}
成果展示
模擬使用者在 LINE Bot 官方帳號傳遞 Image 類型訊息:
Cloudniary 查看指定資料夾儲存的成果:
今天帶大家了解 LINE Bot
可以取得使用者傳送的圖片,這樣以後就不會在亂傳圖片到官方帳號了吧(誤。這次分了兩個階段來說明取得使用者傳送的圖片之後,我們可以有不同方式的後續處理方法。最一開始看到這個功能,很直覺的就是抓下來看看!感覺抓下來看到實際的圖片比較有真的能取得使用者傳送圖片的感覺。
第二階段則是結合了 Cloudinary API
,讓我們可以換個地方存放我們自己的圖片資源,上傳後還可以馬上回傳圖片的連結跟相關資訊,這部分我覺得很方便。
這幾天並沒有很快的進入 LINE Bot 的其他觀念,都是圍繞在我可以透過 LINE Bot 怎麼結合不同的服務,達到不同的結合效果。透過這種方式,感覺像是在想怎麼「玩」 LINE Bot,學習起來特別開心,也希望今天的小小範例,可以讓更多人喜歡並願意嘗試一起「玩」LINE Bot。