การทำงานกับ TypeScript, Dependency Injection และ Discord Bots

เผยแพร่แล้ว: 2022-03-11

ประเภทและโค้ดที่ทดสอบได้ เป็นสองวิธีที่มีประสิทธิภาพที่สุดในการหลีกเลี่ยงจุดบกพร่อง โดยเฉพาะอย่างยิ่งเมื่อโค้ดมีการเปลี่ยนแปลงเมื่อเวลาผ่านไป เราสามารถใช้สองเทคนิคนี้ในการพัฒนา JavaScript โดยใช้ประโยชน์จาก TypeScript และรูปแบบการออกแบบการพึ่งพา (DI) ตามลำดับ

ในบทช่วยสอนของ TypeScript นี้ เราจะไม่พูดถึงพื้นฐานของ TypeScript โดยตรง ยกเว้นการรวบรวม แต่เราจะแสดงวิธีปฏิบัติที่ดีที่สุดของ TypeScript ในขณะที่เราแนะนำวิธีสร้างบอท Discord ตั้งแต่เริ่มต้น เชื่อมต่อการทดสอบและ DI และสร้างบริการตัวอย่าง เราจะใช้:

  • Node.js
  • TypeScript
  • Discord.js ตัวห่อหุ้มสำหรับ Discord API
  • InversifyJS เฟรมเวิร์กการฉีดพึ่งพา
  • ห้องสมุดทดสอบ: Mocha, Chai และ ts-mockito
  • โบนัส: Mongoose และ MongoDB เพื่อเขียนการทดสอบการรวม

การตั้งค่าโปรเจ็กต์ Node.js ของคุณ

ขั้นแรก ให้สร้างไดเร็กทอรีใหม่ชื่อ typescript-bot จากนั้นป้อนและสร้างโปรเจ็กต์ Node.js ใหม่โดยเรียกใช้:

 npm init

หมายเหตุ: คุณสามารถใช้ yarn สำหรับสิ่งนั้นได้ แต่ให้ยึด npm เพื่อความกระชับ

ซึ่งจะเปิดวิซาร์ดแบบโต้ตอบ ซึ่งจะตั้งค่าไฟล์ package.json คุณสามารถกด Enter สำหรับคำถามทั้งหมดได้อย่างปลอดภัย (หรือให้ข้อมูลบางอย่างหากต้องการ) จากนั้น มาติดตั้งการพึ่งพาและการพึ่งพา dev ของเรา (สิ่งที่จำเป็นสำหรับการทดสอบเท่านั้น)

 npm i --save typescript discord.js inversify dotenv @types/node reflect-metadata npm i --save-dev chai mocha ts-mockito ts-node @types/chai @types/mocha

จากนั้นแทนที่ส่วน "scripts" ที่สร้างขึ้นใน package.json ด้วย:

 "scripts": { "start": "node src/index.js", "watch": "tsc -p tsconfig.json -w", "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\"" },

จำเป็นต้องใช้เครื่องหมายอัญประกาศคู่รอบ tests/**/*.spec.ts เพื่อค้นหาไฟล์แบบเรียกซ้ำ (หมายเหตุ: ไวยากรณ์อาจแตกต่างกันไปสำหรับนักพัฒนาที่ใช้ Windows)

สคริปต์ start จะใช้เพื่อเริ่มบอท สคริปต์ watch เพื่อคอมไพล์โค้ด TypeScript และ test เพื่อเรียกใช้การทดสอบ

ตอนนี้ ไฟล์ package.json ของเราควรมีลักษณะดังนี้:

 { "name": "typescript-bot", "version": "1.0.0", "description": "", "main": "index.js", "dependencies": { "@types/node": "^11.9.4", "discord.js": "^11.4.2", "dotenv": "^6.2.0", "inversify": "^5.0.1", "reflect-metadata": "^0.1.13", "typescript": "^3.3.3" }, "devDependencies": { "@types/chai": "^4.1.7", "@types/mocha": "^5.2.6", "chai": "^4.2.0", "mocha": "^5.2.0", "ts-mockito": "^2.3.1", "ts-node": "^8.0.3" }, "scripts": { "start": "node src/index.js", "watch": "tsc -p tsconfig.json -w", "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\"" }, "author": "", "license": "ISC" }

การสร้างแอปพลิเคชันใหม่ในแดชบอร์ดแอพ Discord

ในการโต้ตอบกับ Discord API เราจำเป็นต้องมีโทเค็น ในการสร้างโทเค็นดังกล่าว เราจำเป็นต้องลงทะเบียนแอพใน Discord Developer Dashboard ในการทำเช่นนั้น คุณต้องสร้างบัญชี Discord และไปที่ https://discordapp.com/developers/applications/ จากนั้นคลิกปุ่ม แอปพลิเคชันใหม่ :

ปุ่ม "แอปพลิเคชันใหม่" ของ Discord

เลือกชื่อแล้วคลิก สร้าง จากนั้นคลิก BotAdd Bot และทำเสร็จแล้ว มาเพิ่มบอทในเซิร์ฟเวอร์กันเถอะ แต่อย่าเพิ่งปิดหน้านี้ เราจำเป็นต้องคัดลอกโทเค็นในเร็วๆ นี้

เพิ่ม Discord Bot ของคุณไปยังเซิร์ฟเวอร์ของคุณ

ในการทดสอบบอท เราจำเป็นต้องมีเซิร์ฟเวอร์ Discord คุณสามารถใช้เซิร์ฟเวอร์ที่มีอยู่หรือสร้างเซิร์ฟเวอร์ใหม่ ในการดำเนินการนี้ ให้คัดลอก CLIENT_ID ของบอท ซึ่งอยู่ในแท็บข้อมูลทั่วไป และใช้เป็นส่วนหนึ่งของ URL การอนุญาตพิเศษนี้:

https://discordapp.com/oauth2/authorize?client_id=<CLIENT_ID>&scope=bot

เมื่อคุณกด URL นี้ในเบราว์เซอร์ แบบฟอร์มจะปรากฏขึ้นซึ่งคุณสามารถเลือกเซิร์ฟเวอร์ที่จะเพิ่มบอทได้

ข้อความต้อนรับมาตรฐาน Discord เพื่อตอบสนองต่อบอทของเราที่เข้าร่วมเซิร์ฟเวอร์

หลังจากที่คุณเพิ่มบอทในเซิร์ฟเวอร์ของคุณแล้ว คุณควรเห็นข้อความในลักษณะข้างต้น

การสร้างไฟล์ .env

เราต้องการวิธีบันทึกโทเค็นในแอปของเรา ในการทำเช่นนั้น เราจะใช้แพ็คเกจ dotenv ขั้นแรก รับโทเค็นจาก Discord Application Dashboard ( Botคลิกเพื่อเปิดเผย Token ):

ลิงก์ "คลิกเพื่อเปิดเผยโทเค็น" ในส่วนบอทของ Discord

ตอนนี้ สร้างไฟล์ .env จากนั้นคัดลอกและวางโทเค็นที่นี่:

 TOKEN=paste.the.token.here

หากคุณใช้ Git ไฟล์นี้ควรอยู่ใน .gitignore เพื่อไม่ให้โทเค็นถูกบุกรุก นอกจากนี้ ให้สร้างไฟล์ .env.example เพื่อให้ทราบว่า TOKEN จำเป็นต้องกำหนด:

 TOKEN=

กำลังรวบรวม TypeScript

ในการคอมไพล์ TypeScript คุณสามารถใช้คำสั่ง npm run watch อีกทางหนึ่ง ถ้าคุณใช้ PHPStorm (หรือ IDE อื่น) เพียงใช้เครื่องมือตรวจสอบไฟล์จากปลั๊กอิน TypeScript และปล่อยให้ IDE ของคุณจัดการคอมไพล์ มาทดสอบการตั้งค่าของเราโดยสร้างไฟล์ src/index.ts ที่มีเนื้อหาดังนี้

 console.log('Hello')

เรามาสร้างไฟล์ tsconfig.json ด้านล่างกัน InversifyJS ต้องการ experimentalDecorators , emitDecoratorMetadata , es6 และ reflect-metadata :

 { "compilerOptions": { "module": "commonjs", "moduleResolution": "node", "target": "es2016", "lib": [ "es6", "dom" ], "sourceMap": true, "types": [ // add node as an option "node", "reflect-metadata" ], "typeRoots": [ // add path to @types "node_modules/@types" ], "experimentalDecorators": true, "emitDecoratorMetadata": true, "resolveJsonModule": true }, "exclude": [ "node_modules" ] }

หากโปรแกรมดูไฟล์ทำงานอย่างถูกต้อง โปรแกรมควรสร้างไฟล์ src/index.js และการเรียกใช้ npm start ควรส่งผลให้:

 > node src/index.js Hello

การสร้างคลาสบอท

ตอนนี้ มาเริ่มใช้คุณสมบัติที่มีประโยชน์ที่สุดของ TypeScript: types ไปข้างหน้าและสร้างไฟล์ src/bot.ts ต่อไปนี้:

 import {Client, Message} from "discord.js"; export class Bot { public listen(): Promise<string> { let client = new Client(); client.on('message', (message: Message) => {}); return client.login('token should be here'); } }

ตอนนี้ เราเห็นสิ่งที่เราต้องการที่นี่: โทเค็น! เราจะคัดลอกและวางที่นี่หรือโหลดค่าจากสภาพแวดล้อมโดยตรงหรือไม่

ไม่ใช่ทั้งสองอย่าง. ให้เขียนโค้ดที่สามารถบำรุงรักษา ขยายได้ และทดสอบได้มากขึ้นโดยการฉีดโทเค็นโดยใช้เฟรมเวิร์กการฉีดการพึ่งพาที่เราเลือก InversifyJS

นอกจากนี้ เราจะเห็นได้ว่าการขึ้นต่อกันของ Client นั้นถูกฮาร์ดโค้ด เราก็กำลังจะฉีดเหมือนกัน

การกำหนดค่าคอนเทนเนอร์การฉีดการพึ่งพา

คอนเทนเนอร์การฉีดการพึ่งพา คือออบเจ็กต์ที่รู้วิธีสร้างอินสแตนซ์อ็อบเจ็กต์อื่นๆ โดยปกติ เรากำหนดการอ้างอิงสำหรับแต่ละคลาส และคอนเทนเนอร์ DI จะดูแลแก้ไข

InversifyJS แนะนำให้ใส่การพึ่งพาในไฟล์ inversify.config.ts ดังนั้น ไปข้างหน้าและเพิ่มคอนเทนเนอร์ DI ของเราที่นั่น:

 import "reflect-metadata"; import {Container} from "inversify"; import {TYPES} from "./types"; import {Bot} from "./bot"; import {Client} from "discord.js"; let container = new Container(); container.bind<Bot>(TYPES.Bot).to(Bot).inSingletonScope(); container.bind<Client>(TYPES.Client).toConstantValue(new Client()); container.bind<string>(TYPES.Token).toConstantValue(process.env.TOKEN); export default container;

นอกจากนี้ เอกสาร InversifyJS แนะนำให้สร้างไฟล์ types.ts และแสดงรายการแต่ละประเภทที่เราจะใช้ ร่วมกับ Symbol ที่เกี่ยวข้อง สิ่งนี้ค่อนข้างไม่สะดวก แต่ช่วยให้แน่ใจว่าไม่มีการชนกันของการตั้งชื่อเมื่อแอปของเราเติบโตขึ้น แต่ละ Symbol เป็นตัวระบุที่ไม่ซ้ำกัน แม้ว่าพารามิเตอร์คำอธิบายจะเหมือนกันก็ตาม (พารามิเตอร์นี้ใช้เพื่อจุดประสงค์ในการดีบักเท่านั้น)

 export const TYPES = { Bot: Symbol("Bot"), Client: Symbol("Client"), Token: Symbol("Token"), };

โดยไม่ใช้ Symbol s ต่อไปนี้คือลักษณะเมื่อเกิดการชนกันของการตั้งชื่อ:

 Error: Ambiguous match found for serviceIdentifier: MessageResponder Registered bindings: MessageResponder MessageResponder

ณ จุดนี้ ไม่สะดวก มากกว่า ที่จะแยกแยะว่าควรใช้ MessageResponder ใด โดยเฉพาะอย่างยิ่งถ้าคอนเทนเนอร์ DI ของเรามีขนาดใหญ่ขึ้น การใช้ Symbol นั้นดูแลเรื่องนี้ และเราไม่ได้สร้างตัวอักษรสตริงแปลก ๆ ในกรณีที่มีสองคลาสที่มีชื่อเดียวกัน

การใช้คอนเทนเนอร์ในแอป Discord Bot

ตอนนี้ มาปรับเปลี่ยนคลาส Bot ของเราเพื่อใช้คอนเทนเนอร์ เราจำเป็นต้องเพิ่มคำอธิบายประกอบ @injectable และ @inject() เพื่อทำเช่นนั้น นี่คือคลาส Bot ใหม่:

 import {Client, Message} from "discord.js"; import {inject, injectable} from "inversify"; import {TYPES} from "./types"; import {MessageResponder} from "./services/message-responder"; @injectable() export class Bot { private client: Client; private readonly token: string; constructor( @inject(TYPES.Client) client: Client, @inject(TYPES.Token) token: string ) { this.client = client; this.token = token; } public listen(): Promise < string > { this.client.on('message', (message: Message) => { console.log("Message received! Contents: ", message.content); }); return this.client.login(this.token); } }

สุดท้าย มาสร้างอินสแตนซ์บอทของเราในไฟล์ index.ts :

 require('dotenv').config(); // Recommended way of loading dotenv import container from "./inversify.config"; import {TYPES} from "./types"; import {Bot} from "./bot"; let bot = container.get<Bot>(TYPES.Bot); bot.listen().then(() => { console.log('Logged in!') }).catch((error) => { console.log('Oh no! ', error) });

ตอนนี้ เริ่มต้นบอทและเพิ่มไปยังเซิร์ฟเวอร์ของคุณ จากนั้น หากคุณพิมพ์ข้อความในช่องเซิร์ฟเวอร์ ข้อความนั้นควรปรากฏในบันทึกของบรรทัดคำสั่งดังนี้:

 > node src/index.js Logged in! Message received! Contents: Test

ในที่สุด เราก็ได้ตั้งค่าพื้นฐานแล้ว: ประเภท TypeScript และคอนเทนเนอร์การฉีดพึ่งพาภายในบอทของเรา

การใช้ตรรกะทางธุรกิจ

ไปที่แกนหลักของบทความนี้: การสร้าง codebase ที่ทดสอบได้ กล่าวโดยย่อ โค้ดของเราควรใช้แนวทางปฏิบัติที่ดีที่สุด (เช่น SOLID) ไม่ซ่อนการพึ่งพา ไม่ใช้เมธอดแบบคงที่

นอกจากนี้ ไม่ควรทำให้เกิดผลข้างเคียงเมื่อวิ่ง และเยาะเย้ยได้ง่าย

เพื่อความง่าย บอทของเราจะทำสิ่งเดียวเท่านั้น: มันจะค้นหาข้อความที่เข้ามา และหากมีคำว่า "ping" เราจะใช้คำสั่งบอท Discord ที่มีอยู่เพื่อให้บอทตอบสนองด้วย "pong! ” ให้กับผู้ใช้รายนั้น

เพื่อแสดงวิธีการฉีดวัตถุที่กำหนดเองลงในวัตถุ Bot และทดสอบหน่วย เราจะสร้างสองคลาส: PingFinder และ MessageResponder เราจะฉีด MessageResponder ลงในคลาส Bot และ PingFinder ลงใน MessageResponder

นี่คือไฟล์ src/services/ping-finder.ts :

 import {injectable} from "inversify"; @injectable() export class PingFinder { private regexp = 'ping'; public isPing(stringToSearch: string): boolean { return stringToSearch.search(this.regexp) >= 0; } }

จากนั้นเราฉีดคลาสนั้นลงในไฟล์ src/services/message-responder.ts :

 import {Message} from "discord.js"; import {PingFinder} from "./ping-finder"; import {inject, injectable} from "inversify"; import {TYPES} from "../types"; @injectable() export class MessageResponder { private pingFinder: PingFinder; constructor( @inject(TYPES.PingFinder) pingFinder: PingFinder ) { this.pingFinder = pingFinder; } handle(message: Message): Promise<Message | Message[]> { if (this.pingFinder.isPing(message.content)) { return message.reply('pong!'); } return Promise.reject(); } }

สุดท้าย นี่คือคลาส Bot ที่แก้ไข ซึ่งใช้คลาส MessageResponder :

 import {Client, Message} from "discord.js"; import {inject, injectable} from "inversify"; import {TYPES} from "./types"; import {MessageResponder} from "./services/message-responder"; @injectable() export class Bot { private client: Client; private readonly token: string; private messageResponder: MessageResponder; constructor( @inject(TYPES.Client) client: Client, @inject(TYPES.Token) token: string, @inject(TYPES.MessageResponder) messageResponder: MessageResponder) { this.client = client; this.token = token; this.messageResponder = messageResponder; } public listen(): Promise<string> { this.client.on('message', (message: Message) => { if (message.author.bot) { console.log('Ignoring bot message!') return; } console.log("Message received! Contents: ", message.content); this.messageResponder.handle(message).then(() => { console.log("Response sent!"); }).catch(() => { console.log("Response not sent.") }) }); return this.client.login(this.token); } }

ในสถานะดังกล่าว แอปจะไม่สามารถเรียกใช้ได้เนื่องจากไม่มีคำจำกัดความสำหรับคลาส MessageResponder และ PingFinder มาเพิ่มสิ่งต่อไปนี้ในไฟล์ inversify.config.ts :

 container.bind<MessageResponder>(TYPES.MessageResponder).to(MessageResponder).inSingletonScope(); container.bind<PingFinder>(TYPES.PingFinder).to(PingFinder).inSingletonScope();

นอกจากนี้ เราจะเพิ่มสัญลักษณ์ประเภทลงใน types.ts :

 MessageResponder: Symbol("MessageResponder"), PingFinder: Symbol("PingFinder"),

ตอนนี้ หลังจากรีสตาร์ทแอปของเราแล้ว บอทควรตอบสนองต่อทุกข้อความที่มี "ping":

บอทตอบกลับข้อความที่มีคำว่า "ping"

และนี่คือลักษณะที่ปรากฏในบันทึก:

 > node src/index.js Logged in! Message received! Contents: some message Response not sent. Message received! Contents: message with ping Ignoring bot message! Response sent!

การสร้างหน่วยการทดสอบ

ตอนนี้เราได้เพิ่มการอ้างอิงอย่างถูกต้องแล้ว การเขียนการทดสอบหน่วยจึงเป็นเรื่องง่าย เราจะใช้ Chai และ ts-mockito สำหรับสิ่งนั้น อย่างไรก็ตาม มีนักวิ่งทดสอบและห้องสมุดจำลองอื่นๆ อีกมากมายที่คุณสามารถใช้ได้

ไวยากรณ์เยาะเย้ยใน ts-mockito ค่อนข้างละเอียด แต่ก็เข้าใจง่าย ต่อไปนี้คือวิธีตั้งค่าบริการ MessageResponder และฉีด PingFinder Mock เข้าไป:

 let mockedPingFinderClass = mock(PingFinder); let mockedPingFinderInstance = instance(mockedPingFinderClass); let service = new MessageResponder(mockedPingFinderInstance);

ตอนนี้เราได้ตั้งค่าการจำลองแล้ว เราสามารถกำหนดว่าผลลัพธ์ของการ isPing() ควรเป็นอย่างไร และตรวจสอบ reply() การโทร ประเด็นคือในการทดสอบหน่วย เรากำหนดผลลัพธ์ของการ isPing() : true หรือ false ไม่สำคัญว่าเนื้อหาข้อความคืออะไร ดังนั้นในการทดสอบ เราจึงใช้ "Non-empty string"

 when(mockedPingFinderClass.isPing("Non-empty string")).thenReturn(true); await service.handle(mockedMessageInstance) verify(mockedMessageClass.reply('pong!')).once();

ชุดทดสอบทั้งหมดมีลักษณะดังนี้:

 import "reflect-metadata"; import 'mocha'; import {expect} from 'chai'; import {PingFinder} from "../../../src/services/ping-finder"; import {MessageResponder} from "../../../src/services/message-responder"; import {instance, mock, verify, when} from "ts-mockito"; import {Message} from "discord.js"; describe('MessageResponder', () => { let mockedPingFinderClass: PingFinder; let mockedPingFinderInstance: PingFinder; let mockedMessageClass: Message; let mockedMessageInstance: Message; let service: MessageResponder; beforeEach(() => { mockedPingFinderClass = mock(PingFinder); mockedPingFinderInstance = instance(mockedPingFinderClass); mockedMessageClass = mock(Message); mockedMessageInstance = instance(mockedMessageClass); setMessageContents(); service = new MessageResponder(mockedPingFinderInstance); }) it('should reply', async () => { whenIsPingThenReturn(true); await service.handle(mockedMessageInstance); verify(mockedMessageClass.reply('pong!')).once(); }) it('should not reply', async () => { whenIsPingThenReturn(false); await service.handle(mockedMessageInstance).then(() => { // Successful promise is unexpected, so we fail the test expect.fail('Unexpected promise'); }).catch(() => { // Rejected promise is expected, so nothing happens here }); verify(mockedMessageClass.reply('pong!')).never(); }) function setMessageContents() { mockedMessageInstance.content = "Non-empty string"; } function whenIsPingThenReturn(result: boolean) { when(mockedPingFinderClass.isPing("Non-empty string")).thenReturn(result); } });

การทดสอบสำหรับ PingFinder นั้นค่อนข้างไม่สำคัญ เนื่องจากไม่มีการขึ้นต่อกันให้เยาะเย้ย นี่คือตัวอย่างกรณีทดสอบ:

 describe('PingFinder', () => { let service: PingFinder; beforeEach(() => { service = new PingFinder(); }) it('should find "ping" in the string', () => { expect(service.isPing("ping")).to.be.true }) });

การสร้างการทดสอบบูรณาการ

นอกเหนือจากการทดสอบหน่วย เรายังสามารถเขียนการทดสอบการรวม ความแตกต่างที่สำคัญคือการอ้างอิงในการทดสอบเหล่านั้นไม่ได้ถูกล้อเลียน อย่างไรก็ตาม มีการขึ้นต่อกันบางอย่างที่ไม่ควรทดสอบ เช่น การเชื่อมต่อ API ภายนอก ในกรณีนั้น เราสามารถสร้างแบบจำลองและ rebind เข้ากับคอนเทนเนอร์อีกครั้ง เพื่อที่แบบจำลองจะถูกฉีดเข้าไปแทน นี่คือตัวอย่างวิธีการทำ:

 import container from "../../inversify.config"; import {TYPES} from "../../src/types"; // ... describe('Bot', () => { let discordMock: Client; let discordInstance: Client; let bot: Bot; beforeEach(() => { discordMock = mock(Client); discordInstance = instance(discordMock); container.rebind<Client>(TYPES.Client) .toConstantValue(discordInstance); bot = container.get<Bot>(TYPES.Bot); }); // Test cases here });

สิ่งนี้นำเราไปสู่จุดสิ้นสุดของบทช่วยสอนบอท Discord ของเรา ขอแสดงความยินดี คุณสร้างได้อย่างสมบูรณ์ด้วย TypeScript และ DI ตั้งแต่เริ่มต้น! ตัวอย่างการแทรกการพึ่งพา TypeScript นี้เป็นรูปแบบที่คุณสามารถเพิ่มลงในละครของคุณเพื่อใช้กับโครงการใดก็ได้

TypeScript และ Dependency Injection: ไม่ใช่แค่สำหรับ Discord Bot Development

การนำโลกเชิงวัตถุของ TypeScript มาไว้ใน JavaScript เป็นการเพิ่มประสิทธิภาพที่ยอดเยี่ยม ไม่ว่าเราจะทำงานกับโค้ดส่วนหน้าหรือส่วนหลัง การใช้ประเภทเพียงอย่างเดียวทำให้เราสามารถหลีกเลี่ยงข้อบกพร่องต่างๆ ได้ การฉีดการพึ่งพาใน TypeScript จะช่วยผลักดันแนวทางปฏิบัติที่ดีที่สุดเชิงวัตถุไปสู่การพัฒนาบน JavaScript

แน่นอน เนื่องจากข้อจำกัดของภาษา มันไม่มีทางง่ายและเป็นธรรมชาติเหมือนในภาษาที่พิมพ์แบบสแตติก แต่สิ่งหนึ่งที่แน่นอนคือ TypeScript, การทดสอบหน่วย และการฉีดพึ่งพาช่วยให้เราเขียนโค้ดที่อ่านง่าย จับคู่แบบหลวมๆ และบำรุงรักษาได้ ไม่ว่าเราจะพัฒนาแอปประเภทใด

ที่เกี่ยวข้อง: สร้าง WhatsApp Chatbot ไม่ใช่แอพ