├── .env.yaml ├── .env.example ├── api ├── notFound.js ├── index.js ├── cors.js ├── lineVerifyReplyMsg.js ├── mpCollect.js └── createLineMsgGist.js ├── line ├── msg │ ├── text.js │ ├── json-stringify.js │ ├── send-text-to-bot.js │ ├── richmenu-linked.js │ ├── richmenu-removed.js │ ├── reply-flex-error.js │ └── sitcker-test-result.js └── handler │ ├── cmd │ ├── getBotInfo.js │ ├── getFriendDemographics.js │ ├── getNumberOfMessagesSentThisMonth.js │ ├── version.js │ ├── getTargetLimitForAdditionalMessages.js │ ├── richmenuRefresh.js │ ├── getGroupSummary.js │ ├── getRoomMemberIds.js │ ├── getGroupMemberIds.js │ ├── getRoomMembersCount.js │ ├── getGroupMembersCount.js │ ├── getNarrowcastProgress.js │ ├── getRichMenuList.js │ ├── getUserInteractionStatistics.js │ ├── getDefaultRichMenuId.js │ ├── getNumberOfFollowers.js │ ├── getNumberOfSentPushMessages.js │ ├── leave.js │ ├── getAudienceGroup.js │ ├── getNumberOfMessageDeliveries.js │ ├── getNumberOfSentReplyMessages.js │ ├── getRichMenu.js │ ├── getNumberOfSentBroadcastMessages.js │ ├── getNumberOfSentMulticastMessages.js │ ├── getProfile.js │ ├── getRoomMemberProfile.js │ ├── getGroupMemberProfile.js │ ├── getRichMenuIdOfUser.js │ ├── replyAfterSleep.js │ ├── replySticker.js │ ├── notifySticker.js │ ├── demoRichmenuAlias.js │ ├── richmenuPlayground.js │ ├── index.js │ ├── gistReplaceAltText.js │ └── postbackInputOption.js │ ├── noReplyUrl.js │ ├── replyCalc.js │ ├── replyEventJson.js │ ├── index.js │ ├── initEvent.js │ └── replyFlexFromText.js ├── libs ├── octokit.js ├── linebotsdk.js ├── linenotify.js ├── tryAddShareBtn.js ├── helper.js └── helper.test.js ├── .editorconfig ├── repl.js ├── index.js ├── .eslintrc.js ├── richmenu ├── playground-7.js ├── playground-9.js ├── playground-1.js ├── alias-a.js ├── alias-b.js ├── alias-c.js ├── link-a.js ├── link-b.js ├── link-c.js ├── playground-5.js ├── playground-6.js ├── playground-2.js ├── playground-8.js ├── playground-4.js ├── playground-3.js └── index.js ├── README.md ├── LICENSE ├── package.json ├── .github └── workflows │ └── main.yml ├── .eslintignore ├── .gitignore └── .gcloudignore /.env.yaml: -------------------------------------------------------------------------------- 1 | GA_DEBUG: '0' 2 | GCP_PROJECT: taichunmin 3 | LINE_NOTIFY_TOKEN: '' 4 | NODE_ENV: production 5 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | GA_DEBUG=0 2 | GCP_PROJECT=taichunmin 3 | LINE_NOTIFY_TOKEN= 4 | NODE_ENV=development 5 | OCTOKIT_ACCESS_TOKEN= 6 | -------------------------------------------------------------------------------- /api/notFound.js: -------------------------------------------------------------------------------- 1 | const createError = require('http-errors') 2 | 3 | module.exports = async (ctx, next) => { 4 | throw createError(404) 5 | } 6 | -------------------------------------------------------------------------------- /line/msg/text.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | const TEXT_MAXLEN = 5000 4 | 5 | module.exports = text => ({ 6 | type: 'text', 7 | text: _.truncate(_.toString(text), { length: TEXT_MAXLEN }), 8 | }) 9 | -------------------------------------------------------------------------------- /line/handler/cmd/getBotInfo.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | 3 | module.exports = async (ctx, next) => { 4 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getBotInfo())) 5 | } 6 | -------------------------------------------------------------------------------- /line/handler/cmd/getFriendDemographics.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | 3 | module.exports = async (ctx, next) => { 4 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getFriendDemographics())) 5 | } 6 | -------------------------------------------------------------------------------- /line/handler/cmd/getNumberOfMessagesSentThisMonth.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | 3 | module.exports = async (ctx, next) => { 4 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getNumberOfMessagesSentThisMonth())) 5 | } 6 | -------------------------------------------------------------------------------- /line/handler/cmd/version.js: -------------------------------------------------------------------------------- 1 | const { getenv } = require('../../../libs/helper') 2 | const msgText = require('../../msg/text') 3 | 4 | module.exports = async (ctx, next) => { 5 | await ctx.replyMessage(msgText(`GITHUB_SHA: ${getenv('GITHUB_SHA', 'unknown')}`)) 6 | } 7 | -------------------------------------------------------------------------------- /line/handler/cmd/getTargetLimitForAdditionalMessages.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | 3 | module.exports = async (ctx, next) => { 4 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getTargetLimitForAdditionalMessages())) 5 | } 6 | -------------------------------------------------------------------------------- /line/handler/cmd/richmenuRefresh.js: -------------------------------------------------------------------------------- 1 | const msgText = require('../../msg/text') 2 | const richmenu = require('../../../richmenu') 3 | 4 | module.exports = async (ctx, next) => { 5 | await richmenu.bootstrap(ctx, true) // 確認圖文選單已更新 6 | 7 | await ctx.replyMessage(msgText('已嘗試強制更新圖文選單')) 8 | } 9 | -------------------------------------------------------------------------------- /line/handler/cmd/getGroupSummary.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | 3 | module.exports = async (ctx, next) => { 4 | if (!ctx.groupId) throw new Error('缺少必要參數 groupId') 5 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getGroupSummary(ctx.groupId))) 6 | } 7 | -------------------------------------------------------------------------------- /line/handler/cmd/getRoomMemberIds.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | 3 | module.exports = async (ctx, next) => { 4 | if (!ctx.roomId) throw new Error('缺少必要參數 roomId') 5 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getGroupMemberIds(ctx.roomId))) 6 | } 7 | -------------------------------------------------------------------------------- /line/handler/cmd/getGroupMemberIds.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | 3 | module.exports = async (ctx, next) => { 4 | if (!ctx.groupId) throw new Error('缺少必要參數 groupId') 5 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getGroupMemberIds(ctx.groupId))) 6 | } 7 | -------------------------------------------------------------------------------- /line/handler/cmd/getRoomMembersCount.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | 3 | module.exports = async (ctx, next) => { 4 | if (!ctx.roomId) throw new Error('缺少必要參數 roomId') 5 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getGroupMembersCount(ctx.roomId))) 6 | } 7 | -------------------------------------------------------------------------------- /line/handler/cmd/getGroupMembersCount.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | 3 | module.exports = async (ctx, next) => { 4 | if (!ctx.groupId) throw new Error('缺少必要參數 groupId') 5 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getGroupMembersCount(ctx.groupId))) 6 | } 7 | -------------------------------------------------------------------------------- /line/handler/cmd/getNarrowcastProgress.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | 3 | module.exports = async (ctx, next) => { 4 | if (!ctx.requestId) throw new Error('缺少必要參數 requestId') 5 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getNarrowcastProgress(ctx.requestId))) 6 | } 7 | -------------------------------------------------------------------------------- /line/handler/cmd/getRichMenuList.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | const richmenu = require('../../../richmenu') 3 | 4 | module.exports = async (ctx, next) => { 5 | await richmenu.bootstrap(ctx) // 確認圖文選單已更新 6 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getRichMenuList())) 7 | } 8 | -------------------------------------------------------------------------------- /line/handler/cmd/getUserInteractionStatistics.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | 3 | module.exports = async (ctx, next) => { 4 | if (!ctx.requestId) throw new Error('缺少必要參數 requestId') 5 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getUserInteractionStatistics(ctx.requestId))) 6 | } 7 | -------------------------------------------------------------------------------- /line/handler/cmd/getDefaultRichMenuId.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | const richmenu = require('../../../richmenu') 3 | 4 | module.exports = async (ctx, next) => { 5 | await richmenu.bootstrap(ctx) // 確認圖文選單已更新 6 | 7 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getDefaultRichMenuId())) 8 | } 9 | -------------------------------------------------------------------------------- /line/handler/noReplyUrl.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | module.exports = async (ctx, next) => { 4 | const text = ctx?.event?.message?.text 5 | try { 6 | if (!_.isString(text)) throw new Error('Not a string') 7 | return new URL(text) // 如果是一個合法的 URL 就不作回應 8 | } catch (err) { 9 | return await next() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /line/handler/cmd/getNumberOfFollowers.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const dayjs = require('dayjs') 3 | const msgJsonStringify = require('../../msg/json-stringify') 4 | 5 | module.exports = async (ctx, next) => { 6 | const date = _.get(ctx, 'cmdArg.0') || dayjs().format('YYYYMMDD') 7 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getNumberOfFollowers(date))) 8 | } 9 | -------------------------------------------------------------------------------- /line/handler/cmd/getNumberOfSentPushMessages.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const dayjs = require('dayjs') 3 | const msgJsonStringify = require('../../msg/json-stringify') 4 | 5 | module.exports = async (ctx, next) => { 6 | const date = _.get(ctx, 'cmdArg.0') || dayjs().format('YYYYMMDD') 7 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getNumberOfSentPushMessages(date))) 8 | } 9 | -------------------------------------------------------------------------------- /line/handler/cmd/leave.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | module.exports = async (ctx, next) => { 4 | const type = _.get(ctx, 'event.source.type') 5 | const text = _.get(ctx, 'event.message.text') 6 | if (text !== '/leave' || !_.includes(['room', 'group'], type)) return await next() 7 | await (type === 'room' ? ctx.line.leaveRoom(ctx.roomId) : ctx.line.leaveGroup(ctx.groupId)) 8 | } 9 | -------------------------------------------------------------------------------- /line/handler/cmd/getAudienceGroup.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const msgJsonStringify = require('../../msg/json-stringify') 3 | 4 | module.exports = async (ctx, next) => { 5 | const audienceGroupId = _.get(ctx, 'cmdArg.0') 6 | if (!audienceGroupId) throw new Error('缺少必要參數 audienceGroupId') 7 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getAudienceGroup(audienceGroupId))) 8 | } 9 | -------------------------------------------------------------------------------- /line/handler/cmd/getNumberOfMessageDeliveries.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const dayjs = require('dayjs') 3 | const msgJsonStringify = require('../../msg/json-stringify') 4 | 5 | module.exports = async (ctx, next) => { 6 | const date = _.get(ctx, 'cmdArg.0') || dayjs().format('YYYYMMDD') 7 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getNumberOfMessageDeliveries(date))) 8 | } 9 | -------------------------------------------------------------------------------- /line/handler/cmd/getNumberOfSentReplyMessages.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const dayjs = require('dayjs') 3 | const msgJsonStringify = require('../../msg/json-stringify') 4 | 5 | module.exports = async (ctx, next) => { 6 | const date = _.get(ctx, 'cmdArg.0') || dayjs().format('YYYYMMDD') 7 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getNumberOfSentReplyMessages(date))) 8 | } 9 | -------------------------------------------------------------------------------- /line/handler/cmd/getRichMenu.js: -------------------------------------------------------------------------------- 1 | const msgJsonStringify = require('../../msg/json-stringify') 2 | const richmenu = require('../../../richmenu') 3 | 4 | module.exports = async (ctx, next) => { 5 | if (!ctx.richmenuId) throw new Error('缺少必要參數 richmenuId') 6 | await richmenu.bootstrap(ctx) // 確認圖文選單已更新 7 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getRichMenu(ctx.richmenuId))) 8 | } 9 | -------------------------------------------------------------------------------- /line/handler/cmd/getNumberOfSentBroadcastMessages.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const dayjs = require('dayjs') 3 | const msgJsonStringify = require('../../msg/json-stringify') 4 | 5 | module.exports = async (ctx, next) => { 6 | const date = _.get(ctx, 'cmdArg.0') || dayjs().format('YYYYMMDD') 7 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getNumberOfSentBroadcastMessages(date))) 8 | } 9 | -------------------------------------------------------------------------------- /line/handler/cmd/getNumberOfSentMulticastMessages.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const dayjs = require('dayjs') 3 | const msgJsonStringify = require('../../msg/json-stringify') 4 | 5 | module.exports = async (ctx, next) => { 6 | const date = _.get(ctx, 'cmdArg.0') || dayjs().format('YYYYMMDD') 7 | await ctx.replyMessage(msgJsonStringify(await ctx.line.getNumberOfSentMulticastMessages(date))) 8 | } 9 | -------------------------------------------------------------------------------- /libs/octokit.js: -------------------------------------------------------------------------------- 1 | const { getenv } = require('./helper') 2 | const { Octokit } = require('@octokit/core') 3 | 4 | const OCTOKIT_ACCESS_TOKEN = getenv('OCTOKIT_ACCESS_TOKEN') 5 | 6 | exports.Octokit = Octokit 7 | 8 | if (OCTOKIT_ACCESS_TOKEN) { 9 | exports.octokit = new Octokit({ 10 | auth: OCTOKIT_ACCESS_TOKEN, 11 | timeZone: 'Asia/Taipei', 12 | userAgent: 'gcf-line-devbot/v1.0.0', 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /line/msg/json-stringify.js: -------------------------------------------------------------------------------- 1 | const { beautifyFlex } = require('../../libs/helper') 2 | const msgText = require('./text') 3 | 4 | const TEXT_MAXLEN = 5000 5 | 6 | const jsonStringifyMinify = json => { 7 | json = beautifyFlex(json) 8 | let str = JSON.stringify(json, null, 2) 9 | if (str.length > TEXT_MAXLEN) str = JSON.stringify(json, null, 1) 10 | if (str.length > TEXT_MAXLEN) str = JSON.stringify(json) 11 | return str 12 | } 13 | 14 | module.exports = obj => msgText(jsonStringifyMinify(obj)) 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | # The JSON files contain newlines inconsistently 13 | [*.json] 14 | insert_final_newline = ignore 15 | 16 | # Minified JavaScript files shouldn't be changed 17 | [**.min.js] 18 | indent_style = ignore 19 | insert_final_newline = ignore 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | const apis = new Map([ 4 | ['GET /mp/collect', require('./mpCollect')], 5 | ['POST /api/createLineMsgGist', require('./createLineMsgGist')], 6 | ['POST /api/lineVerifyReplyMsg', require('./lineVerifyReplyMsg')], 7 | ]) 8 | 9 | module.exports = async (ctx, next) => { 10 | const { req } = ctx 11 | const apiMethodPath = `${_.toUpper(req.method)} ${req.path}` 12 | if (!apis.has(apiMethodPath)) return await next() 13 | return await apis.get(apiMethodPath)(ctx, next) 14 | } 15 | -------------------------------------------------------------------------------- /repl.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const Repl = require('repl') 3 | 4 | require('dotenv').config() // init dotenv 5 | 6 | const repl = Repl.start('> ') 7 | if (repl.setupHistory) { 8 | repl.setupHistory('.node_repl_history', (err, r) => { 9 | if (err) console.log(err) 10 | }) 11 | } 12 | 13 | repl.context.process.env = { 14 | ...repl.context.process.env, 15 | NODE_ENV: 'development', 16 | DEBUG: 'app:*', 17 | DEBUG_COLORS: true, 18 | } 19 | 20 | repl.context._ = _ 21 | repl.context.log = require('debug')('app:repl') 22 | -------------------------------------------------------------------------------- /line/handler/cmd/getProfile.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const msgJsonStringify = require('../../msg/json-stringify') 3 | 4 | module.exports = async (ctx, next) => { 5 | const userIds = ctx.mentionUserIds || _.castArray(ctx.userId) 6 | if (!userIds.length) throw new Error('缺少必要參數 userId') 7 | const promises = _.map(userIds, userId => ctx.line.getProfile(userId).catch(err => err?.originalError?.response?.data?.message ?? err.message)) 8 | await ctx.replyMessage(msgJsonStringify(_.zipObject(userIds, await Promise.all(promises)))) 9 | } 10 | -------------------------------------------------------------------------------- /api/cors.js: -------------------------------------------------------------------------------- 1 | module.exports = async (ctx, next) => { 2 | const { req, res } = ctx 3 | const origin = req.get('Origin') || '*' 4 | res.set('Access-Control-Allow-Origin', origin) 5 | res.set('Access-Control-Allow-Credentials', 'true') 6 | 7 | if (req.method !== 'OPTIONS') return await next() 8 | 9 | res.set('Access-Control-Allow-Headers', 'Authorization,Content-Type') 10 | res.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE') 11 | res.set('Access-Control-Max-Age', '3600') 12 | res.set('Vary', 'Origin') 13 | res.status(204).send('') 14 | } 15 | -------------------------------------------------------------------------------- /line/handler/cmd/getRoomMemberProfile.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const msgJsonStringify = require('../../msg/json-stringify') 3 | 4 | module.exports = async (ctx, next) => { 5 | if (!ctx.roomId) throw new Error('缺少必要參數 roomId') 6 | const userIds = ctx.mentionUserIds || _.castArray(ctx.userId) 7 | if (!userIds.length) throw new Error('缺少必要參數 userId') 8 | const promises = _.map(userIds, userId => ctx.line.getRoomMemberProfile(ctx.roomId, userId).catch(err => err?.originalError?.response?.data?.message ?? err.message)) 9 | await ctx.replyMessage(msgJsonStringify(_.zipObject(userIds, await Promise.all(promises)))) 10 | } 11 | -------------------------------------------------------------------------------- /line/handler/cmd/getGroupMemberProfile.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const msgJsonStringify = require('../../msg/json-stringify') 3 | 4 | module.exports = async (ctx, next) => { 5 | if (!ctx.groupId) throw new Error('缺少必要參數 groupId') 6 | const userIds = ctx.mentionUserIds || _.castArray(ctx.userId) 7 | if (!userIds.length) throw new Error('缺少必要參數 userId') 8 | const promises = _.map(userIds, userId => ctx.line.getGroupMemberProfile(ctx.groupId, userId).catch(err => err?.originalError?.response?.data?.message ?? err.message)) 9 | await ctx.replyMessage(msgJsonStringify(_.zipObject(userIds, await Promise.all(promises)))) 10 | } 11 | -------------------------------------------------------------------------------- /line/handler/cmd/getRichMenuIdOfUser.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const msgJsonStringify = require('../../msg/json-stringify') 3 | const richmenu = require('../../../richmenu') 4 | 5 | module.exports = async (ctx, next) => { 6 | const userIds = ctx.mentionUserIds || _.castArray(ctx.userId) 7 | if (!userIds.length) throw new Error('缺少必要參數 userId') 8 | await richmenu.bootstrap(ctx) // 確認圖文選單已更新 9 | const promises = _.map(userIds, userId => ctx.line.getRichMenuIdOfUser(userId).catch(err => err?.originalError?.response?.data?.message ?? err.message)) 10 | await ctx.replyMessage(msgJsonStringify(_.zipObject(userIds, await Promise.all(promises)))) 11 | } 12 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | 3 | const _ = require('lodash') 4 | const { log } = require('./libs/helper') 5 | const { middlewareCompose } = require('./libs/helper') 6 | const functions = require('@google-cloud/functions-framework') 7 | 8 | const handlers = middlewareCompose([ 9 | require('./api/cors'), 10 | require('./api/index'), 11 | require('./line/handler/index'), 12 | require('./api/notFound'), 13 | ]) 14 | 15 | functions.http('main', async (req, res) => { 16 | try { 17 | await handlers({ req, res }) 18 | } catch (err) { 19 | log('ERROR', err) 20 | res.status(err.status ?? 500).json(_.pick(err, ['message'])) 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /line/handler/cmd/replyAfterSleep.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const msgText = require('../../msg/text') 3 | const MAX_SLEEP_MS = 60 * 60 * 1000 // 1 小時 4 | 5 | module.exports = async (ctx, next) => { 6 | const sleepMs = _.chain(ctx.cmdArg[0] ?? 0).toSafeInteger().clamp(0, MAX_SLEEP_MS).value() 7 | const remainMs = sleepMs - _.clamp(Date.now() - ctx.event.timestamp, sleepMs) 8 | // 如果需等候的時間不小於 60 秒,就故意等候幾秒觸發訊息重送機制,然後把 replyToken 留給訊息重送機制使用 9 | if (remainMs >= 6e4) return await new Promise(resolve => setTimeout(resolve, 1500)) 10 | await new Promise(resolve => setTimeout(resolve, remainMs)) 11 | await ctx.replyMessage(msgText(`如果你成功看到這個回覆,代表 replyToken 的時效至少有 ${sleepMs} 毫秒。`)) 12 | } 13 | -------------------------------------------------------------------------------- /libs/linebotsdk.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { Client } = require('@line/bot-sdk') 3 | const axios = require('axios') 4 | 5 | const LINE_API_APIBASE = 'https://api.line.me' 6 | 7 | Client.prototype.validateReplyMessageObjects = async function (msg) { 8 | try { 9 | if (!_.isArray(msg)) msg = [msg] 10 | return await axios.post(`${LINE_API_APIBASE}/v2/bot/message/validate/reply`, { messages: msg }, { 11 | headers: { 12 | Authorization: `Bearer ${this.config.channelAccessToken}`, 13 | }, 14 | }) 15 | } catch (err) { 16 | throw _.merge(new Error('Failed to validate reply message objects'), { originalError: err }) 17 | } 18 | } 19 | 20 | exports.Client = Client 21 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | jest: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'standard', 9 | ], 10 | globals: { 11 | Atomics: 'readonly', 12 | SharedArrayBuffer: 'readonly', 13 | }, 14 | parserOptions: { 15 | ecmaVersion: 2020, 16 | sourceType: 'module', 17 | }, 18 | rules: { 19 | 'multiline-ternary': 0, // 0 = off, 1 = warn, 2 = error 20 | 'no-return-await': 0, // 0 = off, 1 = warn, 2 = error 21 | 'comma-dangle': ['error', { 22 | arrays: 'always-multiline', 23 | objects: 'always-multiline', 24 | imports: 'always-multiline', 25 | exports: 'always-multiline', 26 | functions: 'only-multiline', 27 | }], 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /line/handler/replyCalc.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { default: nstr } = require('nstr') 3 | const { errToJson } = require('../../libs/helper') 4 | const msgText = require('../msg/text') 5 | 6 | module.exports = async (ctx, next) => { 7 | const text = ctx?.event?.message?.text 8 | try { 9 | if (!_.isString(text) || /[^0-9!.()*/&%^+<>|~A-Fa-fnoOxX -]/.test(text)) return await next() // Not a arithmetic string 10 | const result = new Function(`return ${text}`)() // eslint-disable-line no-new-func 11 | const str1 = result.toString() 12 | const str2 = nstr(result) 13 | await ctx.replyMessage([ 14 | msgText(str1), 15 | ...(str1 === str2 ? [] : [msgText(str2)]), 16 | ]) 17 | } catch (err) { 18 | console.log(errToJson(err)) 19 | return await next() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /richmenu/playground-7.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'playground-7' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/ByCnO9GRll.png', 8 | metadata: { 9 | chatBarText: '點此打開圖文選單', 10 | selected: true, 11 | size: { width: 2500, height: 421 }, 12 | areas: [ 13 | { // 換回綠色選單 14 | bounds: { x: 0, y: 0, width: 1600, height: 421 }, 15 | action: { 16 | type: 'richmenuswitch', 17 | richMenuAliasId: 'playground-5', 18 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-5' }), 19 | }, 20 | }, 21 | { // size 說明文件 22 | bounds: { x: 1600, y: 0, width: 660, height: 421 }, 23 | action: { 24 | type: 'uri', 25 | uri: 'https://developers.line.biz/en/reference/messaging-api/#size-object', 26 | }, 27 | }, 28 | ], 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /line/handler/cmd/replySticker.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const msgStickerTestResult = require('../../msg/sitcker-test-result') 3 | 4 | module.exports = async (ctx, next) => { 5 | const stickers = _.chunk(ctx.cmdArg, 2).slice(0, 5) 6 | if (!stickers.length) throw new Error('缺少 packageId 或 stickerId') 7 | await ctx.replyMessage(msgStickerTestResult({ 8 | nextCmd: '/notifySticker', 9 | title: 'RESULTS OF /replySticker', 10 | stickers: await Promise.all(_.map(stickers, async ([packageId, stickerId]) => { 11 | try { 12 | await ctx.line.validateReplyMessageObjects({ 13 | packageId: _.toSafeInteger(packageId), 14 | stickerId: _.toSafeInteger(stickerId), 15 | type: 'sticker', 16 | }) 17 | return { packageId, stickerId } 18 | } catch (err) { 19 | return { packageId, stickerId, message: err?.response?.data?.details?.[0]?.message ?? err.message } 20 | } 21 | })), 22 | })) 23 | } 24 | -------------------------------------------------------------------------------- /api/lineVerifyReplyMsg.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { getenv } = require('../libs/helper') 3 | const { log } = require('../libs/helper') 4 | const Line = require('../libs/linebotsdk').Client 5 | 6 | const LINE_MESSAGING_TOKEN = getenv('LINE_MESSAGING_TOKEN') 7 | 8 | module.exports = async (ctx, next) => { 9 | const { req, res } = ctx 10 | if (_.isNil(LINE_MESSAGING_TOKEN)) return await next() // 未設定 TOKEN 11 | if (!_.isArray(req.body) && !_.isPlainObject(req.body)) return await next() // 轉交給下一個 middleware 處理 12 | 13 | try { 14 | ctx.line = new Line({ channelAccessToken: LINE_MESSAGING_TOKEN }) 15 | await ctx.line.validateReplyMessageObjects(req.body) // 先透過 messaging api 驗證內容 16 | .catch(err => { throw _.merge(err, { status: err?.originalError?.response?.status ?? 500, ..._.pick(err?.originalError?.response?.data, ['message', 'details']) }) }) 17 | res.json({}) 18 | } catch (err) { 19 | log('ERROR', err) 20 | res.status(err.status ?? 500).json(_.pick(err, ['message', 'details'])) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/taichunmin/gcf-line-devbot/workflows/Node.js%20CI/badge.svg)](https://github.com/taichunmin/gcf-line-devbot/actions) 2 | 3 | # LINE Flex 開發人員工具 4 | 5 | ![](https://i.imgur.com/cP5purz.png) 6 | 7 | 加入好友: 8 | 9 | ## 功能 10 | 11 | 1. 直接用 JSON 印出收到的 Event 12 | 2. 以文字傳送 Message JSON 就會直接顯示 13 | 3. 從 [Flex Message Simulator](https://developers.line.biz/flex-simulator/) 直接貼上複製下來的 JSON 也可以直接顯示 14 | 15 | ## 如果沒有反應怎麼辦? 16 | 17 | 1. 由於後端採用 Google Cloud Function,為了避免用量過高被收錢,所以有鎖執行上限,如果無回應的話,可以考慮重新傳送訊息試試喔! 18 | 2. 若重送訊息還是沒有回應,就有可能是發送訊息時發生錯誤,由於 replyToken 無法重複使用,所以會直接沒有回應。 19 | 20 | ## 詳細教學及相關連結 21 | 22 | 1. 部落格介紹: https://taichunmin.idv.tw/blog/2020-04-06-line-devbot.html 23 | 2. 投影片介紹: https://hackmd.io/@taichunmin/COSCUP2022 24 | 3. 原始碼: https://github.com/taichunmin/gcf-line-devbot 25 | 26 | ## LINE Simple Beacon 工作坊測試工具 27 | 28 | 你可以使用這個 Flex 開發人員工具來幫助你使用 ESP32 來製作一個 LINE Simple Beacon!教學連結在此: 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 戴均民 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /line/msg/send-text-to-bot.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ title, body, text }) => ({ 2 | type: 'flex', 3 | altText: title, 4 | contents: { 5 | type: 'bubble', 6 | body: { 7 | borderColor: '#08c356', 8 | borderWidth: '10px', 9 | layout: 'vertical', 10 | paddingAll: '10px', 11 | spacing: 'md', 12 | type: 'box', 13 | contents: [ 14 | { 15 | align: 'center', 16 | size: 'sm', 17 | text: body, 18 | type: 'text', 19 | wrap: true, 20 | }, 21 | { 22 | color: '#08c356', 23 | style: 'primary', 24 | type: 'button', 25 | action: { 26 | label: '點選後送出文字 (限手機)', 27 | type: 'uri', 28 | uri: `https://line.me/R/oaMessage/@736cebrk/?${encodeURIComponent(text)}`, 29 | }, 30 | }, 31 | ], 32 | }, 33 | header: { 34 | backgroundColor: '#2a2a2a', 35 | layout: 'vertical', 36 | type: 'box', 37 | contents: [ 38 | { 39 | align: 'center', 40 | color: '#ffffff', 41 | text: title, 42 | type: 'text', 43 | }, 44 | ], 45 | }, 46 | }, 47 | }) 48 | -------------------------------------------------------------------------------- /line/msg/richmenu-linked.js: -------------------------------------------------------------------------------- 1 | module.exports = alias => ({ 2 | type: 'flex', 3 | altText: '已切換選單,請於手機上開啟。', 4 | contents: { 5 | type: 'bubble', 6 | body: { 7 | borderColor: '#08c356', 8 | borderWidth: '10px', 9 | layout: 'vertical', 10 | paddingAll: '10px', 11 | spacing: 'md', 12 | type: 'box', 13 | contents: [ 14 | { 15 | align: 'center', 16 | text: `已成功幫您切換至「${alias}」選單,請在手機上點選下方按鈕開啟選單。`, 17 | type: 'text', 18 | wrap: true, 19 | }, 20 | { 21 | color: '#08c356', 22 | style: 'primary', 23 | type: 'button', 24 | action: { 25 | data: 'openRichMenu', 26 | inputOption: 'openRichMenu', 27 | label: '開啟選單(限手機)', 28 | type: 'postback', 29 | }, 30 | }, 31 | ], 32 | }, 33 | header: { 34 | backgroundColor: '#2a2a2a', 35 | layout: 'vertical', 36 | type: 'box', 37 | contents: [ 38 | { 39 | align: 'center', 40 | color: '#ffffff', 41 | size: 'lg', 42 | text: '已切換選單', 43 | type: 'text', 44 | }, 45 | ], 46 | }, 47 | }, 48 | }) 49 | -------------------------------------------------------------------------------- /line/handler/cmd/notifySticker.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { getenv } = require('../../../libs/helper') 3 | const linenotify = require('../../../libs/linenotify') 4 | const msgStickerTestResult = require('../../msg/sitcker-test-result') 5 | 6 | const LINE_NOTIFY_TOKEN = getenv('LINE_NOTIFY_TOKEN') 7 | 8 | module.exports = async (ctx, next) => { 9 | const stickers = _.chunk(ctx.cmdArg, 2).slice(0, 5) 10 | if (!stickers.length) throw new Error('缺少 packageId 或 stickerId') 11 | await ctx.replyMessage(msgStickerTestResult({ 12 | nextCmd: '/replySticker', 13 | title: 'RESULTS OF /notifySticker', 14 | stickers: await Promise.all(_.map(stickers, async ([packageId, stickerId]) => { 15 | try { 16 | ;[packageId, stickerId] = _.map([packageId, stickerId], _.toSafeInteger) 17 | await linenotify.notify(LINE_NOTIFY_TOKEN, { 18 | message: `\npackageId = ${packageId}\nstickerId = ${stickerId}`, 19 | notificationDisabled: 'true', 20 | stickerId, 21 | stickerPackageId: packageId, 22 | }) 23 | return { packageId, stickerId } 24 | } catch (err) { 25 | return { packageId, stickerId, message: err.message } 26 | } 27 | })), 28 | })) 29 | } 30 | -------------------------------------------------------------------------------- /line/handler/replyEventJson.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const msgJsonStringify = require('../msg/json-stringify') 3 | 4 | const eventToMsgsHandlers = [ 5 | // event 轉成 json 6 | ctx => { ctx.msgs.push(msgJsonStringify({ ...ctx.req.body, events: [ctx.event] })) }, 7 | 8 | // quickreply sticker 9 | ctx => { 10 | if (_.get(ctx, 'event.message.type') !== 'sticker') return 11 | const msg = _.last(ctx.msgs) 12 | _.update(msg, 'quickReply.items', items => { 13 | items = items ?? [] 14 | const { stickerId, packageId } = ctx.event.message 15 | items.push({ 16 | type: 'action', 17 | action: { 18 | label: '/replySticker', 19 | text: `/replySticker ${packageId} ${stickerId}`, 20 | type: 'message', 21 | }, 22 | }, { 23 | type: 'action', 24 | action: { 25 | label: '/notifySticker', 26 | text: `/notifySticker ${packageId} ${stickerId}`, 27 | type: 'message', 28 | }, 29 | }) 30 | return items 31 | }) 32 | }, 33 | ] 34 | 35 | module.exports = async (ctx, next) => { 36 | ctx.msgs = [] 37 | for (const handler of eventToMsgsHandlers) { 38 | handler(ctx) 39 | } 40 | await ctx.replyMessage(ctx.msgs) 41 | } 42 | -------------------------------------------------------------------------------- /line/handler/index.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { log, middlewareCompose } = require('../../libs/helper') 3 | const Line = require('../../libs/linebotsdk').Client 4 | 5 | // 模仿 Koajs 的 middleware 6 | const lineEventHander = middlewareCompose([ 7 | require('./initEvent'), // 紀錄事件、過濾測試事件、輔助函式、錯誤處理 8 | require('./cmd'), // 處理指令 9 | require('./replyFlexFromText'), // 嘗試回傳 flex 10 | require('./noReplyUrl'), // 如果是一個合法的 URL 就不作回應 11 | require('./replyCalc'), // 計算數學式 12 | require('./replyEventJson'), // 把事件用 json 回傳 13 | ]) 14 | 15 | module.exports = async (ctx, next) => { 16 | const { req, res } = ctx 17 | if (req.method !== 'POST' || _.isNil(req.get('x-line-signature'))) return await next() 18 | try { 19 | // 處理 access token 20 | const channelAccessToken = req.path.substring(1) 21 | if (!/^[a-zA-Z0-9+/=]+$/.test(channelAccessToken)) throw new Error('invalid channel access token') 22 | const line = new Line({ channelAccessToken }) 23 | 24 | // 處理 events 25 | const ctx = { line, req } 26 | const events = _.get(req, 'body.events', []) 27 | await Promise.all(_.map(events, event => lineEventHander({ ...ctx, event }))) 28 | res.status(200).send({}) 29 | } catch (err) { 30 | log('ERROR', err) 31 | res.status(err.status || 500).send(err.message) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /line/msg/richmenu-removed.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ command, share }) => ({ 2 | type: 'flex', 3 | altText: '已移除選單,在手機中點選下方按鈕可再次啟用。', 4 | contents: { 5 | type: 'bubble', 6 | body: { 7 | borderColor: '#08c356', 8 | borderWidth: '10px', 9 | layout: 'vertical', 10 | paddingAll: '10px', 11 | spacing: 'md', 12 | type: 'box', 13 | contents: [ 14 | { 15 | align: 'center', 16 | text: '已移除選單,在手機中點選下方按鈕可再次啟用。', 17 | type: 'text', 18 | wrap: true, 19 | }, 20 | { 21 | color: '#08c356', 22 | style: 'primary', 23 | type: 'button', 24 | action: { 25 | label: '再次啟用選單', 26 | text: command, 27 | type: 'message', 28 | }, 29 | }, 30 | ...(!share ? [] : [{ 31 | color: '#08c356', 32 | style: 'primary', 33 | type: 'button', 34 | action: { 35 | label: '分享給好友', 36 | uri: share, 37 | type: 'uri', 38 | }, 39 | }]), 40 | ], 41 | }, 42 | header: { 43 | backgroundColor: '#2a2a2a', 44 | layout: 'vertical', 45 | type: 'box', 46 | contents: [ 47 | { 48 | align: 'center', 49 | color: '#ffffff', 50 | size: 'lg', 51 | text: '已移除選單', 52 | type: 'text', 53 | }, 54 | ], 55 | }, 56 | }, 57 | }) 58 | -------------------------------------------------------------------------------- /libs/linenotify.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { httpBuildQuery } = require('./helper') 3 | const axios = require('axios') 4 | 5 | const LINE_NOTIFY_API_APIBASE = 'https://notify-api.line.me' 6 | 7 | /** 8 | * @param {String} body.message 1000 characters max 9 | * @param {URL?} body.imageThumbnail Maximum size of 240×240px JPEG 10 | * @param {URL?} body.imageFullsize Maximum size of 2048×2048px JPEG 11 | * @param {File?} body.imageFile Upload a image file to the LINE server. Supported image format is png and jpeg. If you specified imageThumbnail ,imageFullsize and imageFile, imageFile takes precedence. There is a limit that you can upload to within one hour. For more information, please see the section of the API Rate Limit. 12 | * @param {Number?} body.stickerPackageId Package ID. https://devdocs.line.me/files/sticker_list.pdf 13 | * @param {Number?} body.stickerId Sticker ID. 14 | * @param {Boolean?} body.notificationDisabled true: The user doesn't receive a push notification when the message is sent. false: The user receives a push notification when the message is sent (unless they have disabled push notification in LINE and/or their device). If omitted, the value defaults to false. 15 | */ 16 | exports.notify = async (accessToken, body) => { 17 | try { 18 | await axios.post(`${LINE_NOTIFY_API_APIBASE}/api/notify`, httpBuildQuery(body), { 19 | headers: { Authorization: `Bearer ${accessToken}` }, 20 | }) 21 | } catch (err) { 22 | err.message = _.get(err, 'response.data.message', err.message) 23 | err.status = _.get(err, 'response.status', 500) 24 | if (err.status === 401) err.message = 'Notify Token Revoked' 25 | throw err 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /api/mpCollect.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | const _ = require('lodash') 3 | const { log, parseJsonOrDefault } = require('../libs/helper') 4 | const axios = require('axios') 5 | const createError = require('http-errors') 6 | 7 | const reqToJson = req => { 8 | const tmp = _.omitBy(req, (v, k) => k[0] === '_') 9 | tmp.headers = _.chunk(tmp.rawHeaders, 2) 10 | return _.pick(tmp, ['body', 'headers', 'httpVersion', 'method', 'originalUrl', 'params', 'query', 'url']) 11 | } 12 | 13 | module.exports = async (ctx, next) => { 14 | const { req, res } = ctx 15 | try { 16 | const { api_secret, measurement_id } = req.query 17 | if (!api_secret || !measurement_id) throw createError(400, 'invalid request') 18 | const json = parseJsonOrDefault(Buffer.from(req.query.json, 'base64url').toString('utf8')) 19 | if (!json) throw createError(400, 'invalid request') 20 | const tmpReq = reqToJson(req) 21 | log({ message: `GA4 collect: event = ${JSON.stringify(_.get(json, 'events.0', {}))}, remote_ip = ${_.find(tmpReq.headers, [0, 'x-forwarded-for'])?.[1]}`, json, req: tmpReq }) 22 | // https://www.google-analytics.com/debug/mp/collect 23 | const url = new URL('https://www.google-analytics.com/mp/collect') 24 | url.searchParams.set('api_secret', api_secret) 25 | url.searchParams.set('measurement_id', measurement_id) 26 | return await axios.post(url.href, json) 27 | } catch (err) { 28 | log('ERROR', err) 29 | } finally { 30 | // 一律回傳 1x1 GIF 並快取 1 年 31 | res.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000') 32 | res.set('Content-Type', 'image/gif') 33 | res.send(Buffer.from('R0lGODlhAQABAIAAAP___wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw', 'base64url')) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gcf-line-devbot", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "git@github.com:taichunmin/gcf-line-devbot.git", 6 | "author": "taichunmin ", 7 | "license": "MIT", 8 | "dependencies": { 9 | "@google-cloud/functions-framework": "^4.0.0", 10 | "@josephg/resolvable": "^1.0.1", 11 | "@line/bot-sdk": "^8.4.0", 12 | "axios": "^1.12.2", 13 | "crypto-js": "^4.2.0", 14 | "dayjs": "^1.11.18", 15 | "dotenv": "^17.2.3", 16 | "global": "^4.4.0", 17 | "json5": "^2.2.3", 18 | "lodash": "^4.17.21", 19 | "npm-check-updates": "^19.1.1", 20 | "nstr": "^0.1.3", 21 | "octokit": "^5.0.4", 22 | "qs": "^6.14.0" 23 | }, 24 | "devDependencies": { 25 | "eslint": "^8.56.0", 26 | "eslint-config-standard": "^17.1.0", 27 | "eslint-plugin-import": "^2.32.0", 28 | "eslint-plugin-n": "^17.21.3", 29 | "eslint-plugin-node": "^11.1.0", 30 | "eslint-plugin-promise": "^7.2.1", 31 | "jest": "^30.2.0" 32 | }, 33 | "scripts": { 34 | "deploy": "gcloud functions deploy gcf-line-devbot --allow-unauthenticated --entry-point=main --env-vars-file=.env.yaml --gen2 --max-instances=1 --memory=128Mi --no-user-output-enabled --region=us-central1 --runtime=nodejs20 --timeout=60s --trigger-http && gcloud run services update gcf-line-devbot --region=us-central1 --cpu 1 --concurrency 80", 35 | "lint": "eslint --ext .js --fix .", 36 | "localhost-run": "autossh -M 0 -o ServerAliveInterval=60 -o ServerAliveCountMax=3 -o StrictHostKeyChecking=no -R 80:localhost:3000 nokey@localhost.run", 37 | "repl": "node --experimental-repl-await repl.js", 38 | "start": "functions-framework --port=3000 --target=main --signature-type=http", 39 | "test": "jest" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /line/handler/cmd/demoRichmenuAlias.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const msgRichmenuLinked = require('../../msg/richmenu-linked') 3 | const msgRichmenuRemoved = require('../../msg/richmenu-removed') 4 | const msgSendTextToBot = require('../../msg/send-text-to-bot') 5 | const richmenu = require('../../../richmenu') 6 | 7 | const ACTIONS = [ 8 | 'alias-a', 9 | 'alias-b', 10 | 'alias-c', 11 | 'link-a', 12 | 'link-b', 13 | 'link-c', 14 | 'exit', 15 | ] 16 | 17 | module.exports = async (ctx, next) => { 18 | const userId = _.get(ctx, 'event.source.userId') 19 | const action = ctx.cmdArg[0] || 'alias-a' 20 | if (!_.includes(ACTIONS, action)) return await next() 21 | 22 | const isFromUser = ctx?.event?.source?.type === 'user' 23 | if (!isFromUser) { 24 | await ctx.replyMessage(msgSendTextToBot({ 25 | title: '圖文選單切換範例', 26 | body: '請在手機上點選下方按鈕,加入 LINE 官方帳號,然後送出文字,即可透過這個功能來比較圖文選單的兩種切換方法喔!', 27 | text: '/demoRichmenuAlias', 28 | })) 29 | return 30 | } 31 | 32 | await richmenu.bootstrap(ctx) // 確認圖文選單已更新 33 | 34 | if (action === 'exit') { 35 | await ctx.line.unlinkRichMenuFromUser(userId) 36 | await ctx.replyMessage(msgRichmenuRemoved({ 37 | command: '/demoRichmenuAlias', 38 | share: 'https://liff.line.me/1654437282-A1Bj7p4a/share-google-sheet.html?apiKey=QUl6YVN5QzVVMWJiYkkyNzZZaWFQQ2xEbkx3SDI2aTBQeDZQbXN3&key=aWQ&range=5bel5L2c6KGoMQ&spreadsheetId=MVlveHFXZ3RYa01IUjVfejEwR0hLRkJWWUVsSTA1RmlHeVNnWm5ISWFjQVk&template=aHR0cHM6Ly9naXN0LmdpdGh1YnVzZXJjb250ZW50LmNvbS90YWljaHVubWluL2M5YzllMDA0ZjhkNzdiMGNhYWI2MjRmYzhhZDE2ZGE5L3Jhdy90ZW1wbGF0ZS50eHQ&value=MTM', 39 | })) 40 | } else { 41 | await ctx.line.linkRichMenuToUser(userId, _.get(ctx, ['richmenus', action])) 42 | await ctx.replyMessage(msgRichmenuLinked(action)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /line/handler/initEvent.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { errToJson, log } = require('../../libs/helper') 3 | const msgJsonStringify = require('../msg/json-stringify') 4 | 5 | const describeEventSource = source => { 6 | const type = source.type 7 | if (type === 'user') return `Incoming event from ${source.userId}` 8 | if (!source.userId) return `Incoming event from ${type} ${source[type + 'Id']}` 9 | return `Incoming event from ${source.userId} in ${type} ${source[type + 'Id']}` 10 | } 11 | 12 | module.exports = async (ctx, next) => { 13 | try { 14 | const { event, line, req } = ctx 15 | const source = _.get(event, 'source', {}) 16 | ctx.describeEventSource = describeEventSource(source) 17 | log({ message: ctx.describeEventSource, body: { ...req.body, events: [event] } }) // 先把 event 紀錄到 logger 18 | 19 | // 如果是測試訊息或是沒有 replyToken 就直接不處理 20 | if (!event.replyToken || source.userId === 'Udeadbeefdeadbeefdeadbeefdeadbeef') return 21 | 22 | // 設定輔助函式 23 | ctx.replyMessage = async msg => { 24 | try { 25 | if (ctx.replyed) throw new Error('重複呼叫 event.replyMessage') 26 | await line.replyMessage(event.replyToken, msg) 27 | ctx.replyed = 1 28 | } catch (err) { 29 | _.set(err, 'data.msg', msg) 30 | err.response = _.get(err, 'originalError.response', err.response) 31 | throw err 32 | } 33 | } 34 | 35 | await next() // 繼續執行其他 middleware 36 | } catch (err) { 37 | err.message = _.get(err, 'response.data.message', err.message) 38 | try { // 如果還可以 reply 就嘗試把訊息往回傳 39 | if (!ctx.replyed) await ctx.replyMessage(msgJsonStringify(_.omit(errToJson(err), ['stack']))) 40 | } catch (err) {} 41 | 42 | // 避免錯誤拋到外層 43 | err.message = `fnInitEvent: ${err.message}` 44 | log('ERROR', err) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: 安裝 Node.js 與 yarn 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | cache: 'yarn' 19 | - name: install, lint, test 20 | run: | 21 | yarn 22 | yarn lint 23 | yarn test 24 | deploy: 25 | if: github.ref == 'refs/heads/master' 26 | needs: test 27 | runs-on: ubuntu-latest 28 | 29 | permissions: 30 | id-token: write 31 | 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: 安裝 Node.js 與 yarn 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: 20 38 | cache: 'yarn' 39 | # https://github.com/google-github-actions/auth#setting-up-workload-identity-federation 40 | - id: 'auth' 41 | uses: google-github-actions/auth@v2 42 | with: 43 | workload_identity_provider: projects/391399927959/locations/global/workloadIdentityPools/github-actions-taichunmin-pools/providers/github-actions-provider 44 | service_account: gcf-line-devbot@taichunmin.iam.gserviceaccount.com 45 | # https://github.com/google-github-actions/setup-gcloud 46 | - name: 設定 Google Cloud SDK 47 | uses: google-github-actions/setup-gcloud@v2 48 | - name: 確認 Google Cloud SDK 49 | run: gcloud info 50 | - name: 建立 GCF 環境變數檔案 51 | shell: bash 52 | run: | 53 | [[ -z "$ENV_PROD" ]] || echo "$ENV_PROD" > .env.yaml 54 | echo "GITHUB_SHA: ${GITHUB_SHA}" >> .env.yaml 55 | env: 56 | ENV_PROD: ${{ secrets.ENV_PROD }} 57 | - name: 部署到 Google Cloud Functions 58 | run: yarn deploy 59 | -------------------------------------------------------------------------------- /line/handler/cmd/richmenuPlayground.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const msgRichmenuLinked = require('../../msg/richmenu-linked') 3 | const msgRichmenuRemoved = require('../../msg/richmenu-removed') 4 | const msgSendTextToBot = require('../../msg/send-text-to-bot') 5 | const richmenu = require('../../../richmenu') 6 | 7 | const ACTIONS = [ 8 | 'playground-1', 9 | 'playground-2', 10 | 'playground-3', 11 | 'playground-4', 12 | 'playground-5', 13 | 'playground-6', 14 | 'playground-7', 15 | 'exit', 16 | ] 17 | 18 | module.exports = async (ctx, next) => { 19 | const userId = _.get(ctx, 'event.source.userId') 20 | const action = ctx.cmdArg[0] || 'playground-1' 21 | if (!_.includes(ACTIONS, action)) return await next() 22 | 23 | const isFromUser = ctx?.event?.source?.type === 'user' 24 | if (!isFromUser) { 25 | await ctx.replyMessage(msgSendTextToBot({ 26 | title: '圖文選單遊樂場', 27 | body: '請在手機上點選下方按鈕,加入 LINE 官方帳號,然後送出文字,即可透過這個功能來快速認識圖文選單能玩出什麼花樣喔!', 28 | text: '/richmenuPlayground', 29 | })) 30 | return 31 | } 32 | 33 | await richmenu.bootstrap(ctx) // 確認圖文選單已更新 34 | 35 | if (action === 'exit') { 36 | await ctx.line.unlinkRichMenuFromUser(userId) 37 | await ctx.replyMessage(msgRichmenuRemoved({ 38 | command: '/richmenuPlayground', 39 | share: 'https://liff.line.me/1654437282-A1Bj7p4a/share-google-sheet.html?apiKey=QUl6YVN5QzVVMWJiYkkyNzZZaWFQQ2xEbkx3SDI2aTBQeDZQbXN3&key=aWQ&range=5bel5L2c6KGoMQ&spreadsheetId=MVlveHFXZ3RYa01IUjVfejEwR0hLRkJWWUVsSTA1RmlHeVNnWm5ISWFjQVk&template=aHR0cHM6Ly9naXN0LmdpdGh1YnVzZXJjb250ZW50LmNvbS90YWljaHVubWluL2M5YzllMDA0ZjhkNzdiMGNhYWI2MjRmYzhhZDE2ZGE5L3Jhdy90ZW1wbGF0ZS50eHQ&value=MjE', 40 | })) 41 | } else { 42 | await ctx.line.linkRichMenuToUser(userId, _.get(ctx, ['richmenus', action])) 43 | await ctx.replyMessage(msgRichmenuLinked(action)) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /line/handler/replyFlexFromText.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { log, parseJsonOrDefault } = require('../../libs/helper') 3 | const { tryAddShareBtn } = require('../../libs/tryAddShareBtn') 4 | const msgReplyFlexError = require('../msg/reply-flex-error') 5 | const msgText = require('../msg/text') 6 | 7 | const getAltText = msg => { 8 | msg = _.chain(msg).castArray().last().value() 9 | return msg?.altText ?? msg?.text 10 | } 11 | 12 | module.exports = async (ctx, next) => { 13 | try { 14 | let msg = parseJsonOrDefault(_.get(ctx, 'event.message.text')) 15 | if (!_.isArray(msg) && !_.isPlainObject(msg)) return await next() // 轉交給下一個 middleware 處理 16 | 17 | if (_.has(msg, 'replyToken') || _.has(msg, 'events.0.replyToken')) { // 有 replyToken 代表使用者可能是把剛剛傳送的事件複製貼上 18 | return await ctx.replyMessage(msgText('感謝你傳訊息給我,但因為這個訊息疑似是 Messaging API 中傳送給 Webhook 的事件,所以我不知道該怎麼處理它。\n\n如果你是開發者,請參考相關文件: https://developers.line.biz/en/reference/messaging-api/#message-event')) 19 | } 20 | 21 | // 幫忙補上外層的 flex (從 FLEX MESSAGE SIMULATOR 來的通常有這問題) 22 | const isPartialFlex = _.includes(['bubble', 'carousel'], _.get(msg, 'type')) 23 | if (isPartialFlex) msg = { altText: '缺少替代文字', contents: msg, type: 'flex' } 24 | 25 | log({ message: `reply flex from text, altText: ${getAltText(msg)}`, msg }) // 回傳前先記錄一次 26 | await ctx.line.validateReplyMessageObjects(msg) // 先透過 messaging api 驗證內容 27 | 28 | msg = await tryAddShareBtn(ctx, msg) // 嘗試新增透過 LINE 數位版名片分享的按鈕 29 | await ctx.replyMessage(msg) 30 | } catch (err) { 31 | const lineApiErrData = err?.originalError?.response?.data ?? err?.response?.data 32 | if (!lineApiErrData?.message) throw err // 並非訊息內容有誤 33 | err.message = `reply flex from text: ${lineApiErrData?.message ?? err.message}` 34 | log('ERROR', err) 35 | try { // 如果還可以 reply 就嘗試把訊息往回傳 36 | await ctx.replyMessage(msgReplyFlexError(lineApiErrData)) 37 | } catch (err) {} 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /richmenu/playground-9.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'playground-9' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/Hyq1K5fAxl.png', 8 | metadata: { 9 | chatBarText: '點此打開圖文選單', 10 | selected: true, 11 | size: { width: 2500, height: 1684 }, 12 | areas: [ 13 | { // 關閉圖文選單遊樂場 14 | bounds: { x: 2325, y: 0, width: 175, height: 208 }, 15 | action: { 16 | type: 'message', 17 | text: '/richmenuPlayground exit', 18 | }, 19 | }, 20 | { // 動作說明 21 | bounds: { x: 2150, y: 0, width: 175, height: 208 }, 22 | action: { 23 | type: 'uri', 24 | uri: 'https://developers.line.biz/en/reference/messaging-api/#clipboard-action', 25 | }, 26 | }, 27 | { // 1. message 28 | bounds: { x: 0, y: 208, width: 300, height: 245 }, 29 | action: { 30 | type: 'richmenuswitch', 31 | richMenuAliasId: 'playground-1', 32 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-1' }), 33 | }, 34 | }, 35 | { // 4. 選擇日時 36 | bounds: { x: 300, y: 208, width: 730, height: 245 }, 37 | action: { 38 | type: 'richmenuswitch', 39 | richMenuAliasId: 'playground-4', 40 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-4' }), 41 | }, 42 | }, 43 | { // 5. 切換選單 44 | bounds: { x: 1030, y: 208, width: 730, height: 245 }, 45 | action: { 46 | type: 'richmenuswitch', 47 | richMenuAliasId: 'playground-5', 48 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-5' }), 49 | }, 50 | }, 51 | { // 複製文字 52 | bounds: { x: 576, y: 1055, width: 1346, height: 494 }, 53 | action: { 54 | type: 'clipboard', 55 | clipboardText: '這是你從圖文選單遊樂場複製的文字', 56 | }, 57 | }, 58 | ], 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /richmenu/playground-1.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'playground-1' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/B1P8_qf0lg.png', 8 | metadata: { 9 | chatBarText: '點此打開圖文選單', 10 | selected: true, 11 | size: { width: 2500, height: 1684 }, 12 | areas: [ 13 | { // 關閉圖文選單遊樂場 14 | bounds: { x: 2325, y: 0, width: 175, height: 208 }, 15 | action: { 16 | type: 'message', 17 | text: '/richmenuPlayground exit', 18 | }, 19 | }, 20 | { // 動作說明 21 | bounds: { x: 2150, y: 0, width: 175, height: 208 }, 22 | action: { 23 | type: 'uri', 24 | uri: 'https://developers.line.biz/en/docs/messaging-api/try-rich-menu/#try-message-action', 25 | }, 26 | }, 27 | { // 2. postback 28 | bounds: { x: 742, y: 208, width: 737, height: 245 }, 29 | action: { 30 | type: 'richmenuswitch', 31 | richMenuAliasId: 'playground-2', 32 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-2' }), 33 | }, 34 | }, 35 | { // 3. URI 36 | bounds: { x: 1479, y: 208, width: 730, height: 245 }, 37 | action: { 38 | type: 'richmenuswitch', 39 | richMenuAliasId: 'playground-3', 40 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-3' }), 41 | }, 42 | }, 43 | { // 4. 選擇日時 44 | bounds: { x: 2209, y: 208, width: 291, height: 245 }, 45 | action: { 46 | type: 'richmenuswitch', 47 | richMenuAliasId: 'playground-4', 48 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-4' }), 49 | }, 50 | }, 51 | { // 傳送訊息 52 | bounds: { x: 576, y: 1055, width: 1346, height: 494 }, 53 | action: { 54 | type: 'message', 55 | text: 'message sent successfully!', 56 | }, 57 | }, 58 | ], 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /line/handler/cmd/index.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { log } = require('../../../libs/helper') 3 | const fs = require('fs') 4 | const path = require('path') 5 | 6 | const cmdCache = {} 7 | 8 | const cmdUnknown = async (ctx, next) => { // 未知指令 9 | return await next() // 轉交給下一個 middleware 處理 10 | } 11 | 12 | const findIdFuncs = { 13 | groupId (ctx) { 14 | const found = _.find(ctx.cmdArg, arg => /^C[0-9a-f]{32}$/i.test(arg)) 15 | return found || _.get(ctx, 'event.source.groupId') 16 | }, 17 | roomId (ctx) { 18 | const found = _.find(ctx.cmdArg, arg => /^R[0-9a-f]{32}$/i.test(arg)) 19 | return found || _.get(ctx, 'event.source.roomId') 20 | }, 21 | userId (ctx) { 22 | const found = _.find(ctx.cmdArg, arg => /^U[0-9a-f]{32}$/i.test(arg)) 23 | return found || _.get(ctx, 'event.source.userId') 24 | }, 25 | richmenuId (ctx) { 26 | return _.find(ctx.cmdArg, arg => /^richmenu-[0-9a-f]{32}$/i.test(arg)) 27 | }, 28 | requestId (ctx) { 29 | return _.find(ctx.cmdArg, arg => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(arg)) 30 | }, 31 | mentionUserIds (ctx) { 32 | const mentionees = _.filter(_.map(_.get(ctx, 'event.message.mention.mentionees', []), 'userId')) 33 | return mentionees.length ? mentionees : null 34 | }, 35 | } 36 | 37 | module.exports = async (ctx, next) => { 38 | const text = _.trim(ctx?.event?.message?.text ?? ctx?.event?.postback?.data ?? '') 39 | if (text.length >= 1000) return await next() // 轉交給下一個 middleware 處理 40 | const match = text.match(/^\/(\w+)(?: (.+))?/) 41 | if (!match) return await next() // 轉交給下一個 middleware 處理 42 | 43 | log(text) 44 | const cmd = match[1] 45 | if (!cmdCache[cmd]) { 46 | const cmdPath = path.resolve(__dirname, `${cmd}.js`) 47 | cmdCache[cmd] = fs.existsSync(cmdPath) ? require(cmdPath) : cmdUnknown 48 | } 49 | // 處理參數 50 | ctx.cmdArg = _.filter((match[2] || '').split(/\s+/)) 51 | 52 | // 尋找各種 ID 53 | _.each(findIdFuncs, (fn, key) => { _.set(ctx, key, fn(ctx)) }) 54 | 55 | return await cmdCache[cmd](ctx, next) 56 | } 57 | -------------------------------------------------------------------------------- /api/createLineMsgGist.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { beautifyFlex, getenv, log } = require('../libs/helper') 3 | const { octokit } = require('../libs/octokit') 4 | const dayjs = require('dayjs') 5 | const JSON5 = require('json5') 6 | const Line = require('../libs/linebotsdk').Client 7 | 8 | const LINE_MESSAGING_TOKEN = getenv('LINE_MESSAGING_TOKEN') 9 | 10 | module.exports = async (ctx, next) => { 11 | const { req, res } = ctx 12 | if (_.isNil(LINE_MESSAGING_TOKEN) || _.isNil(octokit)) return await next() // 未設定 TOKEN 13 | if (!_.isArray(req.body) && !_.isPlainObject(req.body)) return await next() // 轉交給下一個 middleware 處理 14 | 15 | try { 16 | ctx.line = new Line({ channelAccessToken: LINE_MESSAGING_TOKEN }) 17 | 18 | // verify message 19 | const tmp1 = _.castArray(req.body) 20 | for (let i = 0; i < tmp1.length; i++) { 21 | if (_.includes(['bubble', 'carousel'], tmp1[i]?.type)) tmp1[i] = { altText: 'altText', contents: tmp1[i], type: 'flex' } 22 | const tmp2 = tmp1[i] 23 | if (!_.isNil(tmp2?.replyToken)) throw new Error(`msg[${i}].replyToken is not allowed`) 24 | if (!_.includes(['text', 'sticker', 'image', 'video', 'audio', 'location', 'imagemap', 'template', 'flex'], tmp2?.type)) throw new Error(`msg[${i}].type = ${JSON5.stringify(tmp2?.type)} is invalid`) 25 | } 26 | await ctx.line.validateReplyMessageObjects(tmp1) // 先透過 messaging api 驗證內容 27 | .catch(err => { throw _.merge(err, { status: err?.originalError?.response?.status ?? 500, ..._.pick(err?.originalError?.response?.data, ['message', 'details']) }) }) 28 | 29 | // 上傳到 gist 30 | const nowts = dayjs() 31 | const filename = `gcf-line-devbot-${+nowts}.json5` 32 | const gist = await octokit.request('POST /gists', { 33 | description: `Upload by line-devbot /gist/createLineMessage at ${nowts.format('YYYY-MM-DD HH:mm:ss')}`, 34 | files: { [filename]: { content: JSON5.stringify(beautifyFlex(req.body)) } }, 35 | }) 36 | res.json({ rawUrl: gist.data.files[filename].raw_url }) 37 | } catch (err) { 38 | log('ERROR', err) 39 | res.status(err.status ?? 500).json(_.pick(err, ['message', 'details'])) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /richmenu/alias-a.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'alias-a' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/r1M9w5zAge.png', 8 | metadata: { 9 | chatBarText: `範例 ${RICHMENU_ALIAS}`, 10 | selected: true, 11 | size: { width: 800, height: 400 }, 12 | areas: [ 13 | { 14 | bounds: { x: 0, y: 0, width: 268, height: 114 }, 15 | action: { 16 | type: 'richmenuswitch', 17 | richMenuAliasId: 'alias-a', 18 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-a' }), 19 | }, 20 | }, 21 | { 22 | bounds: { x: 268, y: 0, width: 264, height: 114 }, 23 | action: { 24 | type: 'richmenuswitch', 25 | richMenuAliasId: 'alias-b', 26 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-b' }), 27 | }, 28 | }, 29 | { 30 | bounds: { x: 532, y: 0, width: 268, height: 114 }, 31 | action: { 32 | type: 'richmenuswitch', 33 | richMenuAliasId: 'alias-c', 34 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-c' }), 35 | }, 36 | }, 37 | { 38 | bounds: { x: 0, y: 114, width: 268, height: 118 }, 39 | action: { 40 | type: 'message', 41 | text: '/demoRichmenuAlias link-a', 42 | }, 43 | }, 44 | { 45 | bounds: { x: 268, y: 114, width: 264, height: 118 }, 46 | action: { 47 | type: 'message', 48 | text: '/demoRichmenuAlias link-b', 49 | }, 50 | }, 51 | { 52 | bounds: { x: 532, y: 114, width: 268, height: 118 }, 53 | action: { 54 | type: 'message', 55 | text: '/demoRichmenuAlias link-c', 56 | }, 57 | }, 58 | { 59 | bounds: { x: 0, y: 232, width: 400, height: 168 }, 60 | action: { 61 | type: 'message', 62 | text: '/demoRichmenuAlias exit', 63 | }, 64 | }, 65 | { 66 | bounds: { x: 400, y: 232, width: 400, height: 168 }, 67 | action: { 68 | type: 'uri', 69 | uri: 'https://lihi1.com/kIcgO', 70 | }, 71 | }, 72 | ], 73 | }, 74 | } 75 | -------------------------------------------------------------------------------- /richmenu/alias-b.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'alias-b' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/SJNpD9zCgl.png', 8 | metadata: { 9 | chatBarText: `範例 ${RICHMENU_ALIAS}`, 10 | selected: true, 11 | size: { width: 800, height: 400 }, 12 | areas: [ 13 | { 14 | bounds: { x: 0, y: 0, width: 268, height: 114 }, 15 | action: { 16 | type: 'richmenuswitch', 17 | richMenuAliasId: 'alias-a', 18 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-a' }), 19 | }, 20 | }, 21 | { 22 | bounds: { x: 268, y: 0, width: 264, height: 114 }, 23 | action: { 24 | type: 'richmenuswitch', 25 | richMenuAliasId: 'alias-b', 26 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-b' }), 27 | }, 28 | }, 29 | { 30 | bounds: { x: 532, y: 0, width: 268, height: 114 }, 31 | action: { 32 | type: 'richmenuswitch', 33 | richMenuAliasId: 'alias-c', 34 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-c' }), 35 | }, 36 | }, 37 | { 38 | bounds: { x: 0, y: 114, width: 268, height: 118 }, 39 | action: { 40 | type: 'message', 41 | text: '/demoRichmenuAlias link-a', 42 | }, 43 | }, 44 | { 45 | bounds: { x: 268, y: 114, width: 264, height: 118 }, 46 | action: { 47 | type: 'message', 48 | text: '/demoRichmenuAlias link-b', 49 | }, 50 | }, 51 | { 52 | bounds: { x: 532, y: 114, width: 268, height: 118 }, 53 | action: { 54 | type: 'message', 55 | text: '/demoRichmenuAlias link-c', 56 | }, 57 | }, 58 | { 59 | bounds: { x: 0, y: 232, width: 400, height: 168 }, 60 | action: { 61 | type: 'message', 62 | text: '/demoRichmenuAlias exit', 63 | }, 64 | }, 65 | { 66 | bounds: { x: 400, y: 232, width: 400, height: 168 }, 67 | action: { 68 | type: 'uri', 69 | uri: 'https://lihi1.com/kIcgO', 70 | }, 71 | }, 72 | ], 73 | }, 74 | } 75 | -------------------------------------------------------------------------------- /richmenu/alias-c.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'alias-c' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/r1_x_qfAgl.png', 8 | metadata: { 9 | chatBarText: `範例 ${RICHMENU_ALIAS}`, 10 | selected: true, 11 | size: { width: 800, height: 400 }, 12 | areas: [ 13 | { 14 | bounds: { x: 0, y: 0, width: 268, height: 114 }, 15 | action: { 16 | type: 'richmenuswitch', 17 | richMenuAliasId: 'alias-a', 18 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-a' }), 19 | }, 20 | }, 21 | { 22 | bounds: { x: 268, y: 0, width: 264, height: 114 }, 23 | action: { 24 | type: 'richmenuswitch', 25 | richMenuAliasId: 'alias-b', 26 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-b' }), 27 | }, 28 | }, 29 | { 30 | bounds: { x: 532, y: 0, width: 268, height: 114 }, 31 | action: { 32 | type: 'richmenuswitch', 33 | richMenuAliasId: 'alias-c', 34 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-c' }), 35 | }, 36 | }, 37 | { 38 | bounds: { x: 0, y: 114, width: 268, height: 118 }, 39 | action: { 40 | type: 'message', 41 | text: '/demoRichmenuAlias link-a', 42 | }, 43 | }, 44 | { 45 | bounds: { x: 268, y: 114, width: 264, height: 118 }, 46 | action: { 47 | type: 'message', 48 | text: '/demoRichmenuAlias link-b', 49 | }, 50 | }, 51 | { 52 | bounds: { x: 532, y: 114, width: 268, height: 118 }, 53 | action: { 54 | type: 'message', 55 | text: '/demoRichmenuAlias link-c', 56 | }, 57 | }, 58 | { 59 | bounds: { x: 0, y: 232, width: 400, height: 168 }, 60 | action: { 61 | type: 'message', 62 | text: '/demoRichmenuAlias exit', 63 | }, 64 | }, 65 | { 66 | bounds: { x: 400, y: 232, width: 400, height: 168 }, 67 | action: { 68 | type: 'uri', 69 | uri: 'https://lihi1.com/kIcgO', 70 | }, 71 | }, 72 | ], 73 | }, 74 | } 75 | -------------------------------------------------------------------------------- /richmenu/link-a.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'link-a' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/ByfzuqG0eg.png', 8 | metadata: { 9 | chatBarText: `範例 ${RICHMENU_ALIAS}`, 10 | selected: true, 11 | size: { width: 800, height: 400 }, 12 | areas: [ 13 | { 14 | bounds: { x: 0, y: 0, width: 268, height: 114 }, 15 | action: { 16 | type: 'richmenuswitch', 17 | richMenuAliasId: 'alias-a', 18 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-a' }), 19 | }, 20 | }, 21 | { 22 | bounds: { x: 268, y: 0, width: 264, height: 114 }, 23 | action: { 24 | type: 'richmenuswitch', 25 | richMenuAliasId: 'alias-b', 26 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-b' }), 27 | }, 28 | }, 29 | { 30 | bounds: { x: 532, y: 0, width: 268, height: 114 }, 31 | action: { 32 | type: 'richmenuswitch', 33 | richMenuAliasId: 'alias-c', 34 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-c' }), 35 | }, 36 | }, 37 | { 38 | bounds: { x: 0, y: 114, width: 268, height: 118 }, 39 | action: { 40 | type: 'message', 41 | text: '/demoRichmenuAlias link-a', 42 | }, 43 | }, 44 | { 45 | bounds: { x: 268, y: 114, width: 264, height: 118 }, 46 | action: { 47 | type: 'message', 48 | text: '/demoRichmenuAlias link-b', 49 | }, 50 | }, 51 | { 52 | bounds: { x: 532, y: 114, width: 268, height: 118 }, 53 | action: { 54 | type: 'message', 55 | text: '/demoRichmenuAlias link-c', 56 | }, 57 | }, 58 | { 59 | bounds: { x: 0, y: 232, width: 400, height: 168 }, 60 | action: { 61 | type: 'message', 62 | text: '/demoRichmenuAlias exit', 63 | }, 64 | }, 65 | { 66 | bounds: { x: 400, y: 232, width: 400, height: 168 }, 67 | action: { 68 | type: 'uri', 69 | uri: 'https://lihi1.com/kIcgO', 70 | }, 71 | }, 72 | ], 73 | }, 74 | } 75 | -------------------------------------------------------------------------------- /richmenu/link-b.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'link-b' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/Hy27dczCeg.png', 8 | metadata: { 9 | chatBarText: `範例 ${RICHMENU_ALIAS}`, 10 | selected: true, 11 | size: { width: 800, height: 400 }, 12 | areas: [ 13 | { 14 | bounds: { x: 0, y: 0, width: 268, height: 114 }, 15 | action: { 16 | type: 'richmenuswitch', 17 | richMenuAliasId: 'alias-a', 18 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-a' }), 19 | }, 20 | }, 21 | { 22 | bounds: { x: 268, y: 0, width: 264, height: 114 }, 23 | action: { 24 | type: 'richmenuswitch', 25 | richMenuAliasId: 'alias-b', 26 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-b' }), 27 | }, 28 | }, 29 | { 30 | bounds: { x: 532, y: 0, width: 268, height: 114 }, 31 | action: { 32 | type: 'richmenuswitch', 33 | richMenuAliasId: 'alias-c', 34 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-c' }), 35 | }, 36 | }, 37 | { 38 | bounds: { x: 0, y: 114, width: 268, height: 118 }, 39 | action: { 40 | type: 'message', 41 | text: '/demoRichmenuAlias link-a', 42 | }, 43 | }, 44 | { 45 | bounds: { x: 268, y: 114, width: 264, height: 118 }, 46 | action: { 47 | type: 'message', 48 | text: '/demoRichmenuAlias link-b', 49 | }, 50 | }, 51 | { 52 | bounds: { x: 532, y: 114, width: 268, height: 118 }, 53 | action: { 54 | type: 'message', 55 | text: '/demoRichmenuAlias link-c', 56 | }, 57 | }, 58 | { 59 | bounds: { x: 0, y: 232, width: 400, height: 168 }, 60 | action: { 61 | type: 'message', 62 | text: '/demoRichmenuAlias exit', 63 | }, 64 | }, 65 | { 66 | bounds: { x: 400, y: 232, width: 400, height: 168 }, 67 | action: { 68 | type: 'uri', 69 | uri: 'https://lihi1.com/kIcgO', 70 | }, 71 | }, 72 | ], 73 | }, 74 | } 75 | -------------------------------------------------------------------------------- /richmenu/link-c.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'link-c' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/SkJBOqMAgx.png', 8 | metadata: { 9 | chatBarText: `範例 ${RICHMENU_ALIAS}`, 10 | selected: true, 11 | size: { width: 800, height: 400 }, 12 | areas: [ 13 | { 14 | bounds: { x: 0, y: 0, width: 268, height: 114 }, 15 | action: { 16 | type: 'richmenuswitch', 17 | richMenuAliasId: 'alias-a', 18 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-a' }), 19 | }, 20 | }, 21 | { 22 | bounds: { x: 268, y: 0, width: 264, height: 114 }, 23 | action: { 24 | type: 'richmenuswitch', 25 | richMenuAliasId: 'alias-b', 26 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-b' }), 27 | }, 28 | }, 29 | { 30 | bounds: { x: 532, y: 0, width: 268, height: 114 }, 31 | action: { 32 | type: 'richmenuswitch', 33 | richMenuAliasId: 'alias-c', 34 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'alias-c' }), 35 | }, 36 | }, 37 | { 38 | bounds: { x: 0, y: 114, width: 268, height: 118 }, 39 | action: { 40 | type: 'message', 41 | text: '/demoRichmenuAlias link-a', 42 | }, 43 | }, 44 | { 45 | bounds: { x: 268, y: 114, width: 264, height: 118 }, 46 | action: { 47 | type: 'message', 48 | text: '/demoRichmenuAlias link-b', 49 | }, 50 | }, 51 | { 52 | bounds: { x: 532, y: 114, width: 268, height: 118 }, 53 | action: { 54 | type: 'message', 55 | text: '/demoRichmenuAlias link-c', 56 | }, 57 | }, 58 | { 59 | bounds: { x: 0, y: 232, width: 400, height: 168 }, 60 | action: { 61 | type: 'message', 62 | text: '/demoRichmenuAlias exit', 63 | }, 64 | }, 65 | { 66 | bounds: { x: 400, y: 232, width: 400, height: 168 }, 67 | action: { 68 | type: 'uri', 69 | uri: 'https://lihi1.com/kIcgO', 70 | }, 71 | }, 72 | ], 73 | }, 74 | } 75 | -------------------------------------------------------------------------------- /line/handler/cmd/gistReplaceAltText.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { log, parseJsonOrDefault } = require('../../../libs/helper') 3 | const { octokit } = require('../../../libs/octokit') 4 | const { tryAddShareBtn } = require('../../../libs/tryAddShareBtn') 5 | const JSON5 = require('json5') 6 | const msgReplyFlexError = require('../../msg/reply-flex-error') 7 | 8 | module.exports = async (ctx, next) => { 9 | try { 10 | if (!octokit) return await next() // 轉交給下一個 middleware 處理 11 | const { event } = ctx 12 | if (event?.type !== 'message') return // 忽略開鍵盤的 postback 13 | const text = _.trim(event?.message?.text ?? event?.postback?.data ?? '') 14 | const match = /^\/\w+\s+(\w+)\s+((?:.|\s)+)$/.exec(text) 15 | if (!match) throw new Error('指令的參數有誤,請點選「改簡介」按鈕後重新輸入指令。') 16 | const [, gistId, altText] = match 17 | const gistOld = await octokit.request('GET /gists/{gistId}', { gistId }) 18 | log({ message: `oldGistId = ${gistId}`, gistOld: gistOld.data }) 19 | const oldJson5 = _.find(_.values(gistOld.data.files), { language: 'JSON5' })?.content 20 | if (!oldJson5) throw new Error(`在指定的 gist 內找不到 json5, ${JSON5.stringify({ gistId })}`) 21 | let msg = parseJsonOrDefault(oldJson5) 22 | if (!_.isArray(msg) && !_.isPlainObject(msg)) throw new Error(`gist 內的 json5 不是陣列或物件, ${JSON5.stringify({ gistId })}`) 23 | 24 | // 幫忙補上外層的 flex (從 FLEX MESSAGE SIMULATOR 來的通常有這問題) 25 | const isPartialFlex = _.includes(['bubble', 'carousel'], _.get(msg, 'type')) 26 | if (isPartialFlex) msg = { altText, contents: msg, type: 'flex' } 27 | const last = _.isArray(msg) ? _.last(msg) : msg 28 | if (_.has(last, 'altText')) last.altText = altText 29 | 30 | log({ message: `reply flex from text, altText: ${last.altText}`, msg }) // 回傳前先記錄一次 31 | await ctx.line.validateReplyMessageObjects(msg) // 先驗證 messaging api 的內容正確 32 | 33 | msg = await tryAddShareBtn(ctx, msg) // 嘗試新增透過 LINE 數位版名片分享的按鈕 34 | await ctx.replyMessage(msg) 35 | } catch (err) { 36 | const lineApiErrData = err?.originalError?.response?.data ?? err?.response?.data 37 | if (!lineApiErrData?.message) throw err // 並非訊息內容有誤 38 | err.message = `reply flex from text: ${lineApiErrData?.message ?? err.message}` 39 | log('ERROR', err) 40 | try { // 如果還可以 reply 就嘗試把訊息往回傳 41 | await ctx.replyMessage(msgReplyFlexError(lineApiErrData)) 42 | } catch (err) {} 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /richmenu/playground-5.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'playground-5' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/SkA9u5fRge.png', 8 | metadata: { 9 | chatBarText: '點此打開圖文選單', 10 | selected: true, 11 | size: { width: 2500, height: 1684 }, 12 | areas: [ 13 | { // 關閉圖文選單遊樂場 14 | bounds: { x: 2325, y: 0, width: 175, height: 208 }, 15 | action: { 16 | type: 'message', 17 | text: '/richmenuPlayground exit', 18 | }, 19 | }, 20 | { // 動作說明 21 | bounds: { x: 2150, y: 0, width: 175, height: 208 }, 22 | action: { 23 | type: 'uri', 24 | uri: 'https://developers.line.biz/en/docs/messaging-api/try-rich-menu/#try-richmenu-switch-action', 25 | }, 26 | }, 27 | { // 1. message 28 | bounds: { x: 0, y: 208, width: 300, height: 245 }, 29 | action: { 30 | type: 'richmenuswitch', 31 | richMenuAliasId: 'playground-1', 32 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-1' }), 33 | }, 34 | }, 35 | { // 4. 選擇日時 36 | bounds: { x: 300, y: 208, width: 730, height: 245 }, 37 | action: { 38 | type: 'richmenuswitch', 39 | richMenuAliasId: 'playground-4', 40 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-4' }), 41 | }, 42 | }, 43 | { // 6. clipboard 44 | bounds: { x: 1760, y: 208, width: 730, height: 245 }, 45 | action: { 46 | type: 'richmenuswitch', 47 | richMenuAliasId: 'playground-9', 48 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-9' }), 49 | }, 50 | }, 51 | { // 換成藍色選單 52 | bounds: { x: 0, y: 1100, width: 1250, height: 450 }, 53 | action: { 54 | type: 'richmenuswitch', 55 | richMenuAliasId: 'playground-6', 56 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-6' }), 57 | }, 58 | }, 59 | { // 換成小型選單 60 | bounds: { x: 1250, y: 1100, width: 1250, height: 450 }, 61 | action: { 62 | type: 'richmenuswitch', 63 | richMenuAliasId: 'playground-7', 64 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-7' }), 65 | }, 66 | }, 67 | ], 68 | }, 69 | } 70 | -------------------------------------------------------------------------------- /richmenu/playground-6.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'playground-6' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/Byao_5G0lx.png', 8 | metadata: { 9 | chatBarText: '點此打開圖文選單', 10 | selected: true, 11 | size: { width: 2500, height: 1684 }, 12 | areas: [ 13 | { // 關閉圖文選單遊樂場 14 | bounds: { x: 2325, y: 0, width: 175, height: 208 }, 15 | action: { 16 | type: 'message', 17 | text: '/richmenuPlayground exit', 18 | }, 19 | }, 20 | { // 動作說明 21 | bounds: { x: 2150, y: 0, width: 175, height: 208 }, 22 | action: { 23 | type: 'uri', 24 | uri: 'https://developers.line.biz/en/docs/messaging-api/try-rich-menu/#try-richmenu-switch-action', 25 | }, 26 | }, 27 | { // 1. message 28 | bounds: { x: 0, y: 208, width: 300, height: 245 }, 29 | action: { 30 | type: 'richmenuswitch', 31 | richMenuAliasId: 'playground-1', 32 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-1' }), 33 | }, 34 | }, 35 | { // 4. 選擇日時 36 | bounds: { x: 300, y: 208, width: 730, height: 245 }, 37 | action: { 38 | type: 'richmenuswitch', 39 | richMenuAliasId: 'playground-4', 40 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-4' }), 41 | }, 42 | }, 43 | { // 6. clipboard 44 | bounds: { x: 1760, y: 208, width: 730, height: 245 }, 45 | action: { 46 | type: 'richmenuswitch', 47 | richMenuAliasId: 'playground-9', 48 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-9' }), 49 | }, 50 | }, 51 | { // 換回綠色選單 52 | bounds: { x: 0, y: 1100, width: 1250, height: 450 }, 53 | action: { 54 | type: 'richmenuswitch', 55 | richMenuAliasId: 'playground-5', 56 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-5' }), 57 | }, 58 | }, 59 | { // 換成小型選單 60 | bounds: { x: 1250, y: 1100, width: 1250, height: 450 }, 61 | action: { 62 | type: 'richmenuswitch', 63 | richMenuAliasId: 'playground-7', 64 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-7' }), 65 | }, 66 | }, 67 | ], 68 | }, 69 | } 70 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | -------------------------------------------------------------------------------- /line/msg/reply-flex-error.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | module.exports = data => ({ 4 | altText: data.message || '訊息物件有誤', 5 | type: 'flex', 6 | contents: { 7 | size: 'giga', 8 | type: 'bubble', 9 | body: { 10 | layout: 'vertical', 11 | spacing: 'md', 12 | type: 'box', 13 | contents: _.map(data.details, detail => ({ 14 | layout: 'horizontal', 15 | spacing: 'md', 16 | type: 'box', 17 | contents: [ 18 | { 19 | height: '40px', 20 | layout: 'vertical', 21 | type: 'box', 22 | width: '40px', 23 | contents: [ 24 | { 25 | aspectMode: 'cover', 26 | aspectRatio: '1:1', 27 | size: 'full', 28 | type: 'image', 29 | url: 'https://i.imgur.com/2VH5JeS.png', 30 | }, 31 | ], 32 | }, 33 | { 34 | layout: 'vertical', 35 | spacing: 'xs', 36 | type: 'box', 37 | contents: [ 38 | { 39 | color: '#666666', 40 | flex: 0, 41 | size: 'xxs', 42 | text: detail.property, 43 | type: 'text', 44 | wrap: true, 45 | }, 46 | { 47 | flex: 1, 48 | layout: 'vertical', 49 | type: 'box', 50 | contents: [ 51 | { 52 | type: 'filler', 53 | }, 54 | ], 55 | }, 56 | { 57 | flex: 0, 58 | size: 'sm', 59 | text: detail.message, 60 | type: 'text', 61 | wrap: true, 62 | }, 63 | ], 64 | }, 65 | ], 66 | })), 67 | }, 68 | header: { 69 | backgroundColor: '#00C300', 70 | layout: 'vertical', 71 | spacing: 'sm', 72 | type: 'box', 73 | contents: [ 74 | { 75 | color: '#99ff99', 76 | size: 'xxs', 77 | text: '訊息物件有誤', 78 | type: 'text', 79 | weight: 'bold', 80 | }, 81 | { 82 | color: '#ffffff', 83 | text: data.message || '訊息物件有誤', 84 | type: 'text', 85 | wrap: true, 86 | }, 87 | ], 88 | }, 89 | }, 90 | }) 91 | -------------------------------------------------------------------------------- /richmenu/playground-2.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'playground-2' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/B1_Pu5fAge.png', 8 | metadata: { 9 | chatBarText: '點此打開圖文選單', 10 | selected: true, 11 | size: { width: 2500, height: 1684 }, 12 | areas: [ 13 | { // 關閉圖文選單遊樂場 14 | bounds: { x: 2325, y: 0, width: 175, height: 208 }, 15 | action: { 16 | type: 'message', 17 | text: '/richmenuPlayground exit', 18 | }, 19 | }, 20 | { // 動作說明 21 | bounds: { x: 2150, y: 0, width: 175, height: 208 }, 22 | action: { 23 | type: 'uri', 24 | uri: 'https://developers.line.biz/en/docs/messaging-api/try-rich-menu/#try-postback-1-action', 25 | }, 26 | }, 27 | { // 1. message 28 | bounds: { x: 0, y: 208, width: 742, height: 245 }, 29 | action: { 30 | type: 'richmenuswitch', 31 | richMenuAliasId: 'playground-1', 32 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-1' }), 33 | }, 34 | }, 35 | { // 3. URI 36 | bounds: { x: 1479, y: 208, width: 730, height: 245 }, 37 | action: { 38 | type: 'richmenuswitch', 39 | richMenuAliasId: 'playground-3', 40 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-3' }), 41 | }, 42 | }, 43 | { // 4. 選擇日時 44 | bounds: { x: 2209, y: 208, width: 291, height: 245 }, 45 | action: { 46 | type: 'richmenuswitch', 47 | richMenuAliasId: 'playground-4', 48 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-4' }), 49 | }, 50 | }, 51 | { // 包含 displayText 52 | bounds: { x: 0, y: 875, width: 1250, height: 420 }, 53 | action: { 54 | type: 'postback', 55 | data: 'actionId=21', 56 | displayText: '這則文字將顯示在聊天視窗內。', 57 | }, 58 | }, 59 | { // 沒有 displayText 60 | bounds: { x: 1250, y: 875, width: 1250, height: 420 }, 61 | action: { 62 | type: 'postback', 63 | data: 'actionId=22', 64 | }, 65 | }, 66 | { // 測試 inputOption 屬性 67 | bounds: { x: 0, y: 1295, width: 2500, height: 320 }, 68 | action: { 69 | type: 'richmenuswitch', 70 | richMenuAliasId: 'playground-8', 71 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-8' }), 72 | }, 73 | }, 74 | ], 75 | }, 76 | } 77 | -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | 118 | # other files 119 | .editorconfig 120 | .env.yaml 121 | .eslintignore 122 | .eslintrc.js 123 | .gcloudignore 124 | .git/ 125 | .github/ 126 | .gitignore 127 | LICENSE 128 | README.md 129 | -------------------------------------------------------------------------------- /richmenu/playground-8.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'playground-8' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/r1IAuqfRxl.png', 8 | metadata: { 9 | chatBarText: '點此打開圖文選單', 10 | selected: true, 11 | size: { width: 2500, height: 1684 }, 12 | areas: [ 13 | { // 關閉圖文選單遊樂場 14 | bounds: { x: 2325, y: 0, width: 175, height: 208 }, 15 | action: { 16 | type: 'message', 17 | text: '/richmenuPlayground exit', 18 | }, 19 | }, 20 | { // 動作說明 21 | bounds: { x: 2150, y: 0, width: 175, height: 208 }, 22 | action: { 23 | type: 'uri', 24 | uri: 'https://developers.line.biz/en/docs/messaging-api/try-rich-menu/#try-postback-2-action', 25 | }, 26 | }, 27 | { // 1. message 28 | bounds: { x: 0, y: 208, width: 742, height: 245 }, 29 | action: { 30 | type: 'richmenuswitch', 31 | richMenuAliasId: 'playground-1', 32 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-1' }), 33 | }, 34 | }, 35 | { // 2. postback 36 | bounds: { x: 742, y: 208, width: 737, height: 245 }, 37 | action: { 38 | type: 'richmenuswitch', 39 | richMenuAliasId: 'playground-2', 40 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-2' }), 41 | }, 42 | }, 43 | { // 3. URI 44 | bounds: { x: 1479, y: 208, width: 730, height: 245 }, 45 | action: { 46 | type: 'richmenuswitch', 47 | richMenuAliasId: 'playground-3', 48 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-3' }), 49 | }, 50 | }, 51 | { // 4. 選擇日時 52 | bounds: { x: 2209, y: 208, width: 291, height: 245 }, 53 | action: { 54 | type: 'richmenuswitch', 55 | richMenuAliasId: 'playground-4', 56 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-4' }), 57 | }, 58 | }, 59 | { // 切換文字輸入 60 | bounds: { x: 0, y: 875, width: 1250, height: 370 }, 61 | action: { 62 | data: 'actionId=61', 63 | fillInText: '---\n這是預先填寫的文字\n---', 64 | inputOption: 'openKeyboard', 65 | type: 'postback', 66 | }, 67 | }, 68 | { // 切換語音訊息 69 | bounds: { x: 1250, y: 875, width: 1250, height: 370 }, 70 | action: { 71 | data: 'actionId=62', 72 | inputOption: 'openVoice', 73 | type: 'postback', 74 | }, 75 | }, 76 | { // 關閉及開啟圖文選單 77 | bounds: { x: 0, y: 1245, width: 2500, height: 370 }, 78 | action: { 79 | data: '/postbackInputOption', 80 | inputOption: 'closeRichMenu', 81 | type: 'postback', 82 | }, 83 | }, 84 | ], 85 | }, 86 | } 87 | -------------------------------------------------------------------------------- /libs/tryAddShareBtn.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { beautifyFlex, encodeBase64url, log } = require('./helper') 3 | const { octokit } = require('./octokit') 4 | const JSON5 = require('json5') 5 | 6 | const flexShareBtn = ({ uri, gistId }) => ({ 7 | altText: '點此透過 LINE 數位版名片分享', 8 | type: 'flex', 9 | contents: { 10 | type: 'carousel', 11 | contents: _.map([ 12 | { 13 | icon: 'https://i.imgur.com/IFjR25G.png', 14 | text: '分享', 15 | action: { type: 'uri', uri }, 16 | }, 17 | { 18 | icon: 'https://i.imgur.com/W6RbIne.png', 19 | text: '複製網址', 20 | action: { type: 'clipboard', clipboardText: uri }, 21 | }, 22 | { 23 | icon: 'https://i.imgur.com/dLElEk7.png', 24 | text: '改替代', 25 | action: { 26 | data: '/gistReplaceAltText', 27 | displayText: '如果你是在手機上點選「改替代」按鈕,你應該可以在下方輸入框看到自動填寫的文字指令,請在指令後面輸入新的替代文字後傳送。', 28 | fillInText: `/gistReplaceAltText ${gistId} `, 29 | inputOption: 'openKeyboard', 30 | type: 'postback', 31 | }, 32 | }, 33 | ], btn => ({ 34 | size: 'nano', 35 | type: 'bubble', 36 | body: { 37 | alignItems: 'center', 38 | justifyContent: 'center', 39 | layout: 'horizontal', 40 | paddingAll: '5px', 41 | spacing: 'md', 42 | type: 'box', 43 | action: btn.action, 44 | contents: [ 45 | { 46 | height: '16px', 47 | layout: 'vertical', 48 | type: 'box', 49 | width: '16px', 50 | contents: [ 51 | { 52 | aspectMode: 'cover', 53 | aspectRatio: '1:1', 54 | size: 'full', 55 | type: 'image', 56 | url: btn.icon, 57 | }, 58 | ], 59 | }, 60 | { 61 | flex: 0, 62 | size: '16px', 63 | text: btn.text, 64 | type: 'text', 65 | }, 66 | ], 67 | }, 68 | })), 69 | }, 70 | sender: { 71 | iconUrl: 'https://i.imgur.com/1KZoSue.png', 72 | name: '數位版名片', 73 | }, 74 | }) 75 | 76 | exports.tryAddShareBtn = async (ctx, msg) => { 77 | try { 78 | if (!octokit) throw new Error() // 未設定 OCTOKIT_ACCESS_TOKEN 79 | if (_.isArray(msg) && msg.length >= 5) throw new Error() // 沒有辦法新增分享按鈕 80 | const { describeEventSource, event } = ctx 81 | const filename = `gcf-line-devbot-${event?.message?.id}.json5` 82 | const gist = await octokit.request('POST /gists', { 83 | description: describeEventSource, 84 | files: { [filename]: { content: JSON5.stringify(beautifyFlex(msg)) } }, 85 | }) 86 | const uri = `https://lihi1.com/kLzL9/${encodeBase64url(gist.data.files[filename].raw_url)}` 87 | return [flexShareBtn({ uri, gistId: gist.data.id }), ..._.castArray(msg)] 88 | } catch (err) { 89 | if (err.message) log('ERROR', err) 90 | return msg 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /richmenu/playground-4.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'playground-4' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/HyaYu9z0xe.png', 8 | metadata: { 9 | chatBarText: '點此打開圖文選單', 10 | selected: true, 11 | size: { width: 2500, height: 1684 }, 12 | areas: [ 13 | { // 關閉圖文選單遊樂場 14 | bounds: { x: 2325, y: 0, width: 175, height: 208 }, 15 | action: { 16 | type: 'message', 17 | text: '/richmenuPlayground exit', 18 | }, 19 | }, 20 | { // 動作說明 21 | bounds: { x: 2150, y: 0, width: 175, height: 208 }, 22 | action: { 23 | type: 'uri', 24 | uri: 'https://developers.line.biz/en/docs/messaging-api/try-rich-menu/#try-datetime-picker-action', 25 | }, 26 | }, 27 | { // 1. message 28 | bounds: { x: 0, y: 208, width: 300, height: 245 }, 29 | action: { 30 | type: 'richmenuswitch', 31 | richMenuAliasId: 'playground-1', 32 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-1' }), 33 | }, 34 | }, 35 | { // 5. 切換選單 36 | bounds: { x: 1030, y: 208, width: 730, height: 245 }, 37 | action: { 38 | type: 'richmenuswitch', 39 | richMenuAliasId: 'playground-5', 40 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-5' }), 41 | }, 42 | }, 43 | { // 6. clipboard 44 | bounds: { x: 1760, y: 208, width: 730, height: 245 }, 45 | action: { 46 | type: 'richmenuswitch', 47 | richMenuAliasId: 'playground-9', 48 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-9' }), 49 | }, 50 | }, 51 | { // 選擇日期與時間 52 | bounds: { x: 0, y: 1016, width: 1600, height: 300 }, 53 | action: { 54 | type: 'datetimepicker', 55 | data: 'actionId=31', 56 | mode: 'datetime', 57 | }, 58 | }, 59 | { // 指定初始值 60 | bounds: { x: 0, y: 1315, width: 830, height: 300 }, 61 | action: { 62 | type: 'datetimepicker', 63 | data: 'actionId=32', 64 | initial: '2021-11-01t00:00', 65 | mode: 'datetime', 66 | }, 67 | }, 68 | { // 指定上下限 69 | bounds: { x: 830, y: 1315, width: 770, height: 300 }, 70 | action: { 71 | type: 'datetimepicker', 72 | data: 'actionId=33', 73 | max: '2021-12-31t23:59', 74 | min: '2021-11-01t00:00', 75 | mode: 'datetime', 76 | }, 77 | }, 78 | { // 選擇日期 79 | bounds: { x: 1600, y: 1016, width: 900, height: 300 }, 80 | action: { 81 | type: 'datetimepicker', 82 | data: 'actionId=34', 83 | mode: 'date', 84 | }, 85 | }, 86 | { // 選擇時間 87 | bounds: { x: 1600, y: 1315, width: 900, height: 300 }, 88 | action: { 89 | type: 'datetimepicker', 90 | data: 'actionId=35', 91 | mode: 'time', 92 | }, 93 | }, 94 | ], 95 | }, 96 | } 97 | -------------------------------------------------------------------------------- /richmenu/playground-3.js: -------------------------------------------------------------------------------- 1 | const { httpBuildQuery } = require('../libs/helper') 2 | 3 | const RICHMENU_ALIAS = 'playground-3' 4 | 5 | module.exports = { 6 | alias: RICHMENU_ALIAS, 7 | image: 'https://hackmd.io/_uploads/SJ5u_qM0el.png', 8 | metadata: { 9 | chatBarText: '點此打開圖文選單', 10 | selected: true, 11 | size: { width: 2500, height: 1684 }, 12 | areas: [ 13 | { // 關閉圖文選單遊樂場 14 | bounds: { x: 2325, y: 0, width: 175, height: 208 }, 15 | action: { 16 | type: 'message', 17 | text: '/richmenuPlayground exit', 18 | }, 19 | }, 20 | { // 動作說明 21 | bounds: { x: 2150, y: 0, width: 175, height: 208 }, 22 | action: { 23 | type: 'uri', 24 | uri: 'https://developers.line.biz/en/docs/messaging-api/try-rich-menu/#try-uri-action', 25 | }, 26 | }, 27 | { // 1. message 28 | bounds: { x: 0, y: 208, width: 742, height: 245 }, 29 | action: { 30 | type: 'richmenuswitch', 31 | richMenuAliasId: 'playground-1', 32 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-1' }), 33 | }, 34 | }, 35 | { // 2. postback 36 | bounds: { x: 742, y: 208, width: 737, height: 245 }, 37 | action: { 38 | type: 'richmenuswitch', 39 | richMenuAliasId: 'playground-2', 40 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-2' }), 41 | }, 42 | }, 43 | { // 4. 選擇日時 44 | bounds: { x: 2209, y: 208, width: 291, height: 245 }, 45 | action: { 46 | type: 'richmenuswitch', 47 | richMenuAliasId: 'playground-4', 48 | data: httpBuildQuery({ from: RICHMENU_ALIAS, to: 'playground-4' }), 49 | }, 50 | }, 51 | { // 開啟 URL 52 | bounds: { x: 0, y: 485, width: 1332, height: 375 }, 53 | action: { 54 | type: 'uri', 55 | uri: 'https://developers.line.biz/en/reference/messaging-api/#uri-action', 56 | }, 57 | }, 58 | { // 開啟 URL 查看網址 59 | bounds: { x: 1332, y: 485, width: 380, height: 375 }, 60 | action: { 61 | type: 'message', 62 | text: '網址為:\nhttps://developers.line.biz/en/reference/messaging-api/#uri-action', 63 | }, 64 | }, 65 | { // 在外部瀏覽器開啟 66 | bounds: { x: 0, y: 860, width: 1332, height: 375 }, 67 | action: { 68 | type: 'uri', 69 | uri: 'https://developers.line.biz/en/reference/messaging-api/?openExternalBrowser=1#uri-action', 70 | }, 71 | }, 72 | { // 在外部瀏覽器開啟 查看網址 73 | bounds: { x: 1332, y: 860, width: 380, height: 375 }, 74 | action: { 75 | type: 'message', 76 | text: '網址為:\nhttps://developers.line.biz/en/reference/messaging-api/?openExternalBrowser=1#uri-action', 77 | }, 78 | }, 79 | { // 在 APP 內的 Chrome 開啟 80 | bounds: { x: 0, y: 1235, width: 1332, height: 375 }, 81 | action: { 82 | type: 'uri', 83 | uri: 'https://developers.line.biz/en/reference/messaging-api/?openInAppBrowser=0#uri-action', 84 | }, 85 | }, 86 | { // 在 APP 內的 Chrome 開啟 查看網址 87 | bounds: { x: 1332, y: 1235, width: 380, height: 375 }, 88 | action: { 89 | type: 'message', 90 | text: '網址為:\nhttps://developers.line.biz/en/reference/messaging-api/?openInAppBrowser=0#uri-action', 91 | }, 92 | }, 93 | ], 94 | }, 95 | } 96 | -------------------------------------------------------------------------------- /line/handler/cmd/postbackInputOption.js: -------------------------------------------------------------------------------- 1 | const msgSendTextToBot = require('../../msg/send-text-to-bot') 2 | 3 | const msgDemo = { 4 | type: 'flex', 5 | altText: '測試 inputOption 屬性(限手機)', 6 | contents: { 7 | type: 'bubble', 8 | body: { 9 | borderColor: '#08c356', 10 | borderWidth: '10px', 11 | layout: 'vertical', 12 | paddingAll: '10px', 13 | spacing: 'sm', 14 | type: 'box', 15 | contents: [ 16 | { 17 | color: '#08c356', 18 | style: 'primary', 19 | type: 'button', 20 | action: { 21 | data: 'actionId=61', 22 | inputOption: 'closeRichMenu', 23 | label: 'closeRichMenu', 24 | type: 'postback', 25 | }, 26 | }, 27 | { 28 | color: '#08c356', 29 | style: 'primary', 30 | type: 'button', 31 | action: { 32 | data: 'actionId=62', 33 | inputOption: 'openRichMenu', 34 | label: 'openRichMenu', 35 | type: 'postback', 36 | }, 37 | }, 38 | { 39 | color: '#08c356', 40 | style: 'primary', 41 | type: 'button', 42 | action: { 43 | data: 'actionId=63', 44 | fillInText: '---\n這是預先填寫的文字\n---', 45 | inputOption: 'openKeyboard', 46 | label: 'openKeyboard', 47 | type: 'postback', 48 | }, 49 | }, 50 | { 51 | color: '#08c356', 52 | style: 'primary', 53 | type: 'button', 54 | action: { 55 | data: 'actionId=64', 56 | inputOption: 'openVoice', 57 | label: 'openVoice', 58 | type: 'postback', 59 | }, 60 | }, 61 | ], 62 | }, 63 | header: { 64 | backgroundColor: '#2a2a2a', 65 | layout: 'vertical', 66 | type: 'box', 67 | contents: [ 68 | { 69 | align: 'center', 70 | color: '#ffffff', 71 | text: '測試 inputOption 屬性(限手機)', 72 | type: 'text', 73 | }, 74 | ], 75 | }, 76 | }, 77 | quickReply: { 78 | items: [ 79 | { 80 | type: 'action', 81 | action: { 82 | data: 'actionId=61', 83 | inputOption: 'closeRichMenu', 84 | label: 'closeRichMenu', 85 | type: 'postback', 86 | }, 87 | }, 88 | { 89 | type: 'action', 90 | action: { 91 | data: 'actionId=62', 92 | inputOption: 'openRichMenu', 93 | label: 'openRichMenu', 94 | type: 'postback', 95 | }, 96 | }, 97 | { 98 | type: 'action', 99 | action: { 100 | data: 'actionId=63', 101 | fillInText: '---\n這是預先填寫的文字\n---', 102 | inputOption: 'openKeyboard', 103 | label: 'openKeyboard', 104 | type: 'postback', 105 | }, 106 | }, 107 | { 108 | type: 'action', 109 | action: { 110 | data: 'actionId=64', 111 | inputOption: 'openVoice', 112 | label: 'openVoice', 113 | type: 'postback', 114 | }, 115 | }, 116 | ], 117 | }, 118 | } 119 | 120 | module.exports = async (ctx, next) => { 121 | const isFromUser = ctx?.event?.source?.type === 'user' 122 | await ctx.replyMessage(isFromUser ? msgDemo : msgSendTextToBot({ 123 | title: '測試 inputOption', 124 | body: '請在手機上點選下方按鈕,加入 LINE 官方帳號,然後送出文字,即可測試 postback 動作的 inputOption 屬性。', 125 | text: '/postbackInputOption', 126 | })) 127 | } 128 | -------------------------------------------------------------------------------- /line/msg/sitcker-test-result.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | module.exports = ({ nextCmd, stickers, title }) => ({ 4 | altText: title, 5 | type: 'flex', 6 | contents: { 7 | type: 'bubble', 8 | body: { 9 | layout: 'vertical', 10 | spacing: 'xl', 11 | type: 'box', 12 | contents: [ 13 | { 14 | color: '#1DB446', 15 | size: 'sm', 16 | text: title, 17 | type: 'text', 18 | weight: 'bold', 19 | }, 20 | ..._.flatMap(stickers, sticker => [ 21 | { 22 | backgroundColor: '#cccccc', 23 | height: '1px', 24 | layout: 'vertical', 25 | type: 'box', 26 | contents: [], 27 | }, 28 | { 29 | layout: 'horizontal', 30 | spacing: 'md', 31 | type: 'box', 32 | contents: [ 33 | { 34 | flex: 0, 35 | height: '64px', 36 | layout: 'vertical', 37 | type: 'box', 38 | width: '64px', 39 | contents: [ 40 | { 41 | aspectMode: 'fit', 42 | aspectRatio: '1:1', 43 | size: 'full', 44 | type: 'image', 45 | url: `https://stickershop.line-scdn.net/stickershop/v1/sticker/${sticker.stickerId}/android/sticker.png;compress=true`, 46 | }, 47 | ], 48 | }, 49 | { 50 | layout: 'vertical', 51 | spacing: 'sm', 52 | type: 'box', 53 | contents: [ 54 | { 55 | flex: 1, 56 | layout: 'vertical', 57 | type: 'box', 58 | contents: [], 59 | }, 60 | { 61 | flex: 0, 62 | layout: 'horizontal', 63 | type: 'box', 64 | contents: [ 65 | { 66 | color: '#555555', 67 | flex: 0, 68 | size: 'sm', 69 | text: 'packageId', 70 | type: 'text', 71 | }, 72 | { 73 | align: 'end', 74 | color: '#111111', 75 | size: 'sm', 76 | text: `${sticker.packageId}`, 77 | type: 'text', 78 | }, 79 | ], 80 | }, 81 | { 82 | flex: 0, 83 | layout: 'horizontal', 84 | type: 'box', 85 | contents: [ 86 | { 87 | color: '#555555', 88 | flex: 0, 89 | size: 'sm', 90 | text: 'stickerId', 91 | type: 'text', 92 | }, 93 | { 94 | align: 'end', 95 | color: '#111111', 96 | size: 'sm', 97 | text: `${sticker.stickerId}`, 98 | type: 'text', 99 | }, 100 | ], 101 | }, 102 | { 103 | color: sticker.message ? '#dc3545' : '#28a745', 104 | flex: 0, 105 | size: 'sm', 106 | text: sticker.message || 'Success', 107 | type: 'text', 108 | wrap: true, 109 | }, 110 | { 111 | flex: 1, 112 | layout: 'vertical', 113 | type: 'box', 114 | contents: [], 115 | }, 116 | ], 117 | }, 118 | ], 119 | }, 120 | ]), 121 | ], 122 | }, 123 | footer: { 124 | layout: 'vertical', 125 | spacing: 'sm', 126 | type: 'box', 127 | contents: [ 128 | { 129 | height: 'sm', 130 | style: 'primary', 131 | type: 'button', 132 | action: { 133 | label: nextCmd, 134 | type: 'message', 135 | text: `${nextCmd} ${_.map(stickers, s => `${s.packageId} ${s.stickerId}`).join(' ')}`, 136 | }, 137 | }, 138 | { 139 | height: 'sm', 140 | style: 'primary', 141 | type: 'button', 142 | action: { 143 | label: '點此查詢可用貼圖', 144 | type: 'uri', 145 | uri: 'https://developers.line.biz/en/docs/messaging-api/sticker-list/', 146 | }, 147 | }, 148 | ], 149 | }, 150 | }, 151 | }) 152 | -------------------------------------------------------------------------------- /libs/helper.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { enc: { Base64, Base64url, Utf8 } } = require('crypto-js') 3 | const JSON5 = require('json5') 4 | const Qs = require('qs') 5 | const crypto = require('crypto') 6 | 7 | const jsonStringify = obj => { 8 | try { 9 | const preventCircular = new Set() 10 | return JSON.stringify(obj, (key, value) => { 11 | if (value instanceof Map) return _.fromPairs([...value.entries()]) 12 | if (value instanceof Set) return [...value.values()] 13 | if (_.isObject(value) && !_.isEmpty(value)) { 14 | if (preventCircular.has(value)) return '[Circular]' 15 | preventCircular.add(value) 16 | } 17 | return value 18 | }) 19 | } catch (err) { 20 | return `[UnexpectedJSONParseError]: ${err.message}` 21 | } 22 | } 23 | 24 | exports.getenv = (key, defaultval) => _.get(process, ['env', key], defaultval) 25 | 26 | exports.errToJson = (() => { 27 | const ERROR_KEYS = [ 28 | 'address', 29 | 'code', 30 | 'data', 31 | 'dest', 32 | 'errno', 33 | 'info', 34 | 'message', 35 | 'name', 36 | 'path', 37 | 'port', 38 | 'reason', 39 | 'response.data', 40 | 'response.headers', 41 | 'response.status', 42 | 'stack', 43 | 'status', 44 | 'statusCode', 45 | 'statusMessage', 46 | 'syscall', 47 | ] 48 | return err => ({ 49 | ..._.pick(err, ERROR_KEYS), 50 | ...(_.isNil(err.originalError) ? {} : { originalError: exports.errToJson(err.originalError) }), 51 | }) 52 | })() 53 | 54 | exports.log = (() => { 55 | const LOG_SEVERITY = ['DEFAULT', 'DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY'] 56 | return (...args) => { 57 | let severity = 'DEFAULT' 58 | if (args.length > 1 && _.includes(LOG_SEVERITY, _.toUpper(args[0]))) severity = _.toUpper(args.shift()) 59 | _.each(args, arg => { 60 | if (_.isString(arg)) arg = { message: arg } 61 | if (arg instanceof Error) arg = exports.errToJson(arg) 62 | console.log(jsonStringify({ severity, ...arg })) 63 | }) 64 | } 65 | })() 66 | 67 | exports.middlewareCompose = middlewares => { 68 | // 型態檢查 69 | if (!_.isArray(middlewares)) throw new TypeError('Middleware stack must be an array!') 70 | if (!_.every(middlewares, _.isFunction)) throw new TypeError('Middleware must be composed of functions!') 71 | 72 | return async (context = {}, next) => { 73 | const cloned = [...middlewares, ...(_.isFunction(next) ? [next] : [])] 74 | if (!cloned.length) return 75 | const executed = _.times(cloned.length + 1, () => 0) 76 | const dispatch = async cur => { 77 | if (executed[cur] !== 0) throw new Error(`middleware[${cur}] called multiple times`) 78 | if (cur >= cloned.length) { 79 | executed[cur] = 2 80 | return 81 | } 82 | try { 83 | executed[cur] = 1 84 | const result = await cloned[cur](context, () => dispatch(cur + 1)) 85 | if (executed[cur + 1] === 1) throw new Error(`next() in middleware[${cur}] should be awaited`) 86 | executed[cur] = 2 87 | return result 88 | } catch (err) { 89 | executed[cur] = 3 90 | if (err.stack) err.stack = err.stack.replace(/at async dispatch[^\n]+\n[^\n]+\n\s*/g, '') 91 | throw err 92 | } 93 | } 94 | return await dispatch(0) 95 | } 96 | } 97 | 98 | exports.parseJsonOrDefault = (str, defaultValue) => { 99 | try { 100 | if (!_.isString(str) && !_.isBuffer(str)) return defaultValue 101 | return JSON5.parse(str) 102 | } catch (err) { 103 | return defaultValue 104 | } 105 | } 106 | 107 | exports.httpBuildQuery = (obj, overrides = {}) => Qs.stringify(obj, { arrayFormat: 'brackets', ...overrides }) 108 | 109 | exports.encodeBase64url = str => { 110 | if (!_.isInteger(str.sigBytes)) str = Utf8.parse(`${str}`) 111 | return Base64url.stringify(str) 112 | } 113 | 114 | exports.decodeBase64 = str => { 115 | return Utf8.stringify(Base64.parse(str.replace(/[-_]/g, c => _.get({ '-': '+', _: '/' }, c)))) 116 | } 117 | 118 | exports.sha1Base64url = str => { 119 | return crypto.createHash('sha1').update(str).digest('base64url') 120 | } 121 | 122 | exports.beautifyFlex = obj => { 123 | if (_.isArray(obj)) return _.map(obj, exports.beautifyFlex) 124 | if (!_.isPlainObject(obj)) return obj 125 | const grp = _.groupBy(_.toPairs(obj), pair => (_.isArray(pair[1]) || _.isPlainObject(pair[1])) ? 'b' : 'a') 126 | _.each(grp.b, v => { v[1] = exports.beautifyFlex(v[1]) }) 127 | return _.fromPairs([..._.sortBy(grp.a, '0'), ..._.sortBy(grp.b, '0')]) 128 | } 129 | 130 | exports.WordArrayToUint8Array = word => { 131 | const len = word.words.length 132 | const view = new DataView(new ArrayBuffer(len << 2)) 133 | for (let i = 0; i < len; i++) view.setInt32(i << 2, word.words[i]) 134 | return new Uint8Array(view.buffer.slice(0, word.sigBytes)) 135 | } 136 | 137 | exports.urlToBase64 = str => str.replace(/[-_]/g, c => _.get({ '-': '+', _: '/' }, c)) 138 | -------------------------------------------------------------------------------- /richmenu/index.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const { log, sha1Base64url } = require('../libs/helper') 3 | const axios = require('axios') 4 | const resolvable = require('@josephg/resolvable') 5 | const fsPromises = require('fs').promises 6 | const path = require('path') 7 | 8 | const RICHMENU_FILES = [ 9 | 'alias-a', 10 | 'alias-b', 11 | 'alias-c', 12 | 'link-a', 13 | 'link-b', 14 | 'link-c', 15 | 'playground-1', 16 | 'playground-2', 17 | 'playground-3', 18 | 'playground-4', 19 | 'playground-5', 20 | 'playground-6', 21 | 'playground-7', 22 | 'playground-8', 23 | 'playground-9', 24 | ] 25 | 26 | exports.bootstrap = (() => { 27 | const cached = {} 28 | return async (ctx, isForce = false) => { 29 | // 避免重複執行 30 | if (ctx.event?.deliveryContext?.isRedelivery === true) return // 重新送達的事件不處理 richmenu 更新 31 | const line = ctx.line 32 | if (line.richmenuReady) return await line.richmenuReady 33 | line.richmenuReady = resolvable() 34 | 35 | try { 36 | const channelAccessToken = line.config.channelAccessToken 37 | const botBasicId = (await line.getBotInfo())?.basicId 38 | const nowts = Date.now() 39 | if (isForce || _.toSafeInteger(cached[botBasicId]?.expiredAt) < nowts) { 40 | // 先取得舊的 richmenu 41 | const [oldMenus, newMenus, oldAliases] = await Promise.all([ 42 | line.getRichMenuList(), 43 | exports.loadMenus(), 44 | exports.getRichMenuAliases(channelAccessToken), 45 | ]) 46 | const oldAliasToId = _.fromPairs(_.map(oldAliases, menu => [menu.richMenuAliasId, menu.richMenuId])) 47 | const oldIdToHash = _.fromPairs(_.map(oldMenus, menu => [menu.richMenuId, menu.name])) 48 | 49 | // 新增 menu 50 | for (const menu of newMenus) { 51 | try { 52 | // 檢查 menu 是否已存在 53 | const oldId = oldAliasToId[menu.alias] ?? null 54 | const oldHash = oldIdToHash[oldId] ?? null 55 | if (!isForce && oldHash === menu.metadata.name) { 56 | menu.richMenuId = oldId 57 | return 58 | } 59 | // 上傳新的 richMenu 60 | menu.richMenuId = await line.createRichMenu(menu.metadata) 61 | log(`${botBasicId}: 上傳新的 Rich Menu, alias = ${menu.alias}, hash = ${menu.metadata.name}, richMenuId = ${menu.richMenuId}`) 62 | // 上傳圖 63 | const image = await axios.get(menu.image, { responseType: 'arraybuffer' }) 64 | await line.setRichMenuImage(menu.richMenuId, image.data, image.headers['content-type']) 65 | // 設定為預設 richMenu 66 | if (menu.default) await line.setDefaultRichMenu(menu.richMenuId) 67 | // 新增或更新 alias 68 | if (!oldId) await exports.setRichmenuAlias(channelAccessToken, menu.alias, menu.richMenuId) 69 | else if (oldId !== menu.richMenuId) await exports.updateRichmenuAlias(channelAccessToken, menu.alias, menu.richMenuId) 70 | } catch (err) { 71 | _.set(err, 'data.menu', menu) 72 | err.message = `${botBasicId}: ${err.message}` 73 | log('ERROR', err) 74 | } 75 | } 76 | const newAliasToId = _.fromPairs(_.map(newMenus, menu => [menu.alias, menu.richMenuId])) 77 | log(`${botBasicId}: newAliasToId = ${JSON.stringify(newAliasToId)}`) 78 | 79 | // 刪除不需要的 menu 和 alias 80 | const delMenuIds = _.difference(_.map(oldMenus, 'richMenuId'), _.map(newMenus, 'richMenuId')) 81 | const delAlias = _.difference(_.map(oldAliases, 'richMenuAliasId'), _.map(newMenus, 'alias')) 82 | await Promise.all([ 83 | ..._.map(delMenuIds, async menuId => { 84 | log(`${botBasicId}: 刪除不需要的 menuId = ${menuId}, hash = ${oldIdToHash[menuId]}`) 85 | await line.deleteRichMenu(menuId) 86 | }), 87 | ..._.map(delAlias, async alias => { 88 | log(`${botBasicId}: 刪除不需要的 menuAlias = ${alias}`) 89 | await exports.deleteRichmenuAlias(channelAccessToken, alias) 90 | }), 91 | ]) 92 | 93 | // 避免重複執行 94 | cached[botBasicId] = { 95 | cache: newAliasToId, 96 | expiredAt: nowts + 36e5, // 1hr 97 | } 98 | } 99 | ctx.richmenus = cached[botBasicId].cache 100 | line.richmenuReady.resolve(ctx) 101 | return await line.richmenuReady 102 | } catch (err) { 103 | log('ERROR', err) 104 | line.richmenuReady.reject(err) 105 | } finally { 106 | line.richmenuReady = null 107 | } 108 | } 109 | })() 110 | 111 | exports.loadMenus = async () => { 112 | const menus = [] 113 | for (const filename of RICHMENU_FILES) { 114 | const file = await fsPromises.readFile(path.resolve(__dirname, `${filename}.js`), { charset: 'utf8' }) 115 | const fileHash = sha1Base64url(file) 116 | const menu = require(`./${filename}`) 117 | const areas = menu?.metadata?.areas 118 | if (_.isArray(areas)) { 119 | menu.metadata.areas = _.sortBy(areas, [ 120 | 'bounds.x', 121 | 'bounds.y', 122 | 'bounds.width', 123 | 'bounds.height', 124 | 'action.type', 125 | ]) 126 | } 127 | _.set(menu, 'metadata.name', fileHash) 128 | menus.push(menu) 129 | } 130 | return menus 131 | } 132 | 133 | exports.getRichMenuAliases = async channelAccessToken => { 134 | return _.get(await axios.get('https://api.line.me/v2/bot/richmenu/alias/list', { 135 | headers: { Authorization: `Bearer ${channelAccessToken}` }, 136 | }), 'data.aliases', []) 137 | } 138 | 139 | exports.setRichmenuAlias = async (channelAccessToken, richMenuAliasId, richMenuId) => { 140 | try { 141 | if (!richMenuAliasId) return 142 | return _.get(await axios.post('https://api.line.me/v2/bot/richmenu/alias', { 143 | richMenuAliasId, 144 | richMenuId, 145 | }, { 146 | headers: { Authorization: `Bearer ${channelAccessToken}` }, 147 | }), 'data') 148 | } catch (err) { 149 | _.set(err, 'data.alias', richMenuAliasId) 150 | _.set(err, 'data.richMenuId', richMenuId) 151 | throw err 152 | } 153 | } 154 | 155 | exports.updateRichmenuAlias = async (channelAccessToken, richMenuAliasId, richMenuId) => { 156 | try { 157 | if (!richMenuAliasId) return 158 | return _.get(await axios.post(`https://api.line.me/v2/bot/richmenu/alias/${richMenuAliasId}`, { 159 | richMenuId, 160 | }, { 161 | headers: { Authorization: `Bearer ${channelAccessToken}` }, 162 | }), 'data') 163 | } catch (err) { 164 | _.set(err, 'data.alias', richMenuAliasId) 165 | _.set(err, 'data.richMenuId', richMenuId) 166 | throw err 167 | } 168 | } 169 | 170 | exports.deleteRichmenuAlias = async (channelAccessToken, richMenuAliasId) => { 171 | try { 172 | if (!richMenuAliasId) return 173 | return _.get(await axios.delete(`https://api.line.me/v2/bot/richmenu/alias/${richMenuAliasId}`, { 174 | headers: { Authorization: `Bearer ${channelAccessToken}` }, 175 | }), 'data') 176 | } catch (err) { 177 | _.set(err, 'data.func', 'deleteRichmenuAlias') 178 | _.set(err, 'data.alias', richMenuAliasId) 179 | throw err 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /libs/helper.test.js: -------------------------------------------------------------------------------- 1 | const sut = require('./helper') 2 | 3 | const sleep = t => new Promise(resolve => { setTimeout(resolve, t) }) 4 | 5 | describe('middlewareCompose', () => { 6 | test('should have correct order', async () => { 7 | const actual = [] 8 | 9 | await sut.middlewareCompose([ 10 | async (ctx, next) => { 11 | actual.push(1) 12 | await sleep(1) 13 | await next() 14 | await sleep(1) 15 | actual.push(6) 16 | }, 17 | async (ctx, next) => { 18 | actual.push(2) 19 | await sleep(1) 20 | await next() 21 | await sleep(1) 22 | actual.push(5) 23 | }, 24 | async (ctx, next) => { 25 | actual.push(3) 26 | await sleep(1) 27 | await next() 28 | await sleep(1) 29 | actual.push(4) 30 | }, 31 | ])({}) 32 | 33 | expect(actual).toEqual([1, 2, 3, 4, 5, 6]) 34 | }) 35 | 36 | test('should be able to called twice', async () => { 37 | const actual1 = { arr: [] } 38 | const actual2 = { arr: [] } 39 | const expected = [1, 2, 3, 4, 5, 6] 40 | 41 | const handler = sut.middlewareCompose([ 42 | async (ctx, next) => { 43 | ctx.arr.push(1) 44 | await sleep(1) 45 | await next() 46 | await sleep(1) 47 | ctx.arr.push(6) 48 | }, 49 | async (ctx, next) => { 50 | ctx.arr.push(2) 51 | await sleep(1) 52 | await next() 53 | await sleep(1) 54 | ctx.arr.push(5) 55 | }, 56 | async (ctx, next) => { 57 | ctx.arr.push(3) 58 | await sleep(1) 59 | await next() 60 | await sleep(1) 61 | ctx.arr.push(4) 62 | }, 63 | ]) 64 | await Promise.all([ 65 | handler(actual1), 66 | handler(actual2), 67 | ]) 68 | 69 | expect(actual1.arr).toEqual(expected) 70 | expect(actual2.arr).toEqual(expected) 71 | }) 72 | 73 | test('should only accept an array', async () => { 74 | expect.hasAssertions() 75 | try { 76 | await sut.middlewareCompose() 77 | } catch (err) { 78 | expect(err).toBeInstanceOf(TypeError) 79 | } 80 | }) 81 | 82 | test('should work with 0 middleware', async () => { 83 | expect.hasAssertions() 84 | await sut.middlewareCompose([])({}) 85 | expect().toBeUndefined() 86 | }) 87 | 88 | test('should only accept an array of functions', async () => { 89 | expect.hasAssertions() 90 | try { 91 | await sut.middlewareCompose([{}]) 92 | } catch (err) { 93 | expect(err).toBeInstanceOf(TypeError) 94 | } 95 | }) 96 | 97 | test('should execute after next() is called', async () => { 98 | expect.hasAssertions() 99 | await sut.middlewareCompose([ 100 | async (ctx, next) => { 101 | await next() 102 | expect().toBeUndefined() 103 | }, 104 | ])({}) 105 | }) 106 | 107 | test('should reject when middleware throw error', async () => { 108 | expect.hasAssertions() 109 | try { 110 | await sut.middlewareCompose([ 111 | async (ctx, next) => { 112 | throw new Error() 113 | }, 114 | ])({}) 115 | } catch (err) { 116 | expect(err).toBeInstanceOf(Error) 117 | } 118 | }) 119 | 120 | test('should share the same context', async () => { 121 | const actual = {} 122 | await sut.middlewareCompose([ 123 | async (ctx, next) => { 124 | await next() 125 | expect(ctx).toBe(actual) 126 | }, 127 | async (ctx, next) => { 128 | await next() 129 | expect(ctx).toBe(actual) 130 | }, 131 | async (ctx, next) => { 132 | await next() 133 | expect(ctx).toBe(actual) 134 | }, 135 | ])(actual) 136 | }) 137 | 138 | test('should catch error throwed in next()', async () => { 139 | expect.hasAssertions() 140 | const actual = [] 141 | 142 | await sut.middlewareCompose([ 143 | async (ctx, next) => { 144 | actual.push(1) 145 | try { 146 | actual.push(2) 147 | await next() 148 | actual.push(6) 149 | } catch (err) { 150 | actual.push(4) 151 | expect(err).toBeInstanceOf(Error) 152 | } 153 | actual.push(5) 154 | }, 155 | async (ctx, next) => { 156 | actual.push(3) 157 | throw new Error() 158 | }, 159 | ])({}) 160 | 161 | expect(actual).toEqual([1, 2, 3, 4, 5]) 162 | }) 163 | 164 | test('should work with next', async () => { 165 | expect.hasAssertions() 166 | await sut.middlewareCompose([])({}, async () => { 167 | expect().toBeUndefined() 168 | }) 169 | }) 170 | 171 | test('should handle error throwed in non-async middleware', async () => { 172 | expect.hasAssertions() 173 | try { 174 | await sut.middlewareCompose([ 175 | () => { 176 | throw new Error() 177 | }, 178 | ])({}) 179 | } catch (err) { 180 | expect(err).toBeInstanceOf(Error) 181 | } 182 | }) 183 | 184 | test('should work with other compositions', async () => { 185 | const actual = [] 186 | 187 | await sut.middlewareCompose([ 188 | sut.middlewareCompose([ 189 | async (ctx, next) => { 190 | actual.push(1) 191 | await next() 192 | }, 193 | async (ctx, next) => { 194 | actual.push(2) 195 | await next() 196 | }, 197 | ]), 198 | async (ctx, next) => { 199 | actual.push(3) 200 | await next() 201 | }, 202 | ])({}) 203 | 204 | expect(actual).toEqual([1, 2, 3]) 205 | }) 206 | 207 | test('should throw error when next() called multiple times', async () => { 208 | expect.hasAssertions() 209 | try { 210 | await sut.middlewareCompose([ 211 | async (ctx, next) => { 212 | await next() 213 | await next() 214 | }, 215 | ])({}) 216 | } catch (err) { 217 | expect(err).toBeInstanceOf(Error) 218 | expect(err.message).toContain('called multiple times') 219 | } 220 | }) 221 | 222 | test('should not mutate original middleware array', async () => { 223 | const fn1 = (ctx, next) => next() 224 | const fns = [fn1] 225 | 226 | await sut.middlewareCompose(fns)({}) 227 | 228 | expect(fns).toEqual([fn1]) 229 | }) 230 | 231 | test('should share the same context in middleware and next()', async () => { 232 | const actual = { middleware: 0, next: 0 } 233 | 234 | await sut.middlewareCompose([ 235 | async (ctx, next) => { 236 | ctx.middleware++ 237 | await next() 238 | }, 239 | ])(actual, async (ctx, next) => { 240 | ctx.next++ 241 | await next() 242 | }) 243 | 244 | expect(actual).toEqual({ middleware: 1, next: 1 }) 245 | }) 246 | 247 | test('should throw error on non-await async middleware', async () => { 248 | expect.hasAssertions() 249 | const ctx1 = { flag: 0 } 250 | try { 251 | await sut.middlewareCompose([ 252 | async (ctx, next) => { 253 | next() 254 | }, 255 | async (ctx, next) => { 256 | await sleep(1) 257 | ctx1.flag = 1 258 | }, 259 | ])(ctx1) 260 | } catch (err) { 261 | expect(err.message).toContain('should be awaited') 262 | expect(ctx1.flag).toBe(0) 263 | } 264 | }) 265 | 266 | test('should have correct return value with next', async () => { 267 | const actual = await sut.middlewareCompose([ 268 | async (ctx, next) => { 269 | expect(await next()).toBe(2) 270 | return 1 271 | }, 272 | async (ctx, next) => { 273 | expect(await next()).toBe(0) 274 | return 2 275 | }, 276 | ])({}, () => 0) 277 | 278 | expect(actual).toBe(1) 279 | }) 280 | 281 | test('should have correct return value without next', async () => { 282 | const actual = await sut.middlewareCompose([ 283 | async (ctx, next) => { 284 | expect(await next()).toBe(2) 285 | return 1 286 | }, 287 | async (ctx, next) => { 288 | expect(await next()).toBeUndefined() 289 | return 2 290 | }, 291 | ])({}) 292 | 293 | expect(actual).toBe(1) 294 | }) 295 | }) 296 | --------------------------------------------------------------------------------