はじめに
この記事は、ギークフィードアドベントカレンダー2024の23日目の記事です。
ギークフィードの有志が毎日ブログを投稿しているので是非チェックしてみてください。
今回は業務上Twilio FlexとLINEを連携する機能を実装する必要があったのですが、
実装方法を調べてもバージョン1系で利用可能だったこちらで発表されているようなTwilio Channelを使った実装方法しか見つけられなく、そちらは現在LINEのサポートを終了していたため途方に暮れてしまいました。
そんな中Twilioの方とコンタクトを取れ、Twilio Flexの開発を担当しているエンジニアの方から情報をお伺いすることができたのでそちらの実装方法をご紹介いたします。
結論
先に結論を書きます。
以下のプラグインを導入すると簡単にLINEとの連携を実装することができます。
こちらは私の検索の方法が悪かったのか、SEOが弱いのか検索しても見つけられなかったので本記事に同じ境遇のエンジニアの方が辿り着き役に立てていただければ幸いでございます。
プラグインに書いてある通り導入をしたら実装できるのですがそれではこの記事が味気ないかと思いますので簡単に私の方でカスタマイズした機能とその実装方法をご紹介いたします。
プラグインのカスタマイズ方法
プラグインをそのまま導入すると公式LINEにチャットが来た時点ですぐにオペレーターとのチャットが始まってしまいます。
しかし企業用のLINEではすぐに有人チャットに入るのではなくある程度システム的なチャットボットを入れてスクリーニングしたいと思うはずです。
こんなイメージですね。
こちらの機能を実装する方法についてご紹介いたします。
自動応答
プラグインでフォークしたソースコードの「serverless-functions/src/functions/api/line/incoming.ts」を変更する必要があります。
こちらはLINE MessageAPIのWebhookURLに設定してあるTwilio Functionsで動くソースコードなのでここを変更するとLINEから入ってきたメッセージに対してカスタマイズができます。
今回は、LINEのリッチメニューで「LINEで質問」というメニューとLINE Official Account Managerの設定でそのメニューを押したらユーザーに「LINEで質問」というメッセージを送らせるように設定しているので「LINEで質問」というメッセージが入ってきた時に上の画像のように自動応答をする機能をカスタマイズ開発していきます。
※今回はLINE リッチメニューの実装方法やLINE Official Account Managerの設定に関する説明は本題ではないので省略させていただきます。
フォークしたての頃はincoming.tsにはこのようになっているかと思います
49 50 51 52 53 54 55 56 57 58 |
// Step 2: Process Twilio Conversations // -- Handle Multiple Events Recieved in Webhook for (const msg of event.events) { // -- Process Each Event if (msg.source.userId && msg.message) { const userId = msg.source.userId; const message = msg.message; await wrappedSendToFlex(context, userId, message); } } |
このwrappedSendToFlex関数内でFlexオペレーターとの有人チャット機能を実装しているので「LINEで質問」というキーワードで入ってきた場合に自動応答チャットを実装してあげる必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
for (const msg of event.events) { if (msg.type === "postback") { await wrappedSendToLineResolver(context, msg.source.userId, msg); if (msg.postback.data === "98" || msg.postback.data === "99") { // 有人チャット開始、オペレーター側のタスクに入ってくるチャットの一言目を記述 await wrappedSendToFlex(context, msg.source.userId, { type: LINEMessageType.TEXT, text: msg.postback.data === "98" ? "紛失・盗難のお問い合わせ" : "いいえ、オペレーターとチャットで相談", } as EventMessage); } } else if (msg.type === "message" && msg.message.text === "LINEで質問") { await wrappedSendToLineResolver(context, msg.source.userId, msg); } else if (msg.source.userId && msg.message) { await wrappedSendToFlex(context, msg.source.userId, msg.message); } } |
あとはwrappedSendToLineResolverを実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
export const wrappedSendToLineResolver = async ( context: Context<LINETypes.LINEContext>, userId: string, msg: MessageEvent | PostbackEvent ) => { const resolvers = resolver[msg.type as keyof ResolverType]; if (resolvers) { const createMessages = resolvers[ msg.type === "message" ? (msg as MessageEvent).message.type === "text" ? ((msg as MessageEvent).message as TextMessage).text : "" : (msg as PostbackEvent).postback.data ](context); const clientConfig: ClientConfig = { channelAccessToken: context.LINE_CHANNEL_ACCESS_TOKEN, channelSecret: context.LINE_CHANNEL_SECRET, }; const lineClient = new Client(clientConfig); // LINE APIでメッセージを送信 for (const message of createMessages) { if (message) { await lineClient.pushMessage(userId, message); } } } else { throw new Error("No resolver found"); } }; type ResolverType = { message: { [key: string]: (context: Context) => any[]; }; postback: { [key: string]: (context: Context) => any[]; }; }; const resolver: ResolverType = { message: { LINEで質問: (context: Context) => [ { type: "template", altText: "よくあるお問い合わせ", template: { type: "buttons", title: "よくあるお問い合わせ", text: "本日はどのようなご相談でしょうか", actions: [ { type: "postback", label: "キャンペーンについて", data: "11", displayText: "キャンペーンについて", }, { type: "postback", label: "サービスについて", data: "12", displayText: "サービスについて", }, { type: "postback", label: "紛失・盗難", data: "13", displayText: "紛失・盗難", }, ], }, }, ], }, postback: { "00": (context: Context) => [ { type: "template", altText: "よくあるお問い合わせ", template: { type: "buttons", title: "よくあるお問い合わせ", text: "本日はどのようなご相談でしょうか", actions: [ { type: "postback", label: "キャンペーンについて", data: "11", displayText: "キャンペーンについて", }, { type: "postback", label: "サービスについて", data: "12", displayText: "サービスについて", }, { type: "postback", label: "紛失・盗難", data: "13", displayText: "紛失・盗難", }, ], }, }, ], 11: (context: Context) => [ { type: "template", altText: "キャンペーンについて", template: { type: "buttons", title: "キャンペーンについて", text: "現在実施中のキャンペーンは下記ページをご覧ください", actions: [ { type: "uri", label: "キャンペーンページ", uri: CAMPAIGN_URL, }, ], }, }, { type: "template", altText: "解決されましたでしょうか", template: { type: "buttons", title: "解決確認", text: "解決されましたでしょうか", actions: [ { type: "postback", label: "はい、よくあるお問い合わせに戻る", data: "00", displayText: "はい、よくあるお問い合わせに戻る", }, { type: "postback", label: "いいえ、オペレーターとチャットで相談", data: "99", displayText: "いいえ、オペレーターとチャットで相談", }, ], }, }, ], 12: (context: Context) => [ { type: "template", altText: "サービスについて", template: { type: "buttons", title: "サービスについて", text: "サービス内容について詳しく知りたい場合は下記ページをご覧ください", actions: [ { type: "uri", label: "サービスページ", uri: SERVICE_URL, }, ], }, }, { type: "template", altText: "解決されましたでしょうか", template: { type: "buttons", title: "解決確認", text: "解決されましたでしょうか", actions: [ { type: "postback", label: "はい、よくあるお問い合わせに戻る", data: "00", displayText: "はい、よくあるお問い合わせに戻る", }, { type: "postback", label: "いいえ、オペレーターとチャットで相談", data: "99", displayText: "いいえ、オペレーターとチャットで相談", }, ], }, }, ], 13: (context: Context) => [ { type: "text", text: "カードを紛失・盗難にあわれた場合、すぐに弊社の紛失・盗難専用窓口までご連絡ください。カードの停止手続きを行い、必要に応じて再発行の手続きをいたします。", }, { type: "template", altText: "オペレーターに連絡しますか?", template: { type: "buttons", title: "連絡確認", text: "オペレーターに連絡しますか?", actions: [ { type: "postback", label: "はい、オペレーターに連絡", data: "98", displayText: "紛失・盗難のお問い合わせ", }, { type: "postback", label: "いいえ、よくあるお問い合わせに戻る", data: "00", displayText: "いいえ、よくあるお問い合わせに戻る", }, ], }, }, ], 98: (context: Context) => [ { type: "text", text: "オペレーターを呼び出しております。\n今しばらくお待ちください", }, ], 99: (context: Context) => [ { type: "text", text: "オペレーターを呼び出しております。\n今しばらくお待ちください", }, ], }, }; |
こんな感じです。
ここまでカスタマイズしたら上で紹介したような自動応答メッセージを実装できます。
これで完成なのですが、今回Flexのチャットにattributesを設定したかったのでwrappedSendToFlex関数の方にもカスタマイズをしたのでそのご紹介もいたします。
attributesの設定方法
wrappedSendToFlex関数はline.helper.private.tsにあります
上で紹介したwrappedSendToLineResolver関数も私はline.helper.private.tsファイルに格納して実装いたしました。
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
export const wrappedSendToFlex = async ( context: Context, userId: string, message: EventMessage ) => { const client = context.getTwilioClient(); // Step 1: Check for any existing conversation. If doesn't exist, create a new conversation -> add participant -> add webhooks const clientConfig: ClientConfig = { channelAccessToken: context.LINE_CHANNEL_ACCESS_TOKEN, channelSecret: context.LINE_CHANNEL_SECRET, }; const lineClient = new Client(clientConfig); const userProfile = await lineClient.getProfile(userId); const identity = `line: ${userProfile.displayName}`; const attributes = { type: "line", userId: userId, userName: userProfile.displayName, }; console.log(identity); let { conversationSid, chatServiceSid } = await twilioFindExistingConversation(client, identity); console.log(`Old Convo ID: ${conversationSid}`); console.log(`[Via Existing] Chat Service ID: ${chatServiceSid}`); if (!conversationSid) { // -- Create Conversation const createConversationResult = await twilioCreateConversation( "LINE", client, userId, {} ); conversationSid = createConversationResult.conversationSid; chatServiceSid = createConversationResult.chatServiceSid; // -- Add Participant into Conversation await twilioCreateParticipant( client, conversationSid, identity, attributes ); // -- Create Webhook (Conversation Scoped) for Studio await twilioCreateScopedWebhookStudio( client, conversationSid, context.LINE_STUDIO_FLOW_SID ); // -- Create Webhook (Conversation Scoped) for Outgoing Conversation (Flex to LINE) let domainName = context.DOMAIN_NAME; if ( context.DOMAIN_NAME_OVERRIDE && context.DOMAIN_NAME_OVERRIDE !== "" ) { domainName = context.DOMAIN_NAME_OVERRIDE; } await twilioCreateScopedWebhook( client, conversationSid, userId, domainName, "api/line/outgoing" ); } console.log("Message type is: ", message.type); // Step 2: Add Message to Conversation // -- Process Message Type if (message.type === LINEMessageType.TEXT) { // -- Message Type: text await twilioCreateMessage( client, conversationSid, identity, (message as TextMessage).text, null, attributes ); } else if ( message.type === LINEMessageType.IMAGE || message.type === LINEMessageType.VIDEO ) { // -- Message Type: image, video console.log("--- Message Type: Media (Verbose) ---"); console.log(`Content Provider Type: ${message.contentProvider.type}`); if (chatServiceSid == undefined) { console.log("Chat Service SID is undefined"); return; } const downloadFile = await lineGetMessageContent(context, message.id); const data = downloadFile.body; const fileType = downloadFile.headers.get("content-type"); if (fileType == undefined) { console.log("File Type is undefined"); return; } console.log(`Incoming File Type (from HTTP Header): ${fileType}`); console.log("Uploading to Twilio MCS..."); let uploadMCSResult = await twilioUploadMediaResource( { accountSid: context.ACCOUNT_SID, authToken: context.AUTH_TOKEN }, chatServiceSid, fileType, data, "file" ); if (!uploadMCSResult.sid) { return false; } console.log(`Uploaded Twilio Media SID: ${uploadMCSResult.sid}`); await twilioCreateMessage( client, conversationSid, identity, "file", uploadMCSResult.sid ); } }; |
こんな感じですね。
合わせて「serverless-functions/src/functions/api/common/common.helper.private.ts」のtwilioCreateMessage関数なども以下の様に変更を加える必要があります
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
export const twilioCreateMessage = async ( client: twilio.Twilio, conversationSid: string, author: string, body: string, mediaSid: string | null = null, attributes?: Object ) => { try { let result; if (!mediaSid) { result = await client.conversations .conversations(conversationSid) .messages.create({ author: author, body: body, xTwilioWebhookEnabled: "true", attributes: JSON.stringify(attributes), }); } else { result = await client.conversations .conversations(conversationSid) .messages.create({ author: author, body: body, mediaSid: mediaSid, xTwilioWebhookEnabled: "true", attributes: JSON.stringify(attributes), }); } if (result.sid) { return result.sid; } else { return false; } } catch (err) { console.log(err); return false; } }; /* * Twilio - Create Participant in Conversations */ export const twilioCreateParticipant = async ( client: twilio.Twilio, conversationSid: string, identity: string, attributes?: Object ) => { try { const result = await client.conversations .conversations(conversationSid) .participants.create({ identity: identity, attributes: JSON.stringify(attributes), }); if (result.sid) { return result.sid; } else { return false; } } catch (err) { console.log(err); return false; } };/* * Twilio - Create Conversation */ export const twilioCreateConversation = async ( adapter: string, client: twilio.Twilio, userId: string, pre_engagement_attributes: any = {} ) => { const result = await client.conversations.v1.conversations.create({ friendlyName: `${adapter} Conversation ${userId}`, attributes: JSON.stringify({ pre_engagement_data: pre_engagement_attributes, }), }); if (result.sid) { return { conversationSid: result.sid, chatServiceSid: result.chatServiceSid, }; } else { throw new Error("Could not create new conversation"); } }; |
ここまで追っていたらフォークしたソースコードにどのように処理を追加することでカスタマイズができるのかわかる様になったかと思います。
反映にはGithubリポジトリにpushしプラグインに紹介されている通りGithub Actionsを動かすことで反映することができます。
最後に
この記事がどなたかの参考になったら幸いでございます。
他にも技術ブログをあげているのでそちらもよろしければ見ていってださい。
また、株式会社ギークフィードでは開発エンジニアなどの職種で一緒に働く仲間を募集しています。
弊社に興味を持っていただいたり、会社のことをカジュアルに聞いてみたいという場合でも、ご気軽にフォームからお問い合わせください。その場合はコメント欄に、カジュアルにお話したいです、と記載ください!
- Twilio Flex v2.x.x系でLINE連携を実装する方法 - 2024-12-23
- AWS Bedrockを活用したAI生成テキスト評価と再生成の実装技法 - 2024-06-17
- AWSから公開されたJavaScriptランタイム「LLRT」を使ったLambdaをAWS CDKで構築する方法 - 2024-02-19
- 【Bun】JavaScriptでシェルスクリプトを書けると噂のBun Shell使ってみた - 2024-02-08
- 【Next.js】Next.js 14をAmplify V6でデプロイ・ホスティングする方法【Amplify】 - 2024-02-06