iT邦幫忙

2025 iThome 鐵人賽

DAY 8
1
Modern Web

Line Bot × NestJS:30 天開發日記系列 第 8

Day 8:Cloudinary 雲端圖片儲存整合

  • 分享至 

  • xImage
  •  

2025 鐵人賽背景圖

前言

Day 6 中,我們透過整合 OpenWeatherMap API座標位置訊息學習了如何結合第三方服務。今天將探索 Cloudinary 雲端服務,解決 LINE 圖片資源會過期的問題,學習如何透過 LINE Message API 取得使用者傳送的圖片並儲存至雲端。

本日程式碼的範例連結


讀取 LINE 靜態資源並下載至本地端

Day4 介紹的 6+1 種 LINE 使用者訊息基礎類型中,一對一聊天情境可接收 Image 類型的靜態圖片。然而,LINE 圖片具有時效性限制,超過保存期限後會顯示過期狀態,無法正常開啟檢視。

首先我們需要取得使用者上傳的靜態檔案資訊,包含圖片(Image)音訊(Audio)影片(Video)檔案(File)等類型。透過訊息 ID 可以取得對應的靜態檔案實體資訊,但需要特別注意的是,此功能僅適用於靜態資源來源為 LINE 平台的情況。

靜態資源傳送來源區分為兩種類型:line(LINE 平台)external(LIFF 發送的情形)

Step 1:匯入 BlobClient 並初始化

官方 Get content API 說明

LINE SDK 在處理上分為 MessagingApiClientMessagingApiBlobClient 兩個部分,前面我們主要使用 MessagingApiClient 處理 Webhook EventMessage 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,
    });
  }
}

Step2:安裝套件 file-type & load-esm

主要用於檢測檔案 magic number 判斷靜態檔案類型

由於 NestJS 預設使用 CommonJS 模組系統,而 file-type 套件在後續版本更新中僅支援 ESM 模組格式,因此我們需要透過 load-esm 的方式來動態引入 file-type 套件。

pnpm i file-type
pnpm i load-esm

Step3:Image 接收訊息中使用 file-type 套件產生檔案資訊的訊息

透過 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}`;
      },
  }

Step 4:測試圖片上傳後的檔案變化

圖片上傳至 LINE 平台後,會由平台內部進行處理與轉換,例如壓縮等優化作業。

我們使用一張原始檔案大小為 3 MB 的 PNG 格式圖片上傳進行測試,驗證 LINE 平台處理後的實際效果。

測試 LINE 內部針對靜態資源轉檔效果

從結果可以明顯看出,不論是副檔名或檔案大小都與原始檔案不同!!

Step 5:儲存至本地端查看效果

fspath 為 Node.js 內建模組,NestJS 可直接使用,不需要另外安裝

在組合回傳 fileMsg 之前,先將讀取到的檔案內容儲存至本地端(使用 fspath 模組)。由於無法取得使用者原始上傳的檔案名稱,這裡使用訊息 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 的圖片囉 🎉🎉🎉

讀取 LINE Bot 照片儲存至本地

看到這裡可能會覺得流程有些複雜,但其實如果直接串接 Cloudinary API,讓 Cloudinary 協助檢查檔案格式,可以省略 file-type 和本地儲存的步驟。接下來讓我們實際動手整合 Cloudinary 雲端服務吧!

串接 Cloudinary 雲端服務平台

前置作業:需要先在 Cloudinary 進行註冊,才可以取得其 API Token

Cloudinary 提供圖床服務,讓使用者能夠將靜態資源儲存在雲端平台上,並提供多種裁切與調整功能,可以有效整合到網站中呈現。接下來我們將透過這個服務,在接收到 Line Bot 傳送的圖片訊息後,將其透過 Cloudinary API 上傳至 Cloudinary

Step 1:設定 Cloudinary env 資訊

這部分的資訊可以在 Cloudinary 官網的設定中找到

Cloudinary API Keys 畫面

.env

CLOUDINARY_CLOUD_NAME=YOUR_CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY=YOUR_CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET=YOUR_CLOUDINARY_API_SECRET

Step 2:創建 Cloudinary module & service,負責上傳服務

nest g module cloudinary
nest g service cloudinary --no-spec

Step 3:設定 Cloudinary configuration

將 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,
    },
  };
  // 略
};

Step 4:創建 Cloudinary Provider 負責動態配置

這部分的設計概念與 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],
};

Step 5:設定 Cloudinary 模組

建立 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 {}

Step 6:定義 Cloudinary 服務方法

當函數參數超過兩個時,我習慣使用物件傳遞參數,這樣不用擔心參數順序問題,未來要新增或修改參數時也比較靈活

這裡使用 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);
    });
  }
}

Step 7:整合 Cloudinary 上傳功能

呼叫 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 類型訊息:
Line 模擬上傳的訊息畫面

Cloudniary 查看指定資料夾儲存的成果:
Cloudinary 資料夾查看儲存成果

本日結語

今天帶大家了解 LINE Bot 可以取得使用者傳送的圖片,這樣以後就不會在亂傳圖片到官方帳號了吧(誤。這次分了兩個階段來說明取得使用者傳送的圖片之後,我們可以有不同方式的後續處理方法。最一開始看到這個功能,很直覺的就是抓下來看看!感覺抓下來看到實際的圖片比較有真的能取得使用者傳送圖片的感覺。

第二階段則是結合了 Cloudinary API,讓我們可以換個地方存放我們自己的圖片資源,上傳後還可以馬上回傳圖片的連結跟相關資訊,這部分我覺得很方便。

這幾天並沒有很快的進入 LINE Bot 的其他觀念,都是圍繞在我可以透過 LINE Bot 怎麼結合不同的服務,達到不同的結合效果。透過這種方式,感覺像是在想怎麼「玩」 LINE Bot,學習起來特別開心,也希望今天的小小範例,可以讓更多人喜歡並願意嘗試一起「玩」LINE Bot。


上一篇
Day 7:Joi 環境變數統一驗證,重構 LINE Bot 天氣服務模組
系列文
Line Bot × NestJS:30 天開發日記8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言