├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── gh-page-docs.yml │ └── node.js.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── .yarnrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── codecov.yml ├── examples ├── .eslintrc.js ├── line │ ├── index.js │ ├── middleware │ │ └── verify.js │ ├── package.json │ ├── server.js │ └── validate │ │ └── signature.js └── messenger │ ├── index.js │ ├── package.json │ └── server.js ├── jest.config.js ├── lerna.json ├── lint-staged.config.js ├── package.json ├── packages ├── axios-error │ ├── README.md │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ └── index.spec.ts.snap │ │ │ └── index.spec.ts │ │ └── index.ts │ └── tsconfig.json ├── facebook-batch │ ├── README.md │ ├── package.json │ ├── src │ │ ├── BatchRequestError.ts │ │ ├── FacebookBatchQueue.ts │ │ ├── __tests__ │ │ │ ├── BatchRequestError.spec.ts │ │ │ ├── FacebookBatchQueue.spec.ts │ │ │ ├── __snapshots__ │ │ │ │ └── BatchRequestError.spec.ts.snap │ │ │ └── index.spec.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── utils.ts │ └── tsconfig.json ├── messaging-api-common │ ├── README.md │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ └── case.spec.ts │ │ ├── case.ts │ │ └── index.ts │ └── tsconfig.json ├── messaging-api-line │ ├── README.md │ ├── package.json │ ├── src │ │ ├── Line.ts │ │ ├── LineClient.ts │ │ ├── LineNotify.ts │ │ ├── LinePay.ts │ │ ├── LineTypes.ts │ │ ├── __tests__ │ │ │ ├── Line.spec.ts │ │ │ ├── LineClient-audience.spec.ts │ │ │ ├── LineClient-broadcast.spec.ts │ │ │ ├── LineClient-constructor.spec.ts │ │ │ ├── LineClient-insight.spec.ts │ │ │ ├── LineClient-liff.spec.ts │ │ │ ├── LineClient-menu.spec.ts │ │ │ ├── LineClient-multicast.spec.ts │ │ │ ├── LineClient-narrowcast.spec.ts │ │ │ ├── LineClient-push.spec.ts │ │ │ ├── LineClient-reply.spec.ts │ │ │ ├── LineClient-room-group.spec.ts │ │ │ ├── LineClient-webhook.spec.ts │ │ │ ├── LineClient.spec.ts │ │ │ ├── LineNotify.spec.ts │ │ │ ├── LinePay.spec.ts │ │ │ ├── browser.spec.ts │ │ │ ├── fixture.png │ │ │ └── index.spec.ts │ │ ├── browser.ts │ │ └── index.ts │ └── tsconfig.json ├── messaging-api-messenger │ ├── README.md │ ├── package.json │ ├── src │ │ ├── Messenger.ts │ │ ├── MessengerBatch.ts │ │ ├── MessengerClient.ts │ │ ├── MessengerTypes.ts │ │ ├── __tests__ │ │ │ ├── MessengerBatch.spec.ts │ │ │ ├── MessengerClient-constructor.spec.ts │ │ │ ├── MessengerClient-handover.spec.ts │ │ │ ├── MessengerClient-insights.spec.ts │ │ │ ├── MessengerClient-persona.spec.ts │ │ │ ├── MessengerClient-profile.spec.ts │ │ │ ├── MessengerClient-send.spec.ts │ │ │ ├── MessengerClient-sendTemplate.spec.ts │ │ │ ├── MessengerClient.spec.ts │ │ │ ├── browser.spec.ts │ │ │ └── index.spec.ts │ │ ├── browser.ts │ │ └── index.ts │ └── tsconfig.json ├── messaging-api-slack │ ├── README.md │ ├── package.json │ ├── src │ │ ├── SlackOAuthClient.ts │ │ ├── SlackTypes.ts │ │ ├── SlackWebhookClient.ts │ │ ├── __tests__ │ │ │ ├── SlackOAuthClient-constructor.spec.ts │ │ │ ├── SlackOAuthClient.spec.ts │ │ │ ├── SlackWebhookClient-constructor.spec.ts │ │ │ ├── SlackWebhookClient.spec.ts │ │ │ └── index.spec.ts │ │ └── index.ts │ └── tsconfig.json ├── messaging-api-telegram │ ├── README.md │ ├── package.json │ ├── src │ │ ├── TelegramClient.ts │ │ ├── TelegramTypes.ts │ │ ├── __tests__ │ │ │ ├── TelegramClient-constructor.spec.ts │ │ │ ├── TelegramClient-game.spec.ts │ │ │ ├── TelegramClient-group.spec.ts │ │ │ ├── TelegramClient-payment.spec.ts │ │ │ ├── TelegramClient-send.spec.ts │ │ │ ├── TelegramClient-stickerSet.spec.ts │ │ │ ├── TelegramClient-update.spec.ts │ │ │ ├── TelegramClient.spec.ts │ │ │ └── index.spec.ts │ │ └── index.ts │ └── tsconfig.json ├── messaging-api-viber │ ├── README.md │ ├── package.json │ ├── src │ │ ├── ViberClient.ts │ │ ├── ViberTypes.ts │ │ ├── __tests__ │ │ │ ├── ViberClient-broadcast.spec.ts │ │ │ ├── ViberClient-constructor.spec.ts │ │ │ ├── ViberClient.spec.ts │ │ │ └── index.spec.ts │ │ └── index.ts │ └── tsconfig.json └── messaging-api-wechat │ ├── README.md │ ├── package.json │ ├── src │ ├── WechatClient.ts │ ├── WechatTypes.ts │ ├── __tests__ │ │ ├── WechatClient-constructor.spec.ts │ │ ├── WechatClient.spec.ts │ │ └── index.spec.ts │ └── index.ts │ └── tsconfig.json ├── prettier.config.js ├── tsconfig.base.json ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.test.json ├── typedoc.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | indent_size = 2 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/coverage/** 2 | **/node_modules/** 3 | bin/ 4 | docs/ 5 | dist 6 | types/** 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: ['yoctol-base', 'plugin:@typescript-eslint/recommended', 'prettier'], 5 | env: { 6 | node: true, 7 | jest: true, 8 | jasmine: true, 9 | }, 10 | plugins: ['@typescript-eslint', 'eslint-plugin-tsdoc'], 11 | rules: { 12 | camelcase: 'off', 13 | 14 | 'no-useless-constructor': 'off', 15 | 16 | 'import/no-extraneous-dependencies': 'off', 17 | 'import/extensions': 'off', 18 | 19 | 'no-use-before-define': 'off', 20 | '@typescript-eslint/no-use-before-define': 'error', 21 | 22 | 'no-shadow': 'off', 23 | '@typescript-eslint/no-shadow': 'error', 24 | 25 | '@typescript-eslint/no-useless-constructor': 'error', 26 | '@typescript-eslint/no-namespace': 'off', 27 | '@typescript-eslint/camelcase': 'off', 28 | '@typescript-eslint/ban-ts-comment': 'off', 29 | '@typescript-eslint/ban-types': 'warn', 30 | }, 31 | overrides: [ 32 | { 33 | files: ['examples/**/*.js'], 34 | rules: { 35 | '@typescript-eslint/no-var-requires': 'off', 36 | }, 37 | }, 38 | { 39 | files: ['packages/**/*.ts'], 40 | rules: { 41 | 'tsdoc/syntax': 'warn', 42 | }, 43 | }, 44 | ], 45 | settings: { 46 | 'import/resolver': { 47 | node: { 48 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 49 | }, 50 | typescript: {}, 51 | }, 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /.github/workflows/gh-page-docs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: GitHub Page Docs 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | 11 | env: 12 | CI: true 13 | 14 | jobs: 15 | build-and-deploy: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 🛎️ 19 | uses: actions/checkout@v2 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly. 20 | with: 21 | persist-credentials: false 22 | 23 | - name: Install npm dependencies 24 | run: yarn --frozen-lockfile 25 | 26 | - name: Build doc files 27 | run: npm run typedoc 28 | 29 | - name: Deploy 🚀 30 | uses: JamesIves/github-pages-deploy-action@4.1.5 31 | with: 32 | branch: gh-pages # The branch the action should deploy to. 33 | folder: docs # The folder the action should deploy. 34 | target-folder: docs/v1.1 35 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: 9 | - "**" 10 | pull_request: 11 | branches: 12 | - "**" 13 | 14 | env: 15 | CI: true 16 | 17 | jobs: 18 | build: 19 | name: Node ${{ matrix.node }} on ${{ matrix.os }} 20 | runs-on: ${{ matrix.os }} 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | node-version: [12, 14, 16] 26 | os: [ubuntu-latest, windows-latest] 27 | 28 | steps: 29 | - name: Prepare git 30 | run: git config --global core.autocrlf false 31 | 32 | - name: Clone repository 33 | uses: actions/checkout@v2 34 | 35 | - name: Use Node.js ${{ matrix.node-version }} 36 | uses: actions/setup-node@v1 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | 40 | - name: Install npm dependencies 41 | run: yarn --frozen-lockfile 42 | 43 | - name: Compile TypeScript files 44 | run: npm run compile 45 | 46 | - name: Run lint rules 47 | if: ${{ matrix.os == 'ubuntu-latest' && matrix.node-version == 12 }} 48 | run: npm run lint -- --report-unused-disable-directives 49 | 50 | - name: Run tests 51 | run: npm run testonly:cov -- --colors 52 | 53 | - name: Upload coverage to Codecov 54 | uses: codecov/codecov-action@v1 55 | if: ${{ matrix.os == 'ubuntu-latest' && matrix.node-version == 12 }} 56 | with: 57 | fail_ci_if_error: true 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore the compiled output. 2 | dist/ 3 | 4 | # TypeScript incremental compilation cache 5 | *.tsbuildinfo 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Coverage (from Jest) 15 | coverage/ 16 | 17 | # JUnit Reports (used mainly in CircleCI) 18 | reports/ 19 | junit.xml 20 | 21 | # Node modules 22 | node_modules/ 23 | 24 | # Mac OS 25 | .DS_Store 26 | 27 | # Typedoc 28 | docs 29 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/__mocks__/** 2 | **/__tests__/** 3 | src 4 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | workspaces-experimental true 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at service@yoctol.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-present Yoctol (github.com/Yoctol/messaging-apis) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: '85%' 6 | patch: 7 | enabled: false 8 | -------------------------------------------------------------------------------- /examples/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'import/no-unresolved': 'off', 4 | 'no-console': 'off', 5 | 6 | '@typescript-eslint/explicit-function-return-type': 'off', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /examples/line/index.js: -------------------------------------------------------------------------------- 1 | const server = require('./server'); 2 | 3 | server.listen(3000, () => { 4 | console.log(`Server is running on localhost:3000`); 5 | }); 6 | -------------------------------------------------------------------------------- /examples/line/middleware/verify.js: -------------------------------------------------------------------------------- 1 | const { raw } = require('body-parser'); 2 | 3 | const validateSignature = require('../validate/signature'); 4 | 5 | const isString = (str) => typeof str === 'string'; 6 | 7 | const verifyMiddleware = (config) => (req, res, next) => { 8 | const signature = req.headers['x-line-signature']; 9 | const { channelSecret } = config; 10 | 11 | if (!signature || !isString(signature)) { 12 | res.status(401); 13 | } else { 14 | const validate = (bodybuffer) => { 15 | if (validateSignature(bodybuffer, channelSecret, signature)) { 16 | req.body = JSON.parse(bodybuffer); 17 | next(); 18 | } else { 19 | throw new Error('Signature Failed.'); 20 | } 21 | }; 22 | if (typeof req.body === 'string' || Buffer.isBuffer(req.body)) { 23 | validate(req.body); 24 | } else { 25 | raw({ type: '*/*' })(req, res, () => validate(req.body)); 26 | } 27 | } 28 | }; 29 | 30 | module.exports = verifyMiddleware; 31 | -------------------------------------------------------------------------------- /examples/line/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "start": "node index.js" 5 | }, 6 | "dependencies": { 7 | "body-parser": "^1.18.1", 8 | "express": "^4.15.4", 9 | "messaging-api-line": "latest" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/line/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { Line, LineClient } = require('messaging-api-line'); 3 | 4 | const verifyMiddleware = require('./middleware/verify'); 5 | 6 | const client = new LineClient({ 7 | accessToken: process.env.ACCESS_TOKEN, 8 | channelSecret: process.env.CHANNEL_SECRET, 9 | }); 10 | 11 | const server = express(); 12 | 13 | const config = { 14 | channelSecret: process.env.CHANNEL_SECRET, 15 | }; 16 | 17 | const imageUrl = 18 | 'https://camo.githubusercontent.com/aa9c9a7b1257706f763b300fbdbd87f252cdf183/687474703a2f2f6973352e6d7a7374617469632e636f6d2f696d6167652f7468756d622f507572706c653131372f76342f30312f63322f34642f30316332346439392d346161652d373165612d323465322d6430623638663863353364322f736f757263652f313230307836333062622e6a7067'; 19 | const verifyEvents = [ 20 | '00000000000000000000000000000000', 21 | 'ffffffffffffffffffffffffffffffff', 22 | ]; 23 | 24 | const handleEvent = (event) => { 25 | const { type, replyToken, message } = event; 26 | const messageType = message.type; 27 | if (type !== 'message' || messageType !== 'text') { 28 | return Promise.resolve(null); 29 | } 30 | if (verifyEvents.includes(replyToken)) return Promise.resolve(null); 31 | return client.reply(replyToken, [ 32 | Line.createText('Hello'), 33 | Line.createImage(imageUrl), 34 | Line.createText('End'), 35 | ]); 36 | }; 37 | 38 | server.post('/webhook', verifyMiddleware(config), (req, res) => { 39 | const { body } = req; 40 | const { events } = body; 41 | 42 | Promise.all(events.map(handleEvent)) 43 | .then((result) => res.status(200).send(result)) 44 | .catch((err) => console.log(err)); 45 | }); 46 | 47 | module.exports = server; 48 | -------------------------------------------------------------------------------- /examples/line/validate/signature.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ref: https://github.com/line/line-bot-sdk-nodejs/blob/master/lib/validate-signature.ts 3 | */ 4 | 5 | const { createHmac, timingSafeEqual } = require('crypto'); 6 | 7 | function s2b(str, encoding) { 8 | if (Buffer.from) { 9 | try { 10 | return Buffer.from(str, encoding); 11 | } catch (err) { 12 | if (err.name === 'TypeError') { 13 | return Buffer.from(str, encoding); 14 | } 15 | throw err; 16 | } 17 | } else { 18 | return Buffer.from(str, encoding); 19 | } 20 | } 21 | 22 | function safeCompare(a, b) { 23 | if (a.length !== b.length) { 24 | return false; 25 | } 26 | 27 | if (timingSafeEqual) { 28 | return timingSafeEqual(a, b); 29 | } 30 | let result = 0; 31 | for (let i = 0; i < a.length; i++) { 32 | result |= a[i] ^ b[i]; /* eslint no-bitwise: 0 */ 33 | } 34 | return result === 0; 35 | } 36 | 37 | function validateSignature(body, channelSecret, signature) { 38 | return safeCompare( 39 | createHmac('SHA256', channelSecret).update(body).digest(), 40 | s2b(signature, 'base64') 41 | ); 42 | } 43 | 44 | module.exports = validateSignature; 45 | -------------------------------------------------------------------------------- /examples/messenger/index.js: -------------------------------------------------------------------------------- 1 | const server = require('./server'); 2 | 3 | server.listen(3000, () => { 4 | console.log(`Server is running on localhost:3000`); 5 | }); 6 | -------------------------------------------------------------------------------- /examples/messenger/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "start": "node index.js" 5 | }, 6 | "dependencies": { 7 | "body-parser": "^1.18.1", 8 | "express": "^4.15.4", 9 | "messaging-api-messenger": "latest" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/messenger/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bodyParser = require('body-parser'); 3 | const { MessengerClient } = require('messaging-api-messenger'); 4 | 5 | const server = express(); 6 | const client = new MessengerClient({ 7 | accessToken: process.env.ACCESS_TOKEN, 8 | }); 9 | 10 | server.use(bodyParser.json()); 11 | 12 | server.get('/', (req, res) => { 13 | if ( 14 | req.query['hub.mode'] === 'subscribe' && 15 | req.query['hub.verify_token'] === process.env.VERIFY_TOKEN 16 | ) { 17 | res.send(req.query['hub.challenge']); 18 | } else { 19 | console.error('Failed validation. Make sure the validation tokens match.'); 20 | res.sendStatus(403); 21 | } 22 | }); 23 | 24 | server.post('/', (req, res) => { 25 | const event = req.body.entry[0].messaging[0]; 26 | const userId = event.sender.id; 27 | const { text } = event.message; 28 | client.sendText(userId, text); 29 | res.sendStatus(200); 30 | }); 31 | 32 | module.exports = server; 33 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | preset: 'ts-jest', 4 | moduleFileExtensions: ['js', 'ts', 'tsx'], 5 | testPathIgnorePatterns: ['/node_modules/', '/dist/'], 6 | coverageDirectory: './coverage/', 7 | transformIgnorePatterns: ['/node_modules/'], 8 | testMatch: ['**/__tests__/*.+(ts|tsx)'], 9 | timers: 'fake', 10 | resetMocks: true, 11 | globals: { 12 | 'ts-jest': { 13 | tsconfig: '/tsconfig.test.json', 14 | diagnostics: false, 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "lerna": "3.4.0", 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "version": "1.1.0", 6 | "command": { 7 | "publish": { 8 | "ignoreChanges": [ 9 | "__tests__/**" 10 | ] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | 3 | module.exports = { 4 | '*.ts': () => 'tsc --build tsconfig.build.json', 5 | '*.{ts,js}': ['eslint --ext=ts,tsx --fix'], 6 | '*.md': ['prettier --write'], 7 | 'package.json': ['prettier-package-json --write'], 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "messaging-apis", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/Yoctol/messaging-apis.git" 8 | }, 9 | "scripts": { 10 | "bootstrap": "lerna bootstrap", 11 | "clean": "git clean -dfqX -- ./node_modules **/{dist,node_modules}/ ./packages/*/tsconfig*tsbuildinfo", 12 | "compile": "tsc --build tsconfig.build.json", 13 | "compile:clean": "tsc --build tsconfig.build.json --clean", 14 | "postinstall": "lerna run prepare && yarn compile", 15 | "lint": "eslint packages examples --ext=js,ts", 16 | "lint:fix": "yarn lint:fix:md && yarn lint --fix", 17 | "lint:fix:md": "prettier --write **/*.md", 18 | "lint:staged": "lint-staged", 19 | "prepare": "husky install", 20 | "publish": "lerna publish", 21 | "prepublishOnly": "yarn clean && yarn && yarn test", 22 | "test": "yarn compile && yarn lint && yarn testonly", 23 | "testonly": "jest", 24 | "testonly:cov": "jest --coverage", 25 | "testonly:watch": "jest --watch", 26 | "typedoc": "typedoc --entryPointStrategy packages .", 27 | "watch": "tsc --build tsconfig.build.json --watch" 28 | }, 29 | "workspaces": [ 30 | "packages/*" 31 | ], 32 | "devDependencies": { 33 | "@types/jest": "^26.0.10", 34 | "@typescript-eslint/eslint-plugin": "^4.31.1", 35 | "@typescript-eslint/parser": "^4.31.1", 36 | "axios": "^0.21.4", 37 | "axios-mock-adapter": "^1.20.0", 38 | "chalk": "^4.1.2", 39 | "eslint": "^7.32.0", 40 | "eslint-config-prettier": "^8.3.0", 41 | "eslint-config-yoctol-base": "^0.24.1", 42 | "eslint-import-resolver-typescript": "^2.5.0", 43 | "eslint-plugin-import": "^2.24.2", 44 | "eslint-plugin-prettier": "^4.0.0", 45 | "eslint-plugin-sort-imports-es6-autofix": "^0.6.0", 46 | "eslint-plugin-tsdoc": "^0.2.14", 47 | "husky": "^7.0.2", 48 | "jest": "^26.4.0", 49 | "lerna": "^4.0.0", 50 | "lint-staged": "^11.1.2", 51 | "micromatch": "^4.0.4", 52 | "mkdir": "^0.0.2", 53 | "msw": "^0.35.0", 54 | "prettier": "^2.4.0", 55 | "prettier-package-json": "^2.6.0", 56 | "rimraf": "^3.0.2", 57 | "ts-jest": "^26.2.0", 58 | "typedoc": "^0.22.3", 59 | "typescript": "^4.4.3" 60 | }, 61 | "engines": { 62 | "node": ">=10" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/axios-error/README.md: -------------------------------------------------------------------------------- 1 | # axios-error 2 | 3 | > Axios error wrapper that aim to provide clear error message to developers 4 | 5 | ## Installation 6 | 7 | ```sh 8 | npm i --save axios-error 9 | ``` 10 | 11 | or 12 | 13 | ```sh 14 | yarn add axios-error 15 | ``` 16 | 17 |
18 | 19 | ## Usage 20 | 21 | ```js 22 | const AxiosError = require('axios-error'); 23 | 24 | // You can construct it from the error thrown by axios 25 | const error = new AxiosError(errorThrownByAxios); 26 | 27 | // Or with an custom error message 28 | const error = new AxiosError(message, errorThrownByAxios); 29 | 30 | // Or construct it from axios config, axios request and axios response 31 | const error = new AxiosError(message, { config, request, response }); 32 | ``` 33 | 34 | Calling `console.log` on the error instance returns the formatted message. If you'd like to get the axios `request`, `response`, or `config`, you can still get them via the following keys on the error instance: 35 | 36 | ```js 37 | console.log(error); // formatted error message 38 | console.log(error.stack); // error stack trace 39 | console.log(error.config); // axios request config 40 | console.log(error.request); // HTTP request 41 | console.log(error.response); // HTTP response 42 | ``` 43 | -------------------------------------------------------------------------------- /packages/axios-error/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "axios-error", 3 | "description": "Axios error wrapper that aim to provide clear error message to developers", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/Yoctol/messaging-apis.git" 8 | }, 9 | "version": "1.0.4", 10 | "main": "dist/index.js", 11 | "types": "dist/index.d.ts", 12 | "dependencies": { 13 | "axios": "^0.21.1", 14 | "type-fest": "^0.15.1" 15 | }, 16 | "keywords": [ 17 | "axios", 18 | "error", 19 | "http" 20 | ], 21 | "engines": { 22 | "node": ">=8" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/axios-error/src/__tests__/__snapshots__/index.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should work 1`] = ` 4 | " 5 | Error: boom.... 6 | at Object. (/Users/xxx/messaging-apis/packages/axios-error/src/__tests__/index.spec.js:16:19) 7 | at Generator.throw () 8 | at step (/Users/xxx/messaging-apis/packages/axios-error/src/__tests__/index.spec.js:4:336) 9 | at /Users/xxx/messaging-apis/packages/axios-error/src/__tests__/index.spec.js:4:535 10 | at 11 | 12 | 13 | Error Message - 14 | boom.... 15 | 16 | Request - 17 | POST / 18 | 19 | Request Data - 20 | { 21 | \\"x\\": 1 22 | } 23 | 24 | Response - 25 | 400 Bad Request 26 | 27 | Response Data - 28 | { 29 | \\"error_status\\": \\"boom....\\" 30 | } 31 | " 32 | `; 33 | 34 | exports[`should work with construct using error instance only 1`] = ` 35 | " 36 | Error: boom.... 37 | at Object. (/Users/xxx/messaging-apis/packages/axios-error/src/__tests__/index.spec.js:16:19) 38 | at Generator.throw () 39 | at step (/Users/xxx/messaging-apis/packages/axios-error/src/__tests__/index.spec.js:4:336) 40 | at /Users/xxx/messaging-apis/packages/axios-error/src/__tests__/index.spec.js:4:535 41 | at 42 | 43 | 44 | Error Message - 45 | Request failed with status code 400 46 | 47 | Request - 48 | POST / 49 | 50 | Request Data - 51 | { 52 | \\"x\\": 1 53 | } 54 | 55 | Response - 56 | 400 Bad Request 57 | 58 | Response Data - 59 | { 60 | \\"error_status\\": \\"boom....\\" 61 | } 62 | " 63 | `; 64 | 65 | exports[`should work with undefined response 1`] = ` 66 | " 67 | Error: boom.... 68 | at Object. (/Users/xxx/messaging-apis/packages/axios-error/src/__tests__/index.spec.js:16:19) 69 | at Generator.throw () 70 | at step (/Users/xxx/messaging-apis/packages/axios-error/src/__tests__/index.spec.js:4:336) 71 | at /Users/xxx/messaging-apis/packages/axios-error/src/__tests__/index.spec.js:4:535 72 | at 73 | 74 | 75 | Error Message - 76 | read ECONNRESET 77 | 78 | Request - 79 | POST / 80 | 81 | Request Data - 82 | { 83 | \\"x\\": 1 84 | } 85 | 86 | " 87 | `; 88 | -------------------------------------------------------------------------------- /packages/axios-error/src/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import util from 'util'; 2 | 3 | import MockAdapter from 'axios-mock-adapter'; 4 | import axios from 'axios'; 5 | 6 | import AxiosError from '..'; 7 | 8 | const mock = new MockAdapter(axios); 9 | 10 | mock.onAny().reply(400, { 11 | error_status: 'boom....', 12 | }); 13 | 14 | const stack = `Error: boom.... 15 | at Object. (/Users/xxx/messaging-apis/packages/axios-error/src/__tests__/index.spec.js:16:19) 16 | at Generator.throw () 17 | at step (/Users/xxx/messaging-apis/packages/axios-error/src/__tests__/index.spec.js:4:336) 18 | at /Users/xxx/messaging-apis/packages/axios-error/src/__tests__/index.spec.js:4:535 19 | at 20 | `; 21 | 22 | it('should work', async () => { 23 | try { 24 | await axios.post('/', { x: 1 }); 25 | } catch (err) { 26 | // overwrite because axios-mock-adapter set it to undefined 27 | err.response.statusText = 'Bad Request'; 28 | 29 | const error = new AxiosError(err.response.data.error_status, err); 30 | 31 | // overwrite stack to test it 32 | error.stack = stack; 33 | 34 | expect(error[util.inspect.custom]()).toMatchSnapshot(); 35 | expect(error.name).toBe('AxiosError'); 36 | } 37 | }); 38 | 39 | it('should set `.status` property', async () => { 40 | try { 41 | await axios.post('/', { x: 1 }); 42 | } catch (err) { 43 | // overwrite because axios-mock-adapter set it to undefined 44 | err.response.statusText = 'Bad Request'; 45 | 46 | const error = new AxiosError(err); 47 | 48 | expect(error.status).toBe(400); 49 | } 50 | }); 51 | 52 | it('should work with construct using error instance only', async () => { 53 | try { 54 | await axios.post('/', { x: 1 }); 55 | } catch (err) { 56 | // overwrite because axios-mock-adapter set it to undefined 57 | err.response.statusText = 'Bad Request'; 58 | 59 | const error = new AxiosError(err); 60 | 61 | // overwrite stack to test it 62 | error.stack = stack; 63 | 64 | expect(error[util.inspect.custom]()).toMatchSnapshot(); 65 | expect(error.name).toBe('AxiosError'); 66 | } 67 | }); 68 | 69 | it('should work with undefined response', async () => { 70 | try { 71 | await axios.post('/', { x: 1 }); 72 | } catch (err) { 73 | // overwrite to undefined 74 | // https://github.com/Yoctol/bottender/issues/246 75 | err.response = undefined; 76 | 77 | const error = new AxiosError('read ECONNRESET', err); 78 | 79 | // overwrite stack to test it 80 | error.stack = stack; 81 | 82 | expect(error[util.inspect.custom]()).toMatchSnapshot(); 83 | expect(error.name).toBe('AxiosError'); 84 | } 85 | }); 86 | -------------------------------------------------------------------------------- /packages/axios-error/src/index.ts: -------------------------------------------------------------------------------- 1 | import util from 'util'; 2 | 3 | import { AxiosError as BaseAxiosError } from 'axios'; 4 | import { JsonValue } from 'type-fest'; 5 | 6 | function indent(str: string): string { 7 | return str 8 | .split('\n') 9 | .map((s) => (s ? ` ${s}` : '')) 10 | .join('\n'); 11 | } 12 | 13 | function json(data: JsonValue): string { 14 | return JSON.stringify(data, null, 2); 15 | } 16 | 17 | class AxiosError extends Error { 18 | config: BaseAxiosError['config']; 19 | 20 | request?: BaseAxiosError['request']; 21 | 22 | response?: BaseAxiosError['response']; 23 | 24 | status?: number; 25 | 26 | /** 27 | * @example 28 | * ```js 29 | * new AxiosError(errorThrownByAxios) 30 | * ``` 31 | */ 32 | constructor(error: BaseAxiosError); 33 | 34 | /** 35 | * @example 36 | * ```js 37 | * new AxiosError('error message', errorThrownByAxios) 38 | * ``` 39 | */ 40 | constructor(message: string, error: BaseAxiosError); 41 | 42 | /** 43 | * @example 44 | * ```js 45 | * new AxiosError('error message', { config, request, response }) 46 | * ``` 47 | */ 48 | constructor( 49 | message: string, 50 | error: Pick 51 | ); 52 | 53 | constructor( 54 | messageOrError: string | BaseAxiosError, 55 | error?: 56 | | BaseAxiosError 57 | | Pick 58 | ) { 59 | let err: Pick; 60 | if (typeof messageOrError === 'string') { 61 | super(messageOrError); 62 | err = error as Pick; 63 | } else { 64 | super(messageOrError.message); 65 | err = messageOrError; 66 | } 67 | 68 | const { config, request, response } = err; 69 | 70 | this.config = config; 71 | this.request = request; 72 | this.response = response; 73 | if (response && response.status) { 74 | this.status = response.status; 75 | } 76 | 77 | this.name = 'AxiosError'; 78 | } 79 | 80 | [util.inspect.custom](): string { 81 | let requestMessage = ''; 82 | 83 | if (this.config) { 84 | let { data } = this.config; 85 | 86 | try { 87 | data = JSON.parse(data); 88 | } catch (_) {} // eslint-disable-line 89 | 90 | let requestData = ''; 91 | 92 | if (this.config.data) { 93 | requestData = ` 94 | Request Data - 95 | ${indent(json(data))}`; 96 | } 97 | 98 | requestMessage = ` 99 | Request - 100 | ${this.config.method ? this.config.method.toUpperCase() : ''} ${ 101 | this.config.url 102 | } 103 | ${requestData}`; 104 | } 105 | 106 | let responseMessage = ''; 107 | 108 | if (this.response) { 109 | let responseData; 110 | 111 | if (this.response.data) { 112 | responseData = ` 113 | Response Data - 114 | ${indent(json(this.response.data))}`; 115 | } 116 | 117 | responseMessage = ` 118 | Response - 119 | ${this.response.status} ${this.response.statusText} 120 | ${responseData}`; 121 | } 122 | 123 | return ` 124 | ${this.stack} 125 | 126 | Error Message - 127 | ${this.message} 128 | ${requestMessage} 129 | ${responseMessage} 130 | `; 131 | } 132 | } 133 | 134 | export = AxiosError; 135 | -------------------------------------------------------------------------------- /packages/axios-error/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/facebook-batch/README.md: -------------------------------------------------------------------------------- 1 | # facebook-batch 2 | 3 | > Gracefully batching facebook requests. 4 | 5 | This module is based on the approach described in [Making Batch Requests](https://developers.facebook.com/docs/graph-api/making-multiple-requests/). 6 | 7 | ## Table of Contents 8 | 9 | - [Installation](#installation) 10 | - [Usage](#usage) 11 | 12 | ## Installation 13 | 14 | ```sh 15 | npm i --save facebook-batch 16 | ``` 17 | 18 | or 19 | 20 | ```sh 21 | yarn add facebook-batch 22 | ``` 23 | 24 |
25 | 26 | ## Usage 27 | 28 | ```js 29 | const { MessengerClient, MessengerBatch } = require('messaging-api-messenger'); 30 | const { FacebookBatchQueue } = require('facebook-batch'); 31 | 32 | const queue = new FacebookBatchQueue({ 33 | accessToken: ACCESS_TOKEN, 34 | }); 35 | 36 | (async () => { 37 | await queue.push( 38 | MessengerBatch.sendText('psid', 'hello!'); 39 | ); 40 | 41 | await queue.push( 42 | MessengerBatch.sendMessage('psid', { 43 | attachment: { 44 | type: 'image', 45 | payload: { 46 | url: 47 | 'https://cdn.free.com.tw/blog/wp-content/uploads/2014/08/Placekitten480-g.jpg', 48 | }, 49 | }, 50 | }) 51 | ); 52 | 53 | const profile = await queue.push(MessengerBatch.getUserProfile('psid')); 54 | 55 | 56 | console.log(profile); 57 | 58 | queue.stop(); 59 | })(); 60 | ``` 61 | 62 | Retry for error: `(#613) Calls to this api have exceeded the rate limit.`. 63 | 64 | ```js 65 | const { FacebookBatchQueue, isError613 } = require('facebook-batch'); 66 | 67 | const queue = new FacebookBatchQueue( 68 | { 69 | accessToken: ACCESS_TOKEN, 70 | }, 71 | { 72 | shouldRetry: isError613, 73 | retryTimes: 2, 74 | } 75 | ); 76 | ``` 77 | 78 | ## Options 79 | 80 | ### delay 81 | 82 | Default: `1000`. 83 | 84 | ### retryTimes 85 | 86 | Default: `0`. 87 | 88 | ### shouldRetry 89 | 90 | Default: `() => true`. 91 | -------------------------------------------------------------------------------- /packages/facebook-batch/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "facebook-batch", 3 | "version": "1.1.0", 4 | "description": "Gracefully batching facebook requests.", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Yoctol/messaging-apis.git" 9 | }, 10 | "main": "dist/index.js", 11 | "types": "dist/index.d.ts", 12 | "dependencies": { 13 | "messaging-api-messenger": "file:../messaging-api-messenger", 14 | "type-fest": "^1.4.0" 15 | }, 16 | "keywords": [ 17 | "batch", 18 | "bottender", 19 | "facebook", 20 | "messaging-apis", 21 | "messenger" 22 | ], 23 | "engines": { 24 | "node": ">=10" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/facebook-batch/src/BatchRequestError.ts: -------------------------------------------------------------------------------- 1 | import { BatchRequest, BatchRequestErrorInfo, BatchResponse } from './types'; 2 | import { getErrorMessage } from './utils'; 3 | 4 | export default class BatchRequestError extends Error { 5 | /** 6 | * The request of the batch error. 7 | */ 8 | readonly request: BatchRequest; 9 | 10 | /** 11 | * The response of the batch error. 12 | */ 13 | readonly response: BatchResponse; 14 | 15 | /** 16 | * @example 17 | * ```js 18 | * new BatchRequestError({ 19 | * request: { 20 | * method: 'POST', 21 | * relativeUrl: 'me/messages', 22 | * body: { 23 | * messagingType: 'UPDATE', 24 | * recipient: 'PSID', 25 | * message: { text: 'Hello World' }, 26 | * }, 27 | * }, 28 | * response: { 29 | * code: 403, 30 | * body: { 31 | * error: { 32 | * type: 'OAuthException', 33 | * message: 'Invalid parameter', 34 | * code: 100, 35 | * }, 36 | * } 37 | * }, 38 | * }) 39 | * ``` 40 | */ 41 | constructor({ request, response }: BatchRequestErrorInfo) { 42 | const message = getErrorMessage({ request, response }); 43 | 44 | super(`Batch Request Error - ${message}`); 45 | 46 | this.request = request; 47 | this.response = response; 48 | } 49 | 50 | inspect(): string { 51 | return ` 52 | Error Message - Batch Request Error 53 | 54 | Request - 55 | 56 | ${this.request.method.toUpperCase()} ${this.request.relativeUrl} 57 | ${JSON.stringify(this.request.body, null, 2) || ''} 58 | 59 | Response - 60 | ${this.response.code} 61 | ${JSON.stringify(this.response.body, null, 2) || ''} 62 | `; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/facebook-batch/src/FacebookBatchQueue.ts: -------------------------------------------------------------------------------- 1 | import { JsonValue } from 'type-fest'; 2 | import { MessengerClient, MessengerTypes } from 'messaging-api-messenger'; 3 | 4 | import BatchRequestError from './BatchRequestError'; 5 | import { 6 | BatchConfig, 7 | BatchErrorResponse, 8 | BatchRequest, 9 | BatchRequestErrorInfo, 10 | QueueItem, 11 | } from './types'; 12 | 13 | const MAX_BATCH_SIZE = 50; 14 | 15 | const alwaysTrue = (): true => true; 16 | 17 | export default class FacebookBatchQueue { 18 | /** 19 | * The queue to store facebook requests. 20 | */ 21 | readonly queue: QueueItem[]; 22 | 23 | private client: MessengerClient; 24 | 25 | private delay: number; 26 | 27 | private shouldRetry: (err: BatchRequestErrorInfo) => boolean; 28 | 29 | private retryTimes: number; 30 | 31 | private includeHeaders: boolean; 32 | 33 | private timeout: NodeJS.Timeout; 34 | 35 | /** 36 | * 37 | * @param client - A MessengerClient or FacebookClient instance. 38 | * @param options - Optional batch config. 39 | * 40 | * @example 41 | * 42 | * ```js 43 | * const client = new MessengerClient(); 44 | * new FacebookBatchQueue(client); 45 | * 46 | * new FacebookBatchQueue(client, { 47 | * delay: 3000, 48 | * shouldRetry: () => true, 49 | * retryTimes: 3, 50 | * }); 51 | * ``` 52 | */ 53 | constructor( 54 | clientConfig: MessengerTypes.ClientConfig, 55 | options: BatchConfig = {} 56 | ) { 57 | this.queue = []; 58 | 59 | // TODO: we use messenger client here for now, but maybe we will replace it with some facebook base client 60 | this.client = new MessengerClient(clientConfig); 61 | this.delay = options.delay ?? 1000; 62 | this.shouldRetry = options.shouldRetry ?? alwaysTrue; 63 | this.retryTimes = options.retryTimes ?? 0; 64 | this.includeHeaders = options.includeHeaders ?? true; 65 | 66 | this.timeout = setTimeout(() => this.flush(), this.delay); 67 | } 68 | 69 | /** 70 | * Pushes a facebook request into the queue. 71 | * 72 | * @param request - The request to be queued. 73 | * @returns A promise resolves the response of the request. 74 | * 75 | * @example 76 | * 77 | * ```js 78 | * await bq.push({ 79 | * method: 'POST', 80 | * relativeUrl: 'me/messages', 81 | * body: { 82 | * messagingType: 'UPDATE', 83 | * recipient: 'PSID', 84 | * message: { text: 'Hello World' }, 85 | * }, 86 | * }); 87 | * //=> { 88 | * // recipientId: '...', 89 | * // messageId: '...', 90 | * // } 91 | * ``` 92 | */ 93 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 94 | push(request: BatchRequest): Promise { 95 | const promise = new Promise((resolve, reject) => { 96 | this.queue.push({ request, resolve, reject }); 97 | }); 98 | 99 | if (this.queue.length >= MAX_BATCH_SIZE) { 100 | this.flush(); 101 | } 102 | 103 | return promise as Promise; 104 | } 105 | 106 | /** 107 | * Flushes the queue proactively. 108 | * 109 | * This queue has a timer to flush items at a time interval, so normally you don't need to call this method. 110 | * 111 | * @example 112 | * 113 | * ```js 114 | * await bq.flush(); 115 | * ``` 116 | */ 117 | async flush(): Promise { 118 | const items = this.queue.splice(0, MAX_BATCH_SIZE); 119 | 120 | clearTimeout(this.timeout); 121 | this.timeout = setTimeout(() => this.flush(), this.delay); 122 | 123 | if (items.length < 1) return; 124 | 125 | try { 126 | const responses = await this.client.sendBatch( 127 | items.map((item) => item.request), 128 | { 129 | includeHeaders: this.includeHeaders, 130 | } 131 | ); 132 | 133 | items.forEach(({ request, resolve, reject, retry = 0 }, i) => { 134 | const response = responses[i]; 135 | if (response.code === 200) { 136 | resolve(response.body); 137 | return; 138 | } 139 | 140 | const err: BatchRequestErrorInfo = { 141 | response: response as BatchErrorResponse, 142 | request, 143 | }; 144 | 145 | if (retry < this.retryTimes && this.shouldRetry(err)) { 146 | this.queue.push({ request, resolve, reject, retry: retry + 1 }); 147 | } else { 148 | reject(new BatchRequestError(err)); 149 | } 150 | }); 151 | } catch (err: any) { 152 | items.forEach(({ request, resolve, reject, retry = 0 }) => { 153 | if (retry < this.retryTimes && this.shouldRetry(err)) { 154 | this.queue.push({ request, resolve, reject, retry: retry + 1 }); 155 | } else { 156 | reject(err); 157 | } 158 | }); 159 | } 160 | } 161 | 162 | /** 163 | * Stops the internal timer. 164 | * 165 | * @example 166 | * 167 | * ```js 168 | * bq.stop(); 169 | * ``` 170 | */ 171 | stop(): void { 172 | clearTimeout(this.timeout); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /packages/facebook-batch/src/__tests__/BatchRequestError.spec.ts: -------------------------------------------------------------------------------- 1 | import BatchRequestError from '../BatchRequestError'; 2 | import { BatchErrorResponse, BatchRequest } from '../types'; 3 | 4 | it('should work', async () => { 5 | const request: BatchRequest = { 6 | method: 'POST', 7 | relativeUrl: 'me/feed', 8 | body: { 9 | message: 'Test status update', 10 | link: 'http://developers.facebook.com/', 11 | }, 12 | }; 13 | const response: BatchErrorResponse = { 14 | code: 403, 15 | headers: [ 16 | { name: 'WWW-Authenticate', value: 'OAuth…' }, 17 | { name: 'Content-Type', value: 'text/javascript; charset=UTF-8' }, 18 | ], 19 | body: { 20 | error: { 21 | type: 'OAuthException', 22 | message: 'Invalid parameter', 23 | code: 100, 24 | }, 25 | }, 26 | }; 27 | const error = new BatchRequestError({ request, response }); 28 | 29 | expect(error.inspect()).toMatchSnapshot(); 30 | }); 31 | 32 | it('should have better error message', async () => { 33 | const request: BatchRequest = { 34 | method: 'POST', 35 | relativeUrl: 'me/feed', 36 | body: { 37 | message: 'Test status update', 38 | link: 'http://developers.facebook.com/', 39 | }, 40 | }; 41 | const response: BatchErrorResponse = { 42 | code: 403, 43 | headers: [ 44 | { name: 'WWW-Authenticate', value: 'OAuth…' }, 45 | { name: 'Content-Type', value: 'text/javascript; charset=UTF-8' }, 46 | ], 47 | body: { 48 | error: { 49 | type: 'OAuthException', 50 | message: 'Invalid parameter', 51 | code: 100, 52 | }, 53 | }, 54 | }; 55 | const error = new BatchRequestError({ request, response }); 56 | 57 | expect(error.message).toEqual('Batch Request Error - Invalid parameter'); 58 | }); 59 | -------------------------------------------------------------------------------- /packages/facebook-batch/src/__tests__/__snapshots__/BatchRequestError.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should work 1`] = ` 4 | " 5 | Error Message - Batch Request Error 6 | 7 | Request - 8 | 9 | POST me/feed 10 | { 11 | \\"message\\": \\"Test status update\\", 12 | \\"link\\": \\"http://developers.facebook.com/\\" 13 | } 14 | 15 | Response - 16 | 403 17 | { 18 | \\"error\\": { 19 | \\"type\\": \\"OAuthException\\", 20 | \\"message\\": \\"Invalid parameter\\", 21 | \\"code\\": 100 22 | } 23 | } 24 | " 25 | `; 26 | -------------------------------------------------------------------------------- /packages/facebook-batch/src/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { BatchRequestError, FacebookBatchQueue, isError613 } from '..'; 2 | 3 | it('FacebookBatchQueue should be exported', () => { 4 | expect(FacebookBatchQueue).toBeDefined(); 5 | }); 6 | 7 | it('BatchRequestError should be exported', () => { 8 | expect(BatchRequestError).toBeDefined(); 9 | }); 10 | 11 | it('error predicate should be exported', () => { 12 | expect(isError613).toBeDefined(); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/facebook-batch/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BatchRequestError } from './BatchRequestError'; 2 | export { default as FacebookBatchQueue } from './FacebookBatchQueue'; 3 | export { getErrorMessage, isError613 } from './utils'; 4 | 5 | export * from './types'; 6 | -------------------------------------------------------------------------------- /packages/facebook-batch/src/types.ts: -------------------------------------------------------------------------------- 1 | import { JsonObject } from 'type-fest'; 2 | 3 | export type BatchRequestOptions = { 4 | name?: string; 5 | dependsOn?: string; 6 | }; 7 | 8 | export type BatchRequest = { 9 | method: string; 10 | relativeUrl: string; 11 | name?: string; 12 | body?: JsonObject; 13 | responseAccessPath?: string; 14 | } & BatchRequestOptions; 15 | 16 | export type QueueItem = { 17 | request: BatchRequest; 18 | resolve: (value?: unknown) => void; 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | reject: (reason?: any) => void; 21 | retry?: number; 22 | }; 23 | 24 | export type BatchResponse = { 25 | code: number; 26 | headers?: { name: string; value: string }[]; 27 | body: T; 28 | }; 29 | 30 | export type BatchErrorResponse = BatchResponse<{ 31 | error: { 32 | type: string; 33 | message: string; 34 | code: number; 35 | }; 36 | }>; 37 | 38 | export type BatchRequestErrorInfo = { 39 | request: BatchRequest; 40 | response: BatchErrorResponse; 41 | }; 42 | 43 | export type BatchConfig = { 44 | delay?: number; 45 | shouldRetry?: (err: BatchRequestErrorInfo) => boolean; 46 | retryTimes?: number; 47 | includeHeaders?: boolean; 48 | }; 49 | -------------------------------------------------------------------------------- /packages/facebook-batch/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { BatchRequestErrorInfo } from './types'; 2 | 3 | export function getErrorMessage(errInfo: BatchRequestErrorInfo): string { 4 | try { 5 | const message = errInfo.response.body?.error?.message; 6 | return message; 7 | } catch (_) { 8 | return ''; 9 | } 10 | } 11 | 12 | export function isError613(errInfo: BatchRequestErrorInfo): boolean { 13 | const message = getErrorMessage(errInfo); 14 | return /#613/.test(message); 15 | } 16 | -------------------------------------------------------------------------------- /packages/facebook-batch/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/messaging-api-common/README.md: -------------------------------------------------------------------------------- 1 | # messaging-api-common 2 | 3 | > Helpers for common usages in Messaging API clients 4 | 5 | ## Table of Contents 6 | 7 | - [Installation](#installation) 8 | - [Usage](#usage) 9 | 10 | ## Installation 11 | 12 | ```sh 13 | npm i --save messaging-api-common 14 | ``` 15 | 16 | or 17 | 18 | ```sh 19 | yarn add messaging-api-common 20 | ``` 21 | 22 |
23 | 24 | ## Usage 25 | 26 | Case Convertors: 27 | 28 | ```js 29 | const { 30 | snakecase, 31 | snakecaseKeys, 32 | snakecaseKeysDeep, 33 | camelcase, 34 | camelcaseKeys, 35 | camelcaseKeysDeep, 36 | pascalcase, 37 | pascalcaseKeys, 38 | pascalcaseKeysDeep, 39 | } = require('messaging-api-common'); 40 | 41 | snakecase('fooBar'); 42 | //=> 'foo_bar' 43 | snakecaseKeys({ fooBar: true }); 44 | //=> { 'foo_bar': true } 45 | snakecaseKeysDeep({ fooBar: { barFoo: true } }); 46 | //=> { 'foo_bar': { 'bar_foo': true } } 47 | 48 | camelcase('foo_bar'); 49 | //=> 'fooBar' 50 | camelcaseKeys({ foo_bar: true }); 51 | //=> { 'fooBar': true } 52 | camelcaseKeysDeep({ foo_bar: { bar_foo: true } }); 53 | //=> { 'fooBar': { 'barFoo': true } } 54 | 55 | pascalcase('fooBar'); 56 | //=> 'FooBar' 57 | pascalcaseKeys({ fooBar: true }); 58 | //=> { 'FooBar': true } 59 | pascalcaseKeysDeep({ fooBar: { barFoo: true } }); 60 | //=> { 'FooBar': { 'BarFoo': true } } 61 | ``` 62 | 63 | Axios Request Interceptors: 64 | 65 | ```js 66 | const { onRequest, createRequestInterceptor } = require('messaging-api-common'); 67 | 68 | // use the default onRequest function 69 | axios.interceptors.request.use(createRequestInterceptor()); 70 | 71 | // use the custom onRequest function 72 | axios.interceptors.request.use( 73 | createRequestInterceptor({ 74 | onRequest: (request) => { 75 | console.log(request); 76 | }, 77 | }) 78 | ); 79 | ``` 80 | -------------------------------------------------------------------------------- /packages/messaging-api-common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "messaging-api-common", 3 | "version": "1.0.4", 4 | "description": "Helpers for common usages in Messaging API clients", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Yoctol/messaging-apis.git" 9 | }, 10 | "main": "dist/index.js", 11 | "types": "dist/index.d.ts", 12 | "dependencies": { 13 | "@types/debug": "^4.1.5", 14 | "@types/lodash": "^4.14.156", 15 | "@types/url-join": "^4.0.0", 16 | "axios": "^0.21.1", 17 | "camel-case": "^4.1.1", 18 | "debug": "^4.1.1", 19 | "lodash": "^4.17.15", 20 | "map-obj": "^4.1.0", 21 | "pascal-case": "^3.1.1", 22 | "snake-case": "^3.0.3", 23 | "type-fest": "1.4.0", 24 | "url-join": "^4.0.1" 25 | }, 26 | "keywords": [ 27 | "bot", 28 | "chatbot", 29 | "messaging-apis" 30 | ], 31 | "engines": { 32 | "node": ">=10" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/messaging-api-common/src/__tests__/case.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | camelcase, 3 | camelcaseKeys, 4 | camelcaseKeysDeep, 5 | pascalcase, 6 | pascalcaseKeys, 7 | pascalcaseKeysDeep, 8 | snakecase, 9 | snakecaseKeys, 10 | snakecaseKeysDeep, 11 | } from '../case'; 12 | 13 | it('snakecase', () => { 14 | expect(snakecase('myKey')).toEqual('my_key'); 15 | expect(snakecase('has2fa')).toEqual('has_2fa'); 16 | expect(snakecase('image1024')).toEqual('image_1024'); 17 | }); 18 | 19 | it('snakecaseKeys', () => { 20 | expect(snakecaseKeys({ myKey: 'value' })).toEqual({ 21 | my_key: 'value', 22 | }); 23 | expect(snakecaseKeys({ myObj: { myKey: 'value' } }, { deep: false })).toEqual( 24 | { 25 | my_obj: { myKey: 'value' }, 26 | } 27 | ); 28 | expect(snakecaseKeys({ myObj: { myKey: 'value' } }, { deep: true })).toEqual({ 29 | my_obj: { my_key: 'value' }, 30 | }); 31 | }); 32 | 33 | it('snakecaseKeysDeep', () => { 34 | expect(snakecaseKeysDeep({ myObj: { myKey: 'value' } })).toEqual({ 35 | my_obj: { my_key: 'value' }, 36 | }); 37 | }); 38 | 39 | it('camelcase', () => { 40 | expect(camelcase('my_key')).toEqual('myKey'); 41 | expect(camelcase('has_2fa')).toEqual('has2fa'); 42 | expect(camelcase('image_1024')).toEqual('image1024'); 43 | }); 44 | 45 | it('camelcaseKeys', () => { 46 | expect(camelcaseKeys({ my_key: 'value' })).toEqual({ 47 | myKey: 'value', 48 | }); 49 | expect( 50 | camelcaseKeys({ my_obj: { my_key: 'value' } }, { deep: false }) 51 | ).toEqual({ 52 | myObj: { my_key: 'value' }, 53 | }); 54 | expect( 55 | camelcaseKeys({ my_obj: { my_key: 'value' } }, { deep: true }) 56 | ).toEqual({ 57 | myObj: { myKey: 'value' }, 58 | }); 59 | }); 60 | 61 | it('camelcaseKeysDeep', () => { 62 | expect(camelcaseKeysDeep({ my_obj: { my_key: 'value' } })).toEqual({ 63 | myObj: { myKey: 'value' }, 64 | }); 65 | }); 66 | 67 | it('pascalcase', () => { 68 | expect(pascalcase('myKey')).toEqual('MyKey'); 69 | }); 70 | 71 | it('pascalcaseKeys', () => { 72 | expect(pascalcaseKeys({ myKey: 'value' })).toEqual({ 73 | MyKey: 'value', 74 | }); 75 | expect( 76 | pascalcaseKeys({ myObj: { myKey: 'value' } }, { deep: false }) 77 | ).toEqual({ 78 | MyObj: { myKey: 'value' }, 79 | }); 80 | expect(pascalcaseKeys({ myObj: { myKey: 'value' } }, { deep: true })).toEqual( 81 | { 82 | MyObj: { MyKey: 'value' }, 83 | } 84 | ); 85 | }); 86 | 87 | it('pascalcaseKeysDeep', () => { 88 | expect(pascalcaseKeysDeep({ myObj: { myKey: 'value' } })).toEqual({ 89 | MyObj: { MyKey: 'value' }, 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /packages/messaging-api-common/src/case.ts: -------------------------------------------------------------------------------- 1 | import mapObject from 'map-obj'; 2 | import { camelCase } from 'camel-case'; 3 | import { pascalCase } from 'pascal-case'; 4 | import { snakeCase } from 'snake-case'; 5 | import type { 6 | CamelCase, 7 | CamelCasedProperties, 8 | CamelCasedPropertiesDeep, 9 | PascalCase, 10 | PascalCasedProperties, 11 | PascalCasedPropertiesDeep, 12 | SnakeCase, 13 | SnakeCasedProperties, 14 | SnakeCasedPropertiesDeep, 15 | } from 'type-fest'; 16 | import type { Options as MapOptions } from 'map-obj'; 17 | 18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 | type PlainObject = Record; 20 | 21 | function isLastCharNumber(key: string): boolean { 22 | return /^\d$/.test(key[key.length - 1]); 23 | } 24 | 25 | function splitLastChar(key: string): string { 26 | return `${key.slice(0, key.length - 1)}_${key.slice( 27 | key.length - 1, 28 | key.length 29 | )}`; 30 | } 31 | 32 | /** 33 | * Converts a string to snake case. 34 | * 35 | * @param text - The input string 36 | * @returns The converted string 37 | * 38 | * @example 39 | * ```js 40 | * snakecase('fooBar'); 41 | * //=> 'foo_bar' 42 | * ``` 43 | */ 44 | function snakecase(text: T): SnakeCase { 45 | const matches = text.match(/\d+/g); 46 | if (!matches) { 47 | return snakeCase(text) as SnakeCase; 48 | } 49 | 50 | let modifiedStr: string = text; 51 | for (let i = 0; i < matches.length; i++) { 52 | const match = matches[i]; 53 | const mathIndex = modifiedStr.indexOf(match); 54 | modifiedStr = `${modifiedStr.slice(0, mathIndex)}_${modifiedStr.slice( 55 | mathIndex, 56 | modifiedStr.length 57 | )}`; 58 | } 59 | 60 | return snakeCase(modifiedStr) as SnakeCase; 61 | } 62 | 63 | /** 64 | * Converts object keys to snake case. 65 | * 66 | * @param obj - The input object 67 | * @param options - The options to config this convert function 68 | * @returns The converted object 69 | * 70 | * @example 71 | * ```js 72 | * snakecaseKeys({ 'fooBar': true }); 73 | * //=> { 'foo_bar': true } 74 | * ``` 75 | */ 76 | function snakecaseKeys( 77 | obj: T, 78 | options?: O 79 | ): O['deep'] extends true 80 | ? SnakeCasedPropertiesDeep 81 | : SnakeCasedProperties { 82 | return mapObject( 83 | obj, 84 | (key, val) => [snakecase(key as string), val], 85 | options 86 | ) as O['deep'] extends true 87 | ? SnakeCasedPropertiesDeep 88 | : SnakeCasedProperties; 89 | } 90 | 91 | /** 92 | * Converts object keys to snake case deeply. 93 | * 94 | * @param obj - The input object 95 | * @returns The converted object 96 | * 97 | * @example 98 | * ```js 99 | * snakecaseKeysDeep({ 'fooBar': { 'barFoo': true } }); 100 | * //=> { 'foo_bar': { 'bar_foo': true } } 101 | * ``` 102 | */ 103 | function snakecaseKeysDeep( 104 | obj: T 105 | ): SnakeCasedPropertiesDeep { 106 | return snakecaseKeys(obj, { deep: true }); 107 | } 108 | 109 | /** 110 | * Converts a string to camel case. 111 | * 112 | * @param text - The input string 113 | * @returns The converted string 114 | * 115 | * @example 116 | * ```js 117 | * camelcase('foo_bar'); 118 | * //=> 'fooBar' 119 | * ``` 120 | */ 121 | function camelcase(text: T): CamelCase { 122 | const parts = text.split('_'); 123 | const modifiedStr = parts.reduce((acc, part) => { 124 | if (acc === '') return part; 125 | if (/^\d+/.test(part)) { 126 | return acc + part; 127 | } 128 | return `${acc}_${part}`; 129 | }, ''); 130 | return camelCase(modifiedStr) as CamelCase; 131 | } 132 | 133 | /** 134 | * Converts object keys to camel case. 135 | * 136 | * @param obj - The input object 137 | * @param options - The options to config this convert function 138 | * @returns The converted object 139 | * 140 | * @example 141 | * ```js 142 | * camelcaseKeys({ 'foo_bar': true }); 143 | * //=> { 'fooBar': true } 144 | * ``` 145 | */ 146 | function camelcaseKeys( 147 | obj: T, 148 | options?: O 149 | ): O['deep'] extends true 150 | ? CamelCasedPropertiesDeep 151 | : CamelCasedProperties { 152 | return mapObject( 153 | obj, 154 | (key, val) => [camelcase(key as string), val], 155 | options 156 | ) as O['deep'] extends true 157 | ? CamelCasedPropertiesDeep 158 | : CamelCasedProperties; 159 | } 160 | 161 | /** 162 | * Converts object keys to camel case deeply. 163 | * 164 | * @param obj - The input object 165 | * @returns The converted object 166 | * 167 | * @example 168 | * ```js 169 | * camelcaseKeysDeep({ 'foo_bar': { 'bar_foo': true } }); 170 | * //=> { 'fooBar': { 'barFoo': true } } 171 | * ``` 172 | */ 173 | function camelcaseKeysDeep( 174 | obj: T 175 | ): CamelCasedPropertiesDeep { 176 | return camelcaseKeys(obj, { deep: true }); 177 | } 178 | 179 | /** 180 | * Converts a string to pascal case. 181 | * 182 | * @param text - The input string 183 | * @returns The converted string 184 | * 185 | * @example 186 | * ```js 187 | * pascalcase('fooBar'); 188 | * //=> 'FooBar' 189 | * ``` 190 | */ 191 | function pascalcase(str: T): PascalCase { 192 | return pascalCase( 193 | isLastCharNumber(str) ? splitLastChar(str) : str 194 | ) as PascalCase; 195 | } 196 | 197 | /** 198 | * Converts object keys to pascal case. 199 | * 200 | * @param obj - The input object 201 | * @param options - The options to config this convert function 202 | * @returns The converted object 203 | * 204 | * @example 205 | * ```js 206 | * pascalcaseKeys({ 'fooBar': true }); 207 | * //=> { 'FooBar': true } 208 | * ``` 209 | */ 210 | function pascalcaseKeys( 211 | obj: T, 212 | options?: O 213 | ): O['deep'] extends true 214 | ? PascalCasedPropertiesDeep 215 | : PascalCasedProperties { 216 | return mapObject( 217 | obj, 218 | (key, val) => [pascalcase(key as string), val], 219 | options 220 | ) as O['deep'] extends true 221 | ? PascalCasedPropertiesDeep 222 | : PascalCasedProperties; 223 | } 224 | 225 | /** 226 | * Converts object keys to pascal case deeply. 227 | * 228 | * @param obj - The input object 229 | * @returns The converted object 230 | * 231 | * @example 232 | * ```js 233 | * pascalcaseKeysDeep({ 'fooBar': { 'barFoo': true } }); 234 | * //=> { 'FooBar': { 'BarFoo': true } } 235 | * ``` 236 | */ 237 | function pascalcaseKeysDeep( 238 | obj: T 239 | ): PascalCasedPropertiesDeep { 240 | return pascalcaseKeys(obj, { deep: true }); 241 | } 242 | 243 | export { 244 | snakecase, 245 | snakecaseKeys, 246 | snakecaseKeysDeep, 247 | camelcase, 248 | camelcaseKeys, 249 | camelcaseKeysDeep, 250 | pascalcase, 251 | pascalcaseKeys, 252 | pascalcaseKeysDeep, 253 | }; 254 | -------------------------------------------------------------------------------- /packages/messaging-api-common/src/index.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | import omit from 'lodash/omit'; 3 | import urlJoin from 'url-join'; 4 | import { AxiosRequestConfig, Method } from 'axios'; 5 | 6 | const debugRequest = debug('messaging-api:request'); 7 | 8 | export type RequestPayload = { 9 | method?: Method; 10 | url: string; 11 | headers: Record; 12 | body: any; // eslint-disable-line @typescript-eslint/no-explicit-any 13 | }; 14 | 15 | export type OnRequestFunction = (request: RequestPayload) => void; 16 | 17 | function defaultOnRequest(request: RequestPayload): void { 18 | debugRequest(`${request.method} - ${request.url}`); 19 | if (request.body) { 20 | debugRequest('Outgoing request body:'); 21 | if (Buffer.isBuffer(request.body)) { 22 | debugRequest(request.body); 23 | } else { 24 | debugRequest(JSON.stringify(request.body, null, 2)); 25 | } 26 | } 27 | } 28 | 29 | function createRequestInterceptor({ 30 | onRequest = defaultOnRequest, 31 | }: { onRequest?: OnRequestFunction } = {}) { 32 | return (config: AxiosRequestConfig): AxiosRequestConfig => { 33 | onRequest({ 34 | method: config.method, 35 | url: urlJoin(config.baseURL || '', config.url || '/'), 36 | headers: { 37 | ...config.headers.common, 38 | ...(config.method ? config.headers[config.method] : {}), 39 | ...omit(config.headers, [ 40 | 'common', 41 | 'get', 42 | 'post', 43 | 'put', 44 | 'patch', 45 | 'delete', 46 | 'head', 47 | ]), 48 | }, 49 | 50 | body: config.data, 51 | }); 52 | 53 | return config; 54 | }; 55 | } 56 | 57 | export * from './case'; 58 | 59 | export { defaultOnRequest as onRequest, createRequestInterceptor }; 60 | -------------------------------------------------------------------------------- /packages/messaging-api-common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/messaging-api-line/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "messaging-api-line", 3 | "version": "1.1.0", 4 | "description": "Messaging API client for LINE", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Yoctol/messaging-apis.git" 9 | }, 10 | "main": "dist/index.js", 11 | "browser": "lib/browser.js", 12 | "types": "dist/index.d.ts", 13 | "dependencies": { 14 | "@types/warning": "^3.0.0", 15 | "axios": "^0.21.1", 16 | "axios-error": "file:../axios-error", 17 | "image-type": "^4.1.0", 18 | "messaging-api-common": "file:../messaging-api-common", 19 | "ts-invariant": "^0.4.4", 20 | "type-fest": "^0.15.1", 21 | "warning": "^4.0.3" 22 | }, 23 | "keywords": [ 24 | "bot", 25 | "chatbot", 26 | "line", 27 | "messaging-apis" 28 | ], 29 | "engines": { 30 | "node": ">=10" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/messaging-api-line/src/Line.ts: -------------------------------------------------------------------------------- 1 | import * as LineTypes from './LineTypes'; 2 | 3 | export function createText( 4 | text: string, 5 | options: LineTypes.MessageOptions & { emojis?: LineTypes.Emoji[] } = {} 6 | ): LineTypes.TextMessage { 7 | return { 8 | type: 'text', 9 | text, 10 | ...options, 11 | }; 12 | } 13 | 14 | export function createImage( 15 | image: { 16 | originalContentUrl: string; 17 | previewImageUrl?: string; 18 | }, 19 | options: LineTypes.MessageOptions = {} 20 | ): LineTypes.ImageMessage { 21 | return { 22 | type: 'image', 23 | originalContentUrl: image.originalContentUrl, 24 | previewImageUrl: image.previewImageUrl || image.originalContentUrl, 25 | ...options, 26 | }; 27 | } 28 | 29 | export function createVideo( 30 | video: { 31 | originalContentUrl: string; 32 | previewImageUrl: string; 33 | }, 34 | options: LineTypes.MessageOptions = {} 35 | ): LineTypes.VideoMessage { 36 | return { 37 | type: 'video', 38 | originalContentUrl: video.originalContentUrl, 39 | previewImageUrl: video.previewImageUrl, 40 | ...options, 41 | }; 42 | } 43 | 44 | export function createAudio( 45 | audio: { 46 | originalContentUrl: string; 47 | duration: number; 48 | }, 49 | options: LineTypes.MessageOptions = {} 50 | ): LineTypes.AudioMessage { 51 | return { 52 | type: 'audio', 53 | originalContentUrl: audio.originalContentUrl, 54 | duration: audio.duration, 55 | ...options, 56 | }; 57 | } 58 | 59 | export function createLocation( 60 | { title, address, latitude, longitude }: LineTypes.Location, 61 | options: LineTypes.MessageOptions = {} 62 | ): LineTypes.LocationMessage { 63 | return { 64 | type: 'location', 65 | title, 66 | address, 67 | latitude, 68 | longitude, 69 | ...options, 70 | }; 71 | } 72 | 73 | export function createSticker( 74 | sticker: Omit, 75 | options: LineTypes.MessageOptions = {} 76 | ): LineTypes.StickerMessage { 77 | return { 78 | type: 'sticker', 79 | packageId: sticker.packageId, 80 | stickerId: sticker.stickerId, 81 | ...options, 82 | }; 83 | } 84 | 85 | export function createImagemap( 86 | altText: string, 87 | { 88 | baseUrl, 89 | baseSize, 90 | video, 91 | actions, 92 | }: Omit, 93 | options: LineTypes.MessageOptions = {} 94 | ): LineTypes.ImagemapMessage { 95 | return { 96 | type: 'imagemap', 97 | baseUrl, 98 | altText, 99 | baseSize, 100 | video, 101 | actions, 102 | ...options, 103 | }; 104 | } 105 | 106 | export function createTemplate( 107 | altText: string, 108 | template: T, 109 | options: LineTypes.MessageOptions = {} 110 | ): LineTypes.TemplateMessage { 111 | return { 112 | type: 'template', 113 | altText, 114 | template, 115 | ...options, 116 | }; 117 | } 118 | 119 | export function createButtonTemplate( 120 | altText: string, 121 | { 122 | thumbnailImageUrl, 123 | imageAspectRatio, 124 | imageSize, 125 | imageBackgroundColor, 126 | title, 127 | text, 128 | defaultAction, 129 | actions, 130 | }: { 131 | thumbnailImageUrl?: string; 132 | imageAspectRatio?: 'rectangle' | 'square'; 133 | imageSize?: 'cover' | 'contain'; 134 | imageBackgroundColor?: string; 135 | title?: string; 136 | text: string; 137 | defaultAction?: LineTypes.Action; 138 | actions: LineTypes.Action[]; 139 | }, 140 | options: LineTypes.MessageOptions = {} 141 | ): LineTypes.TemplateMessage { 142 | return createTemplate( 143 | altText, 144 | { 145 | type: 'buttons', 146 | thumbnailImageUrl, 147 | imageAspectRatio, 148 | imageSize, 149 | imageBackgroundColor, 150 | title, 151 | text, 152 | defaultAction, 153 | actions, 154 | }, 155 | options 156 | ); 157 | } 158 | 159 | export function createButtonsTemplate( 160 | altText: string, 161 | { 162 | thumbnailImageUrl, 163 | imageAspectRatio, 164 | imageSize, 165 | imageBackgroundColor, 166 | title, 167 | text, 168 | defaultAction, 169 | actions, 170 | }: { 171 | thumbnailImageUrl?: string; 172 | imageAspectRatio?: 'rectangle' | 'square'; 173 | imageSize?: 'cover' | 'contain'; 174 | imageBackgroundColor?: string; 175 | title?: string; 176 | text: string; 177 | defaultAction?: LineTypes.Action; 178 | actions: LineTypes.Action[]; 179 | }, 180 | options: LineTypes.MessageOptions = {} 181 | ): LineTypes.TemplateMessage { 182 | return createTemplate( 183 | altText, 184 | { 185 | type: 'buttons', 186 | thumbnailImageUrl, 187 | imageAspectRatio, 188 | imageSize, 189 | imageBackgroundColor, 190 | title, 191 | text, 192 | defaultAction, 193 | actions, 194 | }, 195 | options 196 | ); 197 | } 198 | 199 | export function createConfirmTemplate( 200 | altText: string, 201 | { 202 | text, 203 | actions, 204 | }: { 205 | text: string; 206 | actions: LineTypes.Action[]; 207 | }, 208 | options: LineTypes.MessageOptions = {} 209 | ): LineTypes.TemplateMessage { 210 | return createTemplate( 211 | altText, 212 | { 213 | type: 'confirm', 214 | text, 215 | actions, 216 | }, 217 | options 218 | ); 219 | } 220 | 221 | export function createCarouselTemplate( 222 | altText: string, 223 | columns: LineTypes.ColumnObject[], 224 | { 225 | imageAspectRatio, 226 | imageSize, 227 | quickReply, 228 | }: { 229 | imageAspectRatio?: 'rectangle' | 'square'; 230 | imageSize?: 'cover' | 'contain'; 231 | quickReply?: LineTypes.QuickReply; 232 | } = {} 233 | ): LineTypes.TemplateMessage { 234 | return createTemplate( 235 | altText, 236 | { 237 | type: 'carousel', 238 | columns, 239 | imageAspectRatio, 240 | imageSize, 241 | }, 242 | { quickReply } 243 | ); 244 | } 245 | 246 | export function createImageCarouselTemplate( 247 | altText: string, 248 | columns: LineTypes.ImageCarouselColumnObject[], 249 | options: LineTypes.MessageOptions = {} 250 | ): LineTypes.TemplateMessage { 251 | return createTemplate( 252 | altText, 253 | { 254 | type: 'image_carousel', 255 | columns, 256 | }, 257 | options 258 | ); 259 | } 260 | 261 | export function createFlex( 262 | altText: string, 263 | contents: LineTypes.FlexContainer, 264 | options: LineTypes.MessageOptions = {} 265 | ): LineTypes.FlexMessage { 266 | return { 267 | type: 'flex', 268 | altText, 269 | contents, 270 | ...options, 271 | }; 272 | } 273 | -------------------------------------------------------------------------------- /packages/messaging-api-line/src/LinePay.ts: -------------------------------------------------------------------------------- 1 | import querystring from 'querystring'; 2 | 3 | import AxiosError from 'axios-error'; 4 | import axios, { 5 | AxiosInstance, 6 | AxiosResponse, 7 | AxiosError as BaseAxiosError, 8 | } from 'axios'; 9 | import invariant from 'ts-invariant'; 10 | import warning from 'warning'; 11 | 12 | import * as LineTypes from './LineTypes'; 13 | 14 | function handleError( 15 | err: BaseAxiosError<{ 16 | returnCode: string; 17 | returnMessage: string; 18 | }> 19 | ): never { 20 | if (err.response && err.response.data) { 21 | const { returnCode, returnMessage } = err.response.data; 22 | const msg = `LINE PAY API - ${returnCode} ${returnMessage}`; 23 | throw new AxiosError(msg, err); 24 | } 25 | throw new AxiosError(err.message, err); 26 | } 27 | 28 | function throwWhenNotSuccess( 29 | res: AxiosResponse<{ 30 | returnCode: string; 31 | returnMessage: string; 32 | info?: T; 33 | }> 34 | ): T | undefined { 35 | if (res.data.returnCode !== '0000') { 36 | const { returnCode, returnMessage } = res.data; 37 | const msg = `LINE PAY API - ${returnCode} ${returnMessage}`; 38 | throw new AxiosError(msg, { 39 | response: res, 40 | config: res.config, 41 | request: res.request, 42 | }); 43 | } 44 | return res.data.info; 45 | } 46 | 47 | export default class LinePay { 48 | /** 49 | * @deprecated Use `new LinePay(...)` instead. 50 | */ 51 | static connect(config: LineTypes.LinePayConfig): LinePay { 52 | warning( 53 | false, 54 | '`LineNotify.connect(...)` is deprecated. Use `new LineNotify(...)` instead.' 55 | ); 56 | return new LinePay(config); 57 | } 58 | 59 | /** 60 | * The underlying axios instance. 61 | */ 62 | readonly axios: AxiosInstance; 63 | 64 | constructor({ 65 | channelId, 66 | channelSecret, 67 | sandbox = false, 68 | origin, 69 | }: LineTypes.LinePayConfig) { 70 | const linePayOrigin = sandbox 71 | ? 'https://sandbox-api-pay.line.me' 72 | : 'https://api-pay.line.me'; 73 | 74 | this.axios = axios.create({ 75 | baseURL: `${origin || linePayOrigin}/v2/`, 76 | headers: { 77 | 'Content-Type': 'application/json', 78 | 'X-LINE-ChannelId': channelId, 79 | 'X-LINE-ChannelSecret': channelSecret, 80 | }, 81 | }); 82 | } 83 | 84 | getPayments({ 85 | transactionId, 86 | orderId, 87 | }: { 88 | transactionId?: string; 89 | orderId?: string; 90 | } = {}) { 91 | invariant( 92 | transactionId || orderId, 93 | 'getPayments: One of `transactionId` or `orderId` must be provided' 94 | ); 95 | 96 | const query: { 97 | transactionId?: string; 98 | orderId?: string; 99 | } = {}; 100 | 101 | if (transactionId) { 102 | query.transactionId = transactionId; 103 | } 104 | 105 | if (orderId) { 106 | query.orderId = orderId; 107 | } 108 | 109 | return this.axios 110 | .get(`/payments?${querystring.stringify(query)}`) 111 | .then(throwWhenNotSuccess, handleError); 112 | } 113 | 114 | getAuthorizations({ 115 | transactionId, 116 | orderId, 117 | }: { 118 | transactionId?: string; 119 | orderId?: string; 120 | } = {}) { 121 | invariant( 122 | transactionId || orderId, 123 | 'getAuthorizations: One of `transactionId` or `orderId` must be provided' 124 | ); 125 | 126 | const query: { 127 | transactionId?: string; 128 | orderId?: string; 129 | } = {}; 130 | 131 | if (transactionId) { 132 | query.transactionId = transactionId; 133 | } 134 | 135 | if (orderId) { 136 | query.orderId = orderId; 137 | } 138 | 139 | return this.axios 140 | .get(`/payments/authorizations?${querystring.stringify(query)}`) 141 | .then(throwWhenNotSuccess, handleError); 142 | } 143 | 144 | reserve({ 145 | productName, 146 | amount, 147 | currency, 148 | confirmUrl, 149 | orderId, 150 | ...options 151 | }: { 152 | productName: string; 153 | amount: number; 154 | currency: LineTypes.LinePayCurrency; 155 | confirmUrl: string; 156 | orderId: string; 157 | productImageUrl?: string; 158 | mid?: string; 159 | oneTimeKey?: string; 160 | confirmUrlType?: 'CLIENT' | 'SERVER'; 161 | checkConfirmUrlBrowser?: boolean; 162 | cancelUrl?: string; 163 | packageName?: string; 164 | deliveryPlacePhone?: string; 165 | payType?: 'NORMAL' | 'PREAPPROVED'; 166 | langCd?: 'ja' | 'ko' | 'en' | 'zh-Hans' | 'zh-Hant' | 'th'; 167 | capture?: boolean; 168 | extras?: Record; 169 | }) { 170 | return this.axios 171 | .post('/payments/request', { 172 | productName, 173 | amount, 174 | currency, 175 | confirmUrl, 176 | orderId, 177 | ...options, 178 | }) 179 | .then(throwWhenNotSuccess, handleError); 180 | } 181 | 182 | confirm( 183 | transactionId: string, 184 | { 185 | amount, 186 | currency, 187 | }: { 188 | amount: number; 189 | currency: LineTypes.LinePayCurrency; 190 | } 191 | ) { 192 | return this.axios 193 | .post(`/payments/${transactionId}/confirm`, { 194 | amount, 195 | currency, 196 | }) 197 | .then(throwWhenNotSuccess, handleError); 198 | } 199 | 200 | capture( 201 | transactionId: string, 202 | { 203 | amount, 204 | currency, 205 | }: { 206 | amount: number; 207 | currency: LineTypes.LinePayCurrency; 208 | } 209 | ) { 210 | return this.axios 211 | .post(`/payments/authorizations/${transactionId}/capture`, { 212 | amount, 213 | currency, 214 | }) 215 | .then(throwWhenNotSuccess, handleError); 216 | } 217 | 218 | void(transactionId: string) { 219 | return this.axios 220 | .post(`/payments/authorizations/${transactionId}/void`) 221 | .then(throwWhenNotSuccess, handleError); 222 | } 223 | 224 | refund(transactionId: string, options: { refundAmount?: number } = {}) { 225 | return this.axios 226 | .post(`/payments/${transactionId}/refund`, options) 227 | .then(throwWhenNotSuccess, handleError); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /packages/messaging-api-line/src/__tests__/LineClient-broadcast.spec.ts: -------------------------------------------------------------------------------- 1 | import { RestRequest, rest } from 'msw'; 2 | import { setupServer } from 'msw/node'; 3 | 4 | import LineClient from '../LineClient'; 5 | 6 | const lineServer = setupServer(); 7 | beforeAll(() => { 8 | // Establish requests interception layer before all tests. 9 | lineServer.listen(); 10 | }); 11 | afterEach(() => { 12 | // Reset any runtime handlers tests may use. 13 | lineServer.resetHandlers(); 14 | }); 15 | afterAll(() => { 16 | // Clean up after all tests are done, preventing this 17 | // interception layer from affecting irrelevant tests. 18 | lineServer.close(); 19 | }); 20 | 21 | describe('#broadcastRaw', () => { 22 | it('should call broadcast api', async () => { 23 | let request: RestRequest | undefined; 24 | lineServer.use( 25 | rest.post( 26 | 'https://api.line.me/v2/bot/message/broadcast', 27 | (req, res, ctx) => { 28 | request = req; 29 | return res(ctx.json({})); 30 | } 31 | ) 32 | ); 33 | 34 | const client = new LineClient({ 35 | accessToken: 'ACCESS_TOKEN', 36 | channelSecret: 'CHANNEL_SECRET', 37 | }); 38 | 39 | await client.broadcastRawBody({ 40 | messages: [ 41 | { 42 | type: 'text', 43 | text: 'Hello, world1', 44 | }, 45 | ], 46 | }); 47 | 48 | request = request as RestRequest; 49 | 50 | expect(request).toBeDefined(); 51 | expect(request.method).toBe('POST'); 52 | expect(request.url.toString()).toBe( 53 | 'https://api.line.me/v2/bot/message/broadcast' 54 | ); 55 | expect(request.body).toEqual({ 56 | messages: [ 57 | { 58 | type: 'text', 59 | text: 'Hello, world1', 60 | }, 61 | ], 62 | }); 63 | expect(request.headers.get('Content-Type')).toBe('application/json'); 64 | expect(request.headers.get('Authorization')).toBe('Bearer ACCESS_TOKEN'); 65 | }); 66 | }); 67 | 68 | describe('#broadcast', () => { 69 | it('should call broadcast api', async () => { 70 | let request: RestRequest | undefined; 71 | lineServer.use( 72 | rest.post( 73 | 'https://api.line.me/v2/bot/message/broadcast', 74 | (req, res, ctx) => { 75 | request = req; 76 | return res(ctx.json({})); 77 | } 78 | ) 79 | ); 80 | 81 | const client = new LineClient({ 82 | accessToken: 'ACCESS_TOKEN', 83 | channelSecret: 'CHANNEL_SECRET', 84 | }); 85 | 86 | await client.broadcast([ 87 | { 88 | type: 'text', 89 | text: 'Hello, world1', 90 | }, 91 | ]); 92 | 93 | request = request as RestRequest; 94 | 95 | expect(request).toBeDefined(); 96 | expect(request.method).toBe('POST'); 97 | expect(request.url.toString()).toBe( 98 | 'https://api.line.me/v2/bot/message/broadcast' 99 | ); 100 | expect(request.body).toEqual({ 101 | messages: [ 102 | { 103 | type: 'text', 104 | text: 'Hello, world1', 105 | }, 106 | ], 107 | }); 108 | expect(request.headers.get('Content-Type')).toBe('application/json'); 109 | expect(request.headers.get('Authorization')).toBe('Bearer ACCESS_TOKEN'); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /packages/messaging-api-line/src/__tests__/LineClient-constructor.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import LineClient from '../LineClient'; 4 | 5 | const ACCESS_TOKEN = '1234567890'; 6 | const CHANNEL_SECRET = 'so-secret'; 7 | 8 | describe('connect', () => { 9 | let axios; 10 | let _create; 11 | beforeEach(() => { 12 | axios = require('axios'); 13 | _create = axios.create; 14 | }); 15 | 16 | afterEach(() => { 17 | axios.create = _create; 18 | }); 19 | 20 | describe('create axios with Line API', () => { 21 | it('with config', () => { 22 | axios.create = jest.fn().mockReturnValue({ 23 | interceptors: { 24 | request: { 25 | use: jest.fn(), 26 | }, 27 | }, 28 | }); 29 | LineClient.connect({ 30 | accessToken: ACCESS_TOKEN, 31 | channelSecret: CHANNEL_SECRET, 32 | }); 33 | 34 | expect(axios.create).toBeCalledWith({ 35 | baseURL: 'https://api.line.me/', 36 | headers: { 37 | Authorization: `Bearer ${ACCESS_TOKEN}`, 38 | 'Content-Type': 'application/json', 39 | }, 40 | }); 41 | }); 42 | }); 43 | 44 | it('support origin', () => { 45 | axios.create = jest.fn().mockReturnValue({ 46 | interceptors: { 47 | request: { 48 | use: jest.fn(), 49 | }, 50 | }, 51 | }); 52 | LineClient.connect({ 53 | accessToken: ACCESS_TOKEN, 54 | channelSecret: CHANNEL_SECRET, 55 | origin: 'https://mydummytestserver.com', 56 | }); 57 | 58 | expect(axios.create).toBeCalledWith({ 59 | baseURL: 'https://mydummytestserver.com/', 60 | headers: { 61 | Authorization: `Bearer ${ACCESS_TOKEN}`, 62 | 'Content-Type': 'application/json', 63 | }, 64 | }); 65 | }); 66 | }); 67 | 68 | describe('constructor', () => { 69 | let axios; 70 | let _create; 71 | beforeEach(() => { 72 | axios = require('axios'); 73 | _create = axios.create; 74 | }); 75 | 76 | afterEach(() => { 77 | axios.create = _create; 78 | }); 79 | 80 | describe('create axios with Line API', () => { 81 | it('with config', () => { 82 | axios.create = jest.fn().mockReturnValue({ 83 | interceptors: { 84 | request: { 85 | use: jest.fn(), 86 | }, 87 | }, 88 | }); 89 | // eslint-disable-next-line no-new 90 | new LineClient({ 91 | accessToken: ACCESS_TOKEN, 92 | channelSecret: CHANNEL_SECRET, 93 | }); 94 | 95 | expect(axios.create).toBeCalledWith({ 96 | baseURL: 'https://api.line.me/', 97 | headers: { 98 | Authorization: `Bearer ${ACCESS_TOKEN}`, 99 | 'Content-Type': 'application/json', 100 | }, 101 | }); 102 | }); 103 | }); 104 | 105 | it('support origin', () => { 106 | axios.create = jest.fn().mockReturnValue({ 107 | interceptors: { 108 | request: { 109 | use: jest.fn(), 110 | }, 111 | }, 112 | }); 113 | // eslint-disable-next-line no-new 114 | new LineClient({ 115 | accessToken: ACCESS_TOKEN, 116 | channelSecret: CHANNEL_SECRET, 117 | origin: 'https://mydummytestserver.com', 118 | }); 119 | 120 | expect(axios.create).toBeCalledWith({ 121 | baseURL: 'https://mydummytestserver.com/', 122 | headers: { 123 | Authorization: `Bearer ${ACCESS_TOKEN}`, 124 | 'Content-Type': 'application/json', 125 | }, 126 | }); 127 | }); 128 | }); 129 | 130 | describe('#axios', () => { 131 | it('should return underlying http client', () => { 132 | const client = new LineClient({ 133 | accessToken: ACCESS_TOKEN, 134 | channelSecret: CHANNEL_SECRET, 135 | }); 136 | 137 | expect(client.axios.get).toBeDefined(); 138 | expect(client.axios.post).toBeDefined(); 139 | expect(client.axios.put).toBeDefined(); 140 | expect(client.axios.delete).toBeDefined(); 141 | }); 142 | }); 143 | 144 | describe('#accessToken', () => { 145 | it('should return underlying access token', () => { 146 | const client = new LineClient({ 147 | accessToken: ACCESS_TOKEN, 148 | channelSecret: CHANNEL_SECRET, 149 | }); 150 | 151 | expect(client.accessToken).toBe(ACCESS_TOKEN); 152 | }); 153 | }); 154 | 155 | describe('#onRequest', () => { 156 | it('should call onRequest when calling any API', async () => { 157 | const onRequest = jest.fn(); 158 | const client = new LineClient({ 159 | accessToken: ACCESS_TOKEN, 160 | channelSecret: CHANNEL_SECRET, 161 | onRequest, 162 | }); 163 | 164 | const mock = new MockAdapter(client.axios); 165 | 166 | mock.onPost('/path').reply(200, {}); 167 | 168 | await client.axios.post('/path', { x: 1 }); 169 | 170 | expect(onRequest).toBeCalledWith({ 171 | method: 'post', 172 | url: 'https://api.line.me/path', 173 | body: { 174 | x: 1, 175 | }, 176 | headers: { 177 | Authorization: 'Bearer 1234567890', 178 | 'Content-Type': 'application/json', 179 | Accept: 'application/json, text/plain, */*', 180 | }, 181 | }); 182 | }); 183 | }); 184 | 185 | describe('Client instance', () => { 186 | it('prototype should be defined', () => { 187 | const sendTypes = ['reply', 'push', 'multicast']; 188 | const messageTypes = [ 189 | 'Text', 190 | 'Image', 191 | 'Video', 192 | 'Audio', 193 | 'Location', 194 | 'Sticker', 195 | 'Imagemap', 196 | 'Template', 197 | 'ButtonTemplate', 198 | 'ConfirmTemplate', 199 | 'CarouselTemplate', 200 | 'ImageCarouselTemplate', 201 | ]; 202 | 203 | const client = new LineClient({ 204 | accessToken: ACCESS_TOKEN, 205 | channelSecret: CHANNEL_SECRET, 206 | }); 207 | 208 | sendTypes.forEach((sendType) => { 209 | messageTypes.forEach((messageType) => { 210 | expect(client[`${sendType}${messageType}`]).toBeDefined(); 211 | }); 212 | }); 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /packages/messaging-api-line/src/__tests__/LineClient-liff.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import LineClient from '../LineClient'; 4 | 5 | const ACCESS_TOKEN = '1234567890'; 6 | const CHANNEL_SECRET = 'so-secret'; 7 | 8 | const createMock = (): { 9 | client: LineClient; 10 | mock: MockAdapter; 11 | headers: { 12 | Accept: string; 13 | 'Content-Type': string; 14 | Authorization: string; 15 | }; 16 | } => { 17 | const client = new LineClient({ 18 | accessToken: ACCESS_TOKEN, 19 | channelSecret: CHANNEL_SECRET, 20 | }); 21 | const mock = new MockAdapter(client.axios); 22 | const headers = { 23 | Accept: 'application/json, text/plain, */*', 24 | 'Content-Type': 'application/json', 25 | Authorization: `Bearer ${ACCESS_TOKEN}`, 26 | }; 27 | return { client, mock, headers }; 28 | }; 29 | 30 | describe('LINE Front-end Framework', () => { 31 | describe('#getLiffAppList', () => { 32 | it('should call api', async () => { 33 | expect.assertions(4); 34 | 35 | const { client, mock, headers } = createMock(); 36 | 37 | const reply = { 38 | apps: [ 39 | { 40 | liffId: 'liff-12345', 41 | view: { 42 | type: 'full', 43 | url: 'https://example.com/myservice', 44 | }, 45 | }, 46 | { 47 | liffId: 'liff-67890', 48 | view: { 49 | type: 'tall', 50 | url: 'https://example.com/myservice2', 51 | }, 52 | }, 53 | ], 54 | }; 55 | 56 | mock.onGet().reply((config) => { 57 | expect(config.url).toEqual('/liff/v1/apps'); 58 | expect(config.data).toEqual(undefined); 59 | expect(config.headers).toEqual(headers); 60 | return [200, reply]; 61 | }); 62 | 63 | const res = await client.getLiffAppList(); 64 | 65 | expect(res).toEqual([ 66 | { 67 | liffId: 'liff-12345', 68 | view: { 69 | type: 'full', 70 | url: 'https://example.com/myservice', 71 | }, 72 | }, 73 | { 74 | liffId: 'liff-67890', 75 | view: { 76 | type: 'tall', 77 | url: 'https://example.com/myservice2', 78 | }, 79 | }, 80 | ]); 81 | }); 82 | }); 83 | 84 | describe('#createLiffApp', () => { 85 | it('should call api', async () => { 86 | expect.assertions(4); 87 | 88 | const { client, mock, headers } = createMock(); 89 | 90 | const reply = { 91 | liffId: 'liff-12345', 92 | }; 93 | 94 | mock.onPost().reply((config) => { 95 | expect(config.url).toEqual('/liff/v1/apps'); 96 | expect(JSON.parse(config.data)).toEqual({ 97 | type: 'tall', 98 | url: 'https://example.com/myservice', 99 | }); 100 | expect(config.headers).toEqual(headers); 101 | return [200, reply]; 102 | }); 103 | 104 | const res = await client.createLiffApp({ 105 | type: 'tall', 106 | url: 'https://example.com/myservice', 107 | }); 108 | 109 | expect(res).toEqual({ 110 | liffId: 'liff-12345', 111 | }); 112 | }); 113 | }); 114 | 115 | describe('#updateLiffApp', () => { 116 | it('should call api', async () => { 117 | expect.assertions(4); 118 | 119 | const { client, mock, headers } = createMock(); 120 | 121 | const reply = {}; 122 | 123 | mock.onPut().reply((config) => { 124 | expect(config.url).toEqual('/liff/v1/apps/liff-12345/view'); 125 | expect(JSON.parse(config.data)).toEqual({ 126 | type: 'tall', 127 | url: 'https://example.com/myservice', 128 | }); 129 | expect(config.headers).toEqual(headers); 130 | return [200, reply]; 131 | }); 132 | 133 | const res = await client.updateLiffApp('liff-12345', { 134 | type: 'tall', 135 | url: 'https://example.com/myservice', 136 | }); 137 | 138 | expect(res).toEqual(reply); 139 | }); 140 | }); 141 | 142 | describe('#deleteLiffApp', () => { 143 | it('should call api', async () => { 144 | expect.assertions(4); 145 | 146 | const { client, mock, headers } = createMock(); 147 | 148 | const reply = {}; 149 | 150 | mock.onDelete().reply((config) => { 151 | expect(config.url).toEqual('/liff/v1/apps/liff-12345'); 152 | expect(config.data).toEqual(undefined); 153 | expect(config.headers).toEqual(headers); 154 | return [200, reply]; 155 | }); 156 | 157 | const res = await client.deleteLiffApp('liff-12345'); 158 | 159 | expect(res).toEqual(reply); 160 | }); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /packages/messaging-api-line/src/__tests__/LineClient-narrowcast.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import LineClient from '../LineClient'; 4 | import * as Types from '../LineTypes'; 5 | 6 | const ACCESS_TOKEN = '1234567890'; 7 | const CHANNEL_SECRET = 'so-secret'; 8 | 9 | const createMock = (): { 10 | client: LineClient; 11 | mock: MockAdapter; 12 | headers: { 13 | Accept: string; 14 | 'Content-Type': string; 15 | Authorization: string; 16 | }; 17 | } => { 18 | const client = new LineClient({ 19 | accessToken: ACCESS_TOKEN, 20 | channelSecret: CHANNEL_SECRET, 21 | }); 22 | const mock = new MockAdapter(client.axios); 23 | const headers = { 24 | Accept: 'application/json, text/plain, */*', 25 | 'Content-Type': 'application/json', 26 | Authorization: `Bearer ${ACCESS_TOKEN}`, 27 | }; 28 | return { client, mock, headers }; 29 | }; 30 | 31 | describe('Narrowcast', () => { 32 | const messages: Types.Message[] = [ 33 | { 34 | type: 'text', 35 | text: 'test message', 36 | }, 37 | ]; 38 | 39 | const recipient: Types.RecipientObject = { 40 | type: 'operator', 41 | and: [ 42 | { 43 | type: 'audience', 44 | audienceGroupId: 5614991017776, 45 | }, 46 | { 47 | type: 'operator', 48 | not: { 49 | type: 'audience', 50 | audienceGroupId: 4389303728991, 51 | }, 52 | }, 53 | ], 54 | }; 55 | 56 | const demographic: Types.DemographicFilterObject = { 57 | type: 'operator' as const, 58 | or: [ 59 | { 60 | type: 'operator' as const, 61 | and: [ 62 | { 63 | type: 'gender', 64 | oneOf: ['male', 'female'], 65 | }, 66 | { 67 | type: 'age', 68 | gte: 'age_20', 69 | lt: 'age_25', 70 | }, 71 | { 72 | type: 'appType', 73 | oneOf: ['android', 'ios'], 74 | }, 75 | { 76 | type: 'area', 77 | oneOf: ['jp_23', 'jp_05'], 78 | }, 79 | { 80 | type: 'subscriptionPeriod', 81 | gte: 'day_7', 82 | lt: 'day_30', 83 | }, 84 | ], 85 | }, 86 | { 87 | type: 'operator' as const, 88 | and: [ 89 | { 90 | type: 'age', 91 | gte: 'age_35', 92 | lt: 'age_40', 93 | }, 94 | { 95 | type: 'operator' as const, 96 | not: { 97 | type: 'gender', 98 | oneOf: ['male'], 99 | }, 100 | }, 101 | ], 102 | }, 103 | ], 104 | }; 105 | 106 | const rawBody = { 107 | messages, 108 | recipient, 109 | filter: { 110 | demographic, 111 | }, 112 | limit: { 113 | max: 100, 114 | }, 115 | }; 116 | 117 | describe('#narrowcastRawBody', () => { 118 | const reply = { requestId: 'abc' }; 119 | 120 | it('should call narrowcast api', async () => { 121 | expect.assertions(4); 122 | 123 | const { client, mock, headers } = createMock(); 124 | 125 | mock.onPost().reply((config) => { 126 | expect(config.url).toEqual('/v2/bot/message/narrowcast'); 127 | expect(JSON.parse(config.data)).toEqual(rawBody); 128 | expect(config.headers).toEqual(headers); 129 | return [200, reply, { 'x-line-request-id': 'abc' }]; 130 | }); 131 | 132 | const res = await client.narrowcastRawBody(rawBody); 133 | 134 | expect(res).toEqual(reply); 135 | }); 136 | }); 137 | 138 | describe('#narrowcast', () => { 139 | const reply = { requestId: 'abc' }; 140 | 141 | it('should call narrowcast api', async () => { 142 | expect.assertions(4); 143 | 144 | const { client, mock, headers } = createMock(); 145 | 146 | mock.onPost().reply((config) => { 147 | expect(config.url).toEqual('/v2/bot/message/narrowcast'); 148 | expect(JSON.parse(config.data)).toEqual(rawBody); 149 | expect(config.headers).toEqual(headers); 150 | return [200, reply, { 'x-line-request-id': 'abc' }]; 151 | }); 152 | 153 | const res = await client.narrowcast(messages, { 154 | recipient, 155 | demographic, 156 | max: 100, 157 | }); 158 | 159 | expect(res).toEqual(reply); 160 | }); 161 | }); 162 | 163 | describe('#narrowcastMessages', () => { 164 | const reply = { requestId: 'abc' }; 165 | 166 | it('should call narrowcast api', async () => { 167 | expect.assertions(4); 168 | 169 | const { client, mock, headers } = createMock(); 170 | 171 | mock.onPost().reply((config) => { 172 | expect(config.url).toEqual('/v2/bot/message/narrowcast'); 173 | expect(JSON.parse(config.data)).toEqual(rawBody); 174 | expect(config.headers).toEqual(headers); 175 | return [200, reply, { 'x-line-request-id': 'abc' }]; 176 | }); 177 | 178 | const res = await client.narrowcastMessages(messages, { 179 | recipient, 180 | demographic, 181 | max: 100, 182 | }); 183 | 184 | expect(res).toEqual(reply); 185 | }); 186 | }); 187 | 188 | describe('#getNarrowcastProgress', () => { 189 | const requestId = '123'; 190 | const reply = { 191 | phase: 'succeeded', 192 | successCount: 1, 193 | failureCount: 1, 194 | targetCount: 2, 195 | }; 196 | 197 | it('should call getNarrowcastProgress api', async () => { 198 | expect.assertions(3); 199 | 200 | const { client, mock, headers } = createMock(); 201 | 202 | mock.onGet().reply((config) => { 203 | expect(config.url).toEqual( 204 | `/v2/bot/message/progress/narrowcast?requestId=${requestId}` 205 | ); 206 | expect(config.headers).toEqual(headers); 207 | return [200, reply]; 208 | }); 209 | 210 | const res = await client.getNarrowcastProgress(requestId); 211 | 212 | expect(res).toEqual(reply); 213 | }); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /packages/messaging-api-line/src/__tests__/LineClient-webhook.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import LineClient from '../LineClient'; 4 | 5 | const ACCESS_TOKEN = '1234567890'; 6 | const CHANNEL_SECRET = 'so-secret'; 7 | 8 | const createMock = (): { 9 | client: LineClient; 10 | mock: MockAdapter; 11 | headers: { 12 | Accept: string; 13 | 'Content-Type': string; 14 | Authorization: string; 15 | }; 16 | } => { 17 | const client = new LineClient({ 18 | accessToken: ACCESS_TOKEN, 19 | channelSecret: CHANNEL_SECRET, 20 | }); 21 | const mock = new MockAdapter(client.axios); 22 | const headers = { 23 | Accept: 'application/json, text/plain, */*', 24 | 'Content-Type': 'application/json', 25 | Authorization: `Bearer ${ACCESS_TOKEN}`, 26 | }; 27 | return { client, mock, headers }; 28 | }; 29 | 30 | describe('#getBotInfo', () => { 31 | it('should call api', async () => { 32 | const { client, mock } = createMock(); 33 | 34 | const reply = { 35 | userId: 'Ub9952f8...', 36 | basicId: '@216ru...', 37 | displayName: 'Example name', 38 | pictureUrl: 'https://obs.line-apps.com/...', 39 | chatMode: 'chat', 40 | markAsReadMode: 'manual', 41 | }; 42 | 43 | let url; 44 | let headers; 45 | mock.onGet().reply((config) => { 46 | url = config.url; 47 | headers = config.headers; 48 | return [200, reply]; 49 | }); 50 | 51 | const res = await client.getBotInfo(); 52 | 53 | expect(url).toEqual('/v2/bot/info'); 54 | expect(headers).toEqual(headers); 55 | 56 | expect(res).toEqual(reply); 57 | }); 58 | }); 59 | 60 | describe('#getWebhookEndpointInfo', () => { 61 | it('should call api', async () => { 62 | const { client, mock } = createMock(); 63 | 64 | const reply = { 65 | endpoint: 'https://example.herokuapp.com/test', 66 | active: true, 67 | }; 68 | 69 | let url; 70 | let headers; 71 | mock.onGet().reply((config) => { 72 | url = config.url; 73 | headers = config.headers; 74 | return [200, reply]; 75 | }); 76 | 77 | const res = await client.getWebhookEndpointInfo(); 78 | 79 | expect(url).toEqual('/v2/bot/channel/webhook/endpoint'); 80 | expect(headers).toEqual(headers); 81 | 82 | expect(res).toEqual(reply); 83 | }); 84 | }); 85 | 86 | describe('#setWebhookEndpointUrl', () => { 87 | it('should call api', async () => { 88 | const { client, mock } = createMock(); 89 | 90 | const reply = {}; 91 | 92 | let url; 93 | let headers; 94 | mock.onPut().reply((config) => { 95 | url = config.url; 96 | headers = config.headers; 97 | return [200, reply]; 98 | }); 99 | 100 | const res = await client.setWebhookEndpointUrl( 101 | 'https://example.herokuapp.com/test' 102 | ); 103 | 104 | expect(url).toEqual('/v2/bot/channel/webhook/endpoint'); 105 | expect(headers).toEqual(headers); 106 | 107 | expect(res).toEqual(reply); 108 | }); 109 | }); 110 | 111 | describe('#testWebhookEndpoint', () => { 112 | it('should call api', async () => { 113 | const { client, mock } = createMock(); 114 | 115 | const reply = { 116 | success: true, 117 | timestamp: '2020-09-30T05:38:20.031Z', 118 | statusCode: 200, 119 | reason: 'OK', 120 | detail: '200', 121 | }; 122 | 123 | let url; 124 | let headers; 125 | mock.onPost().reply((config) => { 126 | url = config.url; 127 | headers = config.headers; 128 | return [200, reply]; 129 | }); 130 | 131 | const res = await client.testWebhookEndpoint(); 132 | 133 | expect(url).toEqual('/v2/bot/channel/webhook/test'); 134 | expect(headers).toEqual(headers); 135 | 136 | expect(res).toEqual(reply); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /packages/messaging-api-line/src/__tests__/LineClient.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import LineClient from '../LineClient'; 4 | 5 | const RECIPIENT_ID = '1QAZ2WSX'; 6 | const REPLY_TOKEN = 'nHuyWiB7yP5Zw52FIkcQobQuGDXCTA'; 7 | const ACCESS_TOKEN = '1234567890'; 8 | const CHANNEL_SECRET = 'so-secret'; 9 | 10 | const createMock = (): { 11 | client: LineClient; 12 | mock: MockAdapter; 13 | dataMock: MockAdapter; 14 | headers: { 15 | Accept: string; 16 | 'Content-Type': string; 17 | Authorization: string; 18 | }; 19 | } => { 20 | const client = new LineClient({ 21 | accessToken: ACCESS_TOKEN, 22 | channelSecret: CHANNEL_SECRET, 23 | }); 24 | const mock = new MockAdapter(client.axios); 25 | const dataMock = new MockAdapter(client.dataAxios); 26 | const headers = { 27 | Accept: 'application/json, text/plain, */*', 28 | 'Content-Type': 'application/json', 29 | Authorization: `Bearer ${ACCESS_TOKEN}`, 30 | }; 31 | return { client, mock, dataMock, headers }; 32 | }; 33 | 34 | describe('Content', () => { 35 | describe('#getMessageContent', () => { 36 | it('should call getMessageContent api', async () => { 37 | const { client, dataMock } = createMock(); 38 | 39 | const reply = Buffer.from('a content buffer'); 40 | 41 | const MESSAGE_ID = '1234567890'; 42 | 43 | dataMock.onGet(`/v2/bot/message/${MESSAGE_ID}/content`).reply(200, reply); 44 | 45 | const res = await client.getMessageContent(MESSAGE_ID); 46 | 47 | expect(res).toEqual(reply); 48 | }); 49 | }); 50 | }); 51 | 52 | describe('Profile', () => { 53 | describe('#getUserProfile', () => { 54 | it('should response user profile', async () => { 55 | expect.assertions(4); 56 | 57 | const { client, mock, headers } = createMock(); 58 | const reply = { 59 | displayName: 'LINE taro', 60 | userId: RECIPIENT_ID, 61 | pictureUrl: 'http://obs.line-apps.com/...', 62 | statusMessage: 'Hello, LINE!', 63 | }; 64 | 65 | mock.onGet().reply((config) => { 66 | expect(config.url).toEqual(`/v2/bot/profile/${RECIPIENT_ID}`); 67 | expect(config.data).toEqual(undefined); 68 | expect(config.headers).toEqual(headers); 69 | return [200, reply]; 70 | }); 71 | 72 | const res = await client.getUserProfile(RECIPIENT_ID); 73 | 74 | expect(res).toEqual(reply); 75 | }); 76 | 77 | it('should return null when no user found', async () => { 78 | const { client, mock } = createMock(); 79 | 80 | mock.onGet().reply(404, { 81 | message: 'Not found', 82 | }); 83 | 84 | const res = await client.getUserProfile(RECIPIENT_ID); 85 | 86 | expect(res).toEqual(null); 87 | }); 88 | }); 89 | }); 90 | 91 | describe('Account link', () => { 92 | describe('#getLinkToken', () => { 93 | it('should response data with link token', async () => { 94 | expect.assertions(4); 95 | 96 | const { client, mock, headers } = createMock(); 97 | const reply = { 98 | linkToken: 'NMZTNuVrPTqlr2IF8Bnymkb7rXfYv5EY', 99 | }; 100 | 101 | mock.onPost().reply((config) => { 102 | expect(config.url).toEqual(`/v2/bot/user/${RECIPIENT_ID}/linkToken`); 103 | expect(config.data).toEqual(undefined); 104 | expect(config.headers).toEqual(headers); 105 | return [200, reply]; 106 | }); 107 | 108 | const res = await client.getLinkToken(RECIPIENT_ID); 109 | 110 | expect(res).toEqual('NMZTNuVrPTqlr2IF8Bnymkb7rXfYv5EY'); 111 | }); 112 | }); 113 | }); 114 | 115 | describe('Error', () => { 116 | it('should format correctly when no details', async () => { 117 | const { client, mock } = createMock(); 118 | 119 | const reply = { 120 | message: 'The request body has 2 error(s)', 121 | }; 122 | 123 | mock.onAny().reply(400, reply); 124 | 125 | let error; 126 | try { 127 | await client.replyText(REPLY_TOKEN, 'Hello!'); 128 | } catch (err) { 129 | error = err; 130 | } 131 | 132 | expect(error.message).toEqual('LINE API - The request body has 2 error(s)'); 133 | }); 134 | 135 | it('should format correctly when details exist', async () => { 136 | const { client, mock } = createMock(); 137 | 138 | const reply = { 139 | message: 'The request body has 2 error(s)', 140 | details: [ 141 | { message: 'May not be empty', property: 'messages[0].text' }, 142 | { 143 | message: 144 | 'Must be one of the following values: [text, image, video, audio, location, sticker, template, imagemap]', 145 | property: 'messages[1].type', 146 | }, 147 | ], 148 | }; 149 | 150 | mock.onAny().reply(400, reply); 151 | 152 | let error; 153 | try { 154 | await client.replyText(REPLY_TOKEN, 'Hello!'); 155 | } catch (err) { 156 | error = err; 157 | } 158 | 159 | expect(error.message).toEqual(`LINE API - The request body has 2 error(s) 160 | - messages[0].text: May not be empty 161 | - messages[1].type: Must be one of the following values: [text, image, video, audio, location, sticker, template, imagemap]`); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /packages/messaging-api-line/src/__tests__/LineNotify.spec.ts: -------------------------------------------------------------------------------- 1 | import querystring from 'querystring'; 2 | 3 | import MockAdapter from 'axios-mock-adapter'; 4 | 5 | import LineNotify from '../LineNotify'; 6 | 7 | const CLIENT_ID = 'client-id'; 8 | const CLIENT_SECRET = 'client-secret'; 9 | const REDIRECT_URI = 'https://example.com/callback'; 10 | 11 | const createMock = (): { 12 | client: LineNotify; 13 | mock: MockAdapter; 14 | apiMock: MockAdapter; 15 | } => { 16 | const client = new LineNotify({ 17 | clientId: CLIENT_ID, 18 | clientSecret: CLIENT_SECRET, 19 | redirectUri: REDIRECT_URI, 20 | }); 21 | const mock = new MockAdapter(client.axios); 22 | const apiMock = new MockAdapter(client.apiAxios); 23 | return { client, mock, apiMock }; 24 | }; 25 | 26 | describe('connect', () => { 27 | let axios; 28 | let _create; 29 | beforeEach(() => { 30 | axios = require('axios'); 31 | _create = axios.create; 32 | }); 33 | 34 | afterEach(() => { 35 | axios.create = _create; 36 | }); 37 | 38 | it('create axios with LINE Notify API', () => { 39 | axios.create = jest.fn(); 40 | LineNotify.connect({ 41 | clientId: CLIENT_ID, 42 | clientSecret: CLIENT_SECRET, 43 | redirectUri: REDIRECT_URI, 44 | }); 45 | 46 | expect(axios.create).toBeCalledWith({ 47 | baseURL: 'https://notify-bot.line.me/', 48 | }); 49 | 50 | expect(axios.create).toBeCalledWith({ 51 | baseURL: 'https://notify-api.line.me/', 52 | }); 53 | }); 54 | }); 55 | 56 | describe('constructor', () => { 57 | let axios; 58 | let _create; 59 | beforeEach(() => { 60 | axios = require('axios'); 61 | _create = axios.create; 62 | }); 63 | 64 | afterEach(() => { 65 | axios.create = _create; 66 | }); 67 | 68 | it('create axios with LINE Notify API', () => { 69 | axios.create = jest.fn(); 70 | // eslint-disable-next-line no-new 71 | new LineNotify({ 72 | clientId: CLIENT_ID, 73 | clientSecret: CLIENT_SECRET, 74 | redirectUri: REDIRECT_URI, 75 | }); 76 | 77 | expect(axios.create).toBeCalledWith({ 78 | baseURL: 'https://notify-bot.line.me/', 79 | }); 80 | 81 | expect(axios.create).toBeCalledWith({ 82 | baseURL: 'https://notify-api.line.me/', 83 | }); 84 | }); 85 | }); 86 | 87 | describe('#axios', () => { 88 | it('should return underlying http client', () => { 89 | const client = new LineNotify({ 90 | clientId: CLIENT_ID, 91 | clientSecret: CLIENT_SECRET, 92 | redirectUri: REDIRECT_URI, 93 | }); 94 | expect(client.axios.get).toBeDefined(); 95 | expect(client.axios.post).toBeDefined(); 96 | expect(client.axios.put).toBeDefined(); 97 | expect(client.axios.delete).toBeDefined(); 98 | expect(client.apiAxios.get).toBeDefined(); 99 | expect(client.apiAxios.post).toBeDefined(); 100 | expect(client.apiAxios.put).toBeDefined(); 101 | expect(client.apiAxios.delete).toBeDefined(); 102 | }); 103 | }); 104 | 105 | describe('#getAuthLink', () => { 106 | it('should work', async () => { 107 | const { client } = createMock(); 108 | 109 | const result = client.getAuthLink('state'); 110 | 111 | expect(result).toEqual( 112 | 'https://notify-bot.line.me/oauth/authorize?scope=notify&response_type=code&client_id=client-id&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=state' 113 | ); 114 | }); 115 | }); 116 | 117 | describe('#getToken', () => { 118 | it('should work', async () => { 119 | const { client, mock } = createMock(); 120 | 121 | const reply = { 122 | access_token: 'access_token', 123 | }; 124 | 125 | const code = 'code'; 126 | 127 | const body = { 128 | grant_type: 'authorization_code', 129 | client_id: CLIENT_ID, 130 | client_secret: CLIENT_SECRET, 131 | redirect_uri: REDIRECT_URI, 132 | code, 133 | }; 134 | 135 | const headers = { 136 | 'Content-Type': 'application/x-www-form-urlencoded', 137 | }; 138 | 139 | mock.onPost().reply((config) => { 140 | expect(config.url).toEqual('/oauth/token'); 141 | expect(querystring.decode(config.data)).toEqual(body); 142 | expect(config.headers['Content-Type']).toEqual(headers['Content-Type']); 143 | return [200, reply]; 144 | }); 145 | 146 | const result = await client.getToken('code'); 147 | 148 | expect(result).toEqual('access_token'); 149 | }); 150 | }); 151 | 152 | describe('#getStatus', () => { 153 | it('should work', async () => { 154 | const { client, apiMock } = createMock(); 155 | 156 | const reply = { 157 | status: 200, 158 | message: 'message', 159 | targetType: 'USER', 160 | target: 'user name', 161 | }; 162 | 163 | const headers = { 164 | Authorization: `Bearer access_token`, 165 | }; 166 | 167 | apiMock.onGet().reply((config) => { 168 | expect(config.url).toEqual('/api/status'); 169 | expect(config.headers.Authorization).toEqual(headers.Authorization); 170 | return [200, reply]; 171 | }); 172 | 173 | const result = await client.getStatus('access_token'); 174 | 175 | expect(result).toEqual(reply); 176 | }); 177 | }); 178 | 179 | describe('#sendNotify', () => { 180 | it('should work', async () => { 181 | const { client, apiMock } = createMock(); 182 | 183 | const reply = { 184 | status: 200, 185 | message: 'message', 186 | }; 187 | 188 | const body = querystring.encode({ 189 | message: 'message', 190 | }); 191 | 192 | const headers = { 193 | 'Content-Type': 'application/x-www-form-urlencoded', 194 | Authorization: `Bearer access_token`, 195 | }; 196 | 197 | apiMock.onPost().reply((config) => { 198 | expect(config.url).toEqual('/api/notify'); 199 | expect(config.data).toEqual(body); 200 | expect(config.headers['Content-Type']).toEqual(headers['Content-Type']); 201 | expect(config.headers.Authorization).toEqual(headers.Authorization); 202 | return [200, reply]; 203 | }); 204 | 205 | const result = await client.sendNotify('access_token', 'message'); 206 | 207 | expect(result).toEqual(reply); 208 | }); 209 | }); 210 | 211 | describe('#revokeToken', () => { 212 | it('should work', async () => { 213 | const { client, apiMock } = createMock(); 214 | 215 | const reply = { 216 | status: 200, 217 | message: 'message', 218 | }; 219 | 220 | const body = {}; 221 | 222 | const headers = { 223 | 'Content-Type': 'application/x-www-form-urlencoded', 224 | Authorization: `Bearer access_token`, 225 | }; 226 | 227 | apiMock.onPost().reply((config) => { 228 | expect(config.url).toEqual('/api/revoke'); 229 | expect(JSON.parse(config.data)).toEqual(body); 230 | expect(config.headers['Content-Type']).toEqual(headers['Content-Type']); 231 | expect(config.headers.Authorization).toEqual(headers.Authorization); 232 | return [200, reply]; 233 | }); 234 | 235 | const result = await client.revokeToken('access_token'); 236 | 237 | expect(result).toEqual(reply); 238 | }); 239 | }); 240 | -------------------------------------------------------------------------------- /packages/messaging-api-line/src/__tests__/browser.spec.ts: -------------------------------------------------------------------------------- 1 | import { Line } from '../browser'; 2 | 3 | it('should export api correctly', () => { 4 | expect(Line).toBeDefined(); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/messaging-api-line/src/__tests__/fixture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bottenderjs/messaging-apis/39fef4af57efa4104e4463694e6eb4def5c18dad/packages/messaging-api-line/src/__tests__/fixture.png -------------------------------------------------------------------------------- /packages/messaging-api-line/src/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { Line, LineClient, LineNotify, LinePay } from '..'; 2 | 3 | it('should export api correctly', () => { 4 | expect(Line).toBeDefined(); 5 | expect(LineClient).toBeDefined(); 6 | expect(LinePay).toBeDefined(); 7 | expect(LineNotify).toBeDefined(); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/messaging-api-line/src/browser.ts: -------------------------------------------------------------------------------- 1 | export * as Line from './Line'; 2 | export * as LineTypes from './LineTypes'; 3 | -------------------------------------------------------------------------------- /packages/messaging-api-line/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as LineClient } from './LineClient'; 2 | export { default as LineNotify } from './LineNotify'; 3 | export { default as LinePay } from './LinePay'; 4 | 5 | export * as Line from './Line'; 6 | export * as LineTypes from './LineTypes'; 7 | -------------------------------------------------------------------------------- /packages/messaging-api-line/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/messaging-api-messenger/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "messaging-api-messenger", 3 | "version": "1.1.0", 4 | "description": "Messaging API client for Messenger", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Yoctol/messaging-apis.git" 9 | }, 10 | "main": "dist/index.js", 11 | "browser": "lib/browser.js", 12 | "types": "dist/index.d.ts", 13 | "dependencies": { 14 | "@types/append-query": "^2.0.0", 15 | "@types/lodash": "^4.14.156", 16 | "@types/warning": "^3.0.0", 17 | "append-query": "^2.1.0", 18 | "axios": "^0.21.1", 19 | "axios-error": "file:../axios-error", 20 | "form-data": "^3.0.0", 21 | "lodash": "^4.17.15", 22 | "messaging-api-common": "file:../messaging-api-common", 23 | "ts-invariant": "^0.4.4", 24 | "warning": "^4.0.3" 25 | }, 26 | "keywords": [ 27 | "bot", 28 | "chatbot", 29 | "messaging-apis", 30 | "messenger" 31 | ], 32 | "engines": { 33 | "node": ">=10" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/messaging-api-messenger/src/__tests__/MessengerClient-handover.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import { MessengerClient } from '..'; 4 | 5 | const ACCESS_TOKEN = '1234567890'; 6 | const USER_ID = '1QAZ2WSX'; 7 | 8 | let axios; 9 | let _create; 10 | beforeEach(() => { 11 | axios = require('axios'); 12 | _create = axios.create; 13 | }); 14 | 15 | afterEach(() => { 16 | axios.create = _create; 17 | }); 18 | 19 | const createMock = (): { client: MessengerClient; mock: MockAdapter } => { 20 | const client = new MessengerClient({ 21 | accessToken: ACCESS_TOKEN, 22 | }); 23 | const mock = new MockAdapter(client.axios); 24 | return { client, mock }; 25 | }; 26 | 27 | describe('Handover Protocol API', () => { 28 | describe('#passThreadControl', () => { 29 | it('should call messages api to pass thread control', async () => { 30 | const { client, mock } = createMock(); 31 | 32 | const reply = { 33 | success: true, 34 | }; 35 | 36 | let url; 37 | let data; 38 | mock.onPost().reply((config) => { 39 | url = config.url; 40 | data = config.data; 41 | return [200, reply]; 42 | }); 43 | 44 | const res = await client.passThreadControl( 45 | USER_ID, 46 | 123456789, 47 | 'free formed text for another app' 48 | ); 49 | 50 | expect(url).toEqual( 51 | `/me/pass_thread_control?access_token=${ACCESS_TOKEN}` 52 | ); 53 | expect(JSON.parse(data)).toEqual({ 54 | recipient: { 55 | id: USER_ID, 56 | }, 57 | target_app_id: 123456789, 58 | metadata: 'free formed text for another app', 59 | }); 60 | 61 | expect(res).toEqual(reply); 62 | }); 63 | }); 64 | 65 | describe('#passThreadControlToPageInbox', () => { 66 | it('should call messages api to pass thread control to page inbox', async () => { 67 | const { client, mock } = createMock(); 68 | 69 | const reply = { 70 | success: true, 71 | }; 72 | 73 | let url; 74 | let data; 75 | mock.onPost().reply((config) => { 76 | url = config.url; 77 | data = config.data; 78 | return [200, reply]; 79 | }); 80 | 81 | const res = await client.passThreadControlToPageInbox( 82 | USER_ID, 83 | 'free formed text for another app' 84 | ); 85 | 86 | expect(url).toEqual( 87 | `/me/pass_thread_control?access_token=${ACCESS_TOKEN}` 88 | ); 89 | expect(JSON.parse(data)).toEqual({ 90 | recipient: { 91 | id: USER_ID, 92 | }, 93 | target_app_id: 263902037430900, 94 | metadata: 'free formed text for another app', 95 | }); 96 | 97 | expect(res).toEqual(reply); 98 | }); 99 | }); 100 | 101 | describe('#takeThreadControl', () => { 102 | it('should call messages api to take thread control', async () => { 103 | const { client, mock } = createMock(); 104 | 105 | const reply = { 106 | success: true, 107 | }; 108 | 109 | let url; 110 | let data; 111 | mock.onPost().reply((config) => { 112 | url = config.url; 113 | data = config.data; 114 | return [200, reply]; 115 | }); 116 | 117 | const res = await client.takeThreadControl( 118 | USER_ID, 119 | 'free formed text for another app' 120 | ); 121 | 122 | expect(url).toEqual( 123 | `/me/take_thread_control?access_token=${ACCESS_TOKEN}` 124 | ); 125 | expect(JSON.parse(data)).toEqual({ 126 | recipient: { 127 | id: USER_ID, 128 | }, 129 | metadata: 'free formed text for another app', 130 | }); 131 | 132 | expect(res).toEqual(reply); 133 | }); 134 | }); 135 | 136 | describe('#requestThreadControl', () => { 137 | it('should call messages api to request thread control', async () => { 138 | const { client, mock } = createMock(); 139 | 140 | const reply = { 141 | success: true, 142 | }; 143 | 144 | let url; 145 | let data; 146 | mock.onPost().reply((config) => { 147 | url = config.url; 148 | data = config.data; 149 | return [200, reply]; 150 | }); 151 | 152 | const res = await client.requestThreadControl( 153 | USER_ID, 154 | 'free formed text for primary app' 155 | ); 156 | 157 | expect(url).toEqual( 158 | `/me/request_thread_control?access_token=${ACCESS_TOKEN}` 159 | ); 160 | expect(JSON.parse(data)).toEqual({ 161 | recipient: { 162 | id: USER_ID, 163 | }, 164 | metadata: 'free formed text for primary app', 165 | }); 166 | 167 | expect(res).toEqual(reply); 168 | }); 169 | }); 170 | 171 | describe('#getSecondaryReceivers', () => { 172 | it('should call messages api to get Secondary receivers', async () => { 173 | const { client, mock } = createMock(); 174 | 175 | const reply = { 176 | data: [ 177 | { id: '12345678910', name: "David's Composer" }, 178 | { id: '23456789101', name: 'Messenger Rocks' }, 179 | ], 180 | }; 181 | 182 | let url; 183 | mock.onGet().reply((config) => { 184 | url = config.url; 185 | return [200, reply]; 186 | }); 187 | 188 | const res = await client.getSecondaryReceivers(); 189 | 190 | expect(url).toEqual( 191 | `/me/secondary_receivers?fields=id,name&access_token=${ACCESS_TOKEN}` 192 | ); 193 | 194 | expect(res).toEqual([ 195 | { id: '12345678910', name: "David's Composer" }, 196 | { id: '23456789101', name: 'Messenger Rocks' }, 197 | ]); 198 | }); 199 | }); 200 | }); 201 | 202 | describe('#getThreadOwner', () => { 203 | it('should call messages api to get thread owner', async () => { 204 | const { client, mock } = createMock(); 205 | 206 | const reply = { 207 | data: [ 208 | { 209 | thread_owner: { 210 | app_id: '12345678910', 211 | }, 212 | }, 213 | ], 214 | }; 215 | 216 | let url; 217 | mock.onGet().reply((config) => { 218 | url = config.url; 219 | return [200, reply]; 220 | }); 221 | 222 | const res = await client.getThreadOwner(USER_ID); 223 | 224 | expect(url).toEqual( 225 | `/me/thread_owner?recipient=${USER_ID}&access_token=${ACCESS_TOKEN}` 226 | ); 227 | 228 | expect(res).toEqual({ appId: '12345678910' }); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /packages/messaging-api-messenger/src/__tests__/MessengerClient-persona.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import { MessengerClient } from '..'; 4 | 5 | const ACCESS_TOKEN = '1234567890'; 6 | 7 | let axios; 8 | let _create; 9 | beforeEach(() => { 10 | axios = require('axios'); 11 | _create = axios.create; 12 | }); 13 | 14 | afterEach(() => { 15 | axios.create = _create; 16 | }); 17 | 18 | const createMock = (): { client: MessengerClient; mock: MockAdapter } => { 19 | const client = new MessengerClient({ 20 | accessToken: ACCESS_TOKEN, 21 | }); 22 | const mock = new MockAdapter(client.axios); 23 | return { client, mock }; 24 | }; 25 | 26 | describe('persona api', () => { 27 | describe('#createPersona', () => { 28 | it('should call messenger api to create a persona', async () => { 29 | const { client, mock } = createMock(); 30 | 31 | const reply = { id: '2222146701193608' }; 32 | 33 | let url; 34 | let data; 35 | mock.onPost().reply((config) => { 36 | url = config.url; 37 | data = config.data; 38 | return [200, reply]; 39 | }); 40 | 41 | const res = await client.createPersona({ 42 | name: 'kpman', 43 | profilePictureUrl: 'https://i.imgur.com/zV6uy4T.jpg', 44 | }); 45 | 46 | expect(url).toEqual(`/me/personas?access_token=${ACCESS_TOKEN}`); 47 | expect(JSON.parse(data)).toEqual({ 48 | name: 'kpman', 49 | profile_picture_url: 'https://i.imgur.com/zV6uy4T.jpg', 50 | }); 51 | 52 | expect(res).toEqual(reply); 53 | }); 54 | }); 55 | 56 | describe('#getPersona', () => { 57 | it('should get persona with the id given', async () => { 58 | const { client, mock } = createMock(); 59 | 60 | const reply = { 61 | id: '311884619589478', 62 | name: 'kpman', 63 | profile_picture_url: 'https://i.imgur.com/zV6uy4T.jpg', 64 | }; 65 | 66 | let url; 67 | mock.onGet().reply((config) => { 68 | url = config.url; 69 | return [200, reply]; 70 | }); 71 | 72 | const res = await client.getPersona('311884619589478'); 73 | 74 | expect(url).toEqual(`/311884619589478?access_token=${ACCESS_TOKEN}`); 75 | 76 | expect(res).toEqual({ 77 | name: 'kpman', 78 | profilePictureUrl: 'https://i.imgur.com/zV6uy4T.jpg', 79 | id: '311884619589478', 80 | }); 81 | }); 82 | }); 83 | 84 | describe('#getAllPersonas', () => { 85 | it('should call messenger api to get all created personas', async () => { 86 | const { client, mock } = createMock(); 87 | 88 | const cursor = 89 | 'QVFIUl96LThrbmJrU3gzOHdsR2JaZA2dDM01uaEJNaUZArWnNTNHBhQi1iZA3lvakk2YWlUR3F5bUV3UDJYZAWVxYnJyOFA1VnJwZAG9GUEVzOGRMZAzRsV08wdW1R'; 90 | 91 | const replyWithCursor = { 92 | data: [ 93 | { 94 | name: '7', 95 | profile_picture_url: 'https://i.imgur.com/zV6uy4T.jpg', 96 | id: '1007240332817468', 97 | }, 98 | { 99 | name: '6', 100 | profile_picture_url: 'https://i.imgur.com/zV6uy4T.jpg', 101 | id: '243523459665626', 102 | }, 103 | { 104 | name: '5', 105 | profile_picture_url: 'https://i.imgur.com/zV6uy4T.jpg', 106 | id: '313552169447330', 107 | }, 108 | ], 109 | paging: { 110 | cursors: { 111 | before: 112 | 'QVFIUktTaXVuTUtsYUpVdFhlQjVhV2tRMU1jY0tRekU0d1NVTS1fZAGw4YmFYakU3ay1vRnlKbUh4VktROWxvazQzLXQzbm1YN0M3SHRKaVBGTTVCNFlyZAXBn', 113 | after: cursor, 114 | }, 115 | next: '/138523840252451/personas?access_token=0987654321&limit=25&after=QVFIUl96LThrbmJrU3gzOHdsR2JaZA2dDM01uaEJNaUZArWnNTNHBhQi1iZA3lvakk2YWlUR3F5bUV3UDJYZAWVxYnJyOFA1VnJwZAG9GUEVzOGRMZAzRsV08wdW1R', 116 | }, 117 | }; 118 | 119 | const replyWithoutCursor = { 120 | data: [ 121 | { 122 | name: '8', 123 | profile_picture_url: 'https://i.imgur.com/zV6uy4T.jpg', 124 | id: '1007240332817468', 125 | }, 126 | { 127 | name: '9', 128 | profile_picture_url: 'https://i.imgur.com/zV6uy4T.jpg', 129 | id: '243523459665626', 130 | }, 131 | { 132 | name: '10', 133 | profile_picture_url: 'https://i.imgur.com/zV6uy4T.jpg', 134 | id: '313552169447330', 135 | }, 136 | ], 137 | }; 138 | 139 | mock 140 | .onGet(`/me/personas?access_token=${ACCESS_TOKEN}`) 141 | .replyOnce(200, replyWithCursor); 142 | 143 | mock 144 | .onGet(`/me/personas?access_token=${ACCESS_TOKEN}&after=${cursor}`) 145 | .replyOnce(200, replyWithoutCursor); 146 | 147 | const res = await client.getAllPersonas(); 148 | 149 | expect(res).toEqual([ 150 | { 151 | name: '7', 152 | profilePictureUrl: 'https://i.imgur.com/zV6uy4T.jpg', 153 | id: '1007240332817468', 154 | }, 155 | { 156 | name: '6', 157 | profilePictureUrl: 'https://i.imgur.com/zV6uy4T.jpg', 158 | id: '243523459665626', 159 | }, 160 | { 161 | name: '5', 162 | profilePictureUrl: 'https://i.imgur.com/zV6uy4T.jpg', 163 | id: '313552169447330', 164 | }, 165 | { 166 | name: '8', 167 | profilePictureUrl: 'https://i.imgur.com/zV6uy4T.jpg', 168 | id: '1007240332817468', 169 | }, 170 | { 171 | name: '9', 172 | profilePictureUrl: 'https://i.imgur.com/zV6uy4T.jpg', 173 | id: '243523459665626', 174 | }, 175 | { 176 | name: '10', 177 | profilePictureUrl: 'https://i.imgur.com/zV6uy4T.jpg', 178 | id: '313552169447330', 179 | }, 180 | ]); 181 | }); 182 | }); 183 | 184 | describe('#getPersonas', () => { 185 | it('should call messages api to get personas with cursor', async () => { 186 | const { client, mock } = createMock(); 187 | 188 | const reply = { 189 | data: [ 190 | { 191 | name: '7', 192 | profile_picture_url: 'https://i.imgur.com/zV6uy4T.jpg', 193 | id: '1007240332817468', 194 | }, 195 | { 196 | name: '6', 197 | profile_picture_url: 'https://i.imgur.com/zV6uy4T.jpg', 198 | id: '243523459665626', 199 | }, 200 | { 201 | name: '5', 202 | profile_picture_url: 'https://i.imgur.com/zV6uy4T.jpg', 203 | id: '313552169447330', 204 | }, 205 | ], 206 | paging: { 207 | cursors: { 208 | before: 209 | 'QVFIUktTaXVuTUtsYUpVdFhlQjVhV2tRMU1jY0tRekU0d1NVTS1fZAGw4YmFYakU3ay1vRnlKbUh4VktROWxvazQzLXQzbm1YN0M3SHRKaVBGTTVCNFlyZAXBn', 210 | after: 211 | 'QVFIUl96LThrbmJrU3gzOHdsR2JaZA2dDM01uaEJNaUZArWnNTNHBhQi1iZA3lvakk2YWlUR3F5bUV3UDJYZAWVxYnJyOFA1VnJwZAG9GUEVzOGRMZAzRsV08wdW1R', 212 | }, 213 | next: '/138523840252451/personas?access_token=0987654321&limit=25&after=QVFIUl96LThrbmJrU3gzOHdsR2JaZA2dDM01uaEJNaUZArWnNTNHBhQi1iZA3lvakk2YWlUR3F5bUV3UDJYZAWVxYnJyOFA1VnJwZAG9GUEVzOGRMZAzRsV08wdW1R', 214 | }, 215 | }; 216 | 217 | const cursor = 218 | 'QVFIUmRJYXR4Y3dBN1JpcU5pU0lfLWhZAS0IzMjZADZAWxWYksxLWVHdW1HSnJmV21paEZA3NEl2RW5LY25fRFZAnZAkg2OVBJR0VLZAXIzeFRTZAGFrSldjMVRlV3Fn'; 219 | 220 | mock 221 | .onGet(`/me/personas?access_token=${ACCESS_TOKEN}&after=${cursor}`) 222 | .reply(200, reply); 223 | 224 | const res = await client.getPersonas(cursor); 225 | 226 | expect(res).toEqual({ 227 | data: [ 228 | { 229 | name: '7', 230 | profilePictureUrl: 'https://i.imgur.com/zV6uy4T.jpg', 231 | id: '1007240332817468', 232 | }, 233 | { 234 | name: '6', 235 | profilePictureUrl: 'https://i.imgur.com/zV6uy4T.jpg', 236 | id: '243523459665626', 237 | }, 238 | { 239 | name: '5', 240 | profilePictureUrl: 'https://i.imgur.com/zV6uy4T.jpg', 241 | id: '313552169447330', 242 | }, 243 | ], 244 | paging: { 245 | cursors: { 246 | before: 247 | 'QVFIUktTaXVuTUtsYUpVdFhlQjVhV2tRMU1jY0tRekU0d1NVTS1fZAGw4YmFYakU3ay1vRnlKbUh4VktROWxvazQzLXQzbm1YN0M3SHRKaVBGTTVCNFlyZAXBn', 248 | after: 249 | 'QVFIUl96LThrbmJrU3gzOHdsR2JaZA2dDM01uaEJNaUZArWnNTNHBhQi1iZA3lvakk2YWlUR3F5bUV3UDJYZAWVxYnJyOFA1VnJwZAG9GUEVzOGRMZAzRsV08wdW1R', 250 | }, 251 | next: '/138523840252451/personas?access_token=0987654321&limit=25&after=QVFIUl96LThrbmJrU3gzOHdsR2JaZA2dDM01uaEJNaUZArWnNTNHBhQi1iZA3lvakk2YWlUR3F5bUV3UDJYZAWVxYnJyOFA1VnJwZAG9GUEVzOGRMZAzRsV08wdW1R', 252 | }, 253 | }); 254 | }); 255 | }); 256 | 257 | describe('#deletePersona', () => { 258 | it('should call messages api to delete persona', async () => { 259 | const { client, mock } = createMock(); 260 | 261 | const personaId = '291604368115617'; 262 | 263 | const reply = { 264 | success: true, 265 | }; 266 | 267 | let url; 268 | mock.onDelete().reply((config) => { 269 | url = config.url; 270 | return [200, reply]; 271 | }); 272 | 273 | const res = await client.deletePersona(personaId); 274 | 275 | expect(url).toEqual(`/291604368115617?access_token=${ACCESS_TOKEN}`); 276 | 277 | expect(res).toEqual(reply); 278 | }); 279 | }); 280 | }); 281 | -------------------------------------------------------------------------------- /packages/messaging-api-messenger/src/__tests__/browser.spec.ts: -------------------------------------------------------------------------------- 1 | import { Messenger, MessengerBatch } from '../browser'; 2 | 3 | it('should export api correctly', () => { 4 | expect(Messenger).toBeDefined(); 5 | expect(MessengerBatch).toBeDefined(); 6 | }); 7 | -------------------------------------------------------------------------------- /packages/messaging-api-messenger/src/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { Messenger, MessengerBatch, MessengerClient } from '..'; 2 | 3 | it('should export api correctly', () => { 4 | expect(Messenger).toBeDefined(); 5 | expect(MessengerBatch).toBeDefined(); 6 | expect(MessengerClient).toBeDefined(); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/messaging-api-messenger/src/browser.ts: -------------------------------------------------------------------------------- 1 | export * as Messenger from './Messenger'; 2 | export * as MessengerBatch from './MessengerBatch'; 3 | export * as MessengerTypes from './MessengerTypes'; 4 | -------------------------------------------------------------------------------- /packages/messaging-api-messenger/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as MessengerClient } from './MessengerClient'; 2 | 3 | export * as Messenger from './Messenger'; 4 | export * as MessengerBatch from './MessengerBatch'; 5 | export * as MessengerTypes from './MessengerTypes'; 6 | -------------------------------------------------------------------------------- /packages/messaging-api-messenger/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/messaging-api-slack/README.md: -------------------------------------------------------------------------------- 1 | # messaging-api-slack 2 | 3 | > Messaging API client for Slack 4 | 5 | Slack 6 | 7 | ## Table of Contents 8 | 9 | - [Installation](#installation) 10 | - [OAuth Client](#oauth-client) 11 | - [Usage](#usage) 12 | - [API Reference](#api-reference) 13 | - [Webhook Client](#webhook-client) 14 | - [Usage](#usage-1) 15 | - [API Reference](#api-reference-1) 16 | - [Debug Tips](#debug-tips) 17 | - [Testing](#testing) 18 | 19 | ## Installation 20 | 21 | ```sh 22 | npm i --save messaging-api-slack 23 | ``` 24 | 25 | or 26 | 27 | ```sh 28 | yarn add messaging-api-slack 29 | ``` 30 | 31 |
32 | 33 | ## OAuth Client 34 | 35 | ### Usage 36 | 37 | Get your bot user OAuth access token by setup OAuth & Permissions function to your app or check the [Using OAuth 2.0](https://api.slack.com/docs/oauth) document. 38 | 39 | ```js 40 | const { SlackOAuthClient } = require('messaging-api-slack'); 41 | 42 | // get access token by setup OAuth & Permissions function to your app. 43 | // https://api.slack.com/docs/oauth 44 | const client = new SlackOAuthClient({ 45 | accessToken: 'xoxb-000000000000-xxxxxxxxxxxxxxxxxxxxxxxx', 46 | }); 47 | ``` 48 | 49 | #### Error Handling 50 | 51 | `messaging-api-slack` uses [axios](https://github.com/axios/axios) as HTTP client. We use [axios-error](https://github.com/Yoctol/messaging-apis/tree/master/packages/axios-error) package to wrap API error instances for better formatting error messages. Directly `console.log` on the error instance will return formatted message. If you'd like to get the axios `request`, `response`, or `config`, you can still get them via those keys on the error instance. 52 | 53 | ```js 54 | client.callMethod(method, body).catch((error) => { 55 | console.log(error); // formatted error message 56 | console.log(error.stack); // error stack trace 57 | console.log(error.config); // axios request config 58 | console.log(error.request); // HTTP request 59 | console.log(error.response); // HTTP response 60 | }); 61 | ``` 62 | 63 |
64 | 65 | ### API Reference 66 | 67 | All methods return a Promise. 68 | 69 |
70 | 71 | #### Call available methods 72 | 73 | ## `callMethod(method, body)` - [Official docs](https://api.slack.com/methods) 74 | 75 | Calling any API methods which follow [slack calling conventions](https://api.slack.com/web#basics). 76 | 77 | | Param | Type | Description | 78 | | ------ | -------- | --------------------------------------------------- | 79 | | method | `String` | One of [API Methods](https://api.slack.com/methods) | 80 | | body | `Object` | Body that the method needs. | 81 | 82 | Example: 83 | 84 | ```js 85 | client.callMethod('chat.postMessage', { channel: 'C8763', text: 'Hello!' }); 86 | ``` 87 | 88 |
89 | 90 | #### Chat API 91 | 92 | - [chat.postMessage](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackOAuthClient.html#chat) 93 | - [chat.postEphemeral](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackOAuthClient.html#chat) 94 | 95 |
96 | 97 | #### Users API 98 | 99 | - [getUserList](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackOAuthClient.html#getuserlist) 100 | - [getAllUserList](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackOAuthClient.html#getalluserlist) 101 | - [getUserInfo](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackOAuthClient.html#getuserinfo) 102 | 103 |
104 | 105 | #### Channels API 106 | 107 | - [getChannelInfo](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackOAuthClient.html#getchannelinfo) 108 | 109 |
110 | 111 | #### Conversations API 112 | 113 | - [getConversationInfo](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackOAuthClient.html#getconversationinfo) 114 | - [getConversationMembers](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackOAuthClient.html#getconversationmembers) 115 | - [getAllConversationMembers](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackOAuthClient.html#getallconversationmembers) 116 | - [getConversationList](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackOAuthClient.html#getconversationlist) 117 | - [getAllConversationList](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackOAuthClient.html#getallconversationlist) 118 | 119 |
120 | 121 | ## Webhook Client 122 | 123 | ## Usage 124 | 125 | Get your webhook url by adding a [Incoming Webhooks](https://api.slack.com/incoming-webhooks) integration to your team or setup Incoming Webhooks function to your app. 126 | 127 | ```js 128 | const { SlackWebhookClient } = require('messaging-api-slack'); 129 | 130 | // get webhook URL by adding a Incoming Webhook integration to your team. 131 | // https://my.slack.com/services/new/incoming-webhook/ 132 | const client = new SlackWebhookClient({ 133 | url: 'https://hooks.slack.com/services/XXXXXXXX/YYYYYYYY/zzzzzZZZZZ', 134 | }); 135 | ``` 136 | 137 |
138 | 139 | ## API Reference 140 | 141 | All methods return a Promise. 142 | 143 |
144 | 145 | ### Send API - [Official docs](https://api.slack.com/docs/messages) 146 | 147 | - [sendRawBody](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackWebhookClient.html#sendrawbody) 148 | - [sendText](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackWebhookClient.html#sendtext) 149 | - [sendAttachments](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackWebhookClient.html#sendattachments) 150 | - [sendAttachment](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_slack.SlackWebhookClient.html#sendattachment) 151 | 152 |
153 | 154 | ## Debug Tips 155 | 156 | ### Log Requests Details 157 | 158 | To enable default request debugger, use following `DEBUG` env variable: 159 | 160 | ```sh 161 | DEBUG=messaging-api:request 162 | ``` 163 | 164 | If you want to use a custom request logging function, just provide your own `onRequest`: 165 | 166 | ```js 167 | // for SlackOAuthClient 168 | const client = new SlackOAuthClient({ 169 | accessToken: ACCESS_TOKEN, 170 | onRequest: ({ method, url, headers, body }) => { 171 | /* */ 172 | }, 173 | }); 174 | 175 | // for SlackWebhookClient 176 | const client = new SlackWebhookClient({ 177 | url: URL, 178 | onRequest: ({ method, url, headers, body }) => { 179 | /* */ 180 | }, 181 | }); 182 | ``` 183 | 184 | ## Testing 185 | 186 | ### Point Requests to Your Dummy Server 187 | 188 | To avoid sending requests to real Slack server, specify the `origin` option when constructing your client: 189 | 190 | ```js 191 | const { SlackOAuthClient } = require('messaging-api-slack'); 192 | 193 | const client = new SlackOAuthClient({ 194 | accessToken: ACCESS_TOKEN, 195 | origin: 'https://mydummytestserver.com', 196 | }); 197 | ``` 198 | 199 | > Warning: Don't do this on your production server. 200 | -------------------------------------------------------------------------------- /packages/messaging-api-slack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "messaging-api-slack", 3 | "version": "1.1.0", 4 | "description": "Messaging API client for Slack", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Yoctol/messaging-apis.git" 9 | }, 10 | "main": "dist/index.js", 11 | "types": "dist/index.d.ts", 12 | "dependencies": { 13 | "@types/lodash": "^4.14.156", 14 | "@types/warning": "^3.0.0", 15 | "axios": "^0.21.1", 16 | "axios-error": "^1.0.4", 17 | "lodash": "^4.17.15", 18 | "messaging-api-common": "^1.0.4", 19 | "ts-invariant": "^0.4.4", 20 | "warning": "^4.0.3" 21 | }, 22 | "keywords": [ 23 | "bot", 24 | "chatbot", 25 | "messaging-apis", 26 | "slack" 27 | ], 28 | "engines": { 29 | "node": ">=10" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/messaging-api-slack/src/SlackWebhookClient.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from 'axios'; 2 | import invariant from 'ts-invariant'; 3 | import warning from 'warning'; 4 | import { 5 | OnRequestFunction, 6 | createRequestInterceptor, 7 | snakecaseKeysDeep, 8 | } from 'messaging-api-common'; 9 | 10 | import * as SlackTypes from './SlackTypes'; 11 | 12 | interface ClientConfig { 13 | url: string; 14 | onRequest?: OnRequestFunction; 15 | } 16 | 17 | export default class SlackWebhookClient { 18 | /** 19 | * @deprecated Use `new SlackWebhookClient(...)` instead. 20 | */ 21 | static connect(config: ClientConfig): SlackWebhookClient { 22 | warning( 23 | false, 24 | '`SlackWebhookClient.connect(...)` is deprecated. Use `new SlackWebhookClient(...)` instead.' 25 | ); 26 | return new SlackWebhookClient(config); 27 | } 28 | 29 | /** 30 | * The underlying axios instance. 31 | */ 32 | readonly axios: AxiosInstance; 33 | 34 | /** 35 | * The callback to be called when receiving requests. 36 | */ 37 | private onRequest?: OnRequestFunction; 38 | 39 | constructor(config: ClientConfig) { 40 | invariant( 41 | typeof config !== 'string', 42 | `SlackWebhookClient: do not allow constructing client with ${config} string. Use object instead.` 43 | ); 44 | 45 | this.onRequest = config.onRequest; 46 | 47 | // incoming webhooks 48 | // https://api.slack.com/incoming-webhooks 49 | this.axios = axios.create({ 50 | baseURL: config.url, 51 | headers: { 'Content-Type': 'application/json' }, 52 | }); 53 | 54 | this.axios.interceptors.request.use( 55 | createRequestInterceptor({ onRequest: this.onRequest }) 56 | ); 57 | } 58 | 59 | /** 60 | * Send message by using raw body. 61 | * 62 | * @param body - Raw data to be sent. 63 | * 64 | * @see https://api.slack.com/docs/messages 65 | * 66 | * @example 67 | * 68 | * ```js 69 | * await client.sendRawBody({ text: 'Hello!' }); 70 | * ``` 71 | */ 72 | sendRawBody( 73 | body: Record 74 | ): Promise { 75 | return this.axios.post('', snakecaseKeysDeep(body)).then((res) => res.data); 76 | } 77 | 78 | /** 79 | * Send text message. 80 | * 81 | * @param text - Text of the message to be sent. 82 | * 83 | * @see https://api.slack.com/docs/messages 84 | * 85 | * @example 86 | * 87 | * ```js 88 | * await client.sendText('Hello!'); 89 | * ``` 90 | */ 91 | sendText(text: string): Promise { 92 | return this.sendRawBody({ text }); 93 | } 94 | 95 | /** 96 | * Send multiple attachments which let you add more context to a message. 97 | * 98 | * @param attachments - Messages are attachments, defined as an array. Each object contains the parameters to customize the appearance of a message attachment. 99 | * 100 | * @see https://api.slack.com/docs/message-attachments 101 | * 102 | * ```js 103 | * await client.sendAttachments([ 104 | * { 105 | * fallback: 'some text', 106 | * pretext: 'some pretext', 107 | * color: 'good', 108 | * fields: [ 109 | * { 110 | * title: 'aaa', 111 | * value: 'bbb', 112 | * short: false, 113 | * }, 114 | * ], 115 | * }, 116 | * { 117 | * fallback: 'some other text', 118 | * pretext: 'some pther pretext', 119 | * color: '#FF0000', 120 | * fields: [ 121 | * { 122 | * title: 'ccc', 123 | * value: 'ddd', 124 | * short: false, 125 | * }, 126 | * ], 127 | * }, 128 | * ]); 129 | * ``` 130 | */ 131 | 132 | sendAttachments( 133 | attachments: SlackTypes.Attachment[] 134 | ): Promise { 135 | return this.sendRawBody({ attachments }); 136 | } 137 | 138 | /** 139 | * Send only one attachment. 140 | * 141 | * @param attachment - Message is an attachment. The object contains the parameters to customize the appearance of a message attachment. 142 | * 143 | * @see https://api.slack.com/docs/message-attachments 144 | * 145 | * ```js 146 | * await client.sendAttachment({ 147 | * fallback: 'some text', 148 | * pretext: 'some pretext', 149 | * color: 'good', 150 | * fields: [ 151 | * { 152 | * title: 'aaa', 153 | * value: 'bbb', 154 | * short: false, 155 | * }, 156 | * ], 157 | * }); 158 | * ``` 159 | */ 160 | sendAttachment( 161 | attachment: SlackTypes.Attachment 162 | ): Promise { 163 | return this.sendAttachments([attachment]); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /packages/messaging-api-slack/src/__tests__/SlackOAuthClient-constructor.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import SlackOAuthClient from '../SlackOAuthClient'; 4 | 5 | const TOKEN = 'xxxx-xxxxxxxxx-xxxx'; 6 | 7 | describe('connect', () => { 8 | let axios; 9 | let _create; 10 | beforeEach(() => { 11 | axios = require('axios'); 12 | _create = axios.create; 13 | }); 14 | 15 | afterEach(() => { 16 | axios.create = _create; 17 | }); 18 | 19 | describe('create axios with slack api url', () => { 20 | it('with config', () => { 21 | axios.create = jest.fn().mockReturnValue({ 22 | interceptors: { 23 | request: { 24 | use: jest.fn(), 25 | }, 26 | }, 27 | }); 28 | SlackOAuthClient.connect({ accessToken: TOKEN }); 29 | 30 | expect(axios.create).toBeCalledWith({ 31 | baseURL: 'https://slack.com/api/', 32 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 33 | }); 34 | }); 35 | }); 36 | 37 | it('support origin', () => { 38 | axios.create = jest.fn().mockReturnValue({ 39 | interceptors: { 40 | request: { 41 | use: jest.fn(), 42 | }, 43 | }, 44 | }); 45 | SlackOAuthClient.connect({ 46 | accessToken: TOKEN, 47 | origin: 'https://mydummytestserver.com', 48 | }); 49 | 50 | expect(axios.create).toBeCalledWith({ 51 | baseURL: 'https://mydummytestserver.com/api/', 52 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 53 | }); 54 | }); 55 | }); 56 | 57 | describe('constructor', () => { 58 | let axios; 59 | let _create; 60 | beforeEach(() => { 61 | axios = require('axios'); 62 | _create = axios.create; 63 | }); 64 | 65 | afterEach(() => { 66 | axios.create = _create; 67 | }); 68 | 69 | describe('create axios with with slack api url', () => { 70 | it('with config', () => { 71 | axios.create = jest.fn().mockReturnValue({ 72 | interceptors: { 73 | request: { 74 | use: jest.fn(), 75 | }, 76 | }, 77 | }); 78 | new SlackOAuthClient({ accessToken: TOKEN }); // eslint-disable-line no-new 79 | 80 | expect(axios.create).toBeCalledWith({ 81 | baseURL: 'https://slack.com/api/', 82 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 83 | }); 84 | }); 85 | }); 86 | 87 | it('support origin', () => { 88 | axios.create = jest.fn().mockReturnValue({ 89 | interceptors: { 90 | request: { 91 | use: jest.fn(), 92 | }, 93 | }, 94 | }); 95 | // eslint-disable-next-line no-new 96 | new SlackOAuthClient({ 97 | accessToken: TOKEN, 98 | origin: 'https://mydummytestserver.com', 99 | }); 100 | 101 | expect(axios.create).toBeCalledWith({ 102 | baseURL: 'https://mydummytestserver.com/api/', 103 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 104 | }); 105 | }); 106 | }); 107 | 108 | describe('#axios', () => { 109 | it('should return underlying http client', () => { 110 | const client = new SlackOAuthClient({ accessToken: TOKEN }); 111 | 112 | expect(client.axios.get).toBeDefined(); 113 | expect(client.axios.post).toBeDefined(); 114 | expect(client.axios.put).toBeDefined(); 115 | expect(client.axios.delete).toBeDefined(); 116 | }); 117 | }); 118 | 119 | describe('#accessToken', () => { 120 | it('should return underlying access token', () => { 121 | const client = new SlackOAuthClient({ accessToken: TOKEN }); 122 | 123 | expect(client.accessToken).toBe(TOKEN); 124 | }); 125 | }); 126 | 127 | describe('#onRequest', () => { 128 | it('should call onRequest when calling any API', async () => { 129 | const onRequest = jest.fn(); 130 | const client = new SlackOAuthClient({ 131 | accessToken: TOKEN, 132 | onRequest, 133 | }); 134 | 135 | const mock = new MockAdapter(client.axios); 136 | 137 | mock.onPost('/path').reply(200, {}); 138 | 139 | await client.axios.post('/path', { x: 1 }); 140 | 141 | expect(onRequest).toBeCalledWith({ 142 | method: 'post', 143 | url: 'https://slack.com/api/path', 144 | body: { 145 | x: 1, 146 | }, 147 | headers: { 148 | 'Content-Type': 'application/x-www-form-urlencoded', 149 | Accept: 'application/json, text/plain, */*', 150 | }, 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /packages/messaging-api-slack/src/__tests__/SlackWebhookClient-constructor.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import SlackWebhookClient from '../SlackWebhookClient'; 4 | 5 | const URL = 'https://hooks.slack.com/services/XXXXXXXX/YYYYYYYY/zzzzzZZZZZ'; 6 | 7 | describe('connect', () => { 8 | let axios; 9 | let _create; 10 | beforeEach(() => { 11 | axios = require('axios'); 12 | _create = axios.create; 13 | }); 14 | 15 | afterEach(() => { 16 | axios.create = _create; 17 | }); 18 | 19 | describe('create axios with webhook url', () => { 20 | it('with config', () => { 21 | axios.create = jest.fn().mockReturnValue({ 22 | interceptors: { 23 | request: { 24 | use: jest.fn(), 25 | }, 26 | }, 27 | }); 28 | SlackWebhookClient.connect({ url: URL }); 29 | 30 | expect(axios.create).toBeCalledWith({ 31 | baseURL: 32 | 'https://hooks.slack.com/services/XXXXXXXX/YYYYYYYY/zzzzzZZZZZ', 33 | headers: { 'Content-Type': 'application/json' }, 34 | }); 35 | }); 36 | }); 37 | }); 38 | 39 | describe('constructor', () => { 40 | let axios; 41 | let _create; 42 | beforeEach(() => { 43 | axios = require('axios'); 44 | _create = axios.create; 45 | }); 46 | 47 | afterEach(() => { 48 | axios.create = _create; 49 | }); 50 | 51 | describe('create axios with with webhook url', () => { 52 | it('with config', () => { 53 | axios.create = jest.fn().mockReturnValue({ 54 | interceptors: { 55 | request: { 56 | use: jest.fn(), 57 | }, 58 | }, 59 | }); 60 | // eslint-disable-next-line no-new 61 | new SlackWebhookClient({ 62 | url: URL, 63 | }); 64 | 65 | expect(axios.create).toBeCalledWith({ 66 | baseURL: 67 | 'https://hooks.slack.com/services/XXXXXXXX/YYYYYYYY/zzzzzZZZZZ', 68 | headers: { 'Content-Type': 'application/json' }, 69 | }); 70 | }); 71 | }); 72 | }); 73 | 74 | describe('#axios', () => { 75 | it('should return underlying http client', () => { 76 | const client = new SlackWebhookClient({ url: URL }); 77 | 78 | expect(client.axios.get).toBeDefined(); 79 | expect(client.axios.post).toBeDefined(); 80 | expect(client.axios.put).toBeDefined(); 81 | expect(client.axios.delete).toBeDefined(); 82 | }); 83 | }); 84 | 85 | describe('#onRequest', () => { 86 | it('should call onRequest when calling any API', async () => { 87 | const onRequest = jest.fn(); 88 | const client = new SlackWebhookClient({ 89 | url: URL, 90 | onRequest, 91 | }); 92 | 93 | const mock = new MockAdapter(client.axios); 94 | 95 | mock.onPost('/path').reply(200, {}); 96 | 97 | await client.axios.post('/path', { x: 1 }); 98 | 99 | expect(onRequest).toBeCalledWith({ 100 | method: 'post', 101 | url: 'https://hooks.slack.com/services/XXXXXXXX/YYYYYYYY/zzzzzZZZZZ/path', 102 | body: { 103 | x: 1, 104 | }, 105 | headers: { 106 | 'Content-Type': 'application/json', 107 | Accept: 'application/json, text/plain, */*', 108 | }, 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /packages/messaging-api-slack/src/__tests__/SlackWebhookClient.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import SlackWebhookClient from '../SlackWebhookClient'; 4 | 5 | const URL = 'https://hooks.slack.com/services/XXXXXXXX/YYYYYYYY/zzzzzZZZZZ'; 6 | 7 | const createMock = (): { client: SlackWebhookClient; mock: MockAdapter } => { 8 | const client = new SlackWebhookClient({ 9 | url: URL, 10 | }); 11 | const mock = new MockAdapter(client.axios); 12 | return { client, mock }; 13 | }; 14 | 15 | describe('sendRawBody', () => { 16 | it('should call messages api', async () => { 17 | const { client, mock } = createMock(); 18 | 19 | const reply = 'ok'; 20 | 21 | mock 22 | .onPost('', { 23 | text: 'hello', 24 | }) 25 | .reply(200, reply); 26 | 27 | const res = await client.sendRawBody({ 28 | text: 'hello', 29 | }); 30 | 31 | expect(res).toEqual(reply); 32 | }); 33 | }); 34 | 35 | describe('sendText', () => { 36 | it('should call messages api', async () => { 37 | const { client, mock } = createMock(); 38 | 39 | const reply = 'ok'; 40 | 41 | mock 42 | .onPost('', { 43 | text: 'hello', 44 | }) 45 | .reply(200, reply); 46 | 47 | const res = await client.sendText('hello'); 48 | 49 | expect(res).toEqual(reply); 50 | }); 51 | }); 52 | 53 | describe('sendAttachments', () => { 54 | it('should call messages api', async () => { 55 | const { client, mock } = createMock(); 56 | 57 | const reply = 'ok'; 58 | 59 | mock 60 | .onPost('', { 61 | attachments: [{ fallback: 'aaa' }, { fallback: 'bbb' }], 62 | }) 63 | .reply(200, reply); 64 | 65 | const res = await client.sendAttachments([ 66 | { fallback: 'aaa' }, 67 | { fallback: 'bbb' }, 68 | ]); 69 | 70 | expect(res).toEqual(reply); 71 | }); 72 | }); 73 | 74 | describe('sendAttachment', () => { 75 | it('should call messages api', async () => { 76 | const { client, mock } = createMock(); 77 | 78 | const reply = 'ok'; 79 | 80 | mock 81 | .onPost('', { 82 | attachments: [{ fallback: 'aaa' }], 83 | }) 84 | .reply(200, reply); 85 | 86 | const res = await client.sendAttachment({ fallback: 'aaa' }); 87 | 88 | expect(res).toEqual(reply); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/messaging-api-slack/src/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { SlackOAuthClient, SlackWebhookClient } from '..'; 2 | 3 | it('should export api correctly', () => { 4 | expect(SlackWebhookClient).toBeDefined(); 5 | expect(SlackOAuthClient).toBeDefined(); 6 | }); 7 | -------------------------------------------------------------------------------- /packages/messaging-api-slack/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SlackOAuthClient } from './SlackOAuthClient'; 2 | export { default as SlackWebhookClient } from './SlackWebhookClient'; 3 | 4 | export * as SlackTypes from './SlackTypes'; 5 | -------------------------------------------------------------------------------- /packages/messaging-api-slack/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/messaging-api-telegram/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "messaging-api-telegram", 3 | "version": "1.1.0", 4 | "description": "Messaging API client for Telegram", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Yoctol/messaging-apis.git" 9 | }, 10 | "main": "dist/index.js", 11 | "types": "dist/index.d.ts", 12 | "dependencies": { 13 | "@types/lodash": "^4.14.156", 14 | "@types/warning": "^3.0.0", 15 | "axios": "^0.21.1", 16 | "axios-error": "file:../axios-error", 17 | "lodash": "^4.17.15", 18 | "messaging-api-common": "file:../messaging-api-common", 19 | "ts-invariant": "^0.4.4", 20 | "warning": "^4.0.3" 21 | }, 22 | "keywords": [ 23 | "bot", 24 | "chatbot", 25 | "messaging-apis", 26 | "telegram" 27 | ], 28 | "engines": { 29 | "node": ">=10" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/messaging-api-telegram/src/__tests__/TelegramClient-constructor.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import TelegramClient from '../TelegramClient'; 4 | 5 | const ACCESS_TOKEN = '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11'; 6 | 7 | const createMock = (): { client: TelegramClient; mock: MockAdapter } => { 8 | const client = new TelegramClient({ 9 | accessToken: ACCESS_TOKEN, 10 | }); 11 | const mock = new MockAdapter(client.axios); 12 | return { client, mock }; 13 | }; 14 | 15 | describe('connect', () => { 16 | let axios; 17 | let _create; 18 | beforeEach(() => { 19 | axios = require('axios'); 20 | _create = axios.create; 21 | }); 22 | 23 | afterEach(() => { 24 | axios.create = _create; 25 | }); 26 | 27 | describe('create axios with Telegram API', () => { 28 | it('with config', () => { 29 | axios.create = jest.fn().mockReturnValue({ 30 | interceptors: { 31 | request: { 32 | use: jest.fn(), 33 | }, 34 | }, 35 | }); 36 | TelegramClient.connect({ accessToken: ACCESS_TOKEN }); 37 | 38 | expect(axios.create).toBeCalledWith({ 39 | baseURL: 40 | 'https://api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/', 41 | headers: { 42 | 'Content-Type': 'application/json', 43 | }, 44 | }); 45 | }); 46 | }); 47 | 48 | it('support origin', () => { 49 | axios.create = jest.fn().mockReturnValue({ 50 | interceptors: { 51 | request: { 52 | use: jest.fn(), 53 | }, 54 | }, 55 | }); 56 | TelegramClient.connect({ 57 | accessToken: ACCESS_TOKEN, 58 | origin: 'https://mydummytestserver.com', 59 | }); 60 | 61 | expect(axios.create).toBeCalledWith({ 62 | baseURL: 63 | 'https://mydummytestserver.com/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/', 64 | headers: { 65 | 'Content-Type': 'application/json', 66 | }, 67 | }); 68 | }); 69 | }); 70 | 71 | describe('constructor', () => { 72 | let axios; 73 | let _create; 74 | beforeEach(() => { 75 | axios = require('axios'); 76 | _create = axios.create; 77 | }); 78 | 79 | afterEach(() => { 80 | axios.create = _create; 81 | }); 82 | 83 | describe('create axios with Telegram API', () => { 84 | it('with config', () => { 85 | axios.create = jest.fn().mockReturnValue({ 86 | interceptors: { 87 | request: { 88 | use: jest.fn(), 89 | }, 90 | }, 91 | }); 92 | new TelegramClient({ accessToken: ACCESS_TOKEN }); // eslint-disable-line no-new 93 | 94 | expect(axios.create).toBeCalledWith({ 95 | baseURL: 96 | 'https://api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/', 97 | headers: { 98 | 'Content-Type': 'application/json', 99 | }, 100 | }); 101 | }); 102 | }); 103 | 104 | it('support origin', () => { 105 | axios.create = jest.fn().mockReturnValue({ 106 | interceptors: { 107 | request: { 108 | use: jest.fn(), 109 | }, 110 | }, 111 | }); 112 | // eslint-disable-next-line no-new 113 | new TelegramClient({ 114 | accessToken: ACCESS_TOKEN, 115 | origin: 'https://mydummytestserver.com', 116 | }); 117 | 118 | expect(axios.create).toBeCalledWith({ 119 | baseURL: 120 | 'https://mydummytestserver.com/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/', 121 | headers: { 122 | 'Content-Type': 'application/json', 123 | }, 124 | }); 125 | }); 126 | }); 127 | 128 | describe('#axios', () => { 129 | it('should return underlying http client', () => { 130 | const client = new TelegramClient({ accessToken: ACCESS_TOKEN }); 131 | 132 | expect(client.axios.get).toBeDefined(); 133 | expect(client.axios.post).toBeDefined(); 134 | expect(client.axios.put).toBeDefined(); 135 | expect(client.axios.delete).toBeDefined(); 136 | }); 137 | 138 | it('should throw error when ok is false', async () => { 139 | const { client, mock } = createMock(); 140 | const reply = { 141 | ok: false, 142 | description: 'Delete webhook failed', 143 | }; 144 | 145 | mock.onPost('/deleteWebhook').reply(200, reply); 146 | 147 | await expect(client.deleteWebhook()).rejects.toThrow(); 148 | }); 149 | }); 150 | 151 | describe('#accessToken', () => { 152 | it('should return underlying access token', () => { 153 | const client = new TelegramClient({ accessToken: ACCESS_TOKEN }); 154 | 155 | expect(client.accessToken).toBe(ACCESS_TOKEN); 156 | }); 157 | }); 158 | 159 | describe('#onRequest', () => { 160 | it('should call onRequest when calling any API', async () => { 161 | const onRequest = jest.fn(); 162 | const client = new TelegramClient({ 163 | accessToken: ACCESS_TOKEN, 164 | onRequest, 165 | }); 166 | 167 | const mock = new MockAdapter(client.axios); 168 | 169 | mock.onPost('/path').reply(200, {}); 170 | 171 | await client.axios.post('/path', { x: 1 }); 172 | 173 | expect(onRequest).toBeCalledWith({ 174 | method: 'post', 175 | url: 'https://api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/path', 176 | body: { 177 | x: 1, 178 | }, 179 | headers: { 180 | 'Content-Type': 'application/json', 181 | Accept: 'application/json, text/plain, */*', 182 | }, 183 | }); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /packages/messaging-api-telegram/src/__tests__/TelegramClient-payment.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import TelegramClient from '../TelegramClient'; 4 | 5 | const ACCESS_TOKEN = '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11'; 6 | 7 | const createMock = (): { client: TelegramClient; mock: MockAdapter } => { 8 | const client = new TelegramClient({ 9 | accessToken: ACCESS_TOKEN, 10 | }); 11 | const mock = new MockAdapter(client.axios); 12 | return { client, mock }; 13 | }; 14 | 15 | describe('payment api', () => { 16 | describe('#sendInvoice', () => { 17 | const result = { 18 | messageId: 1, 19 | from: { 20 | id: 313534466, 21 | firstName: 'first', 22 | username: 'a_bot', 23 | }, 24 | chat: { 25 | id: 427770117, 26 | firstName: 'first', 27 | lastName: 'last', 28 | type: 'private', 29 | }, 30 | date: 1499403678, 31 | invoice: { 32 | title: 'product name', 33 | description: 'product description', 34 | startParameter: 'pay', 35 | currency: 'USD', 36 | totalCount: 22000, 37 | }, 38 | }; 39 | const reply = { 40 | ok: true, 41 | result: { 42 | message_id: 1, 43 | from: { 44 | id: 313534466, 45 | first_name: 'first', 46 | username: 'a_bot', 47 | }, 48 | chat: { 49 | id: 427770117, 50 | first_name: 'first', 51 | last_name: 'last', 52 | type: 'private', 53 | }, 54 | date: 1499403678, 55 | invoice: { 56 | title: 'product name', 57 | description: 'product description', 58 | start_parameter: 'pay', 59 | currency: 'USD', 60 | total_count: 22000, 61 | }, 62 | }, 63 | }; 64 | 65 | it('should send invoice message to user with snakecase', async () => { 66 | const { client, mock } = createMock(); 67 | mock 68 | .onPost('/sendInvoice', { 69 | chat_id: 427770117, 70 | title: 'product name', 71 | description: 'product description', 72 | payload: 'bot-defined invoice payload', 73 | provider_token: 'PROVIDER_TOKEN', 74 | start_parameter: 'pay', 75 | currency: 'USD', 76 | prices: [ 77 | { label: 'product', amount: 11000 }, 78 | { label: 'tax', amount: 11000 }, 79 | ], 80 | }) 81 | .reply(200, reply); 82 | 83 | const res = await client.sendInvoice(427770117, { 84 | title: 'product name', 85 | description: 'product description', 86 | payload: 'bot-defined invoice payload', 87 | provider_token: 'PROVIDER_TOKEN', 88 | start_parameter: 'pay', 89 | currency: 'USD', 90 | prices: [ 91 | { label: 'product', amount: 11000 }, 92 | { label: 'tax', amount: 11000 }, 93 | ], 94 | }); 95 | 96 | expect(res).toEqual(result); 97 | }); 98 | 99 | it('should send invoice message to user with camelcase', async () => { 100 | const { client, mock } = createMock(); 101 | mock 102 | .onPost('/sendInvoice', { 103 | chat_id: 427770117, 104 | title: 'product name', 105 | description: 'product description', 106 | payload: 'bot-defined invoice payload', 107 | provider_token: 'PROVIDER_TOKEN', 108 | start_parameter: 'pay', 109 | currency: 'USD', 110 | prices: [ 111 | { label: 'product', amount: 11000 }, 112 | { label: 'tax', amount: 11000 }, 113 | ], 114 | }) 115 | .reply(200, reply); 116 | 117 | const res = await client.sendInvoice(427770117, { 118 | title: 'product name', 119 | description: 'product description', 120 | payload: 'bot-defined invoice payload', 121 | providerToken: 'PROVIDER_TOKEN', 122 | startParameter: 'pay', 123 | currency: 'USD', 124 | prices: [ 125 | { label: 'product', amount: 11000 }, 126 | { label: 'tax', amount: 11000 }, 127 | ], 128 | }); 129 | 130 | expect(res).toEqual(result); 131 | }); 132 | }); 133 | 134 | describe('#answerShippingQuery', () => { 135 | const result = true; 136 | const reply = { 137 | ok: true, 138 | result, 139 | }; 140 | 141 | it('should export chat invite link', async () => { 142 | const { client, mock } = createMock(); 143 | mock 144 | .onPost('/answerShippingQuery', { 145 | shipping_query_id: 'UNIQUE_ID', 146 | ok: true, 147 | shipping_options: [ 148 | { 149 | id: 'id', 150 | title: 'title', 151 | prices: [ 152 | { 153 | label: 'label', 154 | amount: '100', 155 | }, 156 | ], 157 | }, 158 | ], 159 | }) 160 | .reply(200, reply); 161 | 162 | const res = await client.answerShippingQuery('UNIQUE_ID', true, { 163 | shippingOptions: [ 164 | { 165 | id: 'id', 166 | title: 'title', 167 | prices: [ 168 | { 169 | label: 'label', 170 | amount: '100', 171 | }, 172 | ], 173 | }, 174 | ], 175 | }); 176 | expect(res).toEqual(result); 177 | }); 178 | }); 179 | 180 | describe('#answerPreCheckoutQuery', () => { 181 | it('should respond to such pre-checkout queries', async () => { 182 | const { client, mock } = createMock(); 183 | const result = true; 184 | const reply = { 185 | ok: true, 186 | result, 187 | }; 188 | 189 | mock 190 | .onPost('/answerPreCheckoutQuery', { 191 | pre_checkout_query_id: 'UNIQUE_ID', 192 | ok: true, 193 | }) 194 | .reply(200, reply); 195 | 196 | const res = await client.answerPreCheckoutQuery('UNIQUE_ID', true); 197 | expect(res).toEqual(result); 198 | }); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /packages/messaging-api-telegram/src/__tests__/TelegramClient-stickerSet.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import TelegramClient from '../TelegramClient'; 4 | 5 | const ACCESS_TOKEN = '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11'; 6 | 7 | const createMock = (): { client: TelegramClient; mock: MockAdapter } => { 8 | const client = new TelegramClient({ 9 | accessToken: ACCESS_TOKEN, 10 | }); 11 | const mock = new MockAdapter(client.axios); 12 | return { client, mock }; 13 | }; 14 | 15 | describe('sticker set api', () => { 16 | describe('#getStickerSet', () => { 17 | it('should return a stickerSet', async () => { 18 | const { client, mock } = createMock(); 19 | const result = { 20 | name: 'sticker set name', 21 | title: 'sticker set title', 22 | isAnimated: false, 23 | containsMasks: false, 24 | stickers: [ 25 | { 26 | width: 512, 27 | height: 512, 28 | emoji: '💛', 29 | setName: 'sticker set name', 30 | isAnimated: false, 31 | thumb: { 32 | fileId: 'AAQEAANDAQACEDVoAAFVA7aGNPt1If3eYTAABAEAB20AAzkOAAIWB', 33 | fileSize: 5706, 34 | width: 128, 35 | height: 128, 36 | }, 37 | fileId: 'CAADBAADQwEAAhA1aAABVQO2hjT7dSEWB', 38 | fileSize: 36424, 39 | }, 40 | ], 41 | }; 42 | const reply = { 43 | ok: true, 44 | result: { 45 | name: 'sticker set name', 46 | title: 'sticker set title', 47 | is_animated: false, 48 | contains_masks: false, 49 | stickers: [ 50 | { 51 | width: 512, 52 | height: 512, 53 | emoji: '💛', 54 | set_name: 'sticker set name', 55 | is_animated: false, 56 | thumb: { 57 | file_id: 58 | 'AAQEAANDAQACEDVoAAFVA7aGNPt1If3eYTAABAEAB20AAzkOAAIWB', 59 | file_size: 5706, 60 | width: 128, 61 | height: 128, 62 | }, 63 | file_id: 'CAADBAADQwEAAhA1aAABVQO2hjT7dSEWB', 64 | file_size: 36424, 65 | }, 66 | ], 67 | }, 68 | }; 69 | 70 | mock 71 | .onPost('/getStickerSet', { 72 | name: 'sticker set name', 73 | }) 74 | .reply(200, reply); 75 | 76 | const res = await client.getStickerSet('sticker set name'); 77 | 78 | expect(res).toEqual(result); 79 | }); 80 | }); 81 | 82 | describe('#createNewStickerSet', () => { 83 | const result = true; 84 | const reply = { 85 | ok: true, 86 | result, 87 | }; 88 | 89 | const mock_params = { 90 | user_id: 1, 91 | name: 'sticker_set_name', 92 | title: 'title', 93 | png_sticker: 'https://example.com/sticker.png', 94 | emojis: '💛', 95 | contains_masks: true, 96 | mask_position: { 97 | point: 'eyes', 98 | x_shift: 10, 99 | y_shift: 10, 100 | scale: 1, 101 | }, 102 | }; 103 | 104 | it('should create a new stickerSet with snakecase', async () => { 105 | const { client, mock } = createMock(); 106 | mock.onPost('/createNewStickerSet', mock_params).reply(200, reply); 107 | 108 | const res = await client.createNewStickerSet( 109 | 1, 110 | 'sticker_set_name', 111 | 'title', 112 | 'https://example.com/sticker.png', 113 | '💛', 114 | { 115 | contains_masks: true, 116 | mask_position: { 117 | point: 'eyes', 118 | x_shift: 10, 119 | y_shift: 10, 120 | scale: 1, 121 | }, 122 | } 123 | ); 124 | 125 | expect(res).toEqual(result); 126 | }); 127 | 128 | it('should create a new stickerSet with camelcase', async () => { 129 | const { client, mock } = createMock(); 130 | mock.onPost('/createNewStickerSet', mock_params).reply(200, reply); 131 | 132 | const res = await client.createNewStickerSet( 133 | 1, 134 | 'sticker_set_name', 135 | 'title', 136 | 'https://example.com/sticker.png', 137 | '💛', 138 | { 139 | containsMasks: true, 140 | maskPosition: { 141 | point: 'eyes', 142 | xShift: 10, 143 | yShift: 10, 144 | scale: 1, 145 | }, 146 | } 147 | ); 148 | 149 | expect(res).toEqual(result); 150 | }); 151 | }); 152 | 153 | describe('#addStickerToSet', () => { 154 | const result = true; 155 | const reply = { 156 | ok: true, 157 | result, 158 | }; 159 | 160 | const mock_params = { 161 | user_id: 1, 162 | name: 'sticker_set_name', 163 | png_sticker: 'https://example.com/sticker.png', 164 | emojis: '💛', 165 | mask_position: { 166 | point: 'eyes', 167 | x_shift: 10, 168 | y_shift: 10, 169 | scale: 1, 170 | }, 171 | }; 172 | 173 | it('should add a sticker to set with snakecase', async () => { 174 | const { client, mock } = createMock(); 175 | mock.onPost('/addStickerToSet', mock_params).reply(200, reply); 176 | 177 | const res = await client.addStickerToSet( 178 | 1, 179 | 'sticker_set_name', 180 | 'https://example.com/sticker.png', 181 | '💛', 182 | { 183 | mask_position: { 184 | point: 'eyes', 185 | x_shift: 10, 186 | y_shift: 10, 187 | scale: 1, 188 | }, 189 | } 190 | ); 191 | 192 | expect(res).toEqual(result); 193 | }); 194 | 195 | it('should add a sticker to set with camelcase', async () => { 196 | const { client, mock } = createMock(); 197 | mock.onPost('/addStickerToSet', mock_params).reply(200, reply); 198 | 199 | const res = await client.addStickerToSet( 200 | 1, 201 | 'sticker_set_name', 202 | 'https://example.com/sticker.png', 203 | '💛', 204 | { 205 | maskPosition: { 206 | point: 'eyes', 207 | xShift: 10, 208 | yShift: 10, 209 | scale: 1, 210 | }, 211 | } 212 | ); 213 | 214 | expect(res).toEqual(result); 215 | }); 216 | }); 217 | 218 | describe('#setStickerPositionInSet', () => { 219 | const result = true; 220 | const reply = { 221 | ok: true, 222 | result, 223 | }; 224 | 225 | const mock_params = { 226 | sticker: 'CAADBAADQwEAAhA1aAABVQO2hjT7dSEWB', 227 | position: 0, 228 | }; 229 | 230 | it('should change sticker position', async () => { 231 | const { client, mock } = createMock(); 232 | mock.onPost('/setStickerPositionInSet', mock_params).reply(200, reply); 233 | 234 | const res = await client.setStickerPositionInSet( 235 | 'CAADBAADQwEAAhA1aAABVQO2hjT7dSEWB', 236 | 0 237 | ); 238 | 239 | expect(res).toEqual(result); 240 | }); 241 | }); 242 | 243 | describe('#deleteStickerFromSet', () => { 244 | const result = true; 245 | const reply = { 246 | ok: true, 247 | result, 248 | }; 249 | 250 | const mock_params = { sticker: 'CAADBAADQwEAAhA1aAABVQO2hjT7dSEWB' }; 251 | 252 | it('should delete sticker successfully', async () => { 253 | const { client, mock } = createMock(); 254 | mock.onPost('/deleteStickerFromSet', mock_params).reply(200, reply); 255 | 256 | const res = await client.deleteStickerFromSet( 257 | 'CAADBAADQwEAAhA1aAABVQO2hjT7dSEWB' 258 | ); 259 | 260 | expect(res).toEqual(result); 261 | }); 262 | }); 263 | }); 264 | -------------------------------------------------------------------------------- /packages/messaging-api-telegram/src/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { TelegramClient } from '..'; 2 | 3 | it('should export api correctly', () => { 4 | expect(TelegramClient).toBeDefined(); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/messaging-api-telegram/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as TelegramClient } from './TelegramClient'; 2 | 3 | export * as TelegramTypes from './TelegramTypes'; 4 | -------------------------------------------------------------------------------- /packages/messaging-api-telegram/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/messaging-api-viber/README.md: -------------------------------------------------------------------------------- 1 | # messaging-api-viber 2 | 3 | > Messaging API client for Viber 4 | 5 | Viber 6 | 7 | ## Table of Contents 8 | 9 | - [Installation](#installation) 10 | - [Usage](#usage) 11 | - [API Reference](#api-reference) 12 | - [Webhook API](#webhook-api) 13 | - [Send API](#send-api) 14 | - [Keyboards](#keyboards) 15 | - [Broadcast API](#broadcast-api) 16 | - [Get Account Info](#get-account-info) 17 | - [Get User Details](#get-user-details) 18 | - [Get Online](#get-online) 19 | - [Debug Tips](#debug-tips) 20 | - [Testing](#testing) 21 | 22 | ## Installation 23 | 24 | ```sh 25 | npm i --save messaging-api-viber 26 | ``` 27 | 28 | or 29 | 30 | ```sh 31 | yarn add messaging-api-viber 32 | ``` 33 | 34 |
35 | 36 | ## Usage 37 | 38 | ### Initialize 39 | 40 | ```js 41 | const { ViberClient } = require('messaging-api-viber'); 42 | 43 | // get authToken from the "edit info" screen of your Public Account. 44 | const client = new ViberClient({ 45 | accessToken: authToken, 46 | sender: { 47 | name: 'Sender', 48 | }, 49 | }); 50 | ``` 51 | 52 | ### Error Handling 53 | 54 | `messaging-api-viber` uses [axios](https://github.com/axios/axios) as HTTP client. We use [axios-error](https://github.com/Yoctol/messaging-apis/tree/master/packages/axios-error) package to wrap API error instances for better formatting error messages. Directly calling `console.log` with the error instance will return formatted message. If you'd like to get the axios `request`, `response`, or `config`, you can still get them via those keys on the error instance. 55 | 56 | ```js 57 | client.setWebhook(url).catch((error) => { 58 | console.log(error); // formatted error message 59 | console.log(error.stack); // error stack trace 60 | console.log(error.config); // axios request config 61 | console.log(error.request); // HTTP request 62 | console.log(error.response); // HTTP response 63 | }); 64 | ``` 65 | 66 |
67 | 68 | ## API Reference 69 | 70 | All methods return a Promise. 71 | 72 |
73 | 74 | ### Webhook API 75 | 76 | - [setWebhook](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#setwebhook) 77 | - [removeWebhook](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#removewebhook) 78 | 79 |
80 | 81 | ### Send API 82 | 83 | - [sendMessage](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#sendmessage) 84 | - [sendText](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#sendtext) 85 | - [sendPicture](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#sendpicture) 86 | - [sendVideo](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#sendvideo) 87 | - [sendFile](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#sendfile) 88 | - [sendContact](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#sendcontact) 89 | - [sendLocation](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#sendlocation) 90 | - [sendURL](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#sendurl) 91 | - [sendSticker](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#sendsticker) 92 | - [sendCarouselContent](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#sendcarouselcontent) 93 | 94 |
95 | 96 | 97 | 98 | ### Keyboards - [Official Docs](https://developers.viber.com/docs/api/rest-bot-api/#keyboards) 99 | 100 | The Viber API allows sending a custom keyboard using the send_message API, to supply the user with a set of predefined replies or actions. Keyboards can be attached to any message type and be sent and displayed together. To attach a keyboard to a message simply add the keyboard’s parameters to the options: 101 | 102 | ```js 103 | client.sendText(USER_ID, 'Hello', { 104 | keyboard: { 105 | type: 'keyboard', 106 | defaultHeight: true, 107 | bgColor: '#FFFFFF', 108 | buttons: [ 109 | { 110 | columns: 6, 111 | rows: 1, 112 | bgColor: '#2db9b9', 113 | bgMediaType: 'gif', 114 | bgMedia: 'http://www.url.by/test.gif', 115 | bgLoop: true, 116 | actionType: 'open-url', 117 | actionBody: 'www.tut.by', 118 | image: 'www.tut.by/img.jpg', 119 | text: 'Key text', 120 | textVAlign: 'middle', 121 | textHAlign: 'center', 122 | textOpacity: 60, 123 | textSize: 'regular', 124 | }, 125 | ], 126 | }, 127 | }); 128 | ``` 129 | 130 | 131 | 132 |
133 | 134 |
135 | 136 | ### Broadcast API - [Official Docs](https://developers.viber.com/docs/api/rest-bot-api/#broadcast-message) 137 | 138 | Those API methods use the same parameters as the send methods with a few variations described below. You should specify a list of receivers instead of a single receiver. 139 | 140 | - [broadcastMessage](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#broadcastmessage) 141 | - [broadcastText](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#broadcasttext) 142 | - [broadcastPicture](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#broadcastpicture) 143 | - [broadcastVideo](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#broadcastvideo) 144 | - [broadcastFile](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#broadcastfile) 145 | - [broadcastContact](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#broadcastcontact) 146 | - [broadcastLocation](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#broadcastlocation) 147 | - [broadcastURL](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#broadcasturl) 148 | - [broadcastSticker](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#broadcaststicker) 149 | - [broadcastCarouselContent](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#broadcastcarouselcontent) 150 | 151 | | Param | Type | Description | 152 | | ------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 153 | | broadcastList | `Array` | This mandatory parameter defines the recipients for the message. Every user must be subscribed and have a valid user id. The maximum list length is 300 receivers. | 154 | 155 | Example: 156 | 157 | ```js 158 | await client.broadcastText( 159 | [ 160 | 'pttm25kSGUo1919sBORWyA==', 161 | '2yBSIsbzs7sSrh4oLm2hdQ==', 162 | 'EGAZ3SZRi6zW1D0uNYhQHg==', 163 | 'kBQYX9LrGyF5mm8JTxdmpw==', 164 | ], 165 | 'a broadcast to everybody' 166 | ); 167 | // { 168 | // messageToken: 40808912438712, 169 | // status: 0, 170 | // statusMessage: 'ok', 171 | // failedList: [ 172 | // { 173 | // receiver: 'pttm25kSGUo1919sBORWyA==', 174 | // status: 6, 175 | // statusMessage: 'Not subscribed', 176 | // }, 177 | // { 178 | // receiver: 'EGAZ3SZRi6zW1D0uNYhQHg==', 179 | // status: 5, 180 | // statusMessage: 'Not found', 181 | // }, 182 | // ], 183 | // } 184 | ``` 185 | 186 |
187 | 188 | ### Get Account Info 189 | 190 | - [getAccountInfo](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#getaccountinfo) 191 | 192 |
193 | 194 | ### Get User Details 195 | 196 | - [getUserDetails](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#getuserdetails) 197 | 198 |
199 | 200 | ### Get Online 201 | 202 | - [getOnlineStatus](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_viber.ViberClient.html#getonlinestatus) 203 | 204 |
205 | 206 | ## Debug Tips 207 | 208 | ### Log Requests Details 209 | 210 | To enable default request debugger, use following `DEBUG` env variable: 211 | 212 | ```sh 213 | DEBUG=messaging-api:request 214 | ``` 215 | 216 | If you want to use a custom request logging function, just provide your own `onRequest`: 217 | 218 | ```js 219 | const client = new ViberClient({ 220 | accessToken: ACCESS_TOKEN, 221 | onRequest: ({ method, url, headers, body }) => { 222 | /* */ 223 | }, 224 | }); 225 | ``` 226 | 227 | ## Testing 228 | 229 | ### Point Requests to Your Dummy Server 230 | 231 | To avoid sending requests to real Viber server, specify the `origin` option when constructing your client: 232 | 233 | ```js 234 | const { ViberClient } = require('messaging-api-viber'); 235 | 236 | const client = new ViberClient({ 237 | accessToken: ACCESS_TOKEN, 238 | origin: 'https://mydummytestserver.com', 239 | }); 240 | ``` 241 | 242 | > Warning: Don't do this on your production server. 243 | -------------------------------------------------------------------------------- /packages/messaging-api-viber/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "messaging-api-viber", 3 | "version": "1.1.0", 4 | "description": "Messaging API client for Viber", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Yoctol/messaging-apis.git" 9 | }, 10 | "main": "dist/index.js", 11 | "types": "dist/index.d.ts", 12 | "dependencies": { 13 | "@types/warning": "^3.0.0", 14 | "axios": "^0.21.1", 15 | "axios-error": "file:../axios-error", 16 | "messaging-api-common": "file:../messaging-api-common", 17 | "ts-invariant": "^0.4.4", 18 | "warning": "^4.0.3" 19 | }, 20 | "keywords": [ 21 | "bot", 22 | "chatbot", 23 | "messaging-apis", 24 | "viber" 25 | ], 26 | "engines": { 27 | "node": ">=10" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/messaging-api-viber/src/ViberTypes.ts: -------------------------------------------------------------------------------- 1 | import { OnRequestFunction } from 'messaging-api-common'; 2 | 3 | export type ClientConfig = { 4 | accessToken: string; 5 | sender: Sender; 6 | origin?: string; 7 | onRequest?: OnRequestFunction; 8 | }; 9 | 10 | export type SucceededResponseData = { 11 | status: 0; 12 | statusMessage: 'ok'; 13 | } & T; 14 | 15 | export type FailedResponseData = ( 16 | | { 17 | status: 1; 18 | statusMessage: 'invalidUrl'; 19 | } 20 | | { 21 | status: 2; 22 | statusMessage: 'invalidAuthToken'; 23 | } 24 | | { 25 | status: 3; 26 | statusMessage: 'badData'; 27 | } 28 | | { 29 | status: 4; 30 | statusMessage: 'missingData'; 31 | } 32 | | { 33 | status: 5; 34 | statusMessage: 'receiverNotRegistered'; 35 | } 36 | | { 37 | status: 6; 38 | statusMessage: 'receiverNotSubscribed'; 39 | } 40 | | { 41 | status: 7; 42 | statusMessage: 'publicAccountBlocked'; 43 | } 44 | | { 45 | status: 8; 46 | statusMessage: 'publicAccountNotFound'; 47 | } 48 | | { 49 | status: 9; 50 | statusMessage: 'publicAccountSuspended'; 51 | } 52 | | { 53 | status: 10; 54 | statusMessage: 'webhookNotSet'; 55 | } 56 | | { 57 | status: 11; 58 | statusMessage: 'receiverNoSuitableDevice'; 59 | } 60 | | { 61 | status: 12; 62 | statusMessage: 'tooManyRequests'; 63 | } 64 | | { 65 | status: 13; 66 | statusMessage: 'apiVersionNotSupported'; 67 | } 68 | | { 69 | status: 14; 70 | statusMessage: 'incompatibleWithVersion'; 71 | } 72 | | { 73 | status: 15; 74 | statusMessage: 'publicAccountNotAuthorized'; 75 | } 76 | | { 77 | status: 16; 78 | statusMessage: 'inchatReplyMessageNotAllowed'; 79 | } 80 | | { 81 | status: 17; 82 | statusMessage: 'publicAccountIsNotInline'; 83 | } 84 | | { 85 | status: 18; 86 | statusMessage: 'noPublicChat'; 87 | } 88 | | { 89 | status: 19; 90 | statusMessage: 'cannotSendBroadcast'; 91 | } 92 | | { 93 | status: 20; 94 | statusMessage: 'broadcastNotAllowed'; 95 | } 96 | ) & 97 | T; 98 | 99 | export type SucceededBroadcastResponseData = SucceededResponseData<{ 100 | messageToken: string; 101 | failedList: Failed[]; 102 | }>; 103 | 104 | export type Failed = FailedResponseData<{ receiver: string }>; 105 | 106 | export type ResponseData = 107 | | SucceededResponseData 108 | | FailedResponseData<{}>; 109 | 110 | export type BroadcastResponseData = 111 | | SucceededBroadcastResponseData 112 | | FailedResponseData<{}>; 113 | 114 | export enum EventType { 115 | Delivered = 'delivered', 116 | Seen = 'seen', 117 | Failed = 'failed', 118 | Subscribed = 'subscribed', 119 | Unsubscribed = 'unsubscribed', 120 | ConversationStarted = 'conversation_started', 121 | } 122 | 123 | export type Sender = { 124 | name: string; 125 | avatar?: string; 126 | }; 127 | 128 | export type Message = 129 | | TextMessage 130 | | PictureMessage 131 | | VideoMessage 132 | | FileMessage 133 | | ContactMessage 134 | | LocationMessage 135 | | UrlMessage 136 | | StickerMessage 137 | | RichMediaMessage; 138 | 139 | export type MessageOptions = { 140 | minApiVersion?: number; 141 | sender?: Sender; 142 | trackingData?: string; 143 | keyboard?: Keyboard; 144 | }; 145 | 146 | export type TextMessage = { 147 | type: 'text'; 148 | text: string; 149 | } & MessageOptions; 150 | 151 | export type Picture = { 152 | text: string; 153 | media: string; 154 | thumbnail?: string; 155 | }; 156 | 157 | export type PictureMessage = { 158 | type: 'picture'; 159 | } & Picture & 160 | MessageOptions; 161 | 162 | export type Video = { 163 | media: string; 164 | size: number; 165 | duration?: number; 166 | thumbnail?: string; 167 | }; 168 | 169 | export type VideoMessage = { 170 | type: 'video'; 171 | } & Video & 172 | MessageOptions; 173 | 174 | export type File = { 175 | media: string; 176 | size: number; 177 | fileName: string; 178 | }; 179 | 180 | export type FileMessage = { 181 | type: 'file'; 182 | } & File & 183 | MessageOptions; 184 | 185 | export type Contact = { 186 | name: string; 187 | phoneNumber: string; 188 | }; 189 | 190 | export type ContactMessage = { 191 | type: 'contact'; 192 | contact: Contact; 193 | } & MessageOptions; 194 | 195 | export type Location = { 196 | lat: string; 197 | lon: string; 198 | }; 199 | 200 | export type LocationMessage = { 201 | type: 'location'; 202 | location: Location; 203 | } & MessageOptions; 204 | 205 | export type UrlMessage = { 206 | type: 'url'; 207 | media: string; 208 | } & MessageOptions; 209 | 210 | export type StickerMessage = { 211 | type: 'sticker'; 212 | stickerId: number; 213 | } & MessageOptions; 214 | 215 | export type RichMedia = { 216 | type: 'rich_media'; 217 | buttonsGroupColumns: number; 218 | buttonsGroupRows: number; 219 | bgColor: string; 220 | buttons: RichMediaButton[]; 221 | }; 222 | 223 | export type RichMediaButton = { 224 | columns: number; 225 | rows: number; 226 | text?: string; 227 | actionType: 'open-url' | 'reply'; 228 | actionBody: string; 229 | textSize?: 'small' | 'medium' | 'large'; 230 | textVAlign?: 'middle'; 231 | textHAlign?: 'left' | 'middle' | 'right'; 232 | image?: string; 233 | }; 234 | 235 | export type RichMediaMessage = { 236 | type: 'rich_media'; 237 | richMedia: RichMedia; 238 | } & MessageOptions; 239 | 240 | export type AccountInfo = { 241 | id: string; 242 | name: string; 243 | uri: string; 244 | icon: string; 245 | background: string; 246 | category: string; 247 | subcategory: string; 248 | location: { 249 | lon: number; 250 | lat: number; 251 | }; 252 | country: string; 253 | webhook: string; 254 | eventTypes: 255 | | EventType.Delivered 256 | | EventType.Seen 257 | | EventType.Failed 258 | | EventType.ConversationStarted; 259 | subscribersCount: number; 260 | members: { 261 | id: string; 262 | name: string; 263 | avatar: string; 264 | role: string; 265 | }[]; 266 | }; 267 | 268 | export type UserDetails = { 269 | id: string; 270 | name: string; 271 | avatar: string; 272 | country: string; 273 | language: string; 274 | primaryDeviceOs: string; 275 | apiVersion: number; 276 | viberVersion: string; 277 | mcc: number; 278 | mnc: number; 279 | deviceType: string; 280 | }; 281 | 282 | export type UserOnlineStatus = { 283 | id: string; 284 | onlineStatus: 0 | 1 | 2 | 3 | 4; 285 | onlineStatusMessage: 'online'; 286 | }; 287 | 288 | export type Keyboard = { 289 | type: 'keyboard'; 290 | buttons: KeyboardButton[]; 291 | bgColor?: string; 292 | defaultHeight?: boolean; 293 | customDefaultHeight?: number; 294 | heightScale?: number; 295 | buttonsGroupColumns?: number; 296 | buttonsGroupRows?: number; 297 | inputFieldState?: 'regular' | 'minimized' | 'hidden'; 298 | favoritesMetadata?: any; 299 | }; 300 | 301 | export type KeyboardButton = { 302 | columns?: number; 303 | rows?: number; 304 | bgColor?: string; 305 | silent?: boolean; 306 | bgMediaType?: 'picture' | 'gif'; 307 | bgMedia?: string; 308 | bgMediaScaleType?: 'crop' | 'fill' | 'fit'; 309 | imageScaleType?: 'crop' | 'fill' | 'fit'; 310 | bgLoop?: boolean; 311 | actionType?: 312 | | 'reply' 313 | | 'open-url' 314 | | 'location-picker' 315 | | 'share-phone' 316 | | 'none'; 317 | actionBody: string; 318 | image?: string; 319 | text?: string; 320 | textVAlign?: 'top' | 'middle' | 'bottom'; 321 | textHAlign?: 'left' | 'center' | 'right'; 322 | textPaddings?: [number, number, number, number]; 323 | textOpacity?: number; 324 | textSize?: 'small' | 'regular' | 'large'; 325 | openURLType?: 'internal' | 'external'; 326 | openURLMediaType?: 'not-media' | 'video' | 'gif' | 'picture'; 327 | textBgGradientColor?: string; 328 | textShouldFit?: boolean; 329 | internalBrowser?: KeyboardButtonInternalBrowser; 330 | map?: KeyboardButtonMap; 331 | frame?: KeyboardButtonFrame; 332 | mediaPlayer?: KeyboardButtonMediaPlayer; 333 | }; 334 | 335 | export type KeyboardButtonInternalBrowser = { 336 | actionButton?: 337 | | 'forward' 338 | | 'send' 339 | | 'open-externally' 340 | | 'send-to-bot' 341 | | 'none'; 342 | actionPredefinedURL?: string; 343 | titleType?: 'domain' | 'default'; 344 | customTitle?: string; 345 | mode?: 346 | | 'fullscreen' 347 | | 'fullscreen-portrait' 348 | | 'fullscreen-landscape' 349 | | 'partial-size'; 350 | footerType?: 'default' | 'hidden'; 351 | actionReplyData?: string; 352 | }; 353 | 354 | export type KeyboardButtonMap = { 355 | latitude?: number; 356 | longitude?: number; 357 | }; 358 | 359 | export type KeyboardButtonFrame = { 360 | borderWidth?: number; 361 | borderColor?: string; 362 | cornerRadius?: number; 363 | }; 364 | 365 | export type KeyboardButtonMediaPlayer = { 366 | title?: string; 367 | subtitle?: string; 368 | thumbnailURL?: string; 369 | loop?: boolean; 370 | }; 371 | -------------------------------------------------------------------------------- /packages/messaging-api-viber/src/__tests__/ViberClient-constructor.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import ViberClient from '../ViberClient'; 4 | 5 | const AUTH_TOKEN = '445da6az1s345z78-dazcczb2542zv51a-e0vc5fva17480im9'; 6 | 7 | const SENDER = { 8 | name: 'John McClane', 9 | avatar: 'http://avatar.example.com', 10 | }; 11 | 12 | describe('connect', () => { 13 | let axios; 14 | let _create; 15 | beforeEach(() => { 16 | axios = require('axios'); 17 | _create = axios.create; 18 | }); 19 | 20 | afterEach(() => { 21 | axios.create = _create; 22 | }); 23 | 24 | describe('create axios with Viber API', () => { 25 | it('with config', () => { 26 | axios.create = jest.fn().mockReturnValue({ 27 | interceptors: { 28 | request: { 29 | use: jest.fn(), 30 | }, 31 | }, 32 | }); 33 | ViberClient.connect({ accessToken: AUTH_TOKEN, sender: SENDER }); 34 | 35 | expect(axios.create).toBeCalledWith({ 36 | baseURL: 'https://chatapi.viber.com/pa/', 37 | headers: { 38 | 'Content-Type': 'application/json', 39 | 'X-Viber-Auth-Token': AUTH_TOKEN, 40 | }, 41 | }); 42 | }); 43 | }); 44 | 45 | it('support origin', () => { 46 | axios.create = jest.fn().mockReturnValue({ 47 | interceptors: { 48 | request: { 49 | use: jest.fn(), 50 | }, 51 | }, 52 | }); 53 | ViberClient.connect({ 54 | accessToken: AUTH_TOKEN, 55 | sender: SENDER, 56 | origin: 'https://mydummytestserver.com', 57 | }); 58 | 59 | expect(axios.create).toBeCalledWith({ 60 | baseURL: 'https://mydummytestserver.com/pa/', 61 | headers: { 62 | 'Content-Type': 'application/json', 63 | 'X-Viber-Auth-Token': AUTH_TOKEN, 64 | }, 65 | }); 66 | }); 67 | }); 68 | 69 | describe('constructor', () => { 70 | let axios; 71 | let _create; 72 | beforeEach(() => { 73 | axios = require('axios'); 74 | _create = axios.create; 75 | }); 76 | 77 | afterEach(() => { 78 | axios.create = _create; 79 | }); 80 | 81 | describe('create axios with Viber API', () => { 82 | it('with config', () => { 83 | axios.create = jest.fn().mockReturnValue({ 84 | interceptors: { 85 | request: { 86 | use: jest.fn(), 87 | }, 88 | }, 89 | }); 90 | new ViberClient({ accessToken: AUTH_TOKEN, sender: SENDER }); // eslint-disable-line no-new 91 | 92 | expect(axios.create).toBeCalledWith({ 93 | baseURL: 'https://chatapi.viber.com/pa/', 94 | headers: { 95 | 'Content-Type': 'application/json', 96 | 'X-Viber-Auth-Token': AUTH_TOKEN, 97 | }, 98 | }); 99 | }); 100 | }); 101 | 102 | it('support origin', () => { 103 | axios.create = jest.fn().mockReturnValue({ 104 | interceptors: { 105 | request: { 106 | use: jest.fn(), 107 | }, 108 | }, 109 | }); 110 | // eslint-disable-next-line no-new 111 | new ViberClient({ 112 | accessToken: AUTH_TOKEN, 113 | sender: SENDER, 114 | origin: 'https://mydummytestserver.com', 115 | }); 116 | 117 | expect(axios.create).toBeCalledWith({ 118 | baseURL: 'https://mydummytestserver.com/pa/', 119 | headers: { 120 | 'Content-Type': 'application/json', 121 | 'X-Viber-Auth-Token': AUTH_TOKEN, 122 | }, 123 | }); 124 | }); 125 | }); 126 | 127 | describe('#axios', () => { 128 | it('should return underlying http client', () => { 129 | const client = new ViberClient({ accessToken: AUTH_TOKEN, sender: SENDER }); 130 | 131 | expect(client.axios.get).toBeDefined(); 132 | expect(client.axios.post).toBeDefined(); 133 | expect(client.axios.put).toBeDefined(); 134 | expect(client.axios.delete).toBeDefined(); 135 | }); 136 | }); 137 | 138 | describe('#accessToken', () => { 139 | it('should return underlying access token', () => { 140 | const client = new ViberClient({ accessToken: AUTH_TOKEN, sender: SENDER }); 141 | 142 | expect(client.accessToken).toBe(AUTH_TOKEN); 143 | }); 144 | }); 145 | 146 | describe('#onRequest', () => { 147 | it('should call onRequest when calling any API', async () => { 148 | const onRequest = jest.fn(); 149 | const client = new ViberClient({ 150 | accessToken: AUTH_TOKEN, 151 | sender: SENDER, 152 | onRequest, 153 | }); 154 | 155 | const mock = new MockAdapter(client.axios); 156 | 157 | mock.onPost('/path').reply(200, {}); 158 | 159 | await client.axios.post('/path', { x: 1 }); 160 | 161 | expect(onRequest).toBeCalledWith({ 162 | method: 'post', 163 | url: 'https://chatapi.viber.com/pa/path', 164 | body: { 165 | x: 1, 166 | }, 167 | headers: { 168 | 'Content-Type': 'application/json', 169 | Accept: 'application/json, text/plain, */*', 170 | 'X-Viber-Auth-Token': 171 | '445da6az1s345z78-dazcczb2542zv51a-e0vc5fva17480im9', 172 | }, 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /packages/messaging-api-viber/src/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { ViberClient } from '..'; 2 | 3 | it('should export api correctly', () => { 4 | expect(ViberClient).toBeDefined(); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/messaging-api-viber/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ViberClient } from './ViberClient'; 2 | 3 | export * as ViberTypes from './ViberTypes'; 4 | -------------------------------------------------------------------------------- /packages/messaging-api-viber/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/messaging-api-wechat/README.md: -------------------------------------------------------------------------------- 1 | # messaging-api-wechat 2 | 3 | > Messaging API client for WeChat 4 | 5 | ## Table of Contents 6 | 7 | - [Installation](#installation) 8 | - [Usage](#usage) 9 | - [API Reference](#api-reference) 10 | - [Send API](#send-api) 11 | - [Medai API](#media-api) 12 | - [Debug Tips](#debug-tips) 13 | - [Testing](#testing) 14 | 15 | ## Installation 16 | 17 | ```sh 18 | npm i --save messaging-api-wechat 19 | ``` 20 | 21 | or 22 | 23 | ```sh 24 | yarn add messaging-api-wechat 25 | ``` 26 | 27 |
28 | 29 | ## Usage 30 | 31 | ### Initialize 32 | 33 | ```js 34 | const { WechatClient } = require('messaging-api-wechat'); 35 | 36 | // get appId, appSecret from「微信公众平台-开发-基本配置」page 37 | const client = new WechatClient({ 38 | appId: APP_ID, 39 | appSecret: APP_SECRET, 40 | }); 41 | ``` 42 | 43 | ### Error Handling 44 | 45 | `messaging-api-wechat` uses [axios](https://github.com/axios/axios) as HTTP client. We use [axios-error](https://github.com/Yoctol/messaging-apis/tree/master/packages/axios-error) package to wrap API error instances for better formatting error messages. Directly calling `console.log` with the error instance will return formatted message. If you'd like to get the axios `request`, `response`, or `config`, you can still get them via those keys on the error instance. 46 | 47 | ```js 48 | client.sendText(userId, text).catch((error) => { 49 | console.log(error); // formatted error message 50 | console.log(error.stack); // error stack trace 51 | console.log(error.config); // axios request config 52 | console.log(error.request); // HTTP request 53 | console.log(error.response); // HTTP response 54 | }); 55 | ``` 56 | 57 |
58 | 59 | ## API Reference 60 | 61 | All methods return a Promise. 62 | 63 |
64 | 65 |
66 | 67 | ### Send API - [Official Docs](https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421140547) 68 | 69 | - [sendText](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_wechat.WechatClient.html#sendtext) 70 | - [sendImage](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_wechat.WechatClient.html#sendimage) 71 | - [sendVoice](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_wechat.WechatClient.html#sendvoice) 72 | - [sendVideo](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_wechat.WechatClient.html#sendvideo) 73 | - [sendMusic](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_wechat.WechatClient.html#sendmusic) 74 | - [sendNews](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_wechat.WechatClient.html#sendnews) 75 | - [sendMPNews](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_wechat.WechatClient.html#sendmpnews) 76 | - [sendWXCard](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_wechat.WechatClient.html#sendwxcard) 77 | - [sendMiniProgramPage](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_wechat.WechatClient.html#sendminiprogrampage) 78 | 79 | 80 | 81 | ### Media API - [Official Docs](https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421140547) 82 | 83 | - [uploadMedia](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_wechat.WechatClient.html#uploadmedia) 84 | - [getMedia](https://bottenderjs.github.io/messaging-apis/latest/classes/messaging_api_wechat.WechatClient.html#getmedia) 85 | 86 | ## Debug Tips 87 | 88 | ### Log Requests Details 89 | 90 | To enable default request debugger, use following `DEBUG` env variable: 91 | 92 | ```sh 93 | DEBUG=messaging-api:request 94 | ``` 95 | 96 | If you want to use a custom request logging function, just provide your own `onRequest`: 97 | 98 | ```js 99 | const client = new WechatClient({ 100 | appId: APP_ID, 101 | appSecret: APP_SECRET, 102 | onRequest: ({ method, url, headers, body }) => { 103 | /* */ 104 | }, 105 | }); 106 | ``` 107 | 108 | ## Testing 109 | 110 | ### Point Requests to Your Dummy Server 111 | 112 | To avoid sending requests to real WeChat server, specify the `origin` option when constructing your client: 113 | 114 | ```js 115 | const { WechatClient } = require('messaging-api-wechat'); 116 | 117 | const client = new WechatClient({ 118 | appId: APP_ID, 119 | appSecret: APP_SECRET, 120 | origin: 'https://mydummytestserver.com', 121 | }); 122 | ``` 123 | 124 | > Warning: Don't do this on your production server. 125 | -------------------------------------------------------------------------------- /packages/messaging-api-wechat/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "messaging-api-wechat", 3 | "description": "Messaging API client for WeChat", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/Yoctol/messaging-apis.git" 8 | }, 9 | "version": "1.0.6", 10 | "main": "dist/index.js", 11 | "types": "dist/index.d.ts", 12 | "dependencies": { 13 | "@types/warning": "^3.0.0", 14 | "axios": "^0.21.1", 15 | "axios-error": "file:../axios-error", 16 | "form-data": "^3.0.0", 17 | "messaging-api-common": "file:../messaging-api-common", 18 | "ts-invariant": "^0.4.4", 19 | "warning": "^4.0.3" 20 | }, 21 | "keywords": [ 22 | "bot", 23 | "chatbot", 24 | "messaging-apis", 25 | "wechat" 26 | ], 27 | "engines": { 28 | "node": ">=10" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/messaging-api-wechat/src/WechatTypes.ts: -------------------------------------------------------------------------------- 1 | import { OnRequestFunction } from 'messaging-api-common'; 2 | 3 | export type ClientConfig = { 4 | appId: string; 5 | appSecret: string; 6 | origin?: string; 7 | onRequest?: OnRequestFunction; 8 | }; 9 | 10 | export type SucceededResponseData = { 11 | errcode: 0; 12 | errmsg: 'ok'; 13 | }; 14 | 15 | export type FailedResponseData = { 16 | errcode: number; 17 | errmsg: string; 18 | }; 19 | 20 | export type ResponseData = SucceededResponseData | FailedResponseData; 21 | 22 | export type AccessToken = { 23 | accessToken: string; 24 | expiresIn: number; 25 | }; 26 | 27 | export type UploadedMedia = { 28 | type: string; 29 | mediaId: string; 30 | createdAt: number; 31 | }; 32 | 33 | export type Media = { 34 | videoUrl: string; 35 | }; 36 | 37 | export type Video = { 38 | mediaId: string; 39 | thumbMediaId: string; 40 | title: string; 41 | description: string; 42 | }; 43 | 44 | export type Music = { 45 | title: string; 46 | description: string; 47 | musicurl: string; 48 | hqmusicurl: string; 49 | thumbMediaId: string; 50 | }; 51 | 52 | export type Article = { 53 | title: string; 54 | description: string; 55 | url: string; 56 | picurl: string; 57 | }; 58 | 59 | export type News = { 60 | articles: Article[]; 61 | }; 62 | 63 | export type MsgMenu = { 64 | headContent: string; 65 | list: { 66 | id: string; 67 | content: string; 68 | }[]; 69 | tailContent: string; 70 | }; 71 | 72 | export type MiniProgramPage = { 73 | title: string; 74 | appid: string; 75 | pagepath: string; 76 | thumbMediaId: string; 77 | }; 78 | 79 | export enum MediaType { 80 | Image = 'image', 81 | Voice = 'voice', 82 | Video = 'video', 83 | Thumb = 'thumb', 84 | } 85 | 86 | export type SendMessageOptions = { 87 | customservice?: { 88 | kfAccount: string; 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /packages/messaging-api-wechat/src/__tests__/WechatClient-constructor.spec.ts: -------------------------------------------------------------------------------- 1 | import MockAdapter from 'axios-mock-adapter'; 2 | 3 | import WechatClient from '../WechatClient'; 4 | 5 | const APP_ID = 'APP_ID'; 6 | const APP_SECRET = 'APP_SECRET'; 7 | 8 | describe('connect', () => { 9 | let axios; 10 | let _create; 11 | beforeEach(() => { 12 | axios = require('axios'); 13 | _create = axios.create; 14 | }); 15 | 16 | afterEach(() => { 17 | axios.create = _create; 18 | }); 19 | 20 | describe('create axios with WeChat API', () => { 21 | it('with config', () => { 22 | axios.create = jest.fn().mockReturnValue({ 23 | interceptors: { 24 | request: { 25 | use: jest.fn(), 26 | }, 27 | }, 28 | }); 29 | WechatClient.connect({ appId: APP_ID, appSecret: APP_SECRET }); 30 | 31 | expect(axios.create).toBeCalledWith({ 32 | baseURL: 'https://api.weixin.qq.com/cgi-bin/', 33 | headers: { 34 | 'Content-Type': 'application/json', 35 | }, 36 | }); 37 | }); 38 | }); 39 | 40 | it('support origin', () => { 41 | axios.create = jest.fn().mockReturnValue({ 42 | interceptors: { 43 | request: { 44 | use: jest.fn(), 45 | }, 46 | }, 47 | }); 48 | WechatClient.connect({ 49 | appId: APP_ID, 50 | appSecret: APP_SECRET, 51 | origin: 'https://mydummytestserver.com', 52 | }); 53 | 54 | expect(axios.create).toBeCalledWith({ 55 | baseURL: 'https://mydummytestserver.com/cgi-bin/', 56 | headers: { 57 | 'Content-Type': 'application/json', 58 | }, 59 | }); 60 | }); 61 | }); 62 | 63 | describe('constructor', () => { 64 | let axios; 65 | let _create; 66 | beforeEach(() => { 67 | axios = require('axios'); 68 | _create = axios.create; 69 | }); 70 | 71 | afterEach(() => { 72 | axios.create = _create; 73 | }); 74 | 75 | describe('create axios with WeChat API', () => { 76 | it('with config', () => { 77 | axios.create = jest.fn().mockReturnValue({ 78 | interceptors: { 79 | request: { 80 | use: jest.fn(), 81 | }, 82 | }, 83 | }); 84 | new WechatClient({ appId: APP_ID, appSecret: APP_SECRET }); // eslint-disable-line no-new 85 | 86 | expect(axios.create).toBeCalledWith({ 87 | baseURL: 'https://api.weixin.qq.com/cgi-bin/', 88 | headers: { 89 | 'Content-Type': 'application/json', 90 | }, 91 | }); 92 | }); 93 | }); 94 | 95 | it('support origin', () => { 96 | axios.create = jest.fn().mockReturnValue({ 97 | interceptors: { 98 | request: { 99 | use: jest.fn(), 100 | }, 101 | }, 102 | }); 103 | // eslint-disable-next-line no-new 104 | new WechatClient({ 105 | appId: APP_ID, 106 | appSecret: APP_SECRET, 107 | origin: 'https://mydummytestserver.com', 108 | }); 109 | 110 | expect(axios.create).toBeCalledWith({ 111 | baseURL: 'https://mydummytestserver.com/cgi-bin/', 112 | headers: { 113 | 'Content-Type': 'application/json', 114 | }, 115 | }); 116 | }); 117 | }); 118 | 119 | describe('#axios', () => { 120 | it('should return underlying http client', () => { 121 | const client = new WechatClient({ appId: APP_ID, appSecret: APP_SECRET }); 122 | 123 | expect(client.axios.get).toBeDefined(); 124 | expect(client.axios.post).toBeDefined(); 125 | expect(client.axios.put).toBeDefined(); 126 | expect(client.axios.delete).toBeDefined(); 127 | }); 128 | }); 129 | 130 | describe('#accessToken', () => { 131 | it('should return underlying access token', () => { 132 | const client = new WechatClient({ appId: APP_ID, appSecret: APP_SECRET }); 133 | 134 | expect(typeof client.accessToken).toBe('string'); 135 | }); 136 | }); 137 | 138 | describe('#onRequest', () => { 139 | it('should call onRequest when calling any API', async () => { 140 | const onRequest = jest.fn(); 141 | const client = new WechatClient({ 142 | appId: APP_ID, 143 | appSecret: APP_SECRET, 144 | onRequest, 145 | }); 146 | 147 | const mock = new MockAdapter(client.axios); 148 | 149 | mock.onPost('/path').reply(200, {}); 150 | 151 | await client.axios.post('/path', { x: 1 }); 152 | 153 | expect(onRequest).toBeCalledWith({ 154 | method: 'post', 155 | url: 'https://api.weixin.qq.com/cgi-bin/path', 156 | body: { 157 | x: 1, 158 | }, 159 | headers: { 160 | 'Content-Type': 'application/json', 161 | Accept: 'application/json, text/plain, */*', 162 | }, 163 | }); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /packages/messaging-api-wechat/src/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { WechatClient } from '..'; 2 | 3 | it('should export api correctly', () => { 4 | expect(WechatClient).toBeDefined(); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/messaging-api-wechat/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as WechatClient } from './WechatClient'; 2 | 3 | export * as WechatTypes from './WechatTypes'; 4 | -------------------------------------------------------------------------------- /packages/messaging-api-wechat/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["**/__tests__"] 9 | } 10 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | singleQuote: true, 4 | }; 5 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "target": "es2016", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "declarationMap": true, 11 | "removeComments": false, 12 | "strict": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "noImplicitAny": true, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "noUnusedParameters": true, 21 | "noUnusedLocals": true, 22 | "forceConsistentCasingInFileNames": true, 23 | "lib": ["es2017", "esnext.asynciterable"], 24 | "types": ["node"], 25 | "baseUrl": ".", 26 | "paths": { 27 | "*" : ["types/*"] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true 4 | }, 5 | "files": [], 6 | "include": [], 7 | "references": [ 8 | { "path": "./packages/axios-error" }, 9 | { "path": "./packages/messaging-api-common" }, 10 | { "path": "./packages/messaging-api-line" }, 11 | { "path": "./packages/messaging-api-messenger" }, 12 | { "path": "./packages/messaging-api-slack" }, 13 | { "path": "./packages/messaging-api-telegram" }, 14 | { "path": "./packages/messaging-api-viber" }, 15 | { "path": "./packages/messaging-api-wechat" }, 16 | { "path": "./packages/facebook-batch" } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true 4 | }, 5 | "files": [], 6 | "include": [], 7 | "references": [ 8 | { "path": "./tsconfig.build.json" }, 9 | { "path": "./tsconfig.test.json" }, 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "lib": ["es2017", "esnext.asynciterable"], 6 | "types": ["node", "jest"] 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /typedoc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | out: 'docs', 3 | exclude: [ 4 | '**/node_modules/**', 5 | '**/dist/**', 6 | '**/*.spec.ts', 7 | '**/__tests__/**/*.ts', 8 | ], 9 | name: 'messaging-apis', 10 | excludePrivate: true, 11 | excludeExternals: true, 12 | includeVersion: true, 13 | }; 14 | --------------------------------------------------------------------------------