24時間前までの履歴を保持するSMS受信システムを Twilio + Momento Cache で構築する

24時間前までの履歴を保持するSMS受信システムを Twilio + Momento Cache で構築する

Twilio の永続化機能の制約を Momento Cache で解決し、Next.js と Server-Sent Events を組み合わせて SMS 受信からリアルタイム表示、履歴管理まで一貫したシステムを構築する実装方法を詳しく解説します。Vercel でのデプロイ手順も含めた PoC 実装ガイドです。

はじめに

Twilio Messaging API を使用することで、SMS 受信機能を簡単にアプリケーションに組み込むことができます。Twilio では Twilio Functions という Serverless function の仕組みも提供されており、受信した SMS に対する基本的な処理は Twilio 内で完結させることも可能です。

一方で、Twilio には DB のような永続化の仕組みが存在しない という制約があります。受信した SMS を一時保存して履歴として参照したり、複数のクライアントに配信したりする用途では、外部のストレージサービスとの連携が必要になります。

今回この制約を解決するため、24 時間の TTL 機能を持つ Momento Cache を採用し、SMS 受信データの一時保存と履歴管理を実現しました。本記事では、SMS 受信とリアルタイム表示、履歴取得機能を持つシステムの実装内容を紹介します。

twilio-momento-sms-reciever-demo

Twilio とは

Twilio は、SMS、音声通話、ビデオ通話などの通信機能を API で提供するクラウドサービスです。開発者は簡単に通信機能をアプリケーションに組み込むことができます。今回は Messaging API を使用して SMS の受信機能と Webhook による外部システムとの連携を実装します。

Momento とは

Momento は、フルマネージドなインメモリキャッシュサービスです。従来の Redis や Memcached と異なり、インフラの管理や設定が不要で、API を通じて即座に利用開始できます。TTL (Time To Live) 機能により、データの自動削除も可能で、一時的なデータ保存に最適です。

対象読者

  • Next.js の基本的な知識がある方
  • Twilio の Messaging API に興味がある方
  • リアルタイム通信の実装について学びたい方
  • サーバーレス環境でのデータ永続化について課題を感じている方

参考

システム概要

アーキテクチャ図

  1. SMS 受信と Webhook 送信: 外部電話番号から Twilio 購入番号に SMS が送信されると、Twilio が設定された Webhook URL (/api/sms) に POST リクエストを送信
  2. データ永続化とリアルタイム配信: サーバーが SMS データを Momento Cache に保存し、同時に Server-Sent Events で接続中のクライアントにリアルタイム通知を配信
  3. 履歴表示: フロントエンドでは初回読み込み時に履歴 API (/api/sms/history) から過去 24 時間の SMS データを取得し、リアルタイム通知と重複排除しながら表示

前提となる環境

  • Twilio アカウント
  • Momento アカウント
  • Vercel アカウント (GitHub アカウント連携済み)
  • Node.js のローカル環境

Momento Cache 作成

Momento コンソール で twilio-sms キャッシュを作成し、 API キーを作成します。

キャッシュ作成

  • Type of key: Fine-Grained Access Key
  • Cache Name: twilio-sms
  • Role Type: readwrite

Key 設定

Next.js プロジェクト作成

Next.js プロジェクトを作成し、必要なライブラリをインストールします。

# Next.js プロジェクト作成
npx create-next-app@latest sms-receiver-poc --typescript --tailwind --app
cd sms-receiver-poc

# Momento SDK のインストール
npm install @gomomento/sdk

# 開発サーバー起動
npm run dev

ファイル構成

プロジェクトの主要なファイル構成は以下の通りです。

src/
├── app/
│   ├── api/
│   │   └── sms/
│   │       ├── route.ts           # 統合API(SSE + Webhook)
│   │       └── history/
│   │           └── route.ts       # 履歴取得API
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx                   # メインページ
├── components/
│   ├── SmsMessageList.tsx         # SMS一覧表示コンポーネント
│   └── SystemStatus.tsx           # システム状態表示コンポーネント
├── contexts/
│   └── SmsContext.tsx             # SMS状態管理Context
├── lib/
│   └── momento.ts                 # Momento接続設定
└── types/
    └── sms.ts                     # 型定義

バックエンド実装

Momento 接続設定

まず、Momento Cache への接続設定を作成します。

lib/momento.ts
import { CacheClient, Configurations, CredentialProvider } from '@gomomento/sdk';

let cacheClient: CacheClient | null = null;

export function getMomentoClient(): CacheClient {
    if (!cacheClient) {
        cacheClient = new CacheClient({
            configuration: Configurations.Laptop.v1(),
            credentialProvider: CredentialProvider.fromString({
                apiKey: process.env.MOMENTO_API_KEY!
            }),
            defaultTtlSeconds: 86400, // 24時間
        });
    }
    return cacheClient;
}

export const CACHE_NAME = process.env.MOMENTO_CACHE_NAME!;
  • シングルトンパターンでクライアントを管理し、TTL を 24 時間に設定

型定義

SMS データの型定義を作成します。

src/types/sms.ts
export interface SmsMessage {
    messageId: string;
    from: string;
    to: string;
    body: string;
    timestamp: string;
    source: 'notification' | 'history';
}

export interface SmsNotification {
    type: 'new_sms';
    data: SmsMessage;
    timestamp: number;
}

export interface ConnectionNotification {
    type: 'connection';
    message: string;
    timestamp: number;
}

export interface HeartbeatNotification {
    type: 'heartbeat';
    timestamp: number;
}

export type NotificationData = SmsNotification | ConnectionNotification | HeartbeatNotification;
  • source フィールドでデータの出所を識別し、重複排除

統合 API エンドポイント

SSE 接続と Webhook 処理を単一エンドポイントで実現します。

app/api/sms/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getMomentoClient, CACHE_NAME } from '@/lib/momento';
import { CacheSet, CacheListPushBack } from '@gomomento/sdk';
import { SmsMessage } from '@/types/sms';

// SSE 接続中のクライアントを管理するセット
const clients = new Set<ReadableStreamDefaultController>();

/**
 * SSE (Server-Sent Events) エンドポイント
 * GET リクエストでクライアントとの接続を確立
 */
export async function GET() {
    const stream = new ReadableStream({
        start(controller) {
            // 新しいクライアントを接続リストに追加
            clients.add(controller);
            console.log(`SSE client connected. Total clients: ${clients.size}`);

            // 接続確認メッセージを送信
            const connectMsg = JSON.stringify({ type: 'connected', timestamp: Date.now() });
            controller.enqueue(`data: ${connectMsg}\n\n`);
        },
        cancel(controller) {
            // 接続が切断された際にクライアントをリストから削除
            clients.delete(controller);
            console.log(`SSE client disconnected. Total clients: ${clients.size}`);
        }
    });

    return new Response(stream, {
        headers: {
            'Content-Type': 'text/event-stream',
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive',
            'Access-Control-Allow-Origin': '*',
        },
    });
}

/**
 * Twilio Webhook エンドポイント
 * POST リクエストで SMS データを受信・処理
 */
export async function POST(request: NextRequest) {
    try {
        // Twilio からの Form データを取得
        const formData = await request.formData();

        // SMS データの構築(UTC タイムスタンプで保存)
        const smsData: SmsMessage = {
            messageId: formData.get('MessageSid') as string,
            from: formData.get('From') as string,
            to: formData.get('To') as string,
            body: formData.get('Body') as string,
            timestamp: new Date().toISOString(), // UTC で保存
            source: 'notification'
        };

        console.log('SMS received:', smsData);

        // Momento Cache への保存
        const momento = getMomentoClient();
        const cacheKey = `sms:${smsData.timestamp}:${smsData.messageId}`;

        // 1. SMS データを保存
        const setResponse = await momento.set(CACHE_NAME, cacheKey, JSON.stringify(smsData));

        if (setResponse instanceof CacheSet.Error) {
            console.error('Momento set error:', setResponse.message());
        }

        // 2. インデックス用のキーリストに追加
        const listResponse = await momento.listPushBack(CACHE_NAME, 'sms-keys', cacheKey);

        if (listResponse instanceof CacheListPushBack.Error) {
            console.warn('Failed to add to key list:', listResponse.message());
        }

        // 3. リアルタイム通知を全クライアントに配信
        const message = {
            type: 'new_sms',
            data: smsData,
            timestamp: Date.now()
        };

        const data = `data: ${JSON.stringify(message)}\n\n`;
        console.log(`Broadcasting to ${clients.size} clients`);

        clients.forEach(controller => {
            try {
                controller.enqueue(data);
            } catch {
                // 送信失敗したクライアントは削除
                clients.delete(controller);
            }
        });

        // Twilio への応答
        return new NextResponse(
            '<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
            { headers: { 'Content-Type': 'text/xml' } }
        );

    } catch (error) {
        console.error('Webhook error:', error);
        return NextResponse.json({ error: 'Error' }, { status: 500 });
    }
}
  • clients Set で SSE 接続を管理
  • Momento への保存と SSE 配信を同期実行

履歴取得 API

過去 24 時間の SMS 履歴を取得する API です。

app/api/sms/history/route.ts
import { NextResponse } from 'next/server';
import { getMomentoClient, CACHE_NAME } from '@/lib/momento';
import { CacheListFetch, CacheGet } from '@gomomento/sdk';
import { SmsMessage } from '@/types/sms';

export async function GET() {
    try {
        const momento = getMomentoClient();

        // インデックスリストからキー一覧を取得
        const keysResponse = await momento.listFetch(CACHE_NAME, 'sms-keys');

        if (keysResponse instanceof CacheListFetch.Error) {
            // キーリストが存在しない場合は空配列を返す
            return NextResponse.json([]);
        }

        const messages: SmsMessage[] = [];

        if (keysResponse instanceof CacheListFetch.Hit) {
            const keys = keysResponse.valueListString();

            // 各キーから SMS データを取得
            for (const key of keys) {
                const getResponse = await momento.get(CACHE_NAME, key);

                if (getResponse instanceof CacheGet.Hit) {
                    try {
                        const smsData = JSON.parse(getResponse.valueString()) as SmsMessage;
                        messages.push({
                            ...smsData,
                            source: 'history' // 履歴データとして識別
                        });
                    } catch (parseError) {
                        console.error(`Failed to parse SMS data for key ${key}:`, parseError);
                    }
                }
            }
        }

        // タイムスタンプで降順ソート
        messages.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());

        return NextResponse.json(messages);

    } catch (error) {
        console.error('History fetch error:', error);
        return new Response(
            JSON.stringify({ error: 'Internal server error' }),
            { status: 500, headers: { 'Content-Type': 'application/json' } }
        );
    }
}

フロントエンド実装

SMS 状態管理 Context

React Context で SMS データとリアルタイム接続を管理します。

src/contexts/SmsContext.tsx
'use client';

import React, { createContext, useContext, useReducer, useEffect } from 'react';
import { SmsMessage } from '@/types/sms';

interface SmsState {
    messages: Map<string, SmsMessage>;
    isLoading: boolean;
    error: string | null;
    connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error';
}

type SmsAction =
    | { type: 'SET_LOADING'; payload: boolean }
    | { type: 'SET_ERROR'; payload: string | null }
    | { type: 'ADD_MESSAGE'; payload: SmsMessage }
    | { type: 'SET_MESSAGES'; payload: SmsMessage[] }
    | { type: 'CLEAR_MESSAGES' }
    | { type: 'SET_CONNECTION_STATUS'; payload: SmsState['connectionStatus'] };

const initialState: SmsState = {
    messages: new Map(),
    isLoading: false,
    error: null,
    connectionStatus: 'disconnected',
};

// Reducer で状態を管理
function smsReducer(state: SmsState, action: SmsAction): SmsState {
    switch (action.type) {
        case 'SET_LOADING':
            return { ...state, isLoading: action.payload };
        case 'SET_ERROR':
            return { ...state, error: action.payload };
        case 'ADD_MESSAGE':
            // Map で重複排除
            const newMessages = new Map(state.messages);
            newMessages.set(action.payload.messageId, action.payload);
            return { ...state, messages: newMessages };
        case 'SET_MESSAGES':
            const messageMap = new Map();
            action.payload.forEach(msg => messageMap.set(msg.messageId, msg));
            return { ...state, messages: messageMap };
        case 'CLEAR_MESSAGES':
            return { ...state, messages: new Map() };
        case 'SET_CONNECTION_STATUS':
            return { ...state, connectionStatus: action.payload };
        default:
            return state;
    }
}

const SmsContext = createContext<{
    state: SmsState;
    dispatch: React.Dispatch<SmsAction>;
    loadHistory: () => Promise<void>;
    addMessage: (message: SmsMessage) => void;
} | null>(null);

export function SmsProvider({ children }: { children: React.ReactNode }) {
    const [state, dispatch] = useReducer(smsReducer, initialState);

    // 履歴データの読み込み
    const loadHistory = async () => {
        dispatch({ type: 'SET_LOADING', payload: true });
        dispatch({ type: 'SET_ERROR', payload: null });

        try {
            const response = await fetch('/api/sms/history');
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            const messages: SmsMessage[] = await response.json();
            dispatch({ type: 'SET_MESSAGES', payload: messages });
        } catch (error) {
            console.error('Failed to load SMS history:', error);
            dispatch({ type: 'SET_ERROR', payload: 'Failed to load SMS history' });
        } finally {
            dispatch({ type: 'SET_LOADING', payload: false });
        }
    };

    const addMessage = (message: SmsMessage) => {
        dispatch({ type: 'ADD_MESSAGE', payload: message });
    };

    // SSE 接続を管理
    useEffect(() => {
        dispatch({ type: 'SET_CONNECTION_STATUS', payload: 'connecting' });

        const eventSource = new EventSource('/api/sms');

        eventSource.onopen = () => {
            console.log('SSE connected to /api/sms');
            dispatch({ type: 'SET_CONNECTION_STATUS', payload: 'connected' });
        };

        eventSource.onmessage = (event) => {
            try {
                const data = JSON.parse(event.data);

                if (data.type === 'connected') {
                    console.log('SSE connection confirmed');
                } else if (data.type === 'new_sms') {
                    // リアルタイムで新しいメッセージを追加
                    console.log('New SMS received via SSE:', data.data);
                    addMessage(data.data);
                }
            } catch (error) {
                console.error('Error parsing SSE message:', error);
            }
        };

        eventSource.onerror = (error) => {
            console.error('SSE error:', error);
            dispatch({ type: 'SET_CONNECTION_STATUS', payload: 'error' });
        };

        // クリーンアップ
        return () => {
            console.log('Closing SSE connection');
            eventSource.close();
            dispatch({ type: 'SET_CONNECTION_STATUS', payload: 'disconnected' });
        };
    }, []);

    // 初回履歴読み込み
    useEffect(() => {
        loadHistory();
    }, []);

    return (
        <SmsContext.Provider value={{ state, dispatch, loadHistory, addMessage }}>
            {children}
        </SmsContext.Provider>
    );
}

export function useSms() {
    const context = useContext(SmsContext);
    if (!context) {
        throw new Error('useSms must be used within SmsProvider');
    }
    return context;
}
  • Map データ構造で messageId ベースの重複排除
  • SSE 接続の自動管理
  • 履歴読み込みとリアルタイム更新の統合

SMS 一覧表示コンポーネント

src/components/SmsMessageList.tsx
'use client';

import { useSms } from '@/contexts/SmsContext';

export default function SmsMessageList() {
    const { state, loadHistory } = useSms();

    // Map を配列に変換してソート
    const messages = Array.from(state.messages.values()).sort(
        (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
    );

    // 日時フォーマット(UTC → 日本時間変換)
    const formatDate = (timestamp: string) => {
        try {
            const date = new Date(timestamp);

            if (isNaN(date.getTime())) {
                return '時刻不明';
            }

            return date.toLocaleString('ja-JP', {
                year: 'numeric',
                month: '2-digit',
                day: '2-digit',
                hour: '2-digit',
                minute: '2-digit',
                second: '2-digit',
                timeZone: 'Asia/Tokyo' // UTC から日本時間に変換
            });
        } catch (error) {
            console.error('Date formatting error:', error);
            return '時刻エラー';
        }
    };

    // 電話番号フォーマット
    const formatPhoneNumber = (phone: string) => {
        return phone.replace(/^\+1(\d{3})(\d{3})(\d{4})$/, '+1 ($1) $2-$3');
    };

    if (state.isLoading) {
        return (
            <div className="flex justify-center items-center py-8">
                <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
                <span className="ml-2">読み込み中...</span>
            </div>
        );
    }

    if (state.error) {
        return (
            <div className="bg-red-50 border border-red-200 rounded-md p-4">
                <div className="text-red-600">
                    <span className="font-medium">エラー:</span> {state.error}
                </div>
                <button
                    onClick={loadHistory}
                    className="mt-2 px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700"
                >
                    再読み込み
                </button>
            </div>
        );
    }

    if (messages.length === 0) {
        return (
            <div className="text-center py-8 text-gray-500">
                <p>SMS メッセージがありません</p>
                <button
                    onClick={loadHistory}
                    className="mt-2 px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
                >
                    再読み込み
                </button>
            </div>
        );
    }

    return (
        <div className="space-y-4">
            <div className="flex justify-between items-center">
                <h2 className="text-xl font-semibold">受信 SMS 履歴</h2>
                <button
                    onClick={loadHistory}
                    className="px-3 py-1 text-sm bg-gray-600 text-white rounded hover:bg-gray-700"
                >
                    更新
                </button>
            </div>

            <div className="space-y-3">
                {messages.map((message) => (
                    <div
                        key={message.messageId}
                        className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
                    >
                        <div className="flex justify-between items-start mb-2">
                            <div className="flex-1">
                                <div className="text-sm text-gray-600">
                                    <span className="font-medium">送信者:</span> {formatPhoneNumber(message.from)}
                                </div>
                                <div className="text-sm text-gray-600">
                                    <span className="font-medium">宛先:</span> {formatPhoneNumber(message.to)}
                                </div>
                            </div>
                            <div className="text-xs text-gray-500">
                                {formatDate(message.timestamp)}
                            </div>
                        </div>

                        <div className="bg-gray-50 rounded p-3">
                            <p className="text-gray-800 whitespace-pre-wrap">{message.body}</p>
                        </div>

                        <div className="flex justify-between items-center mt-2">
                            <span className={`text-xs px-2 py-1 rounded ${
                                message.source === 'notification'
                                    ? 'bg-green-100 text-green-800'
                                    : 'bg-blue-100 text-blue-800'
                            }`}>
                                {message.source === 'notification' ? 'リアルタイム' : '履歴'}
                            </span>
                            <span className="text-xs text-gray-400">
                                ID: {message.messageId}
                            </span>
                        </div>
                    </div>
                ))}
            </div>
        </div>
    );
}

システム状態表示コンポーネント

src/components/SystemStatus.tsx
'use client';

import { useState, useEffect } from 'react';
import { useSms } from '@/contexts/SmsContext';

export default function SystemStatus({ className = '' }) {
    const { state } = useSms();
    const [apiStatus, setApiStatus] = useState<'checking' | 'online' | 'error'>('checking');
    const [lastCheck, setLastCheck] = useState<Date | null>(null);

    // API 状態チェック
    const checkApiStatus = async () => {
        setApiStatus('checking');
        try {
            const response = await fetch('/api/sms/history');
            setApiStatus(response.ok ? 'online' : 'error');
        } catch {
            setApiStatus('error');
        }
        setLastCheck(new Date());
    };

    useEffect(() => {
        checkApiStatus();
        const interval = setInterval(checkApiStatus, 30000);
        return () => clearInterval(interval);
    }, []);

    // 総合ステータスの判定
    const getOverallStatus = () => {
        if (apiStatus === 'error' || state.connectionStatus === 'error') return 'error';
        if (apiStatus === 'checking' || state.connectionStatus === 'connecting') return 'checking';
        if (apiStatus === 'online' && state.connectionStatus === 'connected') return 'online';
        return 'checking';
    };

    const getStatusColor = () => {
        const status = getOverallStatus();
        switch (status) {
            case 'online': return 'text-green-600 bg-green-50 border-green-200';
            case 'error': return 'text-red-600 bg-red-50 border-red-200';
            case 'checking': return 'text-yellow-600 bg-yellow-50 border-yellow-200';
        }
    };

    const getStatusText = () => {
        const status = getOverallStatus();
        switch (status) {
            case 'online': return 'システム正常';
            case 'error': return 'システムエラー';
            case 'checking': return '確認中...';
        }
    };

    const getConnectionStatusText = () => {
        switch (state.connectionStatus) {
            case 'connected': return 'リアルタイム接続中';
            case 'connecting': return '接続中...';
            case 'error': return '接続エラー';
            case 'disconnected': return '未接続';
        }
    };

    return (
        <div className={`flex flex-col space-y-1 ${className}`}>
            <div className={`inline-flex items-center px-3 py-1 border rounded-full text-sm ${getStatusColor()}`}>
                <div className={`w-2 h-2 rounded-full mr-2 ${
                    getOverallStatus() === 'online' ? 'bg-green-600' :
                    getOverallStatus() === 'error' ? 'bg-red-600' : 'bg-yellow-600'
                } ${getOverallStatus() === 'checking' ? 'animate-pulse' : ''}`} />
                <span>{getStatusText()}</span>
                {lastCheck && (
                    <span className="ml-2 text-xs opacity-75">
                        {lastCheck.toLocaleTimeString('ja-JP')}
                    </span>
                )}
            </div>
            <div className="text-xs text-gray-500 text-center">
                {getConnectionStatusText()}
            </div>
        </div>
    );
}

メインページ

src/app/page.tsx
'use client';

import SmsMessageList from '@/components/SmsMessageList';
import SystemStatus from '@/components/SystemStatus';
import { SmsProvider } from '@/contexts/SmsContext';

export default function HomePage() {
  return (
      <SmsProvider>
        <div className="min-h-screen bg-gray-50">
          <header className="bg-white shadow-sm border-b">
            <div className="max-w-4xl mx-auto px-4 py-6">
              <div className="flex justify-between items-start">
                <div>
                  <h1 className="text-2xl font-bold text-gray-900">
                    SMS 受信システム PoC
                  </h1>
                  <p className="mt-2 text-sm text-gray-600">
                    Twilio + Momento Cache を使用した SMS 受信・履歴管理システム
                  </p>
                </div>
                <SystemStatus />
              </div>
            </div>
          </header>

          <main className="max-w-4xl mx-auto px-4 py-8">
            <div className="bg-white rounded-lg shadow-sm border p-6">
              <SmsMessageList />
            </div>
          </main>

          <footer className="mt-16 bg-white border-t">
            <div className="max-w-4xl mx-auto px-4 py-4">
              <p className="text-center text-sm text-gray-500">
                SMS Receiver PoC - Powered by Next.js, Twilio & Momento
              </p>
            </div>
          </footer>
        </div>
      </SmsProvider>
  );
}

layout

src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
    title: 'SMS Receiver PoC',
    description: 'Twilio + Momento Cache を使用した SMS 受信システム',
};

export default function RootLayout({
    children,
}: {
    children: React.ReactNode;
}) {
    return (
        <html lang="ja">
            <body className={inter.className}>
                {children}
            </body>
        </html>
    );
}

ローカルでの事前確認

Webhook エンドポイントが正常に動作するかテストします。

.env.local ファイルを作成し、 Momento の API key と cache name をセットします。

MOMENTO_API_KEY=your_momento_api_key_here
MOMENTO_CACHE_NAME=twilio-sms

下記のコマンドでサーバーを起動します。

npm run dev

CURL コマンドを実行し、想定したレスポンスが得られることを確認します。

curl -X POST http://localhost:3000/api/sms \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "MessageSid=SM1234567890abcdef&From=%2B1234567890&To=%2B0987654321&Body=Test%20message&DateCreated=2024-01-20T10%3A30%3A00Z"

正常な場合、以下のレスポンスが返されます。

<?xml version="1.0" encoding="UTF-8"?><Response></Response>

Vercel へのデプロイ

プロジェクトを GitHub リポジトリに push し、 Vercel で Import します。

Vercel の Import ボタン

デプロイ完了後、環境変数を設定して Redeploy します。

環境変数

Name Value
MOMENTO_API_KEY 先ほど作成した Momento API キー
MOMENTO_CACHE_NAME twilio-sms

Redeploy

デプロイが完了すると、以下のような URL が発行されます。この URL を控えておきます。

https://sms-receiver-poc-xxxx.vercel.app

デプロイ後の動作確認

実際の SMS 送信前に、デプロイしたエンドポイントが正常に動作するかテストします。

curl -X POST https://your-vercel-app.vercel.app/api/sms \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "MessageSid=SM1234567890abcdef&From=%2B1234567890&To=%2B0987654321&Body=Test%20message&DateCreated=2024-01-20T10%3A30%3A00Z"

正常な場合、以下のレスポンスが返されます。

<?xml version="1.0" encoding="UTF-8"?><Response></Response>

Twilio コンソールでの設定

Phone Numbers > Manage > Active numbers で購入した番号をクリックし、Messaging Configuration セクションで以下を設定します。設定したら Save configuration をクリックするのを忘れないようにしてください。

  • A message comes in: Webhook
  • URL: https://sms-receiver-poc-xxxx.vercel.app/api/sms
  • HTTP: POST

Webhook 設定

フロントエンドの動作確認

  1. https://sms-receiver-poc-xxxx.vercel.app にアクセス
  2. 外部電話番号から Twilio 購入番号に SMS を送信
  3. フロントエンドに即座に SMS が表示されることを確認
    twilio-momento-sms-reciever-demo
  4. ページをリロードしても履歴が残ることを確認

まとめ

本記事では、Twilio、Momento Cache を組み合わせた SMS 受信システムを構築しました。Twilio の永続化機能の制約を Momento Cache の TTL 機能で補完し、Server-Sent Events によるリアルタイム通知を実現することで、SMS 受信から履歴管理まで一貫したシステムを構築できました。IoT デバイスからの通知システム、チャットアプリケーション、リアルタイムダッシュボードなど、様々なリアルタイム通信要件に応用可能です。

この記事をシェアする

facebookのロゴhatenaのロゴtwitterのロゴ

© Classmethod, Inc. All rights reserved.