From 698c828b9d3542fe8e1bb5aeca077ffe702a858c Mon Sep 17 00:00:00 2001 From: MrOrz Date: Mon, 6 Nov 2023 02:12:35 +0800 Subject: [PATCH 1/5] refacto(webhook): simplify text handlers as well --- src/webhook/handleInput.ts | 119 ++++++++++++++++++------------------- src/webhook/index.ts | 18 +++--- 2 files changed, 69 insertions(+), 68 deletions(-) diff --git a/src/webhook/handleInput.ts b/src/webhook/handleInput.ts index 5151e328..6ae4d4b7 100644 --- a/src/webhook/handleInput.ts +++ b/src/webhook/handleInput.ts @@ -4,104 +4,103 @@ import { extractArticleId } from 'src/lib/sharedUtils'; import { TUTORIAL_STEPS } from './handlers/tutorial'; import handlePostback from './handlePostback'; import { - ChatbotEvent, ChatbotState, ChatbotStateHandlerParams, ChatbotStateHandlerReturnType, } from 'src/types/chatbotState'; import { Result } from 'src/types/result'; -import { Message } from '@line/bot-sdk'; +import { Message, MessageEvent, TextEventMessage } from '@line/bot-sdk'; /** * Given input event and context, outputs the new context and the reply to emit. * - * @param {Object} context The current context of the bot - * @param {*} event The input event - * @param {*} userId LINE user ID that does the input + * @param context The current context of the bot + * @param event The input event + * @param userId LINE user ID that does the input */ export default async function handleInput( { data = {} }, - event: ChatbotEvent, + event: MessageEvent & { message: TextEventMessage }, userId: string ): Promise { let state: ChatbotState; - let replies: Message[] = []; + const replies: Message[] = []; - if (event.input === undefined) { - throw new Error('input undefined'); - } - - if (event.type === 'message') { - // Trim input because these may come from other chatbot + // Trim input because these may come from other chatbot + // + const trimmedInput = event.message.text.trim(); + const articleId = extractArticleId(trimmedInput); + if (articleId) { + // Start new session, reroute to CHOOSING_ARTILCE and simulate "choose article" postback event + const sessionId = Date.now(); + return await handlePostback( + // Start a new session + { + sessionId, + searchedText: '', + }, + { + state: 'CHOOSING_ARTICLE', + sessionId, + input: articleId, + }, + userId + ); + } else if (event.message.text === TUTORIAL_STEPS['RICH_MENU']) { + // Start new session, reroute to TUTORIAL + const sessionId = Date.now(); + return await handlePostback( + { sessionId, searchedText: '' }, + { + state: 'TUTORIAL', + sessionId, + input: TUTORIAL_STEPS['RICH_MENU'], + }, + userId + ); + } else { + // The user forwarded us an new message. + // Create a new "search session". // - const trimmedInput = event.input.trim(); - const articleId = extractArticleId(trimmedInput); - if (articleId) { - // Start new session, reroute to CHOOSING_ARTILCE and simulate "choose article" postback event - const sessionId = Date.now(); - return await handlePostback( - { sessionId, searchedText: '' }, - { - state: 'CHOOSING_ARTICLE', - sessionId, - input: articleId, - }, - userId - ); - } else if (event.input === TUTORIAL_STEPS['RICH_MENU']) { - // Start new session, reroute to TUTORIAL - const sessionId = Date.now(); - return await handlePostback( - { sessionId, searchedText: '' }, - { - state: 'TUTORIAL', - sessionId, - input: event.input, - }, - userId - ); - } else { - // The user forwarded us an new message. - // Create a new "search session". + data = { + // Used to determine button postbacks and GraphQL requests are from + // previous sessions // - data = { - // Used to determine button postbacks and GraphQL requests are from - // previous sessions - // - sessionId: Date.now(), - }; - state = '__INIT__'; - } - } else { - state = 'Error'; + sessionId: Date.now(), + }; + state = '__INIT__'; } - let params: ChatbotStateHandlerParams | ChatbotStateHandlerReturnType = { + const params: ChatbotStateHandlerParams = { data, state, - event, + event: { + ...event, + message: event.message, + input: trimmedInput, + }, userId, replies, }; + let result: ChatbotStateHandlerReturnType; + // Sets data and replies // switch (params.state) { case '__INIT__': { - params = await initState(params); + result = await initState(params); break; } default: { - params = defaultState(params); + result = defaultState(params); break; } } - ({ data, replies } = params); - return { - context: { data }, - replies, + context: { data: result.data }, + replies: result.replies, }; } diff --git a/src/webhook/index.ts b/src/webhook/index.ts index ff0d3e85..c212b19d 100644 --- a/src/webhook/index.ts +++ b/src/webhook/index.ts @@ -18,13 +18,9 @@ import { import processMedia from './handlers/processMedia'; import UserSettings from '../database/models/userSettings'; import { Request } from 'koa'; -import { WebhookEvent } from '@line/bot-sdk'; +import { MessageEvent, TextEventMessage, WebhookEvent } from '@line/bot-sdk'; import { Result } from 'src/types/result'; -import { - ChatbotEvent, - Context, - PostbackActionData, -} from 'src/types/chatbotState'; +import { Context, PostbackActionData } from 'src/types/chatbotState'; const userIdBlacklist = (process.env.USERID_BLACKLIST || '').split(','); @@ -133,7 +129,13 @@ const singleUserHandler = async ( result = await processText( context, - { ...webhookEvent, input }, + // Make TS happy: + // Directly providing `webhookEvent` here can lead to type error + // because it cannot correctly narrow down webhookEvent.message to be TextEventMessage. + { + ...webhookEvent, + message: webhookEvent.message, + }, userId, req ); @@ -224,7 +226,7 @@ const singleUserHandler = async ( async function processText( context: { data: Partial }, - event: ChatbotEvent, + event: MessageEvent & { message: TextEventMessage }, userId: string, req: Request ): Promise { From d8dd7a127ca45a336e4539369c83dac7f38e435d Mon Sep 17 00:00:00 2001 From: MrOrz Date: Mon, 13 Nov 2023 00:47:46 +0800 Subject: [PATCH 2/5] refactor(webhook): simplify signature of initState and handleInput --- ...andleInput.test.js => handleInput.test.ts} | 175 +++++----- src/webhook/handleInput.ts | 54 +-- .../{initState.test.js => initState.test.ts} | 318 ++++++++---------- src/webhook/handlers/initState.ts | 40 +-- src/webhook/index.ts | 9 +- 5 files changed, 241 insertions(+), 355 deletions(-) rename src/webhook/__tests__/{handleInput.test.js => handleInput.test.ts} (55%) rename src/webhook/handlers/__tests__/{initState.test.js => initState.test.ts} (60%) diff --git a/src/webhook/__tests__/handleInput.test.js b/src/webhook/__tests__/handleInput.test.ts similarity index 55% rename from src/webhook/__tests__/handleInput.test.js rename to src/webhook/__tests__/handleInput.test.ts index 185ca571..12bb73ae 100644 --- a/src/webhook/__tests__/handleInput.test.js +++ b/src/webhook/__tests__/handleInput.test.ts @@ -1,14 +1,22 @@ import MockDate from 'mockdate'; -import initState from '../handlers/initState'; import handleInput from '../handleInput'; +import originalInitState from '../handlers/initState'; +import originalHandlePostback from '../handlePostback'; import { TUTORIAL_STEPS } from '../handlers/tutorial'; -import handlePostback from '../handlePostback'; import { VIEW_ARTICLE_PREFIX, getArticleURL } from 'src/lib/sharedUtils'; +import { MessageEvent, TextEventMessage, TextMessage } from '@line/bot-sdk'; jest.mock('../handlers/initState'); jest.mock('../handlePostback'); +const initState = originalInitState as jest.MockedFunction< + typeof originalInitState +>; +const handlePostback = originalHandlePostback as jest.MockedFunction< + typeof originalHandlePostback +>; + // Original session ID in context const FIXED_DATE = 612964800000; @@ -25,32 +33,39 @@ afterEach(() => { MockDate.reset(); }); -it('rejects undefined input', () => { - const data = {}; - const event = {}; - - return expect(handleInput(data, event)).rejects.toMatchInlineSnapshot( - `[Error: input undefined]` - ); -}); - -it('shows reply list when VIEW_ARTICLE_PREFIX is sent', async () => { - const context = { - data: { sessionId: FIXED_DATE }, - }; - const event = { +function createTextMessageEvent( + input: string +): MessageEvent & { message: Pick } { + return { type: 'message', - input: `${VIEW_ARTICLE_PREFIX}${getArticleURL('article-id')}`, + message: { + id: '', + type: 'text', + text: input, + }, + mode: 'active', + timestamp: 0, + source: { + type: 'user', + userId: '', + }, + replyToken: '', }; +} + +it('shows reply list when VIEW_ARTICLE_PREFIX is sent', async () => { + const event = createTextMessageEvent( + `${VIEW_ARTICLE_PREFIX}${getArticleURL('article-id')}` + ); handlePostback.mockImplementationOnce((data) => { return Promise.resolve({ context: { data }, - replies: 'Foo replies', + replies: [], }); }); - await expect(handleInput(context, event)).resolves.toMatchInlineSnapshot(` + await expect(handleInput(event, 'user-id')).resolves.toMatchInlineSnapshot(` Object { "context": Object { "data": Object { @@ -58,7 +73,7 @@ it('shows reply list when VIEW_ARTICLE_PREFIX is sent', async () => { "sessionId": 1561982400000, }, }, - "replies": "Foo replies", + "replies": Array [], } `); @@ -75,29 +90,25 @@ it('shows reply list when VIEW_ARTICLE_PREFIX is sent', async () => { "sessionId": 1561982400000, "state": "CHOOSING_ARTICLE", }, - undefined, + "user-id", ], ] `); }); it('shows reply list when article URL is sent', async () => { - const context = { - data: { sessionId: FIXED_DATE }, - }; - const event = { - type: 'message', - input: getArticleURL('article-id') + ' \n ' /* simulate manual input */, - }; + const event = createTextMessageEvent( + getArticleURL('article-id') + ' \n ' /* simulate manual input */ + ); handlePostback.mockImplementationOnce((data) => { return Promise.resolve({ context: { data }, - replies: 'Foo replies', + replies: [], }); }); - await expect(handleInput(context, event)).resolves.toMatchInlineSnapshot(` + await expect(handleInput(event, 'user-id')).resolves.toMatchInlineSnapshot(` Object { "context": Object { "data": Object { @@ -105,7 +116,7 @@ it('shows reply list when article URL is sent', async () => { "sessionId": 1561982400000, }, }, - "replies": "Foo replies", + "replies": Array [], } `); @@ -122,37 +133,32 @@ it('shows reply list when article URL is sent', async () => { "sessionId": 1561982400000, "state": "CHOOSING_ARTICLE", }, - undefined, + "user-id", ], ] `); }); it('Resets session on free-form input, triggers fast-forward', async () => { - const context = { - data: { sessionId: FIXED_DATE }, - }; - const event = { - type: 'message', - input: 'Newly forwarded message', - }; + const input = 'Newly forwarded message'; + const event = createTextMessageEvent(input); - // eslint-disable-next-line no-unused-vars - initState.mockImplementationOnce(({ data, event, userId, replies }) => { + initState.mockImplementationOnce(({ data }) => { return Promise.resolve({ data, - replies: 'Foo replies', + replies: [], }); }); - await expect(handleInput(context, event)).resolves.toMatchInlineSnapshot(` + await expect(handleInput(event, 'user-id')).resolves.toMatchInlineSnapshot(` Object { "context": Object { "data": Object { + "searchedText": "Newly forwarded message", "sessionId": 1561982400000, }, }, - "replies": "Foo replies", + "replies": Array [], } `); @@ -162,80 +168,53 @@ it('Resets session on free-form input, triggers fast-forward', async () => { Array [ Object { "data": Object { + "searchedText": "Newly forwarded message", "sessionId": 1561982400000, }, "event": Object { - "input": "Newly forwarded message", + "message": Object { + "id": "", + "text": "Newly forwarded message", + "type": "text", + }, + "mode": "active", + "replyToken": "", + "source": Object { + "type": "user", + "userId": "", + }, + "timestamp": 0, "type": "message", }, - "replies": Array [], - "state": "__INIT__", - "userId": undefined, + "userId": "user-id", }, ], ] `); }); -describe('defaultState', () => { - it('handles wrong event type', async () => { - const context = { - data: { sessionId: FIXED_DATE }, - }; - const event = { - type: 'follow', - input: '', - }; - - await expect(handleInput(context, event)).resolves.toMatchInlineSnapshot(` - Object { - "context": Object { - "data": Object { - "sessionId": 612964800000, - }, - }, - "replies": Array [ - Object { - "text": "我們看不懂 QQ - 大俠請重新來過。", - "type": "text", - }, - ], - } - `); - - expect(initState).not.toHaveBeenCalled(); - }); -}); - describe('tutorial', () => { it('handles tutorial trigger from rich menu', async () => { - const context = { - data: { sessionId: FIXED_DATE }, - }; - const event = { - type: 'message', - input: TUTORIAL_STEPS['RICH_MENU'], - }; + const event = createTextMessageEvent(TUTORIAL_STEPS['RICH_MENU']); handlePostback.mockImplementationOnce((data) => { return Promise.resolve({ context: { data }, - replies: 'Foo replies', + replies: [], }); }); - await expect(handleInput(context, event)).resolves.toMatchInlineSnapshot(` - Object { - "context": Object { - "data": Object { - "searchedText": "", - "sessionId": 1561982400000, - }, - }, - "replies": "Foo replies", - } - `); + await expect(handleInput(event, 'user-id')).resolves.toMatchInlineSnapshot(` + Object { + "context": Object { + "data": Object { + "searchedText": "", + "sessionId": 1561982400000, + }, + }, + "replies": Array [], + } + `); expect(handlePostback).toHaveBeenCalledTimes(1); }); diff --git a/src/webhook/handleInput.ts b/src/webhook/handleInput.ts index 6ae4d4b7..201fdba4 100644 --- a/src/webhook/handleInput.ts +++ b/src/webhook/handleInput.ts @@ -1,15 +1,9 @@ import initState from './handlers/initState'; -import defaultState from './handlers/defaultState'; import { extractArticleId } from 'src/lib/sharedUtils'; import { TUTORIAL_STEPS } from './handlers/tutorial'; import handlePostback from './handlePostback'; -import { - ChatbotState, - ChatbotStateHandlerParams, - ChatbotStateHandlerReturnType, -} from 'src/types/chatbotState'; import { Result } from 'src/types/result'; -import { Message, MessageEvent, TextEventMessage } from '@line/bot-sdk'; +import { MessageEvent, TextEventMessage } from '@line/bot-sdk'; /** * Given input event and context, outputs the new context and the reply to emit. @@ -19,13 +13,9 @@ import { Message, MessageEvent, TextEventMessage } from '@line/bot-sdk'; * @param userId LINE user ID that does the input */ export default async function handleInput( - { data = {} }, event: MessageEvent & { message: TextEventMessage }, userId: string ): Promise { - let state: ChatbotState; - const replies: Message[] = []; - // Trim input because these may come from other chatbot // const trimmedInput = event.message.text.trim(); @@ -58,46 +48,22 @@ export default async function handleInput( }, userId ); - } else { - // The user forwarded us an new message. - // Create a new "search session". - // - data = { + } + // The user forwarded us an new message. + // + const result = await initState({ + data: { + // Create a new "search session". // Used to determine button postbacks and GraphQL requests are from // previous sessions // sessionId: Date.now(), - }; - state = '__INIT__'; - } - const params: ChatbotStateHandlerParams = { - data, - state, - event: { - ...event, - message: event.message, - input: trimmedInput, + // Store user input into context + searchedText: trimmedInput, }, userId, - replies, - }; - - let result: ChatbotStateHandlerReturnType; - - // Sets data and replies - // - switch (params.state) { - case '__INIT__': { - result = await initState(params); - break; - } - - default: { - result = defaultState(params); - break; - } - } + }); return { context: { data: result.data }, diff --git a/src/webhook/handlers/__tests__/initState.test.js b/src/webhook/handlers/__tests__/initState.test.ts similarity index 60% rename from src/webhook/handlers/__tests__/initState.test.js rename to src/webhook/handlers/__tests__/initState.test.ts index 971f1b97..481b1fb6 100644 --- a/src/webhook/handlers/__tests__/initState.test.js +++ b/src/webhook/handlers/__tests__/initState.test.ts @@ -6,9 +6,19 @@ import MockDate from 'mockdate'; import initState from '../initState'; import * as apiListArticleResult from '../__fixtures__/initState'; import * as apiGetArticleResult from '../__fixtures__/choosingArticle'; -import gql from 'src/lib/gql'; -import ga from 'src/lib/ga'; -import detectDialogflowIntent from 'src/lib/detectDialogflowIntent'; +import type { MockedGql } from 'src/lib/__mocks__/gql'; +import originalGql from 'src/lib/gql'; +import type { MockedGa } from 'src/lib/__mocks__/ga'; +import originalGa from 'src/lib/ga'; +import originalDetectDialogflowIntent from 'src/lib/detectDialogflowIntent'; +import { FlexMessage } from '@line/bot-sdk'; + +const gql = originalGql as MockedGql; +const ga = originalGa as MockedGa; +const detectDialogflowIntent = + originalDetectDialogflowIntent as jest.MockedFunction< + typeof originalDetectDialogflowIntent + >; beforeEach(() => { ga.clearAllMocks(); @@ -19,26 +29,16 @@ beforeEach(() => { it('article found', async () => { gql.__push(apiListArticleResult.longArticle); - const input = { - data: { - sessionId: 1497994017447, - }, - event: { - type: 'message', - input: - '計程車上有裝悠遊卡感應器,老人悠悠卡可以享受優惠部分由政府補助,不影響司機收入', - timestamp: 1497994016356, - message: { - type: 'text', - id: '6270464463537', - text: '計程車上有裝悠遊卡感應器,老人悠悠卡可以享受優惠部分由政府補助,不影響司機收入', + expect( + await initState({ + data: { + sessionId: 1497994017447, + searchedText: + '計程車上有裝悠遊卡感應器,老人悠悠卡可以享受優惠部分由政府補助,不影響司機收入', }, - }, - userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', - replies: undefined, - }; - - expect(await initState(input)).toMatchSnapshot(); + userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', + }) + ).toMatchSnapshot(); expect(gql.__finished()).toBe(true); expect(ga.eventMock.mock.calls).toMatchInlineSnapshot(` Array [ @@ -72,29 +72,31 @@ it('article found', async () => { it('long article replies still below flex message limit', async () => { gql.__push(apiListArticleResult.twelveLongArticles); - const input = { + const result = await initState({ data: { sessionId: 1502477506267, - }, - event: { - type: 'message', - input: + searchedText: '這樣的大事國內媒體竟然不敢報導!\n我國駐日代表將原「中華民國」申請更名為「台灣」結果被日本裁罰,須繳納7000萬日圓(合約台幣2100萬元)高額稅賦(轉載中時電子報)\n\n我駐日代表謝長廷將原「中華民國」申請更名為「台灣」,自認得意之時,結果遭自認友好日本國給出賣了,必須繳納7000萬日圓(合約台幣2100萬元)高額稅賦...民進黨沒想到如此更名竟然是這樣的下場:被他最信任也最友好的日本政府給坑了。\n果然錯誤的政策比貪污可怕,2100萬就這樣打水漂了,還要資助九州水患,核四停建違約賠償金.......夠全國軍公教退休2次.........\n\nhttp://www.chinatimes.com/newspapers/20170617000318-260118', - timestamp: 1502477505309, - message: { - type: 'text', - id: '6530038889933', - text: '這樣的大事國內媒體竟然不敢報導!\n我國駐日代表將原「中華民國」申請更名為「台灣」結果被日本裁罰,須繳納7000萬日圓(合約台幣2100萬元)高額稅賦(轉載中時電子報)\n\n我駐日代表謝長廷將原「中華民國」申請更名為「台灣」,自認得意之時,結果遭自認友好日本國給出賣了,必須繳納7000萬日圓(合約台幣2100萬元)高額稅賦...民進黨沒想到如此更名竟然是這樣的下場:被他最信任也最友好的日本政府給坑了。\n果然錯誤的政策比貪污可怕,2100萬就這樣打水漂了,還要資助九州水患,核四停建違約賠償金.......夠全國軍公教退休2次.........\n\nhttp://www.chinatimes.com/newspapers/20170617000318-260118', - }, }, userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', - }; - - const result = await initState(input); + }); expect(gql.__finished()).toBe(true); expect(result.replies.length).toBeLessThanOrEqual(5); // Reply message API limit - const carousel = result.replies.find(({ type }) => type === 'flex').contents; - expect(carousel.type).toBe('carousel'); + const flexReply = result.replies.find( + (reply): reply is FlexMessage => reply.type === 'flex' + ); + + // Make TS happy + /* istanbul ignore if */ + if (!flexReply) throw new Error('No flex reply in replies'); + + const carousel = flexReply.contents; + + // Make TS happy + /* istanbul ignore if */ + if (carousel.type !== 'carousel') + throw new Error('Flex reply content is not carousel'); + expect(carousel.contents.length).toBeLessThanOrEqual(10); // Flex message carousel 10 bubble limit expect(JSON.stringify(carousel).length).toBeLessThan(50 * 1000); // Flex message carousel 50K limit }); @@ -102,25 +104,15 @@ it('long article replies still below flex message limit', async () => { it('articles found with high similarity', async () => { gql.__push(apiListArticleResult.twoShortArticles); - const input = { - data: { - sessionId: 1497994017447, - }, - event: { - type: 'message', - input: 'YouTube · 寻找健康人生', - timestamp: 1497994016356, - message: { - type: 'text', - id: '6270464463537', - text: 'YouTube · 寻找健康人生', + expect( + await initState({ + data: { + sessionId: 1497994017447, + searchedText: 'YouTube · 寻找健康人生', }, - }, - userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', - replies: undefined, - }; - - expect(await initState(input)).toMatchSnapshot(); + userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', + }) + ).toMatchSnapshot(); expect(gql.__finished()).toBe(true); expect(ga.eventMock.mock.calls).toMatchInlineSnapshot(` Array [ @@ -163,25 +155,15 @@ it('only one article found with high similarity and choose for user', async () = gql.__push(apiListArticleResult.shortArticle); gql.__push(apiGetArticleResult.shortArticle); - const input = { - data: { - sessionId: 1497994017447, - }, - event: { - type: 'message', - input: 'YouTube · 寻找健康人生', - timestamp: 1497994016356, - message: { - type: 'text', - id: '6270464463537', - text: 'YouTube · 寻找健康人生', + expect( + await initState({ + data: { + sessionId: 1497994017447, + searchedText: 'YouTube · 寻找健康人生', }, - }, - userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', - replies: undefined, - }; - - expect(await initState(input)).toMatchSnapshot(); + userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', + }) + ).toMatchSnapshot(); expect(gql.__finished()).toBe(true); expect(ga.eventMock.mock.calls).toMatchInlineSnapshot(` @@ -230,25 +212,15 @@ it('only one article found with high similarity and choose for user', async () = it('should handle message matches only hyperlinks', async () => { gql.__push(apiListArticleResult.hyperlinksArticles); - const input = { - data: { - sessionId: 1497994017447, - }, - event: { - type: 'message', - input: 'YouTube · 寻找健康人生', - timestamp: 1497994016356, - message: { - type: 'text', - id: '6270464463537', - text: 'YouTube · 寻找健康人生', + expect( + await initState({ + data: { + sessionId: 1497994017447, + searchedText: 'YouTube · 寻找健康人生', }, - }, - userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', - replies: undefined, - }; - - expect(await initState(input)).toMatchSnapshot(); + userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', + }) + ).toMatchSnapshot(); expect(gql.__finished()).toBe(true); expect(ga.eventMock.mock.calls).toMatchInlineSnapshot(` Array [ @@ -290,27 +262,17 @@ it('should handle message matches only hyperlinks', async () => { it('should handle text not found', async () => { gql.__push(apiListArticleResult.notFound); - const input = { - data: { - sessionId: 1497994017447, - }, - event: { - type: 'message', - input: - 'YouTube · 寻找健康人生 驚!大批香蕉受到愛滋血污染!這種香蕉千萬不要吃!吃到可能會被 ...', - timestamp: 1497994016356, - message: { - type: 'text', - id: '6270464463537', - text: 'YouTube · 寻找健康人生 驚!大批香蕉受到愛滋血污染!這種香蕉千萬不要吃!吃到可能會被 ...', - }, - }, - userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', - replies: undefined, - }; - MockDate.set('2020-01-01'); - expect(await initState(input)).toMatchSnapshot(); + expect( + await initState({ + data: { + sessionId: 1497994017447, + searchedText: + 'YouTube · 寻找健康人生 驚!大批香蕉受到愛滋血污染!這種香蕉千萬不要吃!吃到可能會被 ...', + }, + userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', + }) + ).toMatchSnapshot(); MockDate.reset(); expect(gql.__finished()).toBe(true); @@ -338,32 +300,26 @@ it('should handle text not found', async () => { describe('input matches dialogflow intent', () => { it('uses dialogflow response when input length < 10', async () => { gql.__push(apiListArticleResult.notFound); - detectDialogflowIntent.mockImplementationOnce(() => ({ - queryResult: { - fulfillmentText: '歡迎光臨', - intent: { - displayName: 'Welcome', + detectDialogflowIntent.mockImplementationOnce(() => + Promise.resolve({ + queryResult: { + fulfillmentText: '歡迎光臨', + intent: { + displayName: 'Welcome', + }, }, - }, - })); - const input = { - data: { - sessionId: 1497994017447, - }, - event: { - type: 'message', - input: '你好', - timestamp: 1497994016356, - message: { - type: 'text', - id: '6270464463537', - text: '你好', + }) + ); + + expect( + await initState({ + data: { + sessionId: 1497994017447, + searchedText: '你好', }, - }, - userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', - replies: undefined, - }; - expect(await initState(input)).toMatchSnapshot(); + userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', + }) + ).toMatchSnapshot(); expect(gql.__finished()).toBe(false); expect(detectDialogflowIntent).toHaveBeenCalledTimes(1); @@ -390,33 +346,27 @@ describe('input matches dialogflow intent', () => { it('uses dialogflow response when input length > 10 and intentDetectionConfidence = 1', async () => { gql.__push(apiListArticleResult.notFound); - detectDialogflowIntent.mockImplementationOnce(() => ({ - queryResult: { - fulfillmentText: '歡迎光臨', - intent: { - displayName: 'Welcome', + detectDialogflowIntent.mockImplementationOnce(() => + Promise.resolve({ + queryResult: { + fulfillmentText: '歡迎光臨', + intent: { + displayName: 'Welcome', + }, + intentDetectionConfidence: 1.0, }, - intentDetectionConfidence: 1.0, - }, - })); - const input = { - data: { - sessionId: 1497994017447, - }, - event: { - type: 'message', - input: '零一二三四五六七八九十', - timestamp: 1497994016356, - message: { - type: 'text', - id: '6270464463537', - text: '零一二三四五六七八九十', + }) + ); + + expect( + await initState({ + data: { + sessionId: 1497994017447, + searchedText: '零一二三四五六七八九十', }, - }, - userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', - replies: undefined, - }; - expect(await initState(input)).toMatchSnapshot(); + userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', + }) + ).toMatchSnapshot(); expect(gql.__finished()).toBe(false); expect(detectDialogflowIntent).toHaveBeenCalledTimes(1); @@ -443,34 +393,28 @@ describe('input matches dialogflow intent', () => { it('search article when input length > 10 and intentDetectionConfidence != 1', async () => { gql.__push(apiListArticleResult.notFound); - detectDialogflowIntent.mockImplementationOnce(() => ({ - queryResult: { - fulfillmentText: '歡迎光臨', - intent: { - displayName: 'Welcome', - }, - intentDetectionConfidence: 0.87, - }, - })); - const input = { - data: { - sessionId: 1497994017447, - }, - event: { - type: 'message', - input: '零一二三四五六七八九十', - timestamp: 1497994016356, - message: { - type: 'text', - id: '6270464463537', - text: '零一二三四五六七八九十', + detectDialogflowIntent.mockImplementationOnce(() => + Promise.resolve({ + queryResult: { + fulfillmentText: '歡迎光臨', + intent: { + displayName: 'Welcome', + }, + intentDetectionConfidence: 0.87, }, - }, - userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', - replies: undefined, - }; + }) + ); + MockDate.set('2020-01-01'); - expect(await initState(input)).toMatchSnapshot(); + expect( + await initState({ + data: { + sessionId: 1497994017447, + searchedText: '零一二三四五六七八九十', + }, + userId: 'Uc76d8ae9ccd1ada4f06c4e1515d46466', + }) + ).toMatchSnapshot(); MockDate.reset(); expect(gql.__finished()).toBe(true); expect(detectDialogflowIntent).toHaveBeenCalledTimes(1); diff --git a/src/webhook/handlers/initState.ts b/src/webhook/handlers/initState.ts index f0fec184..513824b1 100644 --- a/src/webhook/handlers/initState.ts +++ b/src/webhook/handlers/initState.ts @@ -7,7 +7,10 @@ import { Message, TextMessage, } from '@line/bot-sdk'; -import type { ChatbotStateHandler, Context } from 'src/types/chatbotState'; +import type { + ChatbotStateHandlerReturnType, + Context, +} from 'src/types/chatbotState'; import gql from 'src/lib/gql'; import { createPostbackAction, @@ -27,28 +30,27 @@ import { const SIMILARITY_THRESHOLD = 0.95; -const initState: ChatbotStateHandler = async ({ - data: inputData, +const initState = async ({ + data, userId, - event, -}) => { +}: { + // Context initiated by text search + data: Context & { searchedText: string }; + userId: string; +}): Promise => { const state = '__INIT__'; let replies: Message[] = []; + const input = data.searchedText; + // Track text message type send by user - const visitor = ga(userId, state, event.input); + const visitor = ga(userId, state, input); visitor.event({ ec: 'UserInput', ea: 'MessageType', - el: 'message' in event ? event.message.type : '', + el: 'text', }); - // Store user input into context - const data: Context = { - sessionId: inputData.sessionId, - searchedText: event.input, - }; - // send input to dialogflow before doing search // uses dialogflowResponse as reply only when there's a intent matched and // input.length <= 10 or input.length > 10 but intentDetectionConfidence == 1 @@ -57,7 +59,7 @@ const initState: ChatbotStateHandler = async ({ dialogflowResponse && dialogflowResponse.queryResult && dialogflowResponse.queryResult.intent && - (event.input.length <= 10 || + (data.searchedText.length <= 10 || dialogflowResponse.queryResult.intentDetectionConfidence == 1) ) { replies = [ @@ -72,7 +74,7 @@ const initState: ChatbotStateHandler = async ({ el: dialogflowResponse.queryResult.intent.displayName ?? undefined, }); visitor.send(); - return { data, event, userId, replies }; + return { data, replies }; } // Search for articles @@ -102,10 +104,10 @@ const initState: ChatbotStateHandler = async ({ } } `({ - text: event.input, + text: data.searchedText, }); - const inputSummary = ellipsis(event.input, 12); + const inputSummary = ellipsis(data.searchedText, 12); if (ListArticles?.edges.length) { // Track if find similar Articles in DB. @@ -128,7 +130,7 @@ const initState: ChatbotStateHandler = async ({ // Remove spaces so that we count word's similarities only // (edge.node.text ?? '').replace(/\s/g, ''), - event.input.replace(/\s/g, '') + data.searchedText.replace(/\s/g, '') ), })) .sort((edge1, edge2) => edge2.similarity - edge1.similarity) @@ -352,7 +354,7 @@ const initState: ChatbotStateHandler = async ({ ]; } visitor.send(); - return { data, event, userId, replies }; + return { data, replies }; }; export default initState; diff --git a/src/webhook/index.ts b/src/webhook/index.ts index c212b19d..80956aff 100644 --- a/src/webhook/index.ts +++ b/src/webhook/index.ts @@ -116,19 +116,15 @@ const singleUserHandler = async ( // React to certain type of events // if (webhookEvent.type === 'message' && webhookEvent.message.type === 'text') { - // normalized "input" - const input = webhookEvent.message.text; - // Debugging: type 'RESET' to reset user's context and start all over. // - if (input === 'RESET') { + if (webhookEvent.message.text === 'RESET') { redis.del(userId); clearTimeout(timerId); return; } result = await processText( - context, // Make TS happy: // Directly providing `webhookEvent` here can lead to type error // because it cannot correctly narrow down webhookEvent.message to be TextEventMessage. @@ -225,14 +221,13 @@ const singleUserHandler = async ( }; async function processText( - context: { data: Partial }, event: MessageEvent & { message: TextEventMessage }, userId: string, req: Request ): Promise { let result: Result; try { - result = await handleInput(context, event, userId); + result = await handleInput(event, userId); if (!result.replies) { throw new Error( 'Returned replies is empty, please check processMessages() implementation.' From fd985f3a64c7aab5057f34d1eafe9dd08acbeb93 Mon Sep 17 00:00:00 2001 From: MrOrz Date: Mon, 13 Nov 2023 00:48:56 +0800 Subject: [PATCH 3/5] refactor(types): Remove generic state handler type and fix type --- src/types/chatbotState.ts | 54 ++------------------ src/webhook/__tests__/handlePostback.test.ts | 17 +++--- src/webhook/handlers/defaultState.ts | 3 +- 3 files changed, 15 insertions(+), 59 deletions(-) diff --git a/src/types/chatbotState.ts b/src/types/chatbotState.ts index 04d5602d..cd96d502 100644 --- a/src/types/chatbotState.ts +++ b/src/types/chatbotState.ts @@ -1,4 +1,4 @@ -import type { Message, MessageEvent, PostbackEvent } from '@line/bot-sdk'; +import type { Message, MessageEvent } from '@line/bot-sdk'; export type ChatbotState = | '__INIT__' @@ -9,38 +9,6 @@ export type ChatbotState = | 'ASKING_ARTICLE_SUBMISSION_CONSENT' | 'Error'; -/** - * Dummy event, used exclusively when calling handler from another handler - */ -type ServerChooseEvent = { - type: 'server_choose'; -}; - -/** - * Parameters that are added by handleInput. - * - * @todo: We should consider using value from authentic event instead of manually adding fields. - */ -type ArgumentedEventParams = { - /** - * The text in text message, or value from payload in actions. - */ - input: string; -}; - -export type ChatbotEvent = ( - | MessageEvent - | ServerChooseEvent - /** - * A special format of postback that Chatbot actually uses: postback + input (provided in `ArgumentedEventParams`) - * @FIXME Replace with original PostbackEvent and parse its action to support passing more thing than a string - */ - | { - type: 'postback'; - } -) & - ArgumentedEventParams; - export type Context = { /** Used to differientiate different search sessions (searched text or media) */ sessionId: number; @@ -58,27 +26,11 @@ export type Context = { } ); -export type ChatbotStateHandlerParams = { - /** Record is for empty object and it's the default parameter in handleInput and handlePostback */ - data: Context | Record; - state: ChatbotState; - event: ChatbotEvent; - userId: string; +export type ChatbotStateHandlerReturnType = { + data: Context; replies: Message[]; }; -export type ChatbotStateHandlerReturnType = Pick< - ChatbotStateHandlerParams, - 'data' | 'replies' ->; - -/** - * Generic handler type for function under src/webhook/handlers - */ -export type ChatbotStateHandler = ( - params: ChatbotStateHandlerParams -) => Promise; - /** * The data that postback action stores as JSON. * diff --git a/src/webhook/__tests__/handlePostback.test.ts b/src/webhook/__tests__/handlePostback.test.ts index a3c6b951..4646d61c 100644 --- a/src/webhook/__tests__/handlePostback.test.ts +++ b/src/webhook/__tests__/handlePostback.test.ts @@ -82,8 +82,7 @@ it('invokes state handler specified by event.postbackHandlerState', async () => ] as const) { expectedHandler.mockImplementationOnce(() => { return Promise.resolve({ - // Bare minimal return values by handlers for handlePostback to work without crash - data: {}, + data: { sessionId: 0, searchedText: '' }, replies: [], } as ChatbotStateHandlerReturnType); }); @@ -109,7 +108,7 @@ describe('defaultState', () => { const data: Context = { sessionId: FIXED_DATE, searchedText: '' }; defaultState.mockImplementationOnce(() => { return { - data: {}, + data: { sessionId: 0, searchedText: '' }, replies: [], }; }); @@ -127,7 +126,10 @@ describe('defaultState', () => { ).resolves.toMatchInlineSnapshot(` Object { "context": Object { - "data": Object {}, + "data": Object { + "searchedText": "", + "sessionId": 0, + }, }, "replies": Array [], } @@ -231,7 +233,7 @@ describe('tutorial', () => { tutorial.mockImplementationOnce(() => { return { - data: {}, + data: { sessionId: 0, searchedText: '' }, replies: [], }; }); @@ -245,7 +247,10 @@ describe('tutorial', () => { ).resolves.toMatchInlineSnapshot(` Object { "context": Object { - "data": Object {}, + "data": Object { + "searchedText": "", + "sessionId": 0, + }, }, "replies": Array [], } diff --git a/src/webhook/handlers/defaultState.ts b/src/webhook/handlers/defaultState.ts index c9face8f..f7421717 100644 --- a/src/webhook/handlers/defaultState.ts +++ b/src/webhook/handlers/defaultState.ts @@ -1,12 +1,11 @@ import { Message } from '@line/bot-sdk'; import { - ChatbotStateHandlerParams, ChatbotPostbackHandlerParams, ChatbotStateHandlerReturnType, } from 'src/types/chatbotState'; export default function defaultState( - params: ChatbotPostbackHandlerParams | ChatbotStateHandlerParams + params: ChatbotPostbackHandlerParams ): ChatbotStateHandlerReturnType { const replies: Message[] = [ { From bf3a1269b3dc736192a557f4709220c32b217ffd Mon Sep 17 00:00:00 2001 From: MrOrz Date: Mon, 13 Nov 2023 00:58:19 +0800 Subject: [PATCH 4/5] fix(webhook): update handleInput test snapshot --- src/webhook/__tests__/handleInput.test.ts | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/src/webhook/__tests__/handleInput.test.ts b/src/webhook/__tests__/handleInput.test.ts index 12bb73ae..f2839f6c 100644 --- a/src/webhook/__tests__/handleInput.test.ts +++ b/src/webhook/__tests__/handleInput.test.ts @@ -5,7 +5,7 @@ import originalHandlePostback from '../handlePostback'; import { TUTORIAL_STEPS } from '../handlers/tutorial'; import { VIEW_ARTICLE_PREFIX, getArticleURL } from 'src/lib/sharedUtils'; -import { MessageEvent, TextEventMessage, TextMessage } from '@line/bot-sdk'; +import { MessageEvent, TextEventMessage } from '@line/bot-sdk'; jest.mock('../handlers/initState'); jest.mock('../handlePostback'); @@ -17,9 +17,6 @@ const handlePostback = originalHandlePostback as jest.MockedFunction< typeof originalHandlePostback >; -// Original session ID in context -const FIXED_DATE = 612964800000; - // If session is renewed, sessionId will become this value const NOW = 1561982400000; @@ -171,21 +168,6 @@ it('Resets session on free-form input, triggers fast-forward', async () => { "searchedText": "Newly forwarded message", "sessionId": 1561982400000, }, - "event": Object { - "message": Object { - "id": "", - "text": "Newly forwarded message", - "type": "text", - }, - "mode": "active", - "replyToken": "", - "source": Object { - "type": "user", - "userId": "", - }, - "timestamp": 0, - "type": "message", - }, "userId": "user-id", }, ], From 1cf2ae92a095bc04e2c64ecfccf6fdcb8a555745 Mon Sep 17 00:00:00 2001 From: MrOrz Date: Mon, 13 Nov 2023 01:00:33 +0800 Subject: [PATCH 5/5] fix(webhook): update initState test snapshots --- ...te.test.js.snap => initState.test.ts.snap} | 77 ------------------- 1 file changed, 77 deletions(-) rename src/webhook/handlers/__tests__/__snapshots__/{initState.test.js.snap => initState.test.ts.snap} (92%) diff --git a/src/webhook/handlers/__tests__/__snapshots__/initState.test.js.snap b/src/webhook/handlers/__tests__/__snapshots__/initState.test.ts.snap similarity index 92% rename from src/webhook/handlers/__tests__/__snapshots__/initState.test.js.snap rename to src/webhook/handlers/__tests__/__snapshots__/initState.test.ts.snap index f1514373..785fcfa9 100644 --- a/src/webhook/handlers/__tests__/__snapshots__/initState.test.js.snap +++ b/src/webhook/handlers/__tests__/__snapshots__/initState.test.ts.snap @@ -6,16 +6,6 @@ Object { "searchedText": "計程車上有裝悠遊卡感應器,老人悠悠卡可以享受優惠部分由政府補助,不影響司機收入", "sessionId": 1497994017447, }, - "event": Object { - "input": "計程車上有裝悠遊卡感應器,老人悠悠卡可以享受優惠部分由政府補助,不影響司機收入", - "message": Object { - "id": "6270464463537", - "text": "計程車上有裝悠遊卡感應器,老人悠悠卡可以享受優惠部分由政府補助,不影響司機收入", - "type": "text", - }, - "timestamp": 1497994016356, - "type": "message", - }, "replies": Array [ Object { "text": "🔍 There are some messages that looks similar to \\"計程車上有裝悠遊卡感⋯⋯\\" you have sent to me.", @@ -166,7 +156,6 @@ Please choose the version that looks the most similar👇", "type": "flex", }, ], - "userId": "Uc76d8ae9ccd1ada4f06c4e1515d46466", } `; @@ -176,16 +165,6 @@ Object { "searchedText": "YouTube · 寻找健康人生", "sessionId": 1497994017447, }, - "event": Object { - "input": "YouTube · 寻找健康人生", - "message": Object { - "id": "6270464463537", - "text": "YouTube · 寻找健康人生", - "type": "text", - }, - "timestamp": 1497994016356, - "type": "message", - }, "replies": Array [ Object { "text": "🔍 There are some messages that looks similar to \\"YouTube · ⋯⋯\\" you have sent to me.", @@ -364,7 +343,6 @@ Please choose the version that looks the most similar👇", "type": "flex", }, ], - "userId": "Uc76d8ae9ccd1ada4f06c4e1515d46466", } `; @@ -374,16 +352,6 @@ Object { "searchedText": "零一二三四五六七八九十", "sessionId": 1497994017447, }, - "event": Object { - "input": "零一二三四五六七八九十", - "message": Object { - "id": "6270464463537", - "text": "零一二三四五六七八九十", - "type": "text", - }, - "timestamp": 1497994016356, - "type": "message", - }, "replies": Array [ Object { "altText": "Unfortunately, I currently don’t recognize “零一二三四五六七八九十”, but I would still like to help. @@ -458,7 +426,6 @@ May I ask you a quick question?", "type": "flex", }, ], - "userId": "Uc76d8ae9ccd1ada4f06c4e1515d46466", } `; @@ -468,23 +435,12 @@ Object { "searchedText": "你好", "sessionId": 1497994017447, }, - "event": Object { - "input": "你好", - "message": Object { - "id": "6270464463537", - "text": "你好", - "type": "text", - }, - "timestamp": 1497994016356, - "type": "message", - }, "replies": Array [ Object { "text": "歡迎光臨", "type": "text", }, ], - "userId": "Uc76d8ae9ccd1ada4f06c4e1515d46466", } `; @@ -494,23 +450,12 @@ Object { "searchedText": "零一二三四五六七八九十", "sessionId": 1497994017447, }, - "event": Object { - "input": "零一二三四五六七八九十", - "message": Object { - "id": "6270464463537", - "text": "零一二三四五六七八九十", - "type": "text", - }, - "timestamp": 1497994016356, - "type": "message", - }, "replies": Array [ Object { "text": "歡迎光臨", "type": "text", }, ], - "userId": "Uc76d8ae9ccd1ada4f06c4e1515d46466", } `; @@ -673,16 +618,6 @@ Object { "searchedText": "YouTube · 寻找健康人生", "sessionId": 1497994017447, }, - "event": Object { - "input": "YouTube · 寻找健康人生", - "message": Object { - "id": "6270464463537", - "text": "YouTube · 寻找健康人生", - "type": "text", - }, - "timestamp": 1497994016356, - "type": "message", - }, "replies": Array [ Object { "text": "🔍 There are some messages that looks similar to \\"YouTube · ⋯⋯\\" you have sent to me.", @@ -981,7 +916,6 @@ title2 title2 title2 ", "type": "flex", }, ], - "userId": "Uc76d8ae9ccd1ada4f06c4e1515d46466", } `; @@ -991,16 +925,6 @@ Object { "searchedText": "YouTube · 寻找健康人生 驚!大批香蕉受到愛滋血污染!這種香蕉千萬不要吃!吃到可能會被 ...", "sessionId": 1497994017447, }, - "event": Object { - "input": "YouTube · 寻找健康人生 驚!大批香蕉受到愛滋血污染!這種香蕉千萬不要吃!吃到可能會被 ...", - "message": Object { - "id": "6270464463537", - "text": "YouTube · 寻找健康人生 驚!大批香蕉受到愛滋血污染!這種香蕉千萬不要吃!吃到可能會被 ...", - "type": "text", - }, - "timestamp": 1497994016356, - "type": "message", - }, "replies": Array [ Object { "altText": "Unfortunately, I currently don’t recognize “YouTube · ⋯⋯”, but I would still like to help. @@ -1075,6 +999,5 @@ May I ask you a quick question?", "type": "flex", }, ], - "userId": "Uc76d8ae9ccd1ada4f06c4e1515d46466", } `;