From 3af7d479fa9cdde1f24bfa886fe2a8a3e4226b63 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Wed, 15 Jan 2020 12:16:59 +0900 Subject: [PATCH 01/43] scrapbox: dummy commit for draft pull request From 72aec1570a862c2ce2ad038dc9e9bfe3b1d7fa2c Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Wed, 15 Jan 2020 15:32:45 +0900 Subject: [PATCH 02/43] scrapbox: implement mute of notifications --- scrapbox/index.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/scrapbox/index.ts b/scrapbox/index.ts index d9a8f86c..c755c06d 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -3,6 +3,7 @@ import axios from 'axios'; import logger from '../lib/logger.js'; import {LinkUnfurls} from '@slack/client'; import qs from 'querystring'; +import plugin from 'fastify-plugin'; const getScrapboxUrl = (pageName: string) => `https://scrapbox.io/api/pages/tsg/${pageName}`; @@ -73,3 +74,47 @@ export default async ({rtmClient: rtm, webClient: slack, eventClient: event}: Sl } }); }; + + +interface SlackAttachment { + // WARN: incomplete + title?: string; + title_link?: string; + text: string; + mrkdwn_in?: string[]; + author_name?: string[] +} + +interface SlackIncomingWebhookRequest { + text: string; + mrkdwn?: boolean; + username?: string; + attachments: SlackAttachment[] +} + +// eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax, padded-blocks +export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { + + /** + * Scrapboxからの更新通知 (Incoming Webhook形式) を受け取り,ミュート処理をしてSlackに投稿する + */ + + fastify.post('/scrapbox', async (req, res) => { + req.body; + await slack.chat.postMessage( + { + channel: process.env.CHANNEL_SCRAPBOX, + icon_emoji: ':scrapbox:', + ...req.body, + attachments: req.body.attachments.map( + (attachment) => + isMuted(attachment.title_link) ? maskAttachment(attachment) : attachment + ), + } + ); + return ''; + }); + + next(); +}); + From 6b48a209f766b82fe6cdbde70636f761e88935f0 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Wed, 15 Jan 2020 15:38:21 +0900 Subject: [PATCH 03/43] scrapbox: implement maskAttachment --- scrapbox/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scrapbox/index.ts b/scrapbox/index.ts index c755c06d..f6b474ac 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -92,6 +92,11 @@ interface SlackIncomingWebhookRequest { attachments: SlackAttachment[] } +const maskAttachment = (attachment: SlackAttachment) => ({ + ...attachment, + text: 'この記事の更新通知はミュートされています。', +}); + // eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax, padded-blocks export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { From 1bf5b2c834299ae2a1da863f3d27ea718ab7d05a Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Wed, 15 Jan 2020 15:39:22 +0900 Subject: [PATCH 04/43] scrapbox: type return value of maskAttachment --- scrapbox/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapbox/index.ts b/scrapbox/index.ts index f6b474ac..79ae6784 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -92,7 +92,7 @@ interface SlackIncomingWebhookRequest { attachments: SlackAttachment[] } -const maskAttachment = (attachment: SlackAttachment) => ({ +const maskAttachment = (attachment: SlackAttachment): SlackAttachment => ({ ...attachment, text: 'この記事の更新通知はミュートされています。', }); From c7dd4dda391fc6156305e21b838691c7e173abc5 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Wed, 15 Jan 2020 16:22:55 +0900 Subject: [PATCH 05/43] scrapbox: implement isMuted --- scrapbox/.eslintrc.json | 8 ++++- scrapbox/index.ts | 75 ++++++++++++++++++++++++----------------- 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/scrapbox/.eslintrc.json b/scrapbox/.eslintrc.json index e5638929..410321e9 100644 --- a/scrapbox/.eslintrc.json +++ b/scrapbox/.eslintrc.json @@ -3,6 +3,12 @@ "parser": "@typescript-eslint/parser", "rules": { "no-console": "off", - "camelcase": "off" + "camelcase": "off", + "valid-jsdoc": [ + "error", { + "requireParamType": false, + "requireReturnType": false + } + ] } } diff --git a/scrapbox/index.ts b/scrapbox/index.ts index 79ae6784..d177466b 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -5,9 +5,19 @@ import {LinkUnfurls} from '@slack/client'; import qs from 'querystring'; import plugin from 'fastify-plugin'; +const scrapboxUrlRegexp = /^https?:\/\/scrapbox.io\/tsg\/(.+)$/; const getScrapboxUrl = (pageName: string) => `https://scrapbox.io/api/pages/tsg/${pageName}`; +const getScrapboxUrlFromPageUrl = (url: string): string => { + let pageName = url.replace(scrapboxUrlRegexp, '$1'); + try { + if (decodeURI(pageName) === pageName) { + pageName = encodeURI(pageName); + } + } catch {} + return getScrapboxUrl(pageName); +}; -import {WebClient, RTMClient} from '@slack/client'; +import {WebClient, RTMClient, MessageAttachment} from '@slack/client'; interface SlackInterface { rtmClient: RTMClient, @@ -23,16 +33,10 @@ export default async ({rtmClient: rtm, webClient: slack, eventClient: event}: Sl const unfurls: LinkUnfurls = {}; for (const link of links) { const {url} = link; - if (!(/^https?:\/\/scrapbox.io\/tsg\/.+/).test(url)) { + if (!scrapboxUrlRegexp.test(url)) { continue; } - let pageName = url.replace(/^https?:\/\/scrapbox.io\/tsg\/(.+)$/, '$1'); - try { - if (decodeURI(pageName) === pageName) { - pageName = encodeURI(pageName); - } - } catch {} - const scrapboxUrl = getScrapboxUrl(pageName); + const scrapboxUrl = getScrapboxUrlFromPageUrl(url); const response = await axios.get(scrapboxUrl, {headers: {Cookie: `connect.sid=${process.env.SCRAPBOX_SID}`}}); const {data} = response; @@ -76,34 +80,46 @@ export default async ({rtmClient: rtm, webClient: slack, eventClient: event}: Sl }; -interface SlackAttachment { - // WARN: incomplete - title?: string; - title_link?: string; - text: string; - mrkdwn_in?: string[]; - author_name?: string[] -} - interface SlackIncomingWebhookRequest { text: string; mrkdwn?: boolean; username?: string; - attachments: SlackAttachment[] + attachments: MessageAttachment[] } -const maskAttachment = (attachment: SlackAttachment): SlackAttachment => ({ +/** + * ミュートしたいattachmentに対し,隠したい情報を消して返す + * + * @param attachment ミュートするattachment + * @return ミュート済みのattachment + */ +const maskAttachment = (attachment: MessageAttachment): MessageAttachment => ({ ...attachment, text: 'この記事の更新通知はミュートされています。', }); -// eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax, padded-blocks -export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { - - /** - * Scrapboxからの更新通知 (Incoming Webhook形式) を受け取り,ミュート処理をしてSlackに投稿する - */ +/** + * 指定したURLの記事がミュート対象かどうかを判定する + * + * @param url Scrapbox記事のURL + * @return ミュート対象ならtrue, 対象外ならfalse + */ +const isMuted = async (url: string): Promise => { + if (!scrapboxUrlRegexp.test(url)) { + // this url is not a scrapbox page + return false; + } + const muteTag = '##ミュート'; + const infoUrl = getScrapboxUrlFromPageUrl(url); + const pageInfo = await axios.get(infoUrl, {headers: {Cookie: `connect.sid=${process.env.SCRAPBOX_SID}`}}); + return pageInfo.data.links.indexOf(muteTag) !== -1; // if found, the page is muted +}; +/** + * Scrapboxからの更新通知 (Incoming Webhook形式) を受け取り,ミュート処理をしてSlackに投稿する + */ +// eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax +export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { fastify.post('/scrapbox', async (req, res) => { req.body; await slack.chat.postMessage( @@ -111,10 +127,9 @@ export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, o channel: process.env.CHANNEL_SCRAPBOX, icon_emoji: ':scrapbox:', ...req.body, - attachments: req.body.attachments.map( - (attachment) => - isMuted(attachment.title_link) ? maskAttachment(attachment) : attachment - ), + attachments: await Promise.all(req.body.attachments.map( + async (attachment) => await isMuted(attachment.title_link) ? maskAttachment(attachment) : attachment + )), } ); return ''; From 8a2141e5e3d93210ef0ed2bd8b56bf415796235f Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Wed, 15 Jan 2020 16:43:28 +0900 Subject: [PATCH 06/43] scrapbox: don't show images --- scrapbox/index.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/scrapbox/index.ts b/scrapbox/index.ts index d177466b..32cd6468 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -84,7 +84,7 @@ interface SlackIncomingWebhookRequest { text: string; mrkdwn?: boolean; username?: string; - attachments: MessageAttachment[] + attachments: MessageAttachment[]; } /** @@ -93,10 +93,16 @@ interface SlackIncomingWebhookRequest { * @param attachment ミュートするattachment * @return ミュート済みのattachment */ -const maskAttachment = (attachment: MessageAttachment): MessageAttachment => ({ - ...attachment, - text: 'この記事の更新通知はミュートされています。', -}); +const maskAttachment = (attachment: MessageAttachment): MessageAttachment => { + const dummyText = 'この記事の更新通知はミュートされています。'; + return { + ...attachment, + text: dummyText, + fallback: dummyText, + image_url: null, + thumb_url: null, + }; +}; /** * 指定したURLの記事がミュート対象かどうかを判定する From 5ca1d05de1cc058ec0ec12b7c74266d16e6b827b Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Wed, 15 Jan 2020 18:27:29 +0900 Subject: [PATCH 07/43] scrapbox: [WIP] add test for mute --- scrapbox/index.test.ts | 80 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index ef0c0760..72548cfa 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -1,23 +1,28 @@ jest.mock('axios'); -import scrapbox from './index'; +import scrapbox from './index.ts'; +import {server} from './index.ts'; // @ts-ignore import Slack from '../lib/slackMock.js'; import axios from 'axios'; import qs from 'querystring'; +import fastifyConstructor from 'fastify'; +import {MessageAttachment} from '@slack/client'; + // @ts-ignore axios.response = {data: {title: 'hoge', descriptions: ['fuga', 'piyo']}}; let slack: Slack = null; -beforeEach(async () => { - slack = new Slack(); - process.env.CHANNEL_SANDBOX = slack.fakeChannel; - await scrapbox(slack); -}); describe('scrapbox', () => { + beforeEach(async () => { + slack = new Slack(); + process.env.CHANNEL_SANDBOX = slack.fakeChannel; + await scrapbox(slack); + }); + it('respond to slack hook of scrapbox unfurling', async () => { const done = new Promise((resolve) => { // @ts-ignore @@ -52,3 +57,66 @@ describe('scrapbox', () => { return done; }); }); + +describe('scrapbox', () => { + it('mutes pages with ##ミュート tag', () => new Promise((resolve) => { + slack = new Slack(); + process.env.CHANNEL_SCRAPBOX = slack.fakeChannel; + const fastify = fastifyConstructor(); + fastify.register(server(slack)); + const attachments_req = [ + { + title: 'page 1', + title_link: 'https://scrapbox.io/tsg/page_1#c632c886dc3061e3b85cabbd', + text: 'hoge', + rawText: 'hoge', + mrkdwn_in: ['text'], + author_name: 'Alice', + image_url: 'https://example.com/hoge1.png', + thumb_url: 'https://example.com/fuga1.png', + }, + { + title: 'page 2', + title_link: 'https://scrapbox.io/tsg/page_2#aaf8924806eb538413c07c43', + text: 'hoge', + rawText: 'hoge', + mrkdwn_in: ['text'], + author_name: 'Bob', + image_url: 'https://example.com/hoge2.png', + thumb_url: 'https://example.com/fuga2.png', + }, + ]; + + slack.on('message', ({channel, text, attachments: attachments_res}: {channel: string; text: string; attachments: MessageAttachment[]}) => { + expect(channel).toBe(slack.fakeChannel); + expect(text).toBeInstanceOf('string'); + const unchanged = ['title', 'title_link', 'mrkdwn_in', 'author_name'] as const; + for (const i of [0, 1]) { + for (const key of unchanged) { + expect(attachments_res[i][key]).toBe(attachments_req[i][key]) + } + } + const nulled = ['image_url', 'thumb_url'] as const; + for (const key of nulled) { + expect(attachments_res[0][key]).toBeNull(); + expect(attachments_res[1][key]).toBe(attachments_req[1][key]); + } + expect(attachments_res[0].text).toContain('ミュート'); + expect(attachments_res[1].text).toBe(attachments_req[1].text); + resolve(); + }); + + // TODO: mock axios + + fastify.inject({ + method: 'POST', + url: '/scrapbox', + payload: { + text: 'New lines on ', + mrkdwn: true, + username: 'Scrapbox', + attachments_req, + }, + }); + })); +}); From b6c6f6bdf88ae16b4a4021d437546bb31830c5be Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Sat, 18 Jan 2020 00:35:17 +0900 Subject: [PATCH 08/43] bcr: [Test Not Passing] mock axios --- scrapbox/index.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 72548cfa..0e2440fd 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -1,7 +1,7 @@ jest.mock('axios'); -import scrapbox from './index.ts'; -import {server} from './index.ts'; +import scrapbox from './index'; +import {server} from './index'; // @ts-ignore import Slack from '../lib/slackMock.js'; import axios from 'axios'; @@ -86,6 +86,16 @@ describe('scrapbox', () => { thumb_url: 'https://example.com/fuga2.png', }, ]; + // @ts-ignore + axios.mockImplementation(({url}: {url: string}) => { + console.log(url); // FIXME + if (url.match(/https:\/\/scrapbox.io\/api\/pages\/tsg\/page_1(?:#.*)?/)) { + return {data: {title: 'page 1', links: ['page 3', '###ミュート']}}; + } else if (url.match(/https:\/\/scrapbox.io\/api\/pages\/tsg\/page_1(?:#.*)?/)) { + return {data: {title: 'page 2', links: ['page 4']}}; + } + throw Error('axios-mock: unexpected URL'); + }); slack.on('message', ({channel, text, attachments: attachments_res}: {channel: string; text: string; attachments: MessageAttachment[]}) => { expect(channel).toBe(slack.fakeChannel); @@ -106,8 +116,6 @@ describe('scrapbox', () => { resolve(); }); - // TODO: mock axios - fastify.inject({ method: 'POST', url: '/scrapbox', From 4b93a9ae1c285b4f3446d7ecf9eb443f8fcd754b Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Sun, 19 Jan 2020 09:59:30 +0900 Subject: [PATCH 09/43] scrapbox: fix tests --- scrapbox/index.test.ts | 79 +++++++++++++++++++++++------------------- scrapbox/index.ts | 8 ++--- 2 files changed, 46 insertions(+), 41 deletions(-) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 0e2440fd..584cfb8c 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -59,12 +59,12 @@ describe('scrapbox', () => { }); describe('scrapbox', () => { - it('mutes pages with ##ミュート tag', () => new Promise((resolve) => { - slack = new Slack(); - process.env.CHANNEL_SCRAPBOX = slack.fakeChannel; + it('mutes pages with ##ミュート tag', async () => { + const fakeChannel = 'CSCRAPBOX'; + process.env.CHANNEL_SCRAPBOX = fakeChannel; const fastify = fastifyConstructor(); - fastify.register(server(slack)); - const attachments_req = [ + // eslint-disable-next-line array-plural/array-plural + const attachments_req: (MessageAttachment & any)[] = [ { title: 'page 1', title_link: 'https://scrapbox.io/tsg/page_1#c632c886dc3061e3b85cabbd', @@ -87,44 +87,51 @@ describe('scrapbox', () => { }, ]; // @ts-ignore - axios.mockImplementation(({url}: {url: string}) => { - console.log(url); // FIXME + axios.get.mockImplementation((url: string) => { if (url.match(/https:\/\/scrapbox.io\/api\/pages\/tsg\/page_1(?:#.*)?/)) { - return {data: {title: 'page 1', links: ['page 3', '###ミュート']}}; - } else if (url.match(/https:\/\/scrapbox.io\/api\/pages\/tsg\/page_1(?:#.*)?/)) { + return {data: {title: 'page 1', links: ['page 3', '##ミュート']}}; + } else if (url.match(/https:\/\/scrapbox.io\/api\/pages\/tsg\/page_2(?:#.*)?/)) { return {data: {title: 'page 2', links: ['page 4']}}; } throw Error('axios-mock: unexpected URL'); }); - slack.on('message', ({channel, text, attachments: attachments_res}: {channel: string; text: string; attachments: MessageAttachment[]}) => { - expect(channel).toBe(slack.fakeChannel); - expect(text).toBeInstanceOf('string'); - const unchanged = ['title', 'title_link', 'mrkdwn_in', 'author_name'] as const; - for (const i of [0, 1]) { - for (const key of unchanged) { - expect(attachments_res[i][key]).toBe(attachments_req[i][key]) - } - } - const nulled = ['image_url', 'thumb_url'] as const; - for (const key of nulled) { - expect(attachments_res[0][key]).toBeNull(); - expect(attachments_res[1][key]).toBe(attachments_req[1][key]); - } - expect(attachments_res[0].text).toContain('ミュート'); - expect(attachments_res[1].text).toBe(attachments_req[1].text); - resolve(); - }); - - fastify.inject({ + slack = {chat: { + postMessage: jest.fn(), + }}; + fastify.register(server({webClient: slack} as any)); + const args = { + text: 'New lines on ', + mrkdwn: true, + username: 'Scrapbox', + attachments: attachments_req, + }; + const {payload, statusCode} = await fastify.inject({ method: 'POST', url: '/scrapbox', - payload: { - text: 'New lines on ', - mrkdwn: true, - username: 'Scrapbox', - attachments_req, - }, + payload: args, }); - })); + if (statusCode !== 200) { + throw JSON.parse(payload); + } + expect(slack.chat.postMessage.mock.calls.length).toBe(1); + const {channel, text, attachments: attachments_res}: {channel: string; text: string; attachments: MessageAttachment[]} = slack.chat.postMessage.mock.calls[0][0]; + expect(channel).toBe(fakeChannel); + expect(text).toBe(args.text); + const unchanged = ['title', 'title_link', 'mrkdwn_in', 'author_name'] as const; + for (const i of [0, 1]) { + for (const key of unchanged) { + expect(attachments_res[i][key]).toEqual(attachments_req[i][key]); + } + } + const nulled = ['image_url', 'thumb_url'] as const; + for (const key of nulled) { + expect(attachments_res[0][key]).toBeNull(); + // eslint-disable-next-line array-plural/array-plural + expect(attachments_res[1][key]).toEqual(attachments_req[1][key]); + } + expect(attachments_res[0].text).toContain('ミュート'); + // eslint-disable-next-line array-plural/array-plural + expect(attachments_res[1].text).toBe(attachments_req[1].text); + }); }); diff --git a/scrapbox/index.ts b/scrapbox/index.ts index 32cd6468..63eaf2c6 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -1,11 +1,11 @@ import axios from 'axios'; // @ts-ignore import logger from '../lib/logger.js'; -import {LinkUnfurls} from '@slack/client'; import qs from 'querystring'; import plugin from 'fastify-plugin'; +import {WebClient, RTMClient, LinkUnfurls, MessageAttachment} from '@slack/client'; -const scrapboxUrlRegexp = /^https?:\/\/scrapbox.io\/tsg\/(.+)$/; +const scrapboxUrlRegexp = /^https?:\/\/scrapbox.io\/tsg\/(?:.+)$/; const getScrapboxUrl = (pageName: string) => `https://scrapbox.io/api/pages/tsg/${pageName}`; const getScrapboxUrlFromPageUrl = (url: string): string => { let pageName = url.replace(scrapboxUrlRegexp, '$1'); @@ -17,7 +17,6 @@ const getScrapboxUrlFromPageUrl = (url: string): string => { return getScrapboxUrl(pageName); }; -import {WebClient, RTMClient, MessageAttachment} from '@slack/client'; interface SlackInterface { rtmClient: RTMClient, @@ -126,8 +125,7 @@ const isMuted = async (url: string): Promise => { */ // eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { - fastify.post('/scrapbox', async (req, res) => { - req.body; + fastify.post('/scrapbox', async (req) => { await slack.chat.postMessage( { channel: process.env.CHANNEL_SCRAPBOX, From e5a0d0eb6bfa97b7b866530098309f6826f978be Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Sun, 19 Jan 2020 10:06:20 +0900 Subject: [PATCH 10/43] scrapbox: fix tests --- scrapbox/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scrapbox/index.ts b/scrapbox/index.ts index 63eaf2c6..a546f28f 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -5,10 +5,10 @@ import qs from 'querystring'; import plugin from 'fastify-plugin'; import {WebClient, RTMClient, LinkUnfurls, MessageAttachment} from '@slack/client'; -const scrapboxUrlRegexp = /^https?:\/\/scrapbox.io\/tsg\/(?:.+)$/; +const scrapboxUrlRegexp = /^https?:\/\/scrapbox.io\/tsg\/(?.+)$/; const getScrapboxUrl = (pageName: string) => `https://scrapbox.io/api/pages/tsg/${pageName}`; const getScrapboxUrlFromPageUrl = (url: string): string => { - let pageName = url.replace(scrapboxUrlRegexp, '$1'); + let pageName = url.replace(scrapboxUrlRegexp, '$'); try { if (decodeURI(pageName) === pageName) { pageName = encodeURI(pageName); From 14441203b9341548342e000ec8eaeefec350fec4 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Sun, 19 Jan 2020 13:34:41 +0900 Subject: [PATCH 11/43] scrapbox: update .env.sample --- .env.example | 2 ++ scrapbox/index.test.ts | 23 +++++++++++++---------- scrapbox/index.ts | 6 ++++-- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 05893d58..4a592944 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ SWARM_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX CHANNEL_SANDBOX=CXXXXXXXX CHANNEL_ESOLANG=CXXXXXXXX CHANNEL_PROCON=CXXXXXXXX +CHANNEL_SCRAPBOX=CXXXXXXXX CHANNEL_RANDOM=CXXXXXXXX USER_TSGBOT=UXXXXXXXX GITHUB_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx @@ -14,6 +15,7 @@ GOOGLE_APPLICATION_CREDENTIALS=google_application_credentials.json FIREBASE_ENDPOINT=https://hakata-shi.firebaseio.com ACCUWEATHER_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx SCRAPBOX_SID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +SCRAPBOX_PROJECT_NAME=xxxxxxxxx GITHUB_WEBHOOK_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx SIGNING_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx PORT=21864 diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 584cfb8c..5d9381a5 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -1,11 +1,10 @@ jest.mock('axios'); -import scrapbox from './index'; -import {server} from './index'; // @ts-ignore import Slack from '../lib/slackMock.js'; import axios from 'axios'; import qs from 'querystring'; +import { escapeRegExp } from 'lodash'; import fastifyConstructor from 'fastify'; import {MessageAttachment} from '@slack/client'; @@ -15,6 +14,10 @@ axios.response = {data: {title: 'hoge', descriptions: ['fuga', 'piyo']}}; let slack: Slack = null; +const projectName = 'PROJECTNAME'; +process.env.SCRAPBOX_PROJECT_NAME = projectName; +import scrapbox from './index'; +import {server} from './index'; describe('scrapbox', () => { beforeEach(async () => { @@ -30,8 +33,8 @@ describe('scrapbox', () => { if (url === 'https://slack.com/api/chat.unfurl') { const parsed = qs.parse(data); const unfurls = JSON.parse(Array.isArray(parsed.unfurls) ? parsed.unfurls[0] : parsed.unfurls); - expect(unfurls['https://scrapbox.io/tsg/hoge']).toBeTruthy(); - expect(unfurls['https://scrapbox.io/tsg/hoge'].text).toBe('fuga\npiyo'); + expect(unfurls[`https://scrapbox.io/${projectName}/hoge`]).toBeTruthy(); + expect(unfurls[`https://scrapbox.io/${projectName}/hoge`].text).toBe('fuga\npiyo'); resolve(); return Promise.resolve({data: {ok: true}}); } @@ -49,7 +52,7 @@ describe('scrapbox', () => { links: [ { domain: 'scrapbox.io', - url: 'https://scrapbox.io/tsg/hoge', + url: `https://scrapbox.io/${projectName}/hoge`, }, ], }); @@ -67,7 +70,7 @@ describe('scrapbox', () => { const attachments_req: (MessageAttachment & any)[] = [ { title: 'page 1', - title_link: 'https://scrapbox.io/tsg/page_1#c632c886dc3061e3b85cabbd', + title_link: `https://scrapbox.io/${projectName}/page_1#c632c886dc3061e3b85cabbd`, text: 'hoge', rawText: 'hoge', mrkdwn_in: ['text'], @@ -77,7 +80,7 @@ describe('scrapbox', () => { }, { title: 'page 2', - title_link: 'https://scrapbox.io/tsg/page_2#aaf8924806eb538413c07c43', + title_link: `https://scrapbox.io/${projectName}/page_2#aaf8924806eb538413c07c43`, text: 'hoge', rawText: 'hoge', mrkdwn_in: ['text'], @@ -88,9 +91,9 @@ describe('scrapbox', () => { ]; // @ts-ignore axios.get.mockImplementation((url: string) => { - if (url.match(/https:\/\/scrapbox.io\/api\/pages\/tsg\/page_1(?:#.*)?/)) { + if (url.match(new RegExp(`${escapeRegExp(`https://scrapbox.io/api/pages/${projectName}/page_1`)}(?:#.*)?`))) { return {data: {title: 'page 1', links: ['page 3', '##ミュート']}}; - } else if (url.match(/https:\/\/scrapbox.io\/api\/pages\/tsg\/page_2(?:#.*)?/)) { + } else if (url.match(new RegExp(`${escapeRegExp(`https://scrapbox.io/api/pages/${projectName}/page_2`)}(?:#.*)?`))) { return {data: {title: 'page 2', links: ['page 4']}}; } throw Error('axios-mock: unexpected URL'); @@ -101,7 +104,7 @@ describe('scrapbox', () => { }}; fastify.register(server({webClient: slack} as any)); const args = { - text: 'New lines on ', + text: `New lines on `, mrkdwn: true, username: 'Scrapbox', attachments: attachments_req, diff --git a/scrapbox/index.ts b/scrapbox/index.ts index a546f28f..42a635ad 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -3,10 +3,12 @@ import axios from 'axios'; import logger from '../lib/logger.js'; import qs from 'querystring'; import plugin from 'fastify-plugin'; +import { escapeRegExp } from 'lodash'; import {WebClient, RTMClient, LinkUnfurls, MessageAttachment} from '@slack/client'; -const scrapboxUrlRegexp = /^https?:\/\/scrapbox.io\/tsg\/(?.+)$/; -const getScrapboxUrl = (pageName: string) => `https://scrapbox.io/api/pages/tsg/${pageName}`; +const projectName = process.env.SCRAPBOX_PROJECT_NAME; +const scrapboxUrlRegexp = new RegExp(`^https?${escapeRegExp(`://scrapbox.io/${projectName}/`)}(?.+)$`); +const getScrapboxUrl = (pageName: string) => `https://scrapbox.io/api/pages/${projectName}/${pageName}`; const getScrapboxUrlFromPageUrl = (url: string): string => { let pageName = url.replace(scrapboxUrlRegexp, '$'); try { From 3c092fdb9f129b26f06fcbeb6d7109ce40066176 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Tue, 28 Jan 2020 00:24:04 +0900 Subject: [PATCH 12/43] scrapbox: use (decode|encode)URIComponent --- scrapbox/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scrapbox/index.ts b/scrapbox/index.ts index 42a635ad..936bcab2 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -12,8 +12,8 @@ const getScrapboxUrl = (pageName: string) => `https://scrapbox.io/api/pages/${pr const getScrapboxUrlFromPageUrl = (url: string): string => { let pageName = url.replace(scrapboxUrlRegexp, '$'); try { - if (decodeURI(pageName) === pageName) { - pageName = encodeURI(pageName); + if (decodeURIComponent(pageName) === pageName) { + pageName = encodeURIComponent(pageName); } } catch {} return getScrapboxUrl(pageName); From 2528f130976eb60c01187d3f6823d0cee1ff5abc Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Tue, 28 Jan 2020 00:24:58 +0900 Subject: [PATCH 13/43] scrapbox: change URL of webhook --- scrapbox/index.test.ts | 2 +- scrapbox/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 5d9381a5..4e7cbf3a 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -111,7 +111,7 @@ describe('scrapbox', () => { }; const {payload, statusCode} = await fastify.inject({ method: 'POST', - url: '/scrapbox', + url: '/hooks/scrapbox', payload: args, }); if (statusCode !== 200) { diff --git a/scrapbox/index.ts b/scrapbox/index.ts index 936bcab2..7d81162a 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -127,7 +127,7 @@ const isMuted = async (url: string): Promise => { */ // eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { - fastify.post('/scrapbox', async (req) => { + fastify.post('/hooks/scrapbox', async (req) => { await slack.chat.postMessage( { channel: process.env.CHANNEL_SCRAPBOX, From 13895cf80fc01c8ea46a3778c03712ac0578922b Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Tue, 28 Jan 2020 00:48:21 +0900 Subject: [PATCH 14/43] scrapbox: handle errors --- scrapbox/index.test.ts | 1 + scrapbox/index.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 4e7cbf3a..75080a9a 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -65,6 +65,7 @@ describe('scrapbox', () => { it('mutes pages with ##ミュート tag', async () => { const fakeChannel = 'CSCRAPBOX'; process.env.CHANNEL_SCRAPBOX = fakeChannel; + process.env.NODE_ENV = 'development'; const fastify = fastifyConstructor(); // eslint-disable-next-line array-plural/array-plural const attachments_req: (MessageAttachment & any)[] = [ diff --git a/scrapbox/index.ts b/scrapbox/index.ts index 7d81162a..7668d5a7 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -127,6 +127,14 @@ const isMuted = async (url: string): Promise => { */ // eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { + fastify.setErrorHandler((err) => { + if (process.env.NODE_ENV === 'development') { + // debug + throw err; + } else { + logger.error(err.stack); + } + }); fastify.post('/hooks/scrapbox', async (req) => { await slack.chat.postMessage( { From 5ee02785ed8d09023f5f0c18fbc15d24a95cf8e1 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Sun, 23 Feb 2020 07:45:56 +0900 Subject: [PATCH 15/43] lib/fastify: add constructor of fastify for unit test --- lib/fastify.ts | 34 ++++++++++++++++++++++++++++++++++ scrapbox/index.test.ts | 5 ++--- scrapbox/index.ts | 8 -------- 3 files changed, 36 insertions(+), 11 deletions(-) create mode 100644 lib/fastify.ts diff --git a/lib/fastify.ts b/lib/fastify.ts new file mode 100644 index 00000000..6c94e1b8 --- /dev/null +++ b/lib/fastify.ts @@ -0,0 +1,34 @@ +import fastifyConstructor, { FastifyInstance } from 'fastify'; + +/** + * 単体テストに適した設定がなされたfastifyインスタンスを生成する + * + * @param opts fastifyConstructor に渡す引数 + * @example + * import slack from '../lib/slackMock.js'; + * import {fastifyDevConstructor} from '../lib/fastify'; + * import {server} from './index'; + * + * const fastify = fastifyDevConstructor(); + * fastify.register(server(slack)); + */ + +export const fastifyDevConstructor = (opts?: Parameters[0]): FastifyInstance => { + // TODO: support generics of fastifyConstructor + /* + * Setting the return type to ReturnType causes typeerror + * because type Server is not compatible with type Http2SecureServer + * Maybe because of not handling the generics of fastifyConstructor + */ + + const fastify = fastifyConstructor(Object.assign({}, { logger: true }, opts)); + + /** + * デフォルトのエラーハンドラはエラーをログに出力して握り潰すため, + * 単体テストでexpectの失敗などによる例外をJestが検知することができない + * 発生した例外は全て再送出するように設定 + */ + fastify.setErrorHandler((err) => { throw err; }); + + return fastify; +}; diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 75080a9a..a50b83ca 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -5,7 +5,7 @@ import Slack from '../lib/slackMock.js'; import axios from 'axios'; import qs from 'querystring'; import { escapeRegExp } from 'lodash'; -import fastifyConstructor from 'fastify'; +import { fastifyDevConstructor } from '../lib/fastify'; import {MessageAttachment} from '@slack/client'; @@ -65,8 +65,7 @@ describe('scrapbox', () => { it('mutes pages with ##ミュート tag', async () => { const fakeChannel = 'CSCRAPBOX'; process.env.CHANNEL_SCRAPBOX = fakeChannel; - process.env.NODE_ENV = 'development'; - const fastify = fastifyConstructor(); + const fastify = fastifyDevConstructor(); // eslint-disable-next-line array-plural/array-plural const attachments_req: (MessageAttachment & any)[] = [ { diff --git a/scrapbox/index.ts b/scrapbox/index.ts index 7668d5a7..7d81162a 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -127,14 +127,6 @@ const isMuted = async (url: string): Promise => { */ // eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { - fastify.setErrorHandler((err) => { - if (process.env.NODE_ENV === 'development') { - // debug - throw err; - } else { - logger.error(err.stack); - } - }); fastify.post('/hooks/scrapbox', async (req) => { await slack.chat.postMessage( { From fbe0c7e6fa749cf3b3e67a9ffb6c088ec8f3f39e Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Sun, 23 Feb 2020 11:07:34 +0900 Subject: [PATCH 16/43] scrapbox: 1 API request per notification --- scrapbox/index.test.ts | 10 ++++------ scrapbox/index.ts | 34 ++++++++++++++++------------------ 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index a50b83ca..b6f823bd 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -4,7 +4,7 @@ jest.mock('axios'); import Slack from '../lib/slackMock.js'; import axios from 'axios'; import qs from 'querystring'; -import { escapeRegExp } from 'lodash'; +import { escapeRegExp, set } from 'lodash'; import { fastifyDevConstructor } from '../lib/fastify'; import {MessageAttachment} from '@slack/client'; @@ -17,7 +17,7 @@ let slack: Slack = null; const projectName = 'PROJECTNAME'; process.env.SCRAPBOX_PROJECT_NAME = projectName; import scrapbox from './index'; -import {server} from './index'; +import {server, muteTag} from './index'; describe('scrapbox', () => { beforeEach(async () => { @@ -91,10 +91,8 @@ describe('scrapbox', () => { ]; // @ts-ignore axios.get.mockImplementation((url: string) => { - if (url.match(new RegExp(`${escapeRegExp(`https://scrapbox.io/api/pages/${projectName}/page_1`)}(?:#.*)?`))) { - return {data: {title: 'page 1', links: ['page 3', '##ミュート']}}; - } else if (url.match(new RegExp(`${escapeRegExp(`https://scrapbox.io/api/pages/${projectName}/page_2`)}(?:#.*)?`))) { - return {data: {title: 'page 2', links: ['page 4']}}; + if (url === `https://scrapbox.io/api/pages/${projectName}/##ミュート`) { + return set({}, ['data', 'relatedPages', 'links1hop'], [{titleLc: 'page_1'}]); } throw Error('axios-mock: unexpected URL'); }); diff --git a/scrapbox/index.ts b/scrapbox/index.ts index 7d81162a..ee4e83fb 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -7,7 +7,14 @@ import { escapeRegExp } from 'lodash'; import {WebClient, RTMClient, LinkUnfurls, MessageAttachment} from '@slack/client'; const projectName = process.env.SCRAPBOX_PROJECT_NAME; -const scrapboxUrlRegexp = new RegExp(`^https?${escapeRegExp(`://scrapbox.io/${projectName}/`)}(?.+)$`); +const scrapboxUrlRegexp = new RegExp(`^https?${escapeRegExp(`://scrapbox.io/${projectName}/`)}(?.+?)(?:#.*)?$`); +const getTitleFromPageUrl = (url: string): string => { + let pageName = url.replace(scrapboxUrlRegexp, '$'); + try { + pageName = decodeURIComponent(pageName); + } catch {} + return pageName; +}; const getScrapboxUrl = (pageName: string) => `https://scrapbox.io/api/pages/${projectName}/${pageName}`; const getScrapboxUrlFromPageUrl = (url: string): string => { let pageName = url.replace(scrapboxUrlRegexp, '$'); @@ -105,21 +112,11 @@ const maskAttachment = (attachment: MessageAttachment): MessageAttachment => { }; }; -/** - * 指定したURLの記事がミュート対象かどうかを判定する - * - * @param url Scrapbox記事のURL - * @return ミュート対象ならtrue, 対象外ならfalse - */ -const isMuted = async (url: string): Promise => { - if (!scrapboxUrlRegexp.test(url)) { - // this url is not a scrapbox page - return false; - } - const muteTag = '##ミュート'; - const infoUrl = getScrapboxUrlFromPageUrl(url); - const pageInfo = await axios.get(infoUrl, {headers: {Cookie: `connect.sid=${process.env.SCRAPBOX_SID}`}}); - return pageInfo.data.links.indexOf(muteTag) !== -1; // if found, the page is muted +export const muteTag = '##ミュート'; +const getMutedList = async (): Promise> => { + const muteTagUrl = getScrapboxUrl(muteTag); + const pageInfo = await axios.get(muteTagUrl, {headers: {Cookie: `connect.sid=${process.env.SCRAPBOX_SID}`}}); + return new Set(pageInfo.data.relatedPages.links1hop.map(({titleLc}: {titleLc: string}) => titleLc)); }; /** @@ -128,15 +125,16 @@ const isMuted = async (url: string): Promise => { // eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { fastify.post('/hooks/scrapbox', async (req) => { + const mutedList = await getMutedList(); await slack.chat.postMessage( { channel: process.env.CHANNEL_SCRAPBOX, icon_emoji: ':scrapbox:', ...req.body, attachments: await Promise.all(req.body.attachments.map( - async (attachment) => await isMuted(attachment.title_link) ? maskAttachment(attachment) : attachment + async (attachment) => await mutedList.has(getTitleFromPageUrl(attachment.title_link)) ? maskAttachment(attachment) : attachment, )), - } + }, ); return ''; }); From b8d06ef60a58ad1af9886cf43e30f22027a72c44 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Mon, 24 Feb 2020 02:23:21 +0900 Subject: [PATCH 17/43] lib/fastify: add test for fastifyDevConstructor --- lib/fastify.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 lib/fastify.test.ts diff --git a/lib/fastify.test.ts b/lib/fastify.test.ts new file mode 100644 index 00000000..614633b9 --- /dev/null +++ b/lib/fastify.test.ts @@ -0,0 +1,24 @@ +import plugin from 'fastify-plugin'; +import { fastifyDevConstructor } from './fastify'; +import { FastifyInstance } from 'fastify'; + +describe('fastifyDevConstructor', () => { + it('throws error when error occures during request', () => { + const fastify: FastifyInstance = fastifyDevConstructor(); + const msg = 'Dummy error.'; + const path = '/path/to/somewhere'; + fastify.register(plugin((fastify, opts, next) => { + fastify.get(path, (req) => { + throw Error(msg); + }) + next(); + })) + expect( + fastify.inject({ + method: 'GET', + url: path, + payload: {something: 'hoge'}, + }) + ).rejects.toThrow(msg); + }) +}); From 9b6a7fea3297b6123e50b52f22a31fc3f9cb4428 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Mon, 24 Feb 2020 21:18:20 +0900 Subject: [PATCH 18/43] lib/scrapbox: add some functions --- lib/scrapbox.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 lib/scrapbox.ts diff --git a/lib/scrapbox.ts b/lib/scrapbox.ts new file mode 100644 index 00000000..99b4f63f --- /dev/null +++ b/lib/scrapbox.ts @@ -0,0 +1,38 @@ +import { escapeRegExp } from 'lodash'; + +export const tsgProjectName = process.env.SCRAPBOX_PROJECT_NAME; + +export const getPageUrlRegExp = ({ projectName=tsgProjectName }) => + new RegExp(`^https?${escapeRegExp(`://scrapbox.io/${projectName}/`)}(?.+?)(?#.*)?$`); + +export const getTitleFromPageUrl = ({ url, projectName=tsgProjectName, decode=undefined } : { url: string; projectName?: string; decode?: boolean }): string => { + let pageName = url.replace(getPageUrlRegExp({ projectName }), '$'); + if (decode !== false) { + try { + pageName = decodeURIComponent(pageName); + } catch (e) { + if (decode === true) throw e; + } + } + return pageName; +}; + +export const getInfoUrl = ({ pageName, projectName = tsgProjectName, isEncoded = undefined }: { pageName: string; projectName?: string; isEncoded?: boolean }) => { + if (isEncoded === undefined) { + isEncoded = false; + try { + if (decodeURIComponent(pageName) !== pageName) { + isEncoded = true; + } + } catch { + // pageName is not a valid encoded string + } + } + const encodedPageName = isEncoded ? encodeURIComponent(pageName) : pageName; + return `https://scrapbox.io/api/pages/${projectName}/${encodedPageName}`; +} + +export const getInfoUrlFromPageUrl = ({ url, projectName = tsgProjectName, isEncoded = undefined }: { url: string; projectName?: string, isEncoded?: boolean }): string => { + let pageName = getTitleFromPageUrl({ url, projectName, decode: false }); + return getInfoUrl({ pageName, projectName, isEncoded }); +}; From 70e3e866f61d5a5f4c60abc281b14999ab8b3b84 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Tue, 25 Feb 2020 11:35:29 +0900 Subject: [PATCH 19/43] add package eslint-plugin-jest --- package-lock.json | 568 ++++++++++++++++++++++++---------------- package.json | 1 + scrapbox/.eslintrc.json | 6 + 3 files changed, 347 insertions(+), 228 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9ed03fda..2fc8334c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,20 +14,21 @@ } }, "@babel/core": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.6.2.tgz", - "integrity": "sha512-l8zto/fuoZIbncm+01p8zPSDZu/VuuJhAfA7d/AbzM09WR7iVhavvfNDYCNpo1VvLk6E6xgAoP9P+/EMJHuRkQ==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.8.4.tgz", + "integrity": "sha512-0LiLrB2PwrVI+a2/IEskBopDYSd8BCb3rOvH7D5tzoWd696TBEduBvuLVm4Nx6rltrLZqvI3MCalB2K2aVzQjA==", "dev": true, "requires": { - "@babel/code-frame": "^7.5.5", - "@babel/generator": "^7.6.2", - "@babel/helpers": "^7.6.2", - "@babel/parser": "^7.6.2", - "@babel/template": "^7.6.0", - "@babel/traverse": "^7.6.2", - "@babel/types": "^7.6.0", - "convert-source-map": "^1.1.0", + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.8.4", + "@babel/helpers": "^7.8.4", + "@babel/parser": "^7.8.4", + "@babel/template": "^7.8.3", + "@babel/traverse": "^7.8.4", + "@babel/types": "^7.8.3", + "convert-source-map": "^1.7.0", "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", "json5": "^2.1.0", "lodash": "^4.17.13", "resolve": "^1.3.2", @@ -35,6 +36,26 @@ "source-map": "^0.5.0" }, "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.8.3.tgz", + "integrity": "sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==", + "dev": true, + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + } + }, "debug": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", @@ -53,61 +74,61 @@ } }, "@babel/generator": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.6.2.tgz", - "integrity": "sha512-j8iHaIW4gGPnViaIHI7e9t/Hl8qLjERI6DcV9kEpAIDJsAOrcnXqRS7t+QbhL76pwbtqP+QCQLL0z1CyVmtjjQ==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.8.4.tgz", + "integrity": "sha512-PwhclGdRpNAf3IxZb0YVuITPZmmrXz9zf6fH8lT4XbrmfQKr6ryBzhv593P5C6poJRciFCL/eHGW2NuGrgEyxA==", "dev": true, "requires": { - "@babel/types": "^7.6.0", + "@babel/types": "^7.8.3", "jsesc": "^2.5.1", "lodash": "^4.17.13", "source-map": "^0.5.0" } }, "@babel/helper-function-name": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz", - "integrity": "sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz", + "integrity": "sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==", "dev": true, "requires": { - "@babel/helper-get-function-arity": "^7.0.0", - "@babel/template": "^7.1.0", - "@babel/types": "^7.0.0" + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.8.3" } }, "@babel/helper-get-function-arity": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz", - "integrity": "sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", "dev": true, "requires": { - "@babel/types": "^7.0.0" + "@babel/types": "^7.8.3" } }, "@babel/helper-plugin-utils": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.0.0.tgz", - "integrity": "sha512-CYAOUCARwExnEixLdB6sDm2dIJ/YgEAKDM1MOeMeZu9Ld/bDgVo8aiWrXwcY7OBh+1Ea2uUcVRcxKk0GJvW7QA==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz", + "integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==", "dev": true }, "@babel/helper-split-export-declaration": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz", - "integrity": "sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", "dev": true, "requires": { - "@babel/types": "^7.4.4" + "@babel/types": "^7.8.3" } }, "@babel/helpers": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.6.2.tgz", - "integrity": "sha512-3/bAUL8zZxYs1cdX2ilEE0WobqbCmKWr/889lf2SS0PpDcpEIY8pb1CCyz0pEcX3pEb+MCbks1jIokz2xLtGTA==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.8.4.tgz", + "integrity": "sha512-VPbe7wcQ4chu4TDQjimHv/5tj73qz88o12EPkO2ValS2QiQS/1F2SsjyIGNnAD0vF/nZS6Cf9i+vW6HIlnaR8w==", "dev": true, "requires": { - "@babel/template": "^7.6.0", - "@babel/traverse": "^7.6.2", - "@babel/types": "^7.6.0" + "@babel/template": "^7.8.3", + "@babel/traverse": "^7.8.4", + "@babel/types": "^7.8.3" } }, "@babel/highlight": { @@ -122,48 +143,90 @@ } }, "@babel/parser": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.6.2.tgz", - "integrity": "sha512-mdFqWrSPCmikBoaBYMuBulzTIKuXVPtEISFbRRVNwMWpCms/hmE2kRq0bblUHaNRKrjRlmVbx1sDHmjmRgD2Xg==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.8.4.tgz", + "integrity": "sha512-0fKu/QqildpXmPVaRBoXOlyBb3MC+J0A66x97qEfLOMkn3u6nfY5esWogQwi/K0BjASYy4DbnsEWnpNL6qT5Mw==", "dev": true }, "@babel/plugin-syntax-object-rest-spread": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.2.0.tgz", - "integrity": "sha512-t0JKGgqk2We+9may3t0xDdmneaXmyxq0xieYcKHxIsrJO64n1OiMWNUtc5gQK1PA0NpdCRrtZp4z+IUaKugrSA==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.0.0" + "@babel/helper-plugin-utils": "^7.8.0" } }, "@babel/template": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.6.0.tgz", - "integrity": "sha512-5AEH2EXD8euCk446b7edmgFdub/qfH1SN6Nii3+fyXP807QRx9Q73A2N5hNwRRslC2H9sNzaFhsPubkS4L8oNQ==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.3.tgz", + "integrity": "sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ==", "dev": true, "requires": { - "@babel/code-frame": "^7.0.0", - "@babel/parser": "^7.6.0", - "@babel/types": "^7.6.0" + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.3", + "@babel/types": "^7.8.3" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.8.3.tgz", + "integrity": "sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==", + "dev": true, + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + } + } } }, "@babel/traverse": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.6.2.tgz", - "integrity": "sha512-8fRE76xNwNttVEF2TwxJDGBLWthUkHWSldmfuBzVRmEDWOtu4XdINTgN7TDWzuLg4bbeIMLvfMFD9we5YcWkRQ==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.8.4.tgz", + "integrity": "sha512-NGLJPZwnVEyBPLI+bl9y9aSnxMhsKz42so7ApAv9D+b4vAFPpY013FTS9LdKxcABoIYFU52HcYga1pPlx454mg==", "dev": true, "requires": { - "@babel/code-frame": "^7.5.5", - "@babel/generator": "^7.6.2", - "@babel/helper-function-name": "^7.1.0", - "@babel/helper-split-export-declaration": "^7.4.4", - "@babel/parser": "^7.6.2", - "@babel/types": "^7.6.0", + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.8.4", + "@babel/helper-function-name": "^7.8.3", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/parser": "^7.8.4", + "@babel/types": "^7.8.3", "debug": "^4.1.0", "globals": "^11.1.0", "lodash": "^4.17.13" }, "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.8.3.tgz", + "integrity": "sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==", + "dev": true, + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + } + }, "debug": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", @@ -182,9 +245,9 @@ } }, "@babel/types": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.6.1.tgz", - "integrity": "sha512-X7gdiuaCmA0uRjCmRtYJNAVCc/q+5xSgsfKJHqMN4iNLILX39677fJE1O40arPMh0TTtS9ItH67yre6c7k6t0g==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.8.3.tgz", + "integrity": "sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg==", "dev": true, "requires": { "esutils": "^2.0.2", @@ -193,9 +256,9 @@ } }, "@cnakazawa/watch": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.3.tgz", - "integrity": "sha512-r5160ogAvGyHsal38Kux7YYtodEKOj89RGb28ht1jh3SJb08VwRwAKKJL0bGb04Zd/3r9FL3BFIc3bBidYffCA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz", + "integrity": "sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==", "dev": true, "requires": { "exec-sh": "^0.3.2", @@ -841,9 +904,9 @@ "dev": true }, "kind-of": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", - "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true }, "micromatch": { @@ -1267,9 +1330,9 @@ "dev": true }, "kind-of": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", - "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true }, "micromatch": { @@ -1566,9 +1629,9 @@ } }, "@types/babel__core": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.3.tgz", - "integrity": "sha512-8fBo0UR2CcwWxeX7WIIgJ7lXjasFxoYgRnFHUj+hRvKkpiBJbxhdAPTCY6/ZKM0uxANFVzt4yObSLuTiTnazDA==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.5.tgz", + "integrity": "sha512-+ckxwNj892FWgvwrUWLOghQ2JDgOgeqTPwrcl+0t1pG59CP8qMJ6S/efmEd999vCFSJKOpyMakvU+w380rduUQ==", "dev": true, "requires": { "@babel/parser": "^7.1.0", @@ -1579,9 +1642,9 @@ } }, "@types/babel__generator": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.0.tgz", - "integrity": "sha512-c1mZUu4up5cp9KROs/QAw0gTeHrw/x7m52LcnvMxxOZ03DmLwPV0MlGmlgzV3cnSdjhJOZsj7E7FHeioai+egw==", + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.1.tgz", + "integrity": "sha512-bBKm+2VPJcMRVwNhxKu8W+5/zT7pwNEqeokFOmbvVSqGzFneNxYcEBro9Ac7/N9tlsaPYnZLK8J1LWKkMsLAew==", "dev": true, "requires": { "@babel/types": "^7.0.0" @@ -1598,9 +1661,9 @@ } }, "@types/babel__traverse": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.0.7.tgz", - "integrity": "sha512-CeBpmX1J8kWLcDEnI3Cl2Eo6RfbGvzUctA+CjZUhOKDFbLfcr7fc4usEqLNWetrlJd7RhAkyYe2czXop4fICpw==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.0.9.tgz", + "integrity": "sha512-jEFQ8L1tuvPjOI8lnpaf73oCJe+aoxL6ygqSy6c8LcW98zaC+4mzWuQIRCEvKeCOu+lbqdXcg4Uqmm1S8AP1tw==", "dev": true, "requires": { "@babel/types": "^7.3.0" @@ -2159,7 +2222,7 @@ }, "array-equal": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", "integrity": "sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=" }, "array-find-index": { @@ -2791,9 +2854,9 @@ } }, "bser": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.0.tgz", - "integrity": "sha512-8zsjWrQkkBoLK6uxASk1nJ2SKv97ltiGDo6A3wA0/yRPz+CwmEyDo0hUrhIuukG2JHpAl3bvFIixw2/3Hi0DOg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", "dev": true, "requires": { "node-int64": "^0.4.0" @@ -2962,7 +3025,7 @@ }, "camelcase-keys": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "resolved": "http://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", "requires": { "camelcase": "^2.0.0", @@ -3630,9 +3693,9 @@ "optional": true }, "convert-source-map": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.6.0.tgz", - "integrity": "sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", "dev": true, "requires": { "safe-buffer": "~5.1.1" @@ -4188,12 +4251,12 @@ "dependencies": { "file-type": { "version": "3.9.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "resolved": "http://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", "integrity": "sha1-JXoHg4TR24CHvESdEH1SpSZyuek=" }, "get-stream": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", "integrity": "sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4=", "requires": { "object-assign": "^4.0.1", @@ -4676,21 +4739,39 @@ } }, "es-abstract": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.14.2.tgz", - "integrity": "sha512-DgoQmbpFNOofkjJtKwr87Ma5EW4Dc8fWhD0R+ndq7Oc456ivUfGOOP6oAZTTKl5/CcNMP+EN+e3/iUzgE0veZg==", + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.4.tgz", + "integrity": "sha512-Ae3um/gb8F0mui/jPL+QiqmglkUsaQf7FwBEHYIFkztkneosu9imhqHpBzQ3h1vit8t5iQ74t6PEVvphBZiuiQ==", "dev": true, "requires": { - "es-to-primitive": "^1.2.0", + "es-to-primitive": "^1.2.1", "function-bind": "^1.1.1", "has": "^1.0.3", - "has-symbols": "^1.0.0", - "is-callable": "^1.1.4", - "is-regex": "^1.0.4", - "object-inspect": "^1.6.0", + "has-symbols": "^1.0.1", + "is-callable": "^1.1.5", + "is-regex": "^1.0.5", + "object-inspect": "^1.7.0", "object-keys": "^1.1.1", - "string.prototype.trimleft": "^2.0.0", - "string.prototype.trimright": "^2.0.0" + "object.assign": "^4.1.0", + "string.prototype.trimleft": "^2.1.1", + "string.prototype.trimright": "^2.1.1" + }, + "dependencies": { + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + }, + "is-regex": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", + "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + } } }, "es-get-iterator": { @@ -4794,9 +4875,9 @@ } }, "es-to-primitive": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz", - "integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", "dev": true, "requires": { "is-callable": "^1.1.4", @@ -4811,7 +4892,7 @@ }, "es6-promisify": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "resolved": "http://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", "requires": { "es6-promise": "^4.0.3" @@ -5158,6 +5239,61 @@ "integrity": "sha512-kT3A/ZJftt28gbl/Cv04qezb/NQ1dwYIbi8lyf806XMxkus7DvOVCLIfTXMrorp322Pnoez7+zabXH29tADIDg==", "dev": true }, + "eslint-plugin-jest": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-23.6.0.tgz", + "integrity": "sha512-GH8AhcFXspOLqak7fqnddLXEJsrFyvgO8Bm60SexvKSn1+3rWYESnCiWUOCUcBTprNSDSE4CtAZdM4EyV6gPPw==", + "dev": true, + "requires": { + "@typescript-eslint/experimental-utils": "^2.5.0", + "micromatch": "^4.0.2" + }, + "dependencies": { + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + } + } + }, "eslint-plugin-mysticatea": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/eslint-plugin-mysticatea/-/eslint-plugin-mysticatea-4.2.4.tgz", @@ -5351,9 +5487,9 @@ "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" }, "exec-sh": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.2.tgz", - "integrity": "sha512-9sLAvzhI5nc8TpuQUh4ahMdCrWT00wPWz7j47/emR5+2qEfoZP5zzUXvx+vdx+H6ohhnsYC31iX04QLYJK8zTg==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.4.tgz", + "integrity": "sha512-sEFIkc61v75sWeOe72qyrqg2Qg0OuLESziUDk/O/z2qgS15y2gWVFrI6f2Qn/qw/0/NCfCEsmNA4zOjkwEZT1A==", "dev": true }, "execa": { @@ -5671,12 +5807,12 @@ } }, "fb-watchman": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.0.tgz", - "integrity": "sha1-VOmr99+i8mzZsWNsWIwa/AXeXVg=", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", + "integrity": "sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==", "dev": true, "requires": { - "bser": "^2.0.0" + "bser": "2.1.1" } }, "fd-slicer": { @@ -5689,7 +5825,7 @@ }, "fecha": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", + "resolved": "http://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", "integrity": "sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==" }, "figures": { @@ -6592,6 +6728,12 @@ "stream-events": "^1.0.4" } }, + "gensync": { + "version": "1.0.0-beta.1", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz", + "integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==", + "dev": true + }, "get-caller-file": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", @@ -6617,7 +6759,7 @@ }, "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" }, "get-value": { @@ -6828,7 +6970,7 @@ }, "p-cancelable": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.4.1.tgz", + "resolved": "http://registry.npmjs.org/p-cancelable/-/p-cancelable-0.4.1.tgz", "integrity": "sha512-HNa1A8LvB1kie7cERyy21VNeHb2CWJJYqyyC2o3klWFfMGlFmWv2Z7sFgZH8ZiaYL95ydToKTFVXgMV/Os0bBQ==" }, "pify": { @@ -6872,26 +7014,6 @@ } } }, - "handlebars": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.4.0.tgz", - "integrity": "sha512-xkRtOt3/3DzTKMOt3xahj2M/EqNhY988T+imYSlMgs5fVhLN2fmKVVj0LtEGmb+3UUYV5Qmm1052Mm3dIQxOvw==", - "dev": true, - "requires": { - "neo-async": "^2.6.0", - "optimist": "^0.6.1", - "source-map": "^0.6.1", - "uglify-js": "^3.1.4" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } - } - }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -7059,6 +7181,12 @@ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.2.1.tgz", "integrity": "sha1-DfKTUfByEWNRXfueVUPl9u7VFi8=" }, + "html-escaper": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.0.tgz", + "integrity": "sha512-a4u9BeERWGu/S8JiWEAQcdrg9v4QArtP9keViQjGMdff20fBdd8waotXaNmODqBe6uZ3Nafi7K/ho4gCQHV3Ig==", + "dev": true + }, "htmlparser2": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", @@ -7362,7 +7490,7 @@ }, "into-stream": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-3.1.0.tgz", + "resolved": "http://registry.npmjs.org/into-stream/-/into-stream-3.1.0.tgz", "integrity": "sha1-lvsKk2wSur1v8XUqF9BWFqvQlMY=", "requires": { "from2": "^2.1.1", @@ -7437,9 +7565,9 @@ "integrity": "sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==" }, "is-callable": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz", - "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", + "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==", "dev": true }, "is-ci": { @@ -7906,12 +8034,12 @@ } }, "istanbul-reports": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-2.2.6.tgz", - "integrity": "sha512-SKi4rnMyLBKe0Jy2uUdx28h8oG7ph2PPuQPvIAh31d+Ci+lSiEu4C+h3oBPuJ9+mPKhOyW0M8gY4U5NM1WLeXA==", + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-2.2.7.tgz", + "integrity": "sha512-uu1F/L1o5Y6LzPVSVZXNOoD/KXpJue9aeLRd0sM9uMXfZvzomB0WxVamWb5ue8kA2vVWEmW7EG+A5n3f1kqHKg==", "dev": true, "requires": { - "handlebars": "^4.1.2" + "html-escaper": "^2.0.0" } }, "isurl": { @@ -8396,9 +8524,9 @@ "dev": true }, "kind-of": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", - "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true }, "micromatch": { @@ -8847,9 +8975,9 @@ "dev": true }, "kind-of": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", - "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true }, "micromatch": { @@ -9200,9 +9328,9 @@ "dev": true }, "kind-of": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", - "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true }, "micromatch": { @@ -9999,7 +10127,7 @@ }, "load-json-file": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", "requires": { "graceful-fs": "^4.1.2", @@ -10230,7 +10358,7 @@ }, "chalk": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "dev": true, "requires": { @@ -10344,7 +10472,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true, "optional": true @@ -10391,7 +10519,7 @@ }, "meow": { "version": "3.7.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "resolved": "http://registry.npmjs.org/meow/-/meow-3.7.0.tgz", "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", "requires": { "camelcase-keys": "^2.0.0", @@ -10408,7 +10536,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" } } @@ -10545,7 +10673,7 @@ }, "minimist": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" }, "minipass": { @@ -10586,7 +10714,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "requires": { "minimist": "0.0.8" @@ -10742,12 +10870,6 @@ "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==", "optional": true }, - "neo-async": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz", - "integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==", - "dev": true - }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -11145,9 +11267,9 @@ "optional": true }, "nwsapi": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.1.4.tgz", - "integrity": "sha512-iGfd9Y6SFdTNldEy2L0GUhcarIutFmk+MPWIn9dmj8NMIup03G08uUF2KGbbmv/Ux4RT0VZJoP/sVbWA6d/VIw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", + "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", "dev": true }, "oauth-sign": { @@ -11189,9 +11311,9 @@ } }, "object-inspect": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz", - "integrity": "sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", "dev": true }, "object-is": { @@ -11413,13 +11535,13 @@ } }, "object.getownpropertydescriptors": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz", - "integrity": "sha1-h1jIRvW0B62rDyNuCYbxSwUcqhY=", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz", + "integrity": "sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg==", "dev": true, "requires": { - "define-properties": "^1.1.2", - "es-abstract": "^1.5.1" + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1" } }, "object.omit": { @@ -11612,7 +11734,7 @@ }, "os-homedir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "resolved": "http://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=" }, "os-locale": { @@ -11625,7 +11747,7 @@ }, "os-tmpdir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "resolved": "http://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" }, "osenv": { @@ -11666,7 +11788,7 @@ }, "p-is-promise": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", + "resolved": "http://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", "integrity": "sha1-nJRWmJ6fZYgBewQ01WCXZ1w9oF4=" }, "p-limit": { @@ -11752,7 +11874,7 @@ "dependencies": { "got": { "version": "6.7.1", - "resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz", + "resolved": "http://registry.npmjs.org/got/-/got-6.7.1.tgz", "integrity": "sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=", "dev": true, "requires": { @@ -11870,7 +11992,7 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-is-inside": { @@ -12031,7 +12153,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" } } @@ -12800,13 +12922,13 @@ } }, "prompts": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.2.1.tgz", - "integrity": "sha512-VObPvJiWPhpZI6C5m60XOzTfnYg/xc/an+r9VYymj9WJW3B/DIH+REzjpAACPf8brwPeP+7vz3bIim3S+AaMjw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.3.1.tgz", + "integrity": "sha512-qIP2lQyCwYbdzcqHIUi2HAxiWixhoM9OdLCWf8txXsapC/X9YdsCoeyRIXE/GP+Q0J37Q7+XN/MFqbUa7IzXNA==", "dev": true, "requires": { "kleur": "^3.0.3", - "sisteransi": "^1.0.3" + "sisteransi": "^1.0.4" } }, "prop-types": { @@ -12950,7 +13072,7 @@ }, "query-string": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", + "resolved": "http://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", "integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==", "requires": { "decode-uri-component": "^0.2.0", @@ -13048,7 +13170,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" } } @@ -14073,9 +14195,9 @@ "dev": true }, "kind-of": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", - "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true }, "micromatch": { @@ -14539,9 +14661,9 @@ } }, "sisteransi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.3.tgz", - "integrity": "sha512-SbEG75TzH8G7eVXFSN5f9EExILKfly7SUvVY5DhhYLvfhKqhDFY0OzevWa/zwak0RLRfWS5AvfMWpd9gJvr5Yg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.4.tgz", + "integrity": "sha512-/ekMoM4NJ59ivGSfKapeG+FWtrmWvA1p6FBZwXrqojw90vJu8lBmrTxCMuBCydKtkaUe2zt4PlxeTKpjwMbyig==", "dev": true }, "slash": { @@ -14973,9 +15095,9 @@ "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==" }, "string.prototype.trimleft": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz", - "integrity": "sha512-FJ6b7EgdKxxbDxc79cOlok6Afd++TTs5szo+zJTUyow3ycrRfJVE2pq3vcN53XexvKZu/DJMDfeI/qMiZTrjTw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz", + "integrity": "sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==", "dev": true, "requires": { "define-properties": "^1.1.3", @@ -14983,9 +15105,9 @@ } }, "string.prototype.trimright": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.0.tgz", - "integrity": "sha512-fXZTSV55dNBwv16uw+hh5jkghxSnc5oHq+5K/gXgizHwAvMetdAJlHqqoFC1FSDVPYWLkAKl2cxpUT41sV7nSg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz", + "integrity": "sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==", "dev": true, "requires": { "define-properties": "^1.1.3", @@ -15026,7 +15148,7 @@ }, "strip-eof": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", "dev": true }, @@ -15411,7 +15533,7 @@ }, "through": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" }, "through2": { @@ -15723,26 +15845,6 @@ "function.name": "^1.0.3" } }, - "uglify-js": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.0.tgz", - "integrity": "sha512-W+jrUHJr3DXKhrsS7NUVxn3zqMOFn0hL/Ei6v0anCIMoKC93TjcflTagwIHLW7SfMFfiQuktQyFVCFHGUE0+yg==", - "dev": true, - "optional": true, - "requires": { - "commander": "~2.20.0", - "source-map": "~0.6.1" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true - } - } - }, "ul": { "version": "5.2.14", "resolved": "https://registry.npmjs.org/ul/-/ul-5.2.14.tgz", @@ -16022,13 +16124,23 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "util.promisify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz", - "integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", + "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", "dev": true, "requires": { - "define-properties": "^1.1.2", - "object.getownpropertydescriptors": "^2.0.3" + "define-properties": "^1.1.3", + "es-abstract": "^1.17.2", + "has-symbols": "^1.0.1", + "object.getownpropertydescriptors": "^2.1.0" + }, + "dependencies": { + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + } } }, "utile": { @@ -16421,7 +16533,7 @@ }, "wrap-ansi": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", "requires": { "string-width": "^1.0.1", diff --git a/package.json b/package.json index 1e4149e3..02f3f81e 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "byline": "^5.0.0", "csv-parse": "^4.8.2", "eslint": "^6.7.2", + "eslint-plugin-jest": "^23.6.0", "eslint-plugin-react": "^7.17.0", "jest": "^24.9.0", "lolex": "^5.1.1", diff --git a/scrapbox/.eslintrc.json b/scrapbox/.eslintrc.json index 410321e9..49e46f01 100644 --- a/scrapbox/.eslintrc.json +++ b/scrapbox/.eslintrc.json @@ -1,6 +1,12 @@ { "extends": "@hakatashi", "parser": "@typescript-eslint/parser", + "env": { + "jest/globals": true + }, + "plugins": [ + "jest" + ], "rules": { "no-console": "off", "camelcase": "off", From 3ac7c6b9123ba9cfb45dbda9c1f6726378af8d21 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Tue, 3 Mar 2020 12:31:43 +0900 Subject: [PATCH 20/43] lib/scrapbox: add scrapbox wrapper class --- lib/scrapbox.ts | 171 ++++++++++++++++++++++++++++++++++++++++------ scrapbox/index.ts | 24 +------ 2 files changed, 153 insertions(+), 42 deletions(-) diff --git a/lib/scrapbox.ts b/lib/scrapbox.ts index 99b4f63f..90af9076 100644 --- a/lib/scrapbox.ts +++ b/lib/scrapbox.ts @@ -1,38 +1,171 @@ +import axios from 'axios'; import { escapeRegExp } from 'lodash'; -export const tsgProjectName = process.env.SCRAPBOX_PROJECT_NAME; +export const tsgProjectName = process.env.SCRAPBOX_PROJECT_NAME!; +export const tsgScrapboxToken = process.env.SCRAPBOX_SID!; -export const getPageUrlRegExp = ({ projectName=tsgProjectName }) => - new RegExp(`^https?${escapeRegExp(`://scrapbox.io/${projectName}/`)}(?.+?)(?#.*)?$`); +export const getPageUrlRegExp = ({ projectName }: { projectName: string | null }) => + new RegExp(`^https?${ + projectName === null ? + '(?.+?)': + escapeRegExp(`://scrapbox.io/${projectName}/`) + }(?.+?)(?#.*)?$`); -export const getTitleFromPageUrl = ({ url, projectName=tsgProjectName, decode=undefined } : { url: string; projectName?: string; decode?: boolean }): string => { - let pageName = url.replace(getPageUrlRegExp({ projectName }), '$'); - if (decode !== false) { +export const fetchScrapboxUrl = async ({ url, token = tsgScrapboxToken }: { url: string; token?: string }): Promise => { + // TODO: support axios config + return (await axios.get( + url, + { headers: { Cookie: `connect.sid=${token ?? tsgScrapboxToken}` } }, + )).data; +} + +export interface User { + id: string; + name: string; + displayName: string; + photo: string; +} + +export interface Line { + id: string; + text: string; + userId: string; + created: number; + updated: number; +} + +export interface Link { + id: string; + title: string; + titleLc: string; + image?: string; + descriptions: string[]; + linksLc: string[]; + updated: number; + accessed: number; +} + +export interface PageInfo { + id: string; + title: string; + image?: string; + descriptions: string[]; + user: User; + pin: number; + views: number; + linked: number; + commitId: string; + created: number; + updated: number; + accessed: number; + snapshotCreated?: number; + persistent: boolean; + lines: Line[]; + links: string[]; + icons: { + [key: string]: number; + }; + relatedPages: { + links1hop: Link[]; + links2hop: Link[]; + icons1hop: unknown[]; // could not find a page that has icons1hop + } + collaborators: User[]; + lastAccessed: number; +} + + +const decodeIfNeeded = ({ str, isEncoded = undefined }: { str: string; isEncoded?: boolean }): { str: string; isEncoded?: boolean } => { + if (isEncoded !== true) { try { - pageName = decodeURIComponent(pageName); - } catch (e) { - if (decode === true) throw e; + str = decodeURIComponent(str); + } catch (err) { + if (isEncoded === false) throw err; + isEncoded = false; } } - return pageName; -}; + return { str, isEncoded }; +} -export const getInfoUrl = ({ pageName, projectName = tsgProjectName, isEncoded = undefined }: { pageName: string; projectName?: string; isEncoded?: boolean }) => { +const encodeIfNeeded = ({ str, isEncoded = undefined }: { str: string; isEncoded?: boolean }): { str: string; isEncoded: boolean } => { if (isEncoded === undefined) { isEncoded = false; try { - if (decodeURIComponent(pageName) !== pageName) { + if (decodeURIComponent(str) !== str) { isEncoded = true; } } catch { - // pageName is not a valid encoded string + // str is not a valid encoded string } } - const encodedPageName = isEncoded ? encodeURIComponent(pageName) : pageName; - return `https://scrapbox.io/api/pages/${projectName}/${encodedPageName}`; + return { str: isEncoded ? str: encodeURIComponent(str), isEncoded }; } -export const getInfoUrlFromPageUrl = ({ url, projectName = tsgProjectName, isEncoded = undefined }: { url: string; projectName?: string, isEncoded?: boolean }): string => { - let pageName = getTitleFromPageUrl({ url, projectName, decode: false }); - return getInfoUrl({ pageName, projectName, isEncoded }); +const parsePageUrl = (url: string): { titleLc: string; projectName: string; hash: string } => { + const match = url.match(getPageUrlRegExp({ projectName: null })); + if (match) { + const { titleLc, projectName, hash } = match.groups!; + return { titleLc, projectName, hash }; + } else { + throw Error(`Invalid Scrapbox URL was given: ${url}`); + } }; + +export class Page { + token: string; + projectName: string; + encodedTitleLc: string; + titleLc: string; + hash?: string; + + constructor(args: { + token?: string; + isEncoded?: boolean; + } & ({ + url: string; + } | { + titleLc: string; + projectName?: string + hash?: string + })) { + this.token = args.token ?? tsgScrapboxToken; + if ('titleLc' in args) { + // specified titleLc + const { titleLc, projectName, hash, isEncoded: isEncodedGiven } = args; + const { str: encodedTitleLc, isEncoded } = encodeIfNeeded({ str: titleLc, isEncoded: isEncodedGiven }); + this.encodedTitleLc = encodedTitleLc; + this.titleLc = decodeIfNeeded({ str: titleLc, isEncoded }).str; + this.projectName = projectName ?? tsgProjectName; + this.hash = hash; + } else if ('url' in args) { + // specified url + const { url, isEncoded: isEncodedGiven } = args; + const { titleLc, projectName, hash } = parsePageUrl(url); + const { str: encodedTitleLc, isEncoded } = encodeIfNeeded({ str: titleLc, isEncoded: isEncodedGiven }); + this.encodedTitleLc = encodedTitleLc; + this.projectName = projectName; + this.hash = hash; + this.titleLc = decodeIfNeeded({ str: titleLc, isEncoded: isEncoded }).str; + } else { + // TODO: do exhaustive check + // this check fails because of a bug of TypeScript (#37039) + /* + this.projectName = args; + this.encodedTitleLc = args; + this.titleLc = args; + */ + } + } + + get url(): string { + return `^https?://scrapbox.io/${this.projectName}/${this.titleLc}${this.hash? `#${this.hash}` : ''}` + } + + get infoUrl(): string { + return `https://scrapbox.io/api/pages/${this.projectName}/${this.encodedTitleLc}`; + } + + async getInfo(): Promise { + return fetchScrapboxUrl({ url: this.infoUrl, token: this.token }); + } +} diff --git a/scrapbox/index.ts b/scrapbox/index.ts index ee4e83fb..84778918 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -3,30 +3,8 @@ import axios from 'axios'; import logger from '../lib/logger.js'; import qs from 'querystring'; import plugin from 'fastify-plugin'; -import { escapeRegExp } from 'lodash'; import {WebClient, RTMClient, LinkUnfurls, MessageAttachment} from '@slack/client'; -const projectName = process.env.SCRAPBOX_PROJECT_NAME; -const scrapboxUrlRegexp = new RegExp(`^https?${escapeRegExp(`://scrapbox.io/${projectName}/`)}(?.+?)(?:#.*)?$`); -const getTitleFromPageUrl = (url: string): string => { - let pageName = url.replace(scrapboxUrlRegexp, '$'); - try { - pageName = decodeURIComponent(pageName); - } catch {} - return pageName; -}; -const getScrapboxUrl = (pageName: string) => `https://scrapbox.io/api/pages/${projectName}/${pageName}`; -const getScrapboxUrlFromPageUrl = (url: string): string => { - let pageName = url.replace(scrapboxUrlRegexp, '$'); - try { - if (decodeURIComponent(pageName) === pageName) { - pageName = encodeURIComponent(pageName); - } - } catch {} - return getScrapboxUrl(pageName); -}; - - interface SlackInterface { rtmClient: RTMClient, webClient: WebClient, @@ -122,7 +100,7 @@ const getMutedList = async (): Promise> => { /** * Scrapboxからの更新通知 (Incoming Webhook形式) を受け取り,ミュート処理をしてSlackに投稿する */ -// eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax +// eslint-disable-next-line node/no-unsupported -features, node/no-unsupported-features/es-syntax export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { fastify.post('/hooks/scrapbox', async (req) => { const mutedList = await getMutedList(); From 69439830dc45d23536b51d48da0a2d0b0c87c469 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Tue, 3 Mar 2020 17:48:02 +0900 Subject: [PATCH 21/43] lib/scrapbox: add tests for Page & fix bugs --- lib/scrapbox.test.ts | 115 +++++++++++++++++++++++++++++++++++++++++++ lib/scrapbox.ts | 15 +++--- 2 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 lib/scrapbox.test.ts diff --git a/lib/scrapbox.test.ts b/lib/scrapbox.test.ts new file mode 100644 index 00000000..277d53db --- /dev/null +++ b/lib/scrapbox.test.ts @@ -0,0 +1,115 @@ +const defaultProjectName = 'test_proj'; +process.env.SCRAPBOX_PROJECT_NAME = defaultProjectName; +const defaultToken = 'test_token'; +process.env.SCRAPBOX_SID = defaultToken; + +jest.mock('axios'); +import _axios from 'axios'; +const axios = _axios as jest.Mocked; + +import { Page } from './scrapbox'; + +beforeEach(() => { + axios.get.mockReset(); +}) + +describe('Page', () => { + const projectName = 'proj'; + const titleLc = 'タイトル'; + + const encodedTitleLc = encodeURIComponent(titleLc); + const hash = 'hash'; + const token = 'token'; + + describe('constructor', () => { + const assertProperties = (page: Page): void => { + expect(page.token).toBe(token); + expect(page.projectName).toBe(projectName); + expect(page.titleLc).toBe(titleLc); + expect(page.encodedTitleLc).toBe(encodedTitleLc); + expect(page.hash).toBe(hash); + }; + + const generateURL = ({ projectName, titleLc, hash }: { projectName: string; titleLc: string; hash: string }) => + `https://scrapbox.io/${projectName}/${titleLc}#${hash}`; + + it('handles unencoded titleLc', () => { + assertProperties(new Page({ titleLc, projectName, hash, isEncoded: false, token })); + }); + + it('handles encoded titleLc', () => { + assertProperties(new Page({ titleLc: encodedTitleLc, projectName, hash, isEncoded: true, token })); + }); + + it('assumes unencoded titleLc to be unencoded', () => { + assertProperties(new Page({ titleLc, projectName, hash, isEncoded: undefined, token })); + }); + + it('assumes encoded titleLc to be encoded', () => { + assertProperties(new Page({ titleLc: encodedTitleLc, projectName, hash, isEncoded: undefined, token })); + }); + + + it('handles unencoded URL', () => { + const url = generateURL({ projectName, titleLc, hash }); + assertProperties(new Page({ url, isEncoded: false, token })); + }); + + it('handles encoded URL', () => { + const url = generateURL({ projectName, titleLc: encodedTitleLc, hash }); + assertProperties(new Page({ url, isEncoded: true, token })); + }); + + it('assumes unencoded URL to be unencoded', () => { + const url = generateURL({ projectName, titleLc, hash }); + assertProperties(new Page({ url, isEncoded: undefined, token })); + }); + + it('assumes encoded URL to be encoded', () => { + const url = generateURL({ projectName, titleLc: encodedTitleLc, hash }); + assertProperties(new Page({ url, isEncoded: undefined, token })); + }); + it('handles encoded URL', () => { + const url = generateURL({ projectName, titleLc: encodedTitleLc, hash }); + assertProperties(new Page({ url, isEncoded: undefined, token })); + }); + + it('handles missing parameters when specified titleLc', () => { + const page = new Page({ titleLc }); + expect(page.projectName).toBe(defaultProjectName); + expect(page.token).toBe(defaultToken); + expect(page.hash).toBeUndefined(); + }); + + it('handles missing parameters when specified URL', () => { + const page = new Page({ titleLc }); + expect(page.token).toBe(defaultToken); + }); + + it('throws error on invalid URL', () => { + expect(() => new Page({ url: 'hoge' })).toThrow(); + }) + }); + + describe('methods', () => { + let page: Page | null = null; + beforeEach(() => { + page = new Page({ titleLc, projectName, hash, token }); + }); + + test('.url is correct', () => { + expect(page!.url).toBe(`https://scrapbox.io/${projectName}/${encodedTitleLc}#${hash}`); + }); + + test('.infoUrl is correct', () => { + expect(page!.infoUrl).toBe(`https://scrapbox.io/api/pages/${projectName}/${encodedTitleLc}`); + }); + + test('.fetchInfo() fetches from correct URL', async () => { + axios.get.mockResolvedValueOnce({ data: {} }); + await page!.fetchInfo(); + expect(axios.get.mock.calls.length).toBe(1); + expect(axios.get.mock.calls[0][0]/* url of 0th call */).toBe(page!.infoUrl); + }); + }); +}); diff --git a/lib/scrapbox.ts b/lib/scrapbox.ts index 90af9076..603e6246 100644 --- a/lib/scrapbox.ts +++ b/lib/scrapbox.ts @@ -5,11 +5,11 @@ export const tsgProjectName = process.env.SCRAPBOX_PROJECT_NAME!; export const tsgScrapboxToken = process.env.SCRAPBOX_SID!; export const getPageUrlRegExp = ({ projectName }: { projectName: string | null }) => - new RegExp(`^https?${ + new RegExp(`^https?${escapeRegExp('://scrapbox.io/')}${ projectName === null ? '(?.+?)': - escapeRegExp(`://scrapbox.io/${projectName}/`) - }(?.+?)(?#.*)?$`); + escapeRegExp(projectName) + }/(?.+?)(?:#(?.*))?$`); export const fetchScrapboxUrl = async ({ url, token = tsgScrapboxToken }: { url: string; token?: string }): Promise => { // TODO: support axios config @@ -76,11 +76,12 @@ export interface PageInfo { const decodeIfNeeded = ({ str, isEncoded = undefined }: { str: string; isEncoded?: boolean }): { str: string; isEncoded?: boolean } => { - if (isEncoded !== true) { + if (isEncoded === true || isEncoded === undefined) { try { str = decodeURIComponent(str); } catch (err) { - if (isEncoded === false) throw err; + // str is not a valid encoded string + if (isEncoded === true) throw err; isEncoded = false; } } @@ -158,14 +159,14 @@ export class Page { } get url(): string { - return `^https?://scrapbox.io/${this.projectName}/${this.titleLc}${this.hash? `#${this.hash}` : ''}` + return `https://scrapbox.io/${this.projectName}/${this.encodedTitleLc}${this.hash? `#${this.hash}` : ''}` } get infoUrl(): string { return `https://scrapbox.io/api/pages/${this.projectName}/${this.encodedTitleLc}`; } - async getInfo(): Promise { + async fetchInfo(): Promise { return fetchScrapboxUrl({ url: this.infoUrl, token: this.token }); } } From db48f9f2a81789fed60dff4a6a9370bd72958a78 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Tue, 3 Mar 2020 17:48:44 +0900 Subject: [PATCH 22/43] lib/scrapbox: add tests for fetchScrapboxUrl --- lib/scrapbox.test.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/scrapbox.test.ts b/lib/scrapbox.test.ts index 277d53db..d905e87a 100644 --- a/lib/scrapbox.test.ts +++ b/lib/scrapbox.test.ts @@ -1,18 +1,41 @@ -const defaultProjectName = 'test_proj'; +const defaultProjectName = 'default_proj'; process.env.SCRAPBOX_PROJECT_NAME = defaultProjectName; -const defaultToken = 'test_token'; +const defaultToken = 'default_token'; process.env.SCRAPBOX_SID = defaultToken; jest.mock('axios'); import _axios from 'axios'; const axios = _axios as jest.Mocked; -import { Page } from './scrapbox'; +import { fetchScrapboxUrl, Page } from './scrapbox'; beforeEach(() => { axios.get.mockReset(); }) +describe('fetchScrapboxUrl', () => { + const data = {dummy: 'data'}; + const url = 'dummy_url'; + const token = 'dummy_token'; + + beforeEach(() => { + axios.get.mockResolvedValueOnce({ data }); + }); + + it('fetches given URL with given token', async () => { + const res = await fetchScrapboxUrl({ url, token }); + expect(res).toEqual(data); + expect(axios.get.mock.calls.length).toBe(1); + expect(axios.get.mock.calls[0][0]).toBe(url); + expect(axios.get.mock.calls[0][1]!.headers.Cookie).toContain(token); + }); + + it('uses default token if not specified', async () => { + await fetchScrapboxUrl({ url }); + expect(axios.get.mock.calls[0][1]!.headers.Cookie).toContain(defaultToken); + }); +}); + describe('Page', () => { const projectName = 'proj'; const titleLc = 'タイトル'; From 1c9178d9020a60569a73b31f5374749105cded45 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Tue, 3 Mar 2020 18:49:43 +0900 Subject: [PATCH 23/43] lib/scrapbox: add tests for getPageUrlRegExp --- lib/scrapbox.test.ts | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/scrapbox.test.ts b/lib/scrapbox.test.ts index d905e87a..a5f27bc6 100644 --- a/lib/scrapbox.test.ts +++ b/lib/scrapbox.test.ts @@ -7,7 +7,7 @@ jest.mock('axios'); import _axios from 'axios'; const axios = _axios as jest.Mocked; -import { fetchScrapboxUrl, Page } from './scrapbox'; +import { getPageUrlRegExp, fetchScrapboxUrl, Page } from './scrapbox'; beforeEach(() => { axios.get.mockReset(); @@ -36,10 +36,39 @@ describe('fetchScrapboxUrl', () => { }); }); +describe('getPageUrlRegExp', () => { + const projectName = 'proj'; + const projectName2 = 'proj2'; + const titleLc = 'タイトル'; + const hash = 'hash'; + + it('parses URL without hash', () => { + const match = `https://scrapbox.io/${projectName}/${titleLc}`.match(getPageUrlRegExp({ projectName: null })); + expect(match).not.toBeNull(); + expect(match!.groups).toMatchObject({ projectName, titleLc }); + }); + + it('parses URL without hash', () => { + const match = `https://scrapbox.io/${projectName}/${titleLc}#${hash}`.match(getPageUrlRegExp({ projectName: null })); + expect(match).not.toBeNull(); + expect(match!.groups).toMatchObject({ projectName, titleLc, hash }); + }); + + it('parses URL when projectName specified', () => { + const url_ok = `https://scrapbox.io/${projectName}/${titleLc}`; + const regexp = getPageUrlRegExp({ projectName }); + const match_ok = url_ok.match(regexp); + expect(match_ok).not.toBeNull(); + expect(match_ok!.groups).toMatchObject({ titleLc }); + const url_ng = `https://scrapbox.io/${projectName2}/${titleLc}`; + const match_ng = url_ng.match(regexp); + expect(match_ng).toBeNull(); + }); +}); + describe('Page', () => { const projectName = 'proj'; const titleLc = 'タイトル'; - const encodedTitleLc = encodeURIComponent(titleLc); const hash = 'hash'; const token = 'token'; From f3aab4e29753bfa2143ce0afefed6e27a00ee448 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Tue, 3 Mar 2020 20:13:45 +0900 Subject: [PATCH 24/43] scrapbox: use lib/scrapbox --- scrapbox/index.test.ts | 4 ++-- scrapbox/index.ts | 17 +++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index b6f823bd..9c2334e8 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -91,10 +91,10 @@ describe('scrapbox', () => { ]; // @ts-ignore axios.get.mockImplementation((url: string) => { - if (url === `https://scrapbox.io/api/pages/${projectName}/##ミュート`) { + if (url === `https://scrapbox.io/api/pages/${projectName}/${encodeURIComponent(muteTag)}`) { return set({}, ['data', 'relatedPages', 'links1hop'], [{titleLc: 'page_1'}]); } - throw Error('axios-mock: unexpected URL'); + throw Error(`axios-mock: unexpected URL: ${url}`); }); slack = {chat: { diff --git a/scrapbox/index.ts b/scrapbox/index.ts index 84778918..a8ba4f19 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -4,6 +4,7 @@ import logger from '../lib/logger.js'; import qs from 'querystring'; import plugin from 'fastify-plugin'; import {WebClient, RTMClient, LinkUnfurls, MessageAttachment} from '@slack/client'; +import {Page} from '../lib/scrapbox'; interface SlackInterface { rtmClient: RTMClient, @@ -19,12 +20,13 @@ export default async ({rtmClient: rtm, webClient: slack, eventClient: event}: Sl const unfurls: LinkUnfurls = {}; for (const link of links) { const {url} = link; - if (!scrapboxUrlRegexp.test(url)) { + let page: Page | null = null; + try { + page = new Page({ url }); + } catch { continue; } - const scrapboxUrl = getScrapboxUrlFromPageUrl(url); - const response = await axios.get(scrapboxUrl, {headers: {Cookie: `connect.sid=${process.env.SCRAPBOX_SID}`}}); - const {data} = response; + const data = await page.fetchInfo(); unfurls[url] = { title: data.title, @@ -92,9 +94,8 @@ const maskAttachment = (attachment: MessageAttachment): MessageAttachment => { export const muteTag = '##ミュート'; const getMutedList = async (): Promise> => { - const muteTagUrl = getScrapboxUrl(muteTag); - const pageInfo = await axios.get(muteTagUrl, {headers: {Cookie: `connect.sid=${process.env.SCRAPBOX_SID}`}}); - return new Set(pageInfo.data.relatedPages.links1hop.map(({titleLc}: {titleLc: string}) => titleLc)); + const muteTagPage = new Page({ titleLc: muteTag, isEncoded: false }); + return new Set((await muteTagPage.fetchInfo()).relatedPages.links1hop.map(({ titleLc }) => titleLc)); }; /** @@ -110,7 +111,7 @@ export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, o icon_emoji: ':scrapbox:', ...req.body, attachments: await Promise.all(req.body.attachments.map( - async (attachment) => await mutedList.has(getTitleFromPageUrl(attachment.title_link)) ? maskAttachment(attachment) : attachment, + async (attachment) => await mutedList.has(new Page({ url: attachment.title_link }).titleLc) ? maskAttachment(attachment) : attachment, )), }, ); From c257e5c09f0e9f1f00fda1bb6382f24eae029704 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Tue, 3 Mar 2020 20:20:16 +0900 Subject: [PATCH 25/43] welcome: use lib/scrapbox --- welcome/index.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/welcome/index.ts b/welcome/index.ts index 7712e362..a2c95b1c 100644 --- a/welcome/index.ts +++ b/welcome/index.ts @@ -2,8 +2,7 @@ import axios from 'axios'; // @ts-ignore import logger from '../lib/logger.js'; -const welcomeScrapboxUrl = `https://scrapbox.io/api/pages/tsg/welcome`; - +import {Page} from '../lib/scrapbox'; import {WebClient, RTMClient} from '@slack/client'; interface SlackInterface { @@ -11,9 +10,11 @@ interface SlackInterface { webClient: WebClient, } +const welcomePageTitleLc = 'welcome'; + async function postWelcomeMessage(slack: WebClient, channel: string) { - const {data} = await axios.get(welcomeScrapboxUrl, {headers: {Cookie: `connect.sid=${process.env.SCRAPBOX_SID}`}}); - const text = data.lines.map(({text}: {text: string}) => text).slice(1).join('\n'); + const welcomePage = new Page({ titleLc: welcomePageTitleLc, isEncoded: false }); + const text = (await welcomePage.fetchInfo()).lines.map(({text}: {text: string}) => text).slice(1).join('\n'); return slack.chat.postMessage({ channel, From 2d5045d70ff8e8279a984925941e794f87c85f45 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Tue, 3 Mar 2020 21:27:31 +0900 Subject: [PATCH 26/43] scrapbox: mock lib/scrapbox in tests --- scrapbox/index.test.ts | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 9c2334e8..25c45573 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -6,7 +6,8 @@ import axios from 'axios'; import qs from 'querystring'; import { escapeRegExp, set } from 'lodash'; import { fastifyDevConstructor } from '../lib/fastify'; -import {MessageAttachment} from '@slack/client'; +import { MessageAttachment } from '@slack/client'; +import { Page, PageInfo } from '../lib/scrapbox'; // @ts-ignore @@ -20,12 +21,22 @@ import scrapbox from './index'; import {server, muteTag} from './index'; describe('scrapbox', () => { + let fetchInfoSpy: jest.SpyInstance> | null = null; + beforeEach(async () => { + fetchInfoSpy = jest.spyOn(Page.prototype, 'fetchInfo').mockImplementation(async () => + ({title: 'hoge', descriptions: ['fuga', 'piyo']} as PageInfo) + // cast this because it is too demanding to completely write down all properties + ); slack = new Slack(); process.env.CHANNEL_SANDBOX = slack.fakeChannel; await scrapbox(slack); }); + afterEach(() => { + fetchInfoSpy!.mockRestore(); + }); + it('respond to slack hook of scrapbox unfurling', async () => { const done = new Promise((resolve) => { // @ts-ignore @@ -37,9 +48,9 @@ describe('scrapbox', () => { expect(unfurls[`https://scrapbox.io/${projectName}/hoge`].text).toBe('fuga\npiyo'); resolve(); return Promise.resolve({data: {ok: true}}); + } else { + throw Error(`axios-mock: unknown URL: ${url}`); } - // @ts-ignore - return Promise.resolve(axios.response); }); }); @@ -62,7 +73,7 @@ describe('scrapbox', () => { }); describe('scrapbox', () => { - it('mutes pages with ##ミュート tag', async () => { + it(`mutes pages with ${muteTag} tag`, async () => { const fakeChannel = 'CSCRAPBOX'; process.env.CHANNEL_SCRAPBOX = fakeChannel; const fastify = fastifyDevConstructor(); @@ -89,13 +100,11 @@ describe('scrapbox', () => { thumb_url: 'https://example.com/fuga2.png', }, ]; - // @ts-ignore - axios.get.mockImplementation((url: string) => { - if (url === `https://scrapbox.io/api/pages/${projectName}/${encodeURIComponent(muteTag)}`) { - return set({}, ['data', 'relatedPages', 'links1hop'], [{titleLc: 'page_1'}]); - } - throw Error(`axios-mock: unexpected URL: ${url}`); - }); + + jest.spyOn(Page.prototype, 'fetchInfo').mockImplementation(async () => + set({}, ['relatedPages', 'links1hop'], [{titleLc: 'page_1'}]) as PageInfo + // cast this because it is too demanding to completely write down all properties + ); slack = {chat: { postMessage: jest.fn(), From a4c559030d51774d1506ae33566d2880ad9ae723 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Wed, 4 Mar 2020 10:00:41 +0900 Subject: [PATCH 27/43] scrapbox: implement splitAttachments --- scrapbox/index.test.ts | 99 +++++++++++++++++++++++++----------------- scrapbox/index.ts | 11 ++++- 2 files changed, 70 insertions(+), 40 deletions(-) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 25c45573..107627de 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -4,7 +4,7 @@ jest.mock('axios'); import Slack from '../lib/slackMock.js'; import axios from 'axios'; import qs from 'querystring'; -import { escapeRegExp, set } from 'lodash'; +import { flatten, set } from 'lodash'; import { fastifyDevConstructor } from '../lib/fastify'; import { MessageAttachment } from '@slack/client'; import { Page, PageInfo } from '../lib/scrapbox'; @@ -18,61 +18,82 @@ let slack: Slack = null; const projectName = 'PROJECTNAME'; process.env.SCRAPBOX_PROJECT_NAME = projectName; import scrapbox from './index'; -import {server, muteTag} from './index'; +import {server, muteTag, splitAttachments} from './index'; -describe('scrapbox', () => { +describe('unfurl', () => { let fetchInfoSpy: jest.SpyInstance> | null = null; - + beforeEach(async () => { fetchInfoSpy = jest.spyOn(Page.prototype, 'fetchInfo').mockImplementation(async () => - ({title: 'hoge', descriptions: ['fuga', 'piyo']} as PageInfo) - // cast this because it is too demanding to completely write down all properties + ({title: 'hoge', descriptions: ['fuga', 'piyo']} as PageInfo) + // cast this because it is too demanding to completely write down all properties ); slack = new Slack(); process.env.CHANNEL_SANDBOX = slack.fakeChannel; await scrapbox(slack); }); - + afterEach(() => { fetchInfoSpy!.mockRestore(); }); - + it('respond to slack hook of scrapbox unfurling', async () => { const done = new Promise((resolve) => { // @ts-ignore axios.mockImplementation(({url, data}: {url: string, data: any}) => { if (url === 'https://slack.com/api/chat.unfurl') { - const parsed = qs.parse(data); - const unfurls = JSON.parse(Array.isArray(parsed.unfurls) ? parsed.unfurls[0] : parsed.unfurls); - expect(unfurls[`https://scrapbox.io/${projectName}/hoge`]).toBeTruthy(); - expect(unfurls[`https://scrapbox.io/${projectName}/hoge`].text).toBe('fuga\npiyo'); - resolve(); - return Promise.resolve({data: {ok: true}}); - } else { - throw Error(`axios-mock: unknown URL: ${url}`); - } - }); - }); - - slack.eventClient.emit('link_shared', { - type: 'link_shared', - channel: 'Cxxxxxx', - user: 'Uxxxxxxx', - message_ts: '123452389.9875', - thread_ts: '123456621.1855', - links: [ - { - domain: 'scrapbox.io', - url: `https://scrapbox.io/${projectName}/hoge`, - }, - ], + const parsed = qs.parse(data); + const unfurls = JSON.parse(Array.isArray(parsed.unfurls) ? parsed.unfurls[0] : parsed.unfurls); + expect(unfurls[`https://scrapbox.io/${projectName}/hoge`]).toBeTruthy(); + expect(unfurls[`https://scrapbox.io/${projectName}/hoge`].text).toBe('fuga\npiyo'); + resolve(); + return Promise.resolve({data: {ok: true}}); + } else { + throw Error(`axios-mock: unknown URL: ${url}`); + } }); - - return done; }); + + slack.eventClient.emit('link_shared', { + type: 'link_shared', + channel: 'Cxxxxxx', + user: 'Uxxxxxxx', + message_ts: '123452389.9875', + thread_ts: '123456621.1855', + links: [ + { + domain: 'scrapbox.io', + url: `https://scrapbox.io/${projectName}/hoge`, + }, + ], + }); + + return done; +}); }); -describe('scrapbox', () => { +describe('mute notification', () => { + it('splits attachments to each pages', () => { + const pages = [[ + { + title_link:`https://scrapbox.io/${projectName}/page_1#hash_1`, + }, + { + image_url:"https://example.com/img1.png" + }, + { + image_url:"https://example.com/img2.png" + } + ], [ + { + title_link:`https://scrapbox.io/${projectName}/page_2#hash_2`, + } + ]]; + const attachments = flatten(pages); + const splittedAttachments = splitAttachments(attachments); + expect(splittedAttachments).toEqual(pages); + }) + it(`mutes pages with ${muteTag} tag`, async () => { const fakeChannel = 'CSCRAPBOX'; process.env.CHANNEL_SCRAPBOX = fakeChannel; @@ -100,12 +121,12 @@ describe('scrapbox', () => { thumb_url: 'https://example.com/fuga2.png', }, ]; - + jest.spyOn(Page.prototype, 'fetchInfo').mockImplementation(async () => - set({}, ['relatedPages', 'links1hop'], [{titleLc: 'page_1'}]) as PageInfo - // cast this because it is too demanding to completely write down all properties + set({}, ['relatedPages', 'links1hop'], [{titleLc: 'page_1'}]) as PageInfo + // cast this because it is too demanding to completely write down all properties ); - + slack = {chat: { postMessage: jest.fn(), }}; diff --git a/scrapbox/index.ts b/scrapbox/index.ts index a8ba4f19..9c50c92c 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -3,8 +3,9 @@ import axios from 'axios'; import logger from '../lib/logger.js'; import qs from 'querystring'; import plugin from 'fastify-plugin'; +import {zip} from 'lodash'; import {WebClient, RTMClient, LinkUnfurls, MessageAttachment} from '@slack/client'; -import {Page} from '../lib/scrapbox'; +import {Page, getPageUrlRegExp} from '../lib/scrapbox'; interface SlackInterface { rtmClient: RTMClient, @@ -98,6 +99,14 @@ const getMutedList = async (): Promise> => { return new Set((await muteTagPage.fetchInfo()).relatedPages.links1hop.map(({ titleLc }) => titleLc)); }; +export const splitAttachments = (attachments: MessageAttachment[]): MessageAttachment[][] => { + const pageIndex = attachments + .map(({ title_link }, i) => ({ url: title_link, i })) + .filter(({ url }) => getPageUrlRegExp({ projectName: null }).test(url)) + .map(({ i }) => i); + const pageRange = zip(pageIndex, pageIndex.concat([attachments.length]).slice(1)); + return pageRange.map(([i, j]) => attachments.slice(i, j)); +}; /** * Scrapboxからの更新通知 (Incoming Webhook形式) を受け取り,ミュート処理をしてSlackに投稿する */ From 12448095c0ef2847f5a3e327d7b762eac45e19a6 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Wed, 4 Mar 2020 10:01:11 +0900 Subject: [PATCH 28/43] lib/scrapbox: cache calls of getPageUrlRegExp --- lib/scrapbox.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/scrapbox.ts b/lib/scrapbox.ts index 603e6246..f3003eaf 100644 --- a/lib/scrapbox.ts +++ b/lib/scrapbox.ts @@ -4,12 +4,21 @@ import { escapeRegExp } from 'lodash'; export const tsgProjectName = process.env.SCRAPBOX_PROJECT_NAME!; export const tsgScrapboxToken = process.env.SCRAPBOX_SID!; -export const getPageUrlRegExp = ({ projectName }: { projectName: string | null }) => - new RegExp(`^https?${escapeRegExp('://scrapbox.io/')}${ - projectName === null ? - '(?.+?)': - escapeRegExp(projectName) - }/(?.+?)(?:#(?.*))?$`); +const getPageUrlRegExpCache = new Map(); + +export const getPageUrlRegExp = ({ projectName }: { projectName: string | null }) => { + if (getPageUrlRegExpCache.has(projectName)) { + return getPageUrlRegExpCache.get(projectName); + } else { + const regexp = new RegExp(`^https?${escapeRegExp('://scrapbox.io/')}${ + projectName === null ? + '(?.+?)': + escapeRegExp(projectName) + }/(?.+?)(?:#(?.*))?$`); + getPageUrlRegExpCache.set(projectName, regexp); + return regexp; + } +}; export const fetchScrapboxUrl = async ({ url, token = tsgScrapboxToken }: { url: string; token?: string }): Promise => { // TODO: support axios config From d3b9f5a020f2755c86bd69cd0a7b667af9236af7 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Wed, 4 Mar 2020 11:08:02 +0900 Subject: [PATCH 29/43] scrapbox: add helper class for tests --- scrapbox/index.test.ts | 62 +++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 107627de..48f5e925 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -72,28 +72,58 @@ describe('unfurl', () => { }); }); +class FakeAttachmentGenerator { + i: number = 0; + + get(kind: 'text' | 'img'): MessageAttachment { + let a: MessageAttachment & { [key: string]: any } | null = null; + switch (kind) { + case 'text': { + const title = `タイトル ${this.i}`; + const text = `page ${this.i}`; + a = { + title, + title_link: `https://scrapbox.io/${projectName}/${encodeURIComponent(title)}#hash_${this.i}`, + text, + rawText: text, + mrkdwn_in: ['text' as const], + author_name: `user ${this.i}`, + image_url: `https://example.com/image_${this.i}.png`, + thumb_url: `https://example.com/thumb_${this.i}.png`, + }; + break; + } + case 'img': { + a = { + image_url: `https://example.com/image_${this.i}.png`, + }; + break; + } + default: { + a = kind; + } + } + ++this.i; + return a; + } +} + describe('mute notification', () => { - it('splits attachments to each pages', () => { + test('splitAttachments splits attachments to each pages', () => { + const gen = new FakeAttachmentGenerator(); const pages = [[ - { - title_link:`https://scrapbox.io/${projectName}/page_1#hash_1`, - }, - { - image_url:"https://example.com/img1.png" - }, - { - image_url:"https://example.com/img2.png" - } - ], [ - { - title_link:`https://scrapbox.io/${projectName}/page_2#hash_2`, - } + gen.get('text'), + gen.get('img'), + gen.get('img'), + ], [ + gen.get('text'), + gen.get('img'), ]]; const attachments = flatten(pages); const splittedAttachments = splitAttachments(attachments); expect(splittedAttachments).toEqual(pages); - }) - + }); + it(`mutes pages with ${muteTag} tag`, async () => { const fakeChannel = 'CSCRAPBOX'; process.env.CHANNEL_SCRAPBOX = fakeChannel; From c83d003801ff2ead7d6ea0c83fe3aee7d92a53de Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Wed, 4 Mar 2020 23:27:02 +0900 Subject: [PATCH 30/43] scrapbox: reimplement mute --- scrapbox/index.test.ts | 216 +++++++++++++++++++++++------------------ scrapbox/index.ts | 75 ++++++++------ 2 files changed, 171 insertions(+), 120 deletions(-) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 48f5e925..7e13aad6 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -2,9 +2,11 @@ jest.mock('axios'); // @ts-ignore import Slack from '../lib/slackMock.js'; +import EventEmitter from 'events'; +import { WebClient } from '@slack/web-api'; import axios from 'axios'; import qs from 'querystring'; -import { flatten, set } from 'lodash'; +import { flatten, set, sum, transform } from 'lodash'; import { fastifyDevConstructor } from '../lib/fastify'; import { MessageAttachment } from '@slack/client'; import { Page, PageInfo } from '../lib/scrapbox'; @@ -17,7 +19,7 @@ let slack: Slack = null; const projectName = 'PROJECTNAME'; process.env.SCRAPBOX_PROJECT_NAME = projectName; -import scrapbox from './index'; +import scrapbox, { maskAttachments, reconstructAttachments } from './index'; import {server, muteTag, splitAttachments} from './index'; describe('unfurl', () => { @@ -73,126 +75,156 @@ describe('unfurl', () => { }); class FakeAttachmentGenerator { - i: number = 0; + main_i: number = 0; + + sub_i: number = 0; get(kind: 'text' | 'img'): MessageAttachment { let a: MessageAttachment & { [key: string]: any } | null = null; switch (kind) { case 'text': { - const title = `タイトル ${this.i}`; - const text = `page ${this.i}`; + const text = `page ${this.main_i}`; a = { - title, - title_link: `https://scrapbox.io/${projectName}/${encodeURIComponent(title)}#hash_${this.i}`, + title: `タイトル ${this.main_i}`, + title_link: `https://scrapbox.io/${projectName}/${encodeURIComponent(`タイトル_${this.main_i}`)}#hash_${this.main_i}`, text, rawText: text, mrkdwn_in: ['text' as const], - author_name: `user ${this.i}`, - image_url: `https://example.com/image_${this.i}.png`, - thumb_url: `https://example.com/thumb_${this.i}.png`, + author_name: `user ${this.main_i}`, + image_url: `https://example.com/image_${this.main_i}.png`, + thumb_url: `https://example.com/thumb_${this.main_i}.png`, }; + ++this.main_i; break; } case 'img': { a = { - image_url: `https://example.com/image_${this.i}.png`, + image_url: `https://example.com/image_${this.main_i}_${this.sub_i}.png`, }; + ++this.sub_i; break; } default: { a = kind; } } - ++this.i; return a; } + + reset() { + this.main_i = 0; + this.sub_i = 0; + } } -describe('mute notification', () => { - test('splitAttachments splits attachments to each pages', () => { - const gen = new FakeAttachmentGenerator(); - const pages = [[ - gen.get('text'), - gen.get('img'), - gen.get('img'), - ], [ - gen.get('text'), - gen.get('img'), - ]]; - const attachments = flatten(pages); - const splittedAttachments = splitAttachments(attachments); - expect(splittedAttachments).toEqual(pages); +const waitEvent = (eventEmitter: EventEmitter, event: string): Promise => new Promise((resolve) => { + eventEmitter.once(event, (args) => { + resolve(args); }); +}); - it(`mutes pages with ${muteTag} tag`, async () => { - const fakeChannel = 'CSCRAPBOX'; - process.env.CHANNEL_SCRAPBOX = fakeChannel; - const fastify = fastifyDevConstructor(); - // eslint-disable-next-line array-plural/array-plural - const attachments_req: (MessageAttachment & any)[] = [ - { - title: 'page 1', - title_link: `https://scrapbox.io/${projectName}/page_1#c632c886dc3061e3b85cabbd`, - text: 'hoge', - rawText: 'hoge', - mrkdwn_in: ['text'], - author_name: 'Alice', - image_url: 'https://example.com/hoge1.png', - thumb_url: 'https://example.com/fuga1.png', - }, - { - title: 'page 2', - title_link: `https://scrapbox.io/${projectName}/page_2#aaf8924806eb538413c07c43`, - text: 'hoge', - rawText: 'hoge', - mrkdwn_in: ['text'], - author_name: 'Bob', - image_url: 'https://example.com/hoge2.png', - thumb_url: 'https://example.com/fuga2.png', - }, - ]; - - jest.spyOn(Page.prototype, 'fetchInfo').mockImplementation(async () => - set({}, ['relatedPages', 'links1hop'], [{titleLc: 'page_1'}]) as PageInfo - // cast this because it is too demanding to completely write down all properties - ); - - slack = {chat: { - postMessage: jest.fn(), - }}; - fastify.register(server({webClient: slack} as any)); - const args = { - text: `New lines on `, - mrkdwn: true, - username: 'Scrapbox', - attachments: attachments_req, - }; - const {payload, statusCode} = await fastify.inject({ - method: 'POST', - url: '/hooks/scrapbox', - payload: args, + +describe('mute notification', () => { + describe('splitAttachments', () => { + it('splits attachments to each pages', () => { + const gen = new FakeAttachmentGenerator(); + const attachments = (['text', 'img', 'img', 'text', 'text', 'img'] as const).map((s) => gen.get(s)); + gen.reset(); + // eslint-disable-next-line array-plural/array-plural + const expected = [{ + main: gen.get('text'), + sub: [gen.get('img'), gen.get('img')], + }, { + main: gen.get('text'), + sub: [], + }, { + main: gen.get('text'), + sub: [gen.get('img')], + }]; + const splittedAttachments = splitAttachments(attachments); + expect(splittedAttachments).toEqual(expected); }); - if (statusCode !== 200) { - throw JSON.parse(payload); - } - expect(slack.chat.postMessage.mock.calls.length).toBe(1); - const {channel, text, attachments: attachments_res}: {channel: string; text: string; attachments: MessageAttachment[]} = slack.chat.postMessage.mock.calls[0][0]; - expect(channel).toBe(fakeChannel); - expect(text).toBe(args.text); - const unchanged = ['title', 'title_link', 'mrkdwn_in', 'author_name'] as const; - for (const i of [0, 1]) { + }); + + describe('maskAttachments', () => { + it('conceals values of notification', () => { + const gen = new FakeAttachmentGenerator(); + const notification = { + main: gen.get('text'), + sub: [gen.get('img'), gen.get('img')], + }; + const attachments = maskAttachments(notification); + expect(attachments.length).toBe(1); + const [attachment] = attachments; + expect(attachment.text).toContain('ミュート'); + const unchanged = ['title', 'title_link', 'mrkdwn_in', 'author_name'] as const; + const nulled = ['image_url', 'thumb_url'] as const; for (const key of unchanged) { - expect(attachments_res[i][key]).toEqual(attachments_req[i][key]); + expect(attachment[key]).toEqual(notification.main[key]); } - } - const nulled = ['image_url', 'thumb_url'] as const; - for (const key of nulled) { - expect(attachments_res[0][key]).toBeNull(); + for (const key of nulled) { + expect(attachment[key]).toBeNull(); + } + }); + }); + + describe('reconstructAttachments', () => { + it('restores original attachment parsed by splitAttachments', () => { + const gen = new FakeAttachmentGenerator(); + const attachments = [gen.get('text'), gen.get('img'), gen.get('img')] + const [notification] = splitAttachments(attachments); + const res = reconstructAttachments(notification); + expect(res).toEqual(attachments); + }); + }); + + describe('server', () => { + it(`mutes pages with ${muteTag} tag`, async () => { // eslint-disable-next-line array-plural/array-plural - expect(attachments_res[1][key]).toEqual(attachments_req[1][key]); - } - expect(attachments_res[0].text).toContain('ミュート'); - // eslint-disable-next-line array-plural/array-plural - expect(attachments_res[1].text).toBe(attachments_req[1].text); + const isMuted = [true, false]; + + const fakeChannel = 'CSCRAPBOX'; + process.env.CHANNEL_SCRAPBOX = fakeChannel; + const fastify = fastifyDevConstructor(); + const gen = new FakeAttachmentGenerator(); + jest.spyOn(Page.prototype, 'fetchInfo').mockImplementation( + () => Promise.resolve(set( + {}, + ['relatedPages', 'links1hop'], + isMuted.map((b, i) => ({b, i})).filter(({b}) => b).map(({i}) => ({titleLc: `タイトル_${i}`})), + ) as PageInfo), + // cast this because it is too demanding to completely write down all properties + ); + fastify.register(server(slack)); + + const separatedAttachments = [[ + gen.get('text'), + gen.get('img'), + gen.get('img'), + ], [ + gen.get('text'), + gen.get('img'), + ]]; + + const args = { + text: `New lines on `, + mrkdwn: true, + username: 'Scrapbox', + attachments: flatten(separatedAttachments), + }; + const messagePromise = waitEvent[0]>(slack, 'chat.postMessage'); + await fastify.inject({ + method: 'POST', + url: '/hooks/scrapbox', + payload: args, + }); + + const {channel, text, attachments: attachments_res} = await messagePromise; + expect(channel).toBe(fakeChannel); + expect(text).toBe(args.text); + expect(attachments_res.length).toBe( + sum(separatedAttachments.map((a, i) => isMuted[i] ? 1 : a.length)), + ); + }); }); }); diff --git a/scrapbox/index.ts b/scrapbox/index.ts index 9c50c92c..dd8f9ada 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -3,7 +3,7 @@ import axios from 'axios'; import logger from '../lib/logger.js'; import qs from 'querystring'; import plugin from 'fastify-plugin'; -import {zip} from 'lodash'; +import {flatten, zip} from 'lodash'; import {WebClient, RTMClient, LinkUnfurls, MessageAttachment} from '@slack/client'; import {Page, getPageUrlRegExp} from '../lib/scrapbox'; @@ -23,7 +23,7 @@ export default async ({rtmClient: rtm, webClient: slack, eventClient: event}: Sl const {url} = link; let page: Page | null = null; try { - page = new Page({ url }); + page = new Page({url}); } catch { continue; } @@ -69,59 +69,78 @@ export default async ({rtmClient: rtm, webClient: slack, eventClient: event}: Sl }; -interface SlackIncomingWebhookRequest { - text: string; - mrkdwn?: boolean; - username?: string; - attachments: MessageAttachment[]; +interface ScrapboxPageNotification { + main: MessageAttachment, + sub: MessageAttachment[], } +export const splitAttachments = (attachments: MessageAttachment[]): ScrapboxPageNotification[] => { + const pageIndices = attachments + .map(({title_link}, i) => ({url: title_link, i})) + .filter(({url}) => getPageUrlRegExp({projectName: null}).test(url)) + .map(({i}) => i); + const pageRange = zip(pageIndices, pageIndices.concat([attachments.length]).slice(1)); + return pageRange.map(([i, j]) => ({main: attachments[i], sub: attachments.slice(i + 1, j)})); +}; + /** - * ミュートしたいattachmentに対し,隠したい情報を消して返す + * ミュートしたい記事に対し,隠したい情報を消したattachmentsを生成 * - * @param attachment ミュートするattachment - * @return ミュート済みのattachment + * @param notification ミュートしたい記事のattachmentと画像 + * @return ミュート済みのattachments */ -const maskAttachment = (attachment: MessageAttachment): MessageAttachment => { +export const maskAttachments = (notification: ScrapboxPageNotification): MessageAttachment[] => { const dummyText = 'この記事の更新通知はミュートされています。'; - return { - ...attachment, + return [{ + ...notification.main, text: dummyText, fallback: dummyText, image_url: null, thumb_url: null, - }; + }]; }; +/** + * ミュートしたくない記事に対し,そのままattachments形式に変換 + * + * @param notification 変換する記事のattachmentと画像 + * @return ミュート済みのattachments + */ +export const reconstructAttachments = (notification: ScrapboxPageNotification): MessageAttachment[] => [notification.main, ...notification.sub]; + export const muteTag = '##ミュート'; const getMutedList = async (): Promise> => { - const muteTagPage = new Page({ titleLc: muteTag, isEncoded: false }); - return new Set((await muteTagPage.fetchInfo()).relatedPages.links1hop.map(({ titleLc }) => titleLc)); + const muteTagPage = new Page({titleLc: muteTag, isEncoded: false}); + return new Set((await muteTagPage.fetchInfo()).relatedPages.links1hop.map(({titleLc}) => titleLc)); }; -export const splitAttachments = (attachments: MessageAttachment[]): MessageAttachment[][] => { - const pageIndex = attachments - .map(({ title_link }, i) => ({ url: title_link, i })) - .filter(({ url }) => getPageUrlRegExp({ projectName: null }).test(url)) - .map(({ i }) => i); - const pageRange = zip(pageIndex, pageIndex.concat([attachments.length]).slice(1)); - return pageRange.map(([i, j]) => attachments.slice(i, j)); -}; +interface SlackIncomingWebhookRequest { + text: string; + mrkdwn?: boolean; + username?: string; + attachments: MessageAttachment[]; +} + /** * Scrapboxからの更新通知 (Incoming Webhook形式) を受け取り,ミュート処理をしてSlackに投稿する */ -// eslint-disable-next-line node/no-unsupported -features, node/no-unsupported-features/es-syntax +// eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { fastify.post('/hooks/scrapbox', async (req) => { const mutedList = await getMutedList(); + const attachments = flatten( + splitAttachments(req.body.attachments).map( + (notification) => mutedList.has(new Page({url: notification.main.title_link}).titleLc) + ? maskAttachments(notification) + : reconstructAttachments(notification), + ), + ); await slack.chat.postMessage( { channel: process.env.CHANNEL_SCRAPBOX, icon_emoji: ':scrapbox:', ...req.body, - attachments: await Promise.all(req.body.attachments.map( - async (attachment) => await mutedList.has(new Page({ url: attachment.title_link }).titleLc) ? maskAttachment(attachment) : attachment, - )), + attachments, }, ); return ''; From 5f6b6ad5d0591f38606c262d9327b280db188e29 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Wed, 4 Mar 2020 23:30:50 +0900 Subject: [PATCH 31/43] lib/scrapbox: fix unintended nullable --- lib/scrapbox.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/scrapbox.ts b/lib/scrapbox.ts index f3003eaf..bfdfc82c 100644 --- a/lib/scrapbox.ts +++ b/lib/scrapbox.ts @@ -6,9 +6,9 @@ export const tsgScrapboxToken = process.env.SCRAPBOX_SID!; const getPageUrlRegExpCache = new Map(); -export const getPageUrlRegExp = ({ projectName }: { projectName: string | null }) => { +export const getPageUrlRegExp = ({ projectName }: { projectName: string | null }): RegExp => { if (getPageUrlRegExpCache.has(projectName)) { - return getPageUrlRegExpCache.get(projectName); + return getPageUrlRegExpCache.get(projectName)!; } else { const regexp = new RegExp(`^https?${escapeRegExp('://scrapbox.io/')}${ projectName === null ? From cbc5f2faffa465a04324ff19a75b2873338cf47d Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Thu, 5 Mar 2020 05:03:05 +0900 Subject: [PATCH 32/43] scrapbox: fix eslintrc --- scrapbox/.eslintrc.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/scrapbox/.eslintrc.json b/scrapbox/.eslintrc.json index 49e46f01..1d677067 100644 --- a/scrapbox/.eslintrc.json +++ b/scrapbox/.eslintrc.json @@ -1,9 +1,6 @@ { - "extends": "@hakatashi", + "extends": ["plugin:jest/recommended", "@hakatashi"], "parser": "@typescript-eslint/parser", - "env": { - "jest/globals": true - }, "plugins": [ "jest" ], From 93aadce78a21972bea368c620e6ef3762d9a690d Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Thu, 5 Mar 2020 05:13:00 +0900 Subject: [PATCH 33/43] scrapbox: lint --- scrapbox/index.test.ts | 114 ++++++++++++++++++++--------------------- scrapbox/index.ts | 8 +-- 2 files changed, 59 insertions(+), 63 deletions(-) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 7e13aad6..1620d3c9 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -1,16 +1,16 @@ -jest.mock('axios'); - -// @ts-ignore -import Slack from '../lib/slackMock.js'; import EventEmitter from 'events'; -import { WebClient } from '@slack/web-api'; -import axios from 'axios'; import qs from 'querystring'; -import { flatten, set, sum, transform } from 'lodash'; -import { fastifyDevConstructor } from '../lib/fastify'; -import { MessageAttachment } from '@slack/client'; -import { Page, PageInfo } from '../lib/scrapbox'; +import {MessageAttachment} from '@slack/client'; +import {WebClient} from '@slack/web-api'; +import axios from 'axios'; +import {flatten, set, sum} from 'lodash'; +import {fastifyDevConstructor} from '../lib/fastify'; +import {Page, PageInfo} from '../lib/scrapbox'; +// @ts-ignore +import Slack from '../lib/slackMock.js'; +import scrapbox, {maskAttachments, reconstructAttachments, server, muteTag, splitAttachments} from './index'; +jest.mock('axios'); // @ts-ignore axios.response = {data: {title: 'hoge', descriptions: ['fuga', 'piyo']}}; @@ -19,89 +19,85 @@ let slack: Slack = null; const projectName = 'PROJECTNAME'; process.env.SCRAPBOX_PROJECT_NAME = projectName; -import scrapbox, { maskAttachments, reconstructAttachments } from './index'; -import {server, muteTag, splitAttachments} from './index'; describe('unfurl', () => { let fetchInfoSpy: jest.SpyInstance> | null = null; - + beforeEach(async () => { - fetchInfoSpy = jest.spyOn(Page.prototype, 'fetchInfo').mockImplementation(async () => - ({title: 'hoge', descriptions: ['fuga', 'piyo']} as PageInfo) + fetchInfoSpy = jest.spyOn(Page.prototype, 'fetchInfo') + .mockImplementation(() => Promise.resolve({title: 'hoge', descriptions: ['fuga', 'piyo']} as PageInfo)); // cast this because it is too demanding to completely write down all properties - ); slack = new Slack(); process.env.CHANNEL_SANDBOX = slack.fakeChannel; await scrapbox(slack); }); - + afterEach(() => { fetchInfoSpy!.mockRestore(); }); - + it('respond to slack hook of scrapbox unfurling', async () => { const done = new Promise((resolve) => { // @ts-ignore axios.mockImplementation(({url, data}: {url: string, data: any}) => { if (url === 'https://slack.com/api/chat.unfurl') { - const parsed = qs.parse(data); - const unfurls = JSON.parse(Array.isArray(parsed.unfurls) ? parsed.unfurls[0] : parsed.unfurls); - expect(unfurls[`https://scrapbox.io/${projectName}/hoge`]).toBeTruthy(); - expect(unfurls[`https://scrapbox.io/${projectName}/hoge`].text).toBe('fuga\npiyo'); - resolve(); - return Promise.resolve({data: {ok: true}}); - } else { + const parsed = qs.parse(data); + const unfurls = JSON.parse(Array.isArray(parsed.unfurls) ? parsed.unfurls[0] : parsed.unfurls); + expect(unfurls[`https://scrapbox.io/${projectName}/hoge`]).toBeTruthy(); + expect(unfurls[`https://scrapbox.io/${projectName}/hoge`].text).toBe('fuga\npiyo'); + resolve(); + return Promise.resolve({data: {ok: true}}); + } throw Error(`axios-mock: unknown URL: ${url}`); - } + }); }); + + slack.eventClient.emit('link_shared', { + type: 'link_shared', + channel: 'Cxxxxxx', + user: 'Uxxxxxxx', + message_ts: '123452389.9875', + thread_ts: '123456621.1855', + links: [ + { + domain: 'scrapbox.io', + url: `https://scrapbox.io/${projectName}/hoge`, + }, + ], + }); + + return done; }); - - slack.eventClient.emit('link_shared', { - type: 'link_shared', - channel: 'Cxxxxxx', - user: 'Uxxxxxxx', - message_ts: '123452389.9875', - thread_ts: '123456621.1855', - links: [ - { - domain: 'scrapbox.io', - url: `https://scrapbox.io/${projectName}/hoge`, - }, - ], - }); - - return done; -}); }); class FakeAttachmentGenerator { - main_i: number = 0; + i: number = 0; - sub_i: number = 0; + j: number = 0; get(kind: 'text' | 'img'): MessageAttachment { let a: MessageAttachment & { [key: string]: any } | null = null; switch (kind) { case 'text': { - const text = `page ${this.main_i}`; + const text = `page ${this.i}`; a = { - title: `タイトル ${this.main_i}`, - title_link: `https://scrapbox.io/${projectName}/${encodeURIComponent(`タイトル_${this.main_i}`)}#hash_${this.main_i}`, + title: `タイトル ${this.i}`, + title_link: `https://scrapbox.io/${projectName}/${encodeURIComponent(`タイトル_${this.i}`)}#hash_${this.i}`, text, rawText: text, mrkdwn_in: ['text' as const], - author_name: `user ${this.main_i}`, - image_url: `https://example.com/image_${this.main_i}.png`, - thumb_url: `https://example.com/thumb_${this.main_i}.png`, + author_name: `user ${this.i}`, + image_url: `https://example.com/image_${this.i}.png`, + thumb_url: `https://example.com/thumb_${this.i}.png`, }; - ++this.main_i; + ++this.i; break; } case 'img': { a = { - image_url: `https://example.com/image_${this.main_i}_${this.sub_i}.png`, + image_url: `https://example.com/image_${this.i}_${this.j}.png`, }; - ++this.sub_i; + ++this.j; break; } default: { @@ -112,8 +108,8 @@ class FakeAttachmentGenerator { } reset() { - this.main_i = 0; - this.sub_i = 0; + this.i = 0; + this.j = 0; } } @@ -171,7 +167,7 @@ describe('mute notification', () => { describe('reconstructAttachments', () => { it('restores original attachment parsed by splitAttachments', () => { const gen = new FakeAttachmentGenerator(); - const attachments = [gen.get('text'), gen.get('img'), gen.get('img')] + const attachments = [gen.get('text'), gen.get('img'), gen.get('img')]; const [notification] = splitAttachments(attachments); const res = reconstructAttachments(notification); expect(res).toEqual(attachments); @@ -219,10 +215,10 @@ describe('mute notification', () => { payload: args, }); - const {channel, text, attachments: attachments_res} = await messagePromise; + const {channel, text, attachments: resultAttachment} = await messagePromise; expect(channel).toBe(fakeChannel); expect(text).toBe(args.text); - expect(attachments_res.length).toBe( + expect(resultAttachment.length).toBe( sum(separatedAttachments.map((a, i) => isMuted[i] ? 1 : a.length)), ); }); diff --git a/scrapbox/index.ts b/scrapbox/index.ts index dd8f9ada..13f1724c 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -1,10 +1,10 @@ -import axios from 'axios'; -// @ts-ignore -import logger from '../lib/logger.js'; import qs from 'querystring'; +import {WebClient, RTMClient, LinkUnfurls, MessageAttachment} from '@slack/client'; +import axios from 'axios'; import plugin from 'fastify-plugin'; import {flatten, zip} from 'lodash'; -import {WebClient, RTMClient, LinkUnfurls, MessageAttachment} from '@slack/client'; +// @ts-ignore +import logger from '../lib/logger.js'; import {Page, getPageUrlRegExp} from '../lib/scrapbox'; interface SlackInterface { From 8ed67dce3909f3ffc235b192951444d9bdccd89d Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Thu, 5 Mar 2020 07:08:58 +0900 Subject: [PATCH 34/43] lib/scrapbox: getPageUrlRegExp -> pageUrlRegExp and isPageOfProject --- lib/scrapbox.test.ts | 47 ++++++++++++++++++++++++++++-------------- lib/scrapbox.ts | 20 +++++------------- scrapbox/index.test.ts | 8 +++++-- scrapbox/index.ts | 7 +++++-- 4 files changed, 48 insertions(+), 34 deletions(-) diff --git a/lib/scrapbox.test.ts b/lib/scrapbox.test.ts index a5f27bc6..95f0e96c 100644 --- a/lib/scrapbox.test.ts +++ b/lib/scrapbox.test.ts @@ -7,7 +7,7 @@ jest.mock('axios'); import _axios from 'axios'; const axios = _axios as jest.Mocked; -import { getPageUrlRegExp, fetchScrapboxUrl, Page } from './scrapbox'; +import { isPageOfProject, fetchScrapboxUrl, Page, pageUrlRegExp } from './scrapbox'; beforeEach(() => { axios.get.mockReset(); @@ -36,33 +36,50 @@ describe('fetchScrapboxUrl', () => { }); }); -describe('getPageUrlRegExp', () => { +describe('pageUrlRegExp', () => { const projectName = 'proj'; - const projectName2 = 'proj2'; const titleLc = 'タイトル'; const hash = 'hash'; it('parses URL without hash', () => { - const match = `https://scrapbox.io/${projectName}/${titleLc}`.match(getPageUrlRegExp({ projectName: null })); + const match = `https://scrapbox.io/${projectName}/${titleLc}`.match(pageUrlRegExp); expect(match).not.toBeNull(); expect(match!.groups).toMatchObject({ projectName, titleLc }); }); - it('parses URL without hash', () => { - const match = `https://scrapbox.io/${projectName}/${titleLc}#${hash}`.match(getPageUrlRegExp({ projectName: null })); + it('parses URL with hash', () => { + const match = `https://scrapbox.io/${projectName}/${titleLc}#${hash}`.match(pageUrlRegExp); expect(match).not.toBeNull(); expect(match!.groups).toMatchObject({ projectName, titleLc, hash }); }); +}); + +describe('isPageOfProject', () => { + const projectName = 'proj'; + const projectName2 = 'proj2'; + const titleLc = 'タイトル'; + + it('returns true for Scrapbox URL of specified project', () => { + const url = `https://scrapbox.io/${projectName}/${titleLc}`; + expect(isPageOfProject({ url, projectName })).toBe(true); + }); + + it('returns false for Scrapbox URL of specified project', () => { + const url = `https://scrapbox.io/${projectName2}/${titleLc}`; + expect(isPageOfProject({ url, projectName })).toBe(false); + }); + + it('returns false for strings not Scrapbox URL', () => { + const url = 'hoge'; + expect(isPageOfProject({ url, projectName })).toBe(false); + }); - it('parses URL when projectName specified', () => { - const url_ok = `https://scrapbox.io/${projectName}/${titleLc}`; - const regexp = getPageUrlRegExp({ projectName }); - const match_ok = url_ok.match(regexp); - expect(match_ok).not.toBeNull(); - expect(match_ok!.groups).toMatchObject({ titleLc }); - const url_ng = `https://scrapbox.io/${projectName2}/${titleLc}`; - const match_ng = url_ng.match(regexp); - expect(match_ng).toBeNull(); + test.each([ + {projectName: defaultProjectName, expected: true}, + {projectName: projectName, expected: false} + ])('uses projectName specified in envvar when not specified #%#', ({ projectName, expected }) => { + const url = `https://scrapbox.io/${projectName}/${titleLc}`; + expect(isPageOfProject({ url })).toBe(expected); }); }); diff --git a/lib/scrapbox.ts b/lib/scrapbox.ts index bfdfc82c..74602e51 100644 --- a/lib/scrapbox.ts +++ b/lib/scrapbox.ts @@ -1,23 +1,13 @@ import axios from 'axios'; -import { escapeRegExp } from 'lodash'; export const tsgProjectName = process.env.SCRAPBOX_PROJECT_NAME!; export const tsgScrapboxToken = process.env.SCRAPBOX_SID!; -const getPageUrlRegExpCache = new Map(); +export const pageUrlRegExp = /^https?:\/\/scrapbox\.io\/(?.+?)\/(?.+?)(?:#(?.*))?$/; -export const getPageUrlRegExp = ({ projectName }: { projectName: string | null }): RegExp => { - if (getPageUrlRegExpCache.has(projectName)) { - return getPageUrlRegExpCache.get(projectName)!; - } else { - const regexp = new RegExp(`^https?${escapeRegExp('://scrapbox.io/')}${ - projectName === null ? - '(?.+?)': - escapeRegExp(projectName) - }/(?.+?)(?:#(?.*))?$`); - getPageUrlRegExpCache.set(projectName, regexp); - return regexp; - } +export const isPageOfProject = ({ url, projectName = tsgProjectName }: { url: string, projectName?: string }) => { + const match = url.match(pageUrlRegExp); + return match !== null && match.groups!.projectName === projectName; }; export const fetchScrapboxUrl = async ({ url, token = tsgScrapboxToken }: { url: string; token?: string }): Promise => { @@ -112,7 +102,7 @@ const encodeIfNeeded = ({ str, isEncoded = undefined }: { str: string; isEncoded } const parsePageUrl = (url: string): { titleLc: string; projectName: string; hash: string } => { - const match = url.match(getPageUrlRegExp({ projectName: null })); + const match = url.match(pageUrlRegExp); if (match) { const { titleLc, projectName, hash } = match.groups!; return { titleLc, projectName, hash }; diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 1620d3c9..274fc3b4 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -5,10 +5,8 @@ import {WebClient} from '@slack/web-api'; import axios from 'axios'; import {flatten, set, sum} from 'lodash'; import {fastifyDevConstructor} from '../lib/fastify'; -import {Page, PageInfo} from '../lib/scrapbox'; // @ts-ignore import Slack from '../lib/slackMock.js'; -import scrapbox, {maskAttachments, reconstructAttachments, server, muteTag, splitAttachments} from './index'; jest.mock('axios'); @@ -20,6 +18,12 @@ let slack: Slack = null; const projectName = 'PROJECTNAME'; process.env.SCRAPBOX_PROJECT_NAME = projectName; + +// eslint-disable-next-line import/first, import/imports-first, import/order +import {Page, PageInfo} from '../lib/scrapbox'; +// eslint-disable-next-line import/first, import/imports-first +import scrapbox, {maskAttachments, reconstructAttachments, server, muteTag, splitAttachments} from './index'; + describe('unfurl', () => { let fetchInfoSpy: jest.SpyInstance> | null = null; diff --git a/scrapbox/index.ts b/scrapbox/index.ts index 13f1724c..0b535d74 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -5,7 +5,7 @@ import plugin from 'fastify-plugin'; import {flatten, zip} from 'lodash'; // @ts-ignore import logger from '../lib/logger.js'; -import {Page, getPageUrlRegExp} from '../lib/scrapbox'; +import {Page, pageUrlRegExp, tsgProjectName} from '../lib/scrapbox'; interface SlackInterface { rtmClient: RTMClient, @@ -27,6 +27,9 @@ export default async ({rtmClient: rtm, webClient: slack, eventClient: event}: Sl } catch { continue; } + if (page.projectName !== tsgProjectName) { + continue; + } const data = await page.fetchInfo(); unfurls[url] = { @@ -77,7 +80,7 @@ interface ScrapboxPageNotification { export const splitAttachments = (attachments: MessageAttachment[]): ScrapboxPageNotification[] => { const pageIndices = attachments .map(({title_link}, i) => ({url: title_link, i})) - .filter(({url}) => getPageUrlRegExp({projectName: null}).test(url)) + .filter(({url}) => pageUrlRegExp.test(url)) .map(({i}) => i); const pageRange = zip(pageIndices, pageIndices.concat([attachments.length]).slice(1)); return pageRange.map(([i, j]) => ({main: attachments[i], sub: attachments.slice(i + 1, j)})); From 6e16349edf09787f9f91ef3984b0bf008ae5b7b6 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Thu, 5 Mar 2020 07:42:32 +0900 Subject: [PATCH 35/43] scrapbox: add doc --- lib/scrapbox.ts | 27 ++++++++++++++++++++++++++- scrapbox/index.test.ts | 18 +++++++++--------- scrapbox/index.ts | 34 +++++++++++++++++++++------------- 3 files changed, 56 insertions(+), 23 deletions(-) diff --git a/lib/scrapbox.ts b/lib/scrapbox.ts index 74602e51..7646a289 100644 --- a/lib/scrapbox.ts +++ b/lib/scrapbox.ts @@ -3,18 +3,28 @@ import axios from 'axios'; export const tsgProjectName = process.env.SCRAPBOX_PROJECT_NAME!; export const tsgScrapboxToken = process.env.SCRAPBOX_SID!; +/** + * ScrapboxのURLにマッチする正規表現 + * Groups: { projectName, titleLc, hash } + */ export const pageUrlRegExp = /^https?:\/\/scrapbox\.io\/(?.+?)\/(?.+?)(?:#(?.*))?$/; +/** + * URLが指定したプロジェクトのURLか判定 + */ export const isPageOfProject = ({ url, projectName = tsgProjectName }: { url: string, projectName?: string }) => { const match = url.match(pageUrlRegExp); return match !== null && match.groups!.projectName === projectName; }; +/** + * ScrapboxのURLをトークンをつけてGETリクエスト + */ export const fetchScrapboxUrl = async ({ url, token = tsgScrapboxToken }: { url: string; token?: string }): Promise => { // TODO: support axios config return (await axios.get( url, - { headers: { Cookie: `connect.sid=${token ?? tsgScrapboxToken}` } }, + { headers: { Cookie: `connect.sid=${token}` } }, )).data; } @@ -44,6 +54,9 @@ export interface Link { accessed: number; } +/** + * Scrapbox APIのページ情報の返り値 + */ export interface PageInfo { id: string; title: string; @@ -111,6 +124,9 @@ const parsePageUrl = (url: string): { titleLc: string; projectName: string; hash } }; +/** + * Scrapboxの記事 + */ export class Page { token: string; projectName: string; @@ -157,14 +173,23 @@ export class Page { } } + /** + * Scrapbox記事のURL + */ get url(): string { return `https://scrapbox.io/${this.projectName}/${this.encodedTitleLc}${this.hash? `#${this.hash}` : ''}` } + /** + * ページ情報APIのURL + */ get infoUrl(): string { return `https://scrapbox.io/api/pages/${this.projectName}/${this.encodedTitleLc}`; } + /** + * ページ情報をAPIから取得 + */ async fetchInfo(): Promise { return fetchScrapboxUrl({ url: this.infoUrl, token: this.token }); } diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 274fc3b4..eae897fe 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -132,14 +132,14 @@ describe('mute notification', () => { gen.reset(); // eslint-disable-next-line array-plural/array-plural const expected = [{ - main: gen.get('text'), - sub: [gen.get('img'), gen.get('img')], + text: gen.get('text'), + images: [gen.get('img'), gen.get('img')], }, { - main: gen.get('text'), - sub: [], + text: gen.get('text'), + images: [], }, { - main: gen.get('text'), - sub: [gen.get('img')], + text: gen.get('text'), + images: [gen.get('img')], }]; const splittedAttachments = splitAttachments(attachments); expect(splittedAttachments).toEqual(expected); @@ -150,8 +150,8 @@ describe('mute notification', () => { it('conceals values of notification', () => { const gen = new FakeAttachmentGenerator(); const notification = { - main: gen.get('text'), - sub: [gen.get('img'), gen.get('img')], + text: gen.get('text'), + images: [gen.get('img'), gen.get('img')], }; const attachments = maskAttachments(notification); expect(attachments.length).toBe(1); @@ -160,7 +160,7 @@ describe('mute notification', () => { const unchanged = ['title', 'title_link', 'mrkdwn_in', 'author_name'] as const; const nulled = ['image_url', 'thumb_url'] as const; for (const key of unchanged) { - expect(attachment[key]).toEqual(notification.main[key]); + expect(attachment[key]).toEqual(notification.text[key]); } for (const key of nulled) { expect(attachment[key]).toBeNull(); diff --git a/scrapbox/index.ts b/scrapbox/index.ts index 0b535d74..8abde4fc 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -71,31 +71,40 @@ export default async ({rtmClient: rtm, webClient: slack, eventClient: event}: Sl }); }; - +/** + * 1つのScrapbox記事に関する通知を表すオブジェクト + * @property text: 文章の更新についてのattachment + * @property images: 添付画像のattachment[] + */ interface ScrapboxPageNotification { - main: MessageAttachment, - sub: MessageAttachment[], + text: MessageAttachment, + images: MessageAttachment[], } +/** + * Scrapboxからの通知attachments全体を記事ごとに分け,通知オブジェクトに変換 + * @param attachments: 複数の記事に関するattachments + * @returns 通知オブジェクトの配列 + */ export const splitAttachments = (attachments: MessageAttachment[]): ScrapboxPageNotification[] => { const pageIndices = attachments .map(({title_link}, i) => ({url: title_link, i})) .filter(({url}) => pageUrlRegExp.test(url)) .map(({i}) => i); const pageRange = zip(pageIndices, pageIndices.concat([attachments.length]).slice(1)); - return pageRange.map(([i, j]) => ({main: attachments[i], sub: attachments.slice(i + 1, j)})); + return pageRange.map(([i, j]) => ({text: attachments[i], images: attachments.slice(i + 1, j)})); }; /** * ミュートしたい記事に対し,隠したい情報を消したattachmentsを生成 - * - * @param notification ミュートしたい記事のattachmentと画像 + * 文章の更新は一部を隠した上で返し,画像の更新は全て消す + * @param notification ミュートしたい記事の通知オブジェクト * @return ミュート済みのattachments */ export const maskAttachments = (notification: ScrapboxPageNotification): MessageAttachment[] => { const dummyText = 'この記事の更新通知はミュートされています。'; return [{ - ...notification.main, + ...notification.text, text: dummyText, fallback: dummyText, image_url: null, @@ -104,12 +113,11 @@ export const maskAttachments = (notification: ScrapboxPageNotification): Message }; /** - * ミュートしたくない記事に対し,そのままattachments形式に変換 - * - * @param notification 変換する記事のattachmentと画像 - * @return ミュート済みのattachments + * 記事の通知オブジェクトをそのままattachments形式に変換 + * @param notification 変換する記事の通知オブジェクト + * @return 変換されたattachments */ -export const reconstructAttachments = (notification: ScrapboxPageNotification): MessageAttachment[] => [notification.main, ...notification.sub]; +export const reconstructAttachments = (notification: ScrapboxPageNotification): MessageAttachment[] => [notification.text, ...notification.images]; export const muteTag = '##ミュート'; const getMutedList = async (): Promise> => { @@ -133,7 +141,7 @@ export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, o const mutedList = await getMutedList(); const attachments = flatten( splitAttachments(req.body.attachments).map( - (notification) => mutedList.has(new Page({url: notification.main.title_link}).titleLc) + (notification) => mutedList.has(new Page({url: notification.text.title_link}).titleLc) ? maskAttachments(notification) : reconstructAttachments(notification), ), From 2cead8bd63a08c744d46b05f213dcf245ba61ef5 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Thu, 5 Mar 2020 09:24:28 +0900 Subject: [PATCH 36/43] scrapbox: split files --- index.js | 1 + scrapbox/index.test.ts | 162 +----------------------------------- scrapbox/index.ts | 96 +--------------------- scrapbox/mute.test.ts | 181 +++++++++++++++++++++++++++++++++++++++++ scrapbox/mute.ts | 101 +++++++++++++++++++++++ 5 files changed, 286 insertions(+), 255 deletions(-) create mode 100644 scrapbox/mute.test.ts create mode 100644 scrapbox/mute.ts diff --git a/index.js b/index.js index 87760ee7..35ddcadc 100644 --- a/index.js +++ b/index.js @@ -31,6 +31,7 @@ const allBots = [ // ...(word2vecInstalled ? ['vocabwar'] : []), 'ricochet-robots', 'scrapbox', + 'scrapbox/mute', 'slack-log', 'welcome', 'deploy', diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index eae897fe..41fa5d5f 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -1,10 +1,5 @@ -import EventEmitter from 'events'; import qs from 'querystring'; -import {MessageAttachment} from '@slack/client'; -import {WebClient} from '@slack/web-api'; import axios from 'axios'; -import {flatten, set, sum} from 'lodash'; -import {fastifyDevConstructor} from '../lib/fastify'; // @ts-ignore import Slack from '../lib/slackMock.js'; @@ -22,7 +17,7 @@ process.env.SCRAPBOX_PROJECT_NAME = projectName; // eslint-disable-next-line import/first, import/imports-first, import/order import {Page, PageInfo} from '../lib/scrapbox'; // eslint-disable-next-line import/first, import/imports-first -import scrapbox, {maskAttachments, reconstructAttachments, server, muteTag, splitAttachments} from './index'; +import scrapbox from './index'; describe('unfurl', () => { let fetchInfoSpy: jest.SpyInstance> | null = null; @@ -73,158 +68,3 @@ describe('unfurl', () => { return done; }); }); - -class FakeAttachmentGenerator { - i: number = 0; - - j: number = 0; - - get(kind: 'text' | 'img'): MessageAttachment { - let a: MessageAttachment & { [key: string]: any } | null = null; - switch (kind) { - case 'text': { - const text = `page ${this.i}`; - a = { - title: `タイトル ${this.i}`, - title_link: `https://scrapbox.io/${projectName}/${encodeURIComponent(`タイトル_${this.i}`)}#hash_${this.i}`, - text, - rawText: text, - mrkdwn_in: ['text' as const], - author_name: `user ${this.i}`, - image_url: `https://example.com/image_${this.i}.png`, - thumb_url: `https://example.com/thumb_${this.i}.png`, - }; - ++this.i; - break; - } - case 'img': { - a = { - image_url: `https://example.com/image_${this.i}_${this.j}.png`, - }; - ++this.j; - break; - } - default: { - a = kind; - } - } - return a; - } - - reset() { - this.i = 0; - this.j = 0; - } -} - -const waitEvent = (eventEmitter: EventEmitter, event: string): Promise => new Promise((resolve) => { - eventEmitter.once(event, (args) => { - resolve(args); - }); -}); - - -describe('mute notification', () => { - describe('splitAttachments', () => { - it('splits attachments to each pages', () => { - const gen = new FakeAttachmentGenerator(); - const attachments = (['text', 'img', 'img', 'text', 'text', 'img'] as const).map((s) => gen.get(s)); - gen.reset(); - // eslint-disable-next-line array-plural/array-plural - const expected = [{ - text: gen.get('text'), - images: [gen.get('img'), gen.get('img')], - }, { - text: gen.get('text'), - images: [], - }, { - text: gen.get('text'), - images: [gen.get('img')], - }]; - const splittedAttachments = splitAttachments(attachments); - expect(splittedAttachments).toEqual(expected); - }); - }); - - describe('maskAttachments', () => { - it('conceals values of notification', () => { - const gen = new FakeAttachmentGenerator(); - const notification = { - text: gen.get('text'), - images: [gen.get('img'), gen.get('img')], - }; - const attachments = maskAttachments(notification); - expect(attachments.length).toBe(1); - const [attachment] = attachments; - expect(attachment.text).toContain('ミュート'); - const unchanged = ['title', 'title_link', 'mrkdwn_in', 'author_name'] as const; - const nulled = ['image_url', 'thumb_url'] as const; - for (const key of unchanged) { - expect(attachment[key]).toEqual(notification.text[key]); - } - for (const key of nulled) { - expect(attachment[key]).toBeNull(); - } - }); - }); - - describe('reconstructAttachments', () => { - it('restores original attachment parsed by splitAttachments', () => { - const gen = new FakeAttachmentGenerator(); - const attachments = [gen.get('text'), gen.get('img'), gen.get('img')]; - const [notification] = splitAttachments(attachments); - const res = reconstructAttachments(notification); - expect(res).toEqual(attachments); - }); - }); - - describe('server', () => { - it(`mutes pages with ${muteTag} tag`, async () => { - // eslint-disable-next-line array-plural/array-plural - const isMuted = [true, false]; - - const fakeChannel = 'CSCRAPBOX'; - process.env.CHANNEL_SCRAPBOX = fakeChannel; - const fastify = fastifyDevConstructor(); - const gen = new FakeAttachmentGenerator(); - jest.spyOn(Page.prototype, 'fetchInfo').mockImplementation( - () => Promise.resolve(set( - {}, - ['relatedPages', 'links1hop'], - isMuted.map((b, i) => ({b, i})).filter(({b}) => b).map(({i}) => ({titleLc: `タイトル_${i}`})), - ) as PageInfo), - // cast this because it is too demanding to completely write down all properties - ); - fastify.register(server(slack)); - - const separatedAttachments = [[ - gen.get('text'), - gen.get('img'), - gen.get('img'), - ], [ - gen.get('text'), - gen.get('img'), - ]]; - - const args = { - text: `New lines on `, - mrkdwn: true, - username: 'Scrapbox', - attachments: flatten(separatedAttachments), - }; - const messagePromise = waitEvent[0]>(slack, 'chat.postMessage'); - await fastify.inject({ - method: 'POST', - url: '/hooks/scrapbox', - payload: args, - }); - - const {channel, text, attachments: resultAttachment} = await messagePromise; - expect(channel).toBe(fakeChannel); - expect(text).toBe(args.text); - expect(resultAttachment.length).toBe( - sum(separatedAttachments.map((a, i) => isMuted[i] ? 1 : a.length)), - ); - }); - }); -}); diff --git a/scrapbox/index.ts b/scrapbox/index.ts index 8abde4fc..7df354de 100644 --- a/scrapbox/index.ts +++ b/scrapbox/index.ts @@ -1,11 +1,9 @@ import qs from 'querystring'; -import {WebClient, RTMClient, LinkUnfurls, MessageAttachment} from '@slack/client'; +import {WebClient, RTMClient, LinkUnfurls} from '@slack/client'; import axios from 'axios'; -import plugin from 'fastify-plugin'; -import {flatten, zip} from 'lodash'; // @ts-ignore import logger from '../lib/logger.js'; -import {Page, pageUrlRegExp, tsgProjectName} from '../lib/scrapbox'; +import {Page, tsgProjectName} from '../lib/scrapbox'; interface SlackInterface { rtmClient: RTMClient, @@ -70,93 +68,3 @@ export default async ({rtmClient: rtm, webClient: slack, eventClient: event}: Sl } }); }; - -/** - * 1つのScrapbox記事に関する通知を表すオブジェクト - * @property text: 文章の更新についてのattachment - * @property images: 添付画像のattachment[] - */ -interface ScrapboxPageNotification { - text: MessageAttachment, - images: MessageAttachment[], -} - -/** - * Scrapboxからの通知attachments全体を記事ごとに分け,通知オブジェクトに変換 - * @param attachments: 複数の記事に関するattachments - * @returns 通知オブジェクトの配列 - */ -export const splitAttachments = (attachments: MessageAttachment[]): ScrapboxPageNotification[] => { - const pageIndices = attachments - .map(({title_link}, i) => ({url: title_link, i})) - .filter(({url}) => pageUrlRegExp.test(url)) - .map(({i}) => i); - const pageRange = zip(pageIndices, pageIndices.concat([attachments.length]).slice(1)); - return pageRange.map(([i, j]) => ({text: attachments[i], images: attachments.slice(i + 1, j)})); -}; - -/** - * ミュートしたい記事に対し,隠したい情報を消したattachmentsを生成 - * 文章の更新は一部を隠した上で返し,画像の更新は全て消す - * @param notification ミュートしたい記事の通知オブジェクト - * @return ミュート済みのattachments - */ -export const maskAttachments = (notification: ScrapboxPageNotification): MessageAttachment[] => { - const dummyText = 'この記事の更新通知はミュートされています。'; - return [{ - ...notification.text, - text: dummyText, - fallback: dummyText, - image_url: null, - thumb_url: null, - }]; -}; - -/** - * 記事の通知オブジェクトをそのままattachments形式に変換 - * @param notification 変換する記事の通知オブジェクト - * @return 変換されたattachments - */ -export const reconstructAttachments = (notification: ScrapboxPageNotification): MessageAttachment[] => [notification.text, ...notification.images]; - -export const muteTag = '##ミュート'; -const getMutedList = async (): Promise> => { - const muteTagPage = new Page({titleLc: muteTag, isEncoded: false}); - return new Set((await muteTagPage.fetchInfo()).relatedPages.links1hop.map(({titleLc}) => titleLc)); -}; - -interface SlackIncomingWebhookRequest { - text: string; - mrkdwn?: boolean; - username?: string; - attachments: MessageAttachment[]; -} - -/** - * Scrapboxからの更新通知 (Incoming Webhook形式) を受け取り,ミュート処理をしてSlackに投稿する - */ -// eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax -export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { - fastify.post('/hooks/scrapbox', async (req) => { - const mutedList = await getMutedList(); - const attachments = flatten( - splitAttachments(req.body.attachments).map( - (notification) => mutedList.has(new Page({url: notification.text.title_link}).titleLc) - ? maskAttachments(notification) - : reconstructAttachments(notification), - ), - ); - await slack.chat.postMessage( - { - channel: process.env.CHANNEL_SCRAPBOX, - icon_emoji: ':scrapbox:', - ...req.body, - attachments, - }, - ); - return ''; - }); - - next(); -}); - diff --git a/scrapbox/mute.test.ts b/scrapbox/mute.test.ts new file mode 100644 index 00000000..2d5bee15 --- /dev/null +++ b/scrapbox/mute.test.ts @@ -0,0 +1,181 @@ +import EventEmitter from 'events'; +import {MessageAttachment} from '@slack/client'; +import {WebClient} from '@slack/web-api'; +import {flatten, set, sum} from 'lodash'; +import {fastifyDevConstructor} from '../lib/fastify'; +// @ts-ignore +import Slack from '../lib/slackMock.js'; + +const projectName = 'PROJECTNAME'; +process.env.SCRAPBOX_PROJECT_NAME = projectName; + +// eslint-disable-next-line import/first, import/imports-first, import/order +import {Page, PageInfo} from '../lib/scrapbox'; +// eslint-disable-next-line import/first, import/imports-first +import {maskAttachments, reconstructAttachments, server, muteTag, splitAttachments} from './mute'; +import { FastifyInstance } from 'fastify'; + +class FakeAttachmentGenerator { + i: number = 0; + + j: number = 0; + + get(kind: 'text' | 'img'): MessageAttachment { + let a: MessageAttachment & { [key: string]: any } | null = null; + switch (kind) { + case 'text': { + const text = `page ${this.i}`; + a = { + title: `タイトル ${this.i}`, + title_link: `https://scrapbox.io/${projectName}/${encodeURIComponent(`タイトル_${this.i}`)}#hash_${this.i}`, + text, + rawText: text, + mrkdwn_in: ['text' as const], + author_name: `user ${this.i}`, + image_url: `https://example.com/image_${this.i}.png`, + thumb_url: `https://example.com/thumb_${this.i}.png`, + }; + ++this.i; + break; + } + case 'img': { + a = { + image_url: `https://example.com/image_${this.i}_${this.j}.png`, + }; + ++this.j; + break; + } + default: { + a = kind; + } + } + return a; + } + + reset() { + this.i = 0; + this.j = 0; + } +} + +const waitEvent = (eventEmitter: EventEmitter, event: string): Promise => new Promise((resolve) => { + eventEmitter.once(event, (args) => { + resolve(args); + }); +}); + +describe('mute notification', () => { + describe('splitAttachments', () => { + it('splits attachments to each pages', () => { + const gen = new FakeAttachmentGenerator(); + const attachments = (['text', 'img', 'img', 'text', 'text', 'img'] as const).map((s) => gen.get(s)); + gen.reset(); + // eslint-disable-next-line array-plural/array-plural + const expected = [{ + text: gen.get('text'), + images: [gen.get('img'), gen.get('img')], + }, { + text: gen.get('text'), + images: [], + }, { + text: gen.get('text'), + images: [gen.get('img')], + }]; + const splittedAttachments = splitAttachments(attachments); + expect(splittedAttachments).toEqual(expected); + }); + }); + + describe('maskAttachments', () => { + it('conceals values of notification', () => { + const gen = new FakeAttachmentGenerator(); + const notification = { + text: gen.get('text'), + images: [gen.get('img'), gen.get('img')], + }; + const attachments = maskAttachments(notification); + expect(attachments.length).toBe(1); + const [attachment] = attachments; + expect(attachment.text).toContain('ミュート'); + const unchanged = ['title', 'title_link', 'mrkdwn_in', 'author_name'] as const; + const nulled = ['image_url', 'thumb_url'] as const; + for (const key of unchanged) { + expect(attachment[key]).toEqual(notification.text[key]); + } + for (const key of nulled) { + expect(attachment[key]).toBeNull(); + } + }); + }); + + describe('reconstructAttachments', () => { + it('restores original attachment parsed by splitAttachments', () => { + const gen = new FakeAttachmentGenerator(); + const attachments = [gen.get('text'), gen.get('img'), gen.get('img')]; + const [notification] = splitAttachments(attachments); + const res = reconstructAttachments(notification); + expect(res).toEqual(attachments); + }); + }); + + describe('server', () => { + const fakeChannel = 'CSCRAPBOX'; + let fastify: FastifyInstance | null = null; + let slack: Slack | null = null; + + beforeAll(() => { + process.env.CHANNEL_SCRAPBOX = fakeChannel; + }); + + beforeEach(() => { + slack = new Slack(); + fastify = fastifyDevConstructor(); + fastify.register(server(slack)); + }); + + it(`mutes pages with ${muteTag} tag`, async () => { + // eslint-disable-next-line array-plural/array-plural + const isMuted = [true, false]; + const fetchInfoSpy = jest.spyOn(Page.prototype, 'fetchInfo').mockImplementation( + () => Promise.resolve(set( + {}, + ['relatedPages', 'links1hop'], + isMuted.map((b, i) => ({b, i})).filter(({b}) => b).map(({i}) => ({titleLc: `タイトル_${i}`})), + ) as PageInfo), + // cast this because it is too demanding to completely write down all properties + ); + const gen = new FakeAttachmentGenerator(); + + const separatedAttachments = [[ + gen.get('text'), + gen.get('img'), + gen.get('img'), + ], [ + gen.get('text'), + gen.get('img'), + ]]; + + const args = { + text: `New lines on `, + mrkdwn: true, + username: 'Scrapbox', + attachments: flatten(separatedAttachments), + }; + const messagePromise = waitEvent[0]>(slack, 'chat.postMessage'); + await fastify.inject({ + method: 'POST', + url: '/hooks/scrapbox', + payload: args, + }); + + const {channel, text, attachments: resultAttachment} = await messagePromise; + expect(channel).toBe(fakeChannel); + expect(text).toBe(args.text); + expect(resultAttachment.length).toBe( + sum(separatedAttachments.map((a, i) => isMuted[i] ? 1 : a.length)), + ); + + fetchInfoSpy!.mockRestore(); + }); + }); +}); diff --git a/scrapbox/mute.ts b/scrapbox/mute.ts new file mode 100644 index 00000000..6c57a895 --- /dev/null +++ b/scrapbox/mute.ts @@ -0,0 +1,101 @@ +import {WebClient, RTMClient, MessageAttachment} from '@slack/client'; +import plugin from 'fastify-plugin'; +import {flatten, zip} from 'lodash'; +// @ts-ignore +import {Page, pageUrlRegExp} from '../lib/scrapbox'; + + +interface SlackInterface { + rtmClient: RTMClient, + webClient: WebClient, + eventClient: any, +} + +/** + * 1つのScrapbox記事に関する通知を表すオブジェクト + * @property text: 文章の更新についてのattachment + * @property images: 添付画像のattachment[] + */ +interface ScrapboxPageNotification { + text: MessageAttachment, + images: MessageAttachment[], +} + +/** + * Scrapboxからの通知attachments全体を記事ごとに分け,通知オブジェクトに変換 + * @param attachments: 複数の記事に関するattachments + * @returns 通知オブジェクトの配列 + */ +export const splitAttachments = (attachments: MessageAttachment[]): ScrapboxPageNotification[] => { + const pageIndices = attachments + .map(({title_link}, i) => ({url: title_link, i})) + .filter(({url}) => pageUrlRegExp.test(url)) + .map(({i}) => i); + const pageRange = zip(pageIndices, pageIndices.concat([attachments.length]).slice(1)); + return pageRange.map(([i, j]) => ({text: attachments[i], images: attachments.slice(i + 1, j)})); +}; + +/** + * ミュートしたい記事に対し,隠したい情報を消したattachmentsを生成 + * 文章の更新は一部を隠した上で返し,画像の更新は全て消す + * @param notification ミュートしたい記事の通知オブジェクト + * @return ミュート済みのattachments + */ +export const maskAttachments = (notification: ScrapboxPageNotification): MessageAttachment[] => { + const dummyText = 'この記事の更新通知はミュートされています。'; + return [{ + ...notification.text, + text: dummyText, + fallback: dummyText, + image_url: null, + thumb_url: null, + }]; +}; + +/** + * 記事の通知オブジェクトをそのままattachments形式に変換 + * @param notification 変換する記事の通知オブジェクト + * @return 変換されたattachments + */ +export const reconstructAttachments = (notification: ScrapboxPageNotification): MessageAttachment[] => [notification.text, ...notification.images]; + +export const muteTag = '##ミュート'; +const getMutedList = async (): Promise> => { + const muteTagPage = new Page({titleLc: muteTag, isEncoded: false}); + return new Set((await muteTagPage.fetchInfo()).relatedPages.links1hop.map(({titleLc}) => titleLc)); +}; + +interface SlackIncomingWebhookRequest { + text: string; + mrkdwn?: boolean; + username?: string; + attachments: MessageAttachment[]; +} + +/** + * Scrapboxからの更新通知 (Incoming Webhook形式) を受け取り,ミュート処理をしてSlackに投稿する + */ +// eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax +export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { + fastify.post('/hooks/scrapbox', async (req) => { + const mutedList = await getMutedList(); + const attachments = flatten( + splitAttachments(req.body.attachments).map( + (notification) => mutedList.has(new Page({url: notification.text.title_link}).titleLc) + ? maskAttachments(notification) + : reconstructAttachments(notification), + ), + ); + await slack.chat.postMessage( + { + channel: process.env.CHANNEL_SCRAPBOX, + icon_emoji: ':scrapbox:', + ...req.body, + attachments, + }, + ); + return ''; + }); + + next(); +}); From f435b6dd2326237984765d8455445b44b631da75 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Thu, 5 Mar 2020 10:16:27 +0900 Subject: [PATCH 37/43] scrapbox/mute: add text: '' in image attachments --- scrapbox/mute.test.ts | 5 +++-- scrapbox/mute.ts | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/scrapbox/mute.test.ts b/scrapbox/mute.test.ts index 2d5bee15..e1dce289 100644 --- a/scrapbox/mute.test.ts +++ b/scrapbox/mute.test.ts @@ -82,7 +82,8 @@ describe('mute notification', () => { images: [gen.get('img')], }]; const splittedAttachments = splitAttachments(attachments); - expect(splittedAttachments).toEqual(expected); + expect(splittedAttachments).toMatchObject(expected); + splittedAttachments.forEach(({images}) => images.forEach(a => expect(a.text).toBe(''))); }); }); @@ -114,7 +115,7 @@ describe('mute notification', () => { const attachments = [gen.get('text'), gen.get('img'), gen.get('img')]; const [notification] = splitAttachments(attachments); const res = reconstructAttachments(notification); - expect(res).toEqual(attachments); + expect(res).toMatchObject(attachments); }); }); diff --git a/scrapbox/mute.ts b/scrapbox/mute.ts index 6c57a895..f57ca613 100644 --- a/scrapbox/mute.ts +++ b/scrapbox/mute.ts @@ -32,7 +32,10 @@ export const splitAttachments = (attachments: MessageAttachment[]): ScrapboxPage .filter(({url}) => pageUrlRegExp.test(url)) .map(({i}) => i); const pageRange = zip(pageIndices, pageIndices.concat([attachments.length]).slice(1)); - return pageRange.map(([i, j]) => ({text: attachments[i], images: attachments.slice(i + 1, j)})); + return pageRange.map(([i, j]) => ({ + text: attachments[i], + images: attachments.slice(i + 1, j).map((a) => ({text: '', ...a})), + })); }; /** From 2e717b06a1b6d3c6eab098572562492c581c4f2c Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Mon, 9 Mar 2020 10:31:30 +0900 Subject: [PATCH 38/43] lib/scrapbox: refactoring --- lib/scrapbox.ts | 61 +++++++++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/lib/scrapbox.ts b/lib/scrapbox.ts index 7646a289..cc91658e 100644 --- a/lib/scrapbox.ts +++ b/lib/scrapbox.ts @@ -128,12 +128,33 @@ const parsePageUrl = (url: string): { titleLc: string; projectName: string; hash * Scrapboxの記事 */ export class Page { + /** Scrapbox SID */ token: string; + + /** Scrapbox プロジェクト名 */ projectName: string; + + /** URIエンコードされたtitleLc */ encodedTitleLc: string; - titleLc: string; + + /** URIエンコードされていないtitleLc */ + titleLc: string + + /** URL末尾のhash */ hash?: string; + /** + * Scrapboxの記事 + * + * URLあるいはtitleLc, [プロジェクト名, hash]を指定可能 + * + * @param args.token - Scrapbox SID + * @param args.isEncoded - URLのtitleLc部分がURIエンコード済みかどうか. デフォルトではurlまたはtitleLcから判断. + * @param args.url - Scrapbox 記事のURL + * @param args.titleLc - URL上の記事タイトル. スペースが_に変換されるなど,表示上のタイトルとは異なる場合がある. + * @param args.projectName - Scrapboxプロジェクト名. デフォルトでは環境変数を用いる + * @param hash - URL末尾のhash + */ constructor(args: { token?: string; isEncoded?: boolean; @@ -145,32 +166,18 @@ export class Page { hash?: string })) { this.token = args.token ?? tsgScrapboxToken; - if ('titleLc' in args) { - // specified titleLc - const { titleLc, projectName, hash, isEncoded: isEncodedGiven } = args; - const { str: encodedTitleLc, isEncoded } = encodeIfNeeded({ str: titleLc, isEncoded: isEncodedGiven }); - this.encodedTitleLc = encodedTitleLc; - this.titleLc = decodeIfNeeded({ str: titleLc, isEncoded }).str; - this.projectName = projectName ?? tsgProjectName; - this.hash = hash; - } else if ('url' in args) { - // specified url - const { url, isEncoded: isEncodedGiven } = args; - const { titleLc, projectName, hash } = parsePageUrl(url); - const { str: encodedTitleLc, isEncoded } = encodeIfNeeded({ str: titleLc, isEncoded: isEncodedGiven }); - this.encodedTitleLc = encodedTitleLc; - this.projectName = projectName; - this.hash = hash; - this.titleLc = decodeIfNeeded({ str: titleLc, isEncoded: isEncoded }).str; - } else { - // TODO: do exhaustive check - // this check fails because of a bug of TypeScript (#37039) - /* - this.projectName = args; - this.encodedTitleLc = args; - this.titleLc = args; - */ - } + const { isEncoded: isEncodedGiven } = args; + const { titleLc, projectName, hash } = + 'titleLc' in args ? args : + 'url' in args ? parsePageUrl(args.url) : + args as never; // exhaustive check + // TODO: remove `as never` + // args should be but is not never here because of a bug of TypeScript (#37039) + const { str: encodedTitleLc, isEncoded } = encodeIfNeeded({ str: titleLc, isEncoded: isEncodedGiven }); + this.projectName = projectName ?? tsgProjectName; + this.encodedTitleLc = encodedTitleLc; + this.titleLc = decodeIfNeeded({ str: titleLc, isEncoded }).str; + this.hash = hash; } /** From 90e2ba8d5398e348aa80a6e1e35ca3ccceb4bbf3 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Mon, 9 Mar 2020 12:39:18 +0900 Subject: [PATCH 39/43] scrapbox/mute: add comments --- scrapbox/mute.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scrapbox/mute.ts b/scrapbox/mute.ts index f57ca613..8d5d0e45 100644 --- a/scrapbox/mute.ts +++ b/scrapbox/mute.ts @@ -4,7 +4,6 @@ import {flatten, zip} from 'lodash'; // @ts-ignore import {Page, pageUrlRegExp} from '../lib/scrapbox'; - interface SlackInterface { rtmClient: RTMClient, webClient: WebClient, @@ -78,6 +77,8 @@ interface SlackIncomingWebhookRequest { /** * Scrapboxからの更新通知 (Incoming Webhook形式) を受け取り,ミュート処理をしてSlackに投稿する */ +// for developers: 実際に動かすのは手間がかかるので,fastify.inject などで動作確認するのがおすすめです。mute.test.ts 参照 + // eslint-disable-next-line node/no-unsupported-features, node/no-unsupported-features/es-syntax export const server = ({webClient: slack}: SlackInterface) => plugin((fastify, opts, next) => { fastify.post('/hooks/scrapbox', async (req) => { From c2977de6846a7e64fd288202e596951a1c66bfe7 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Mon, 9 Mar 2020 19:11:51 +0900 Subject: [PATCH 40/43] scrapbox: delete unnecessary mock --- scrapbox/index.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 41fa5d5f..945286ea 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -3,8 +3,6 @@ import axios from 'axios'; // @ts-ignore import Slack from '../lib/slackMock.js'; -jest.mock('axios'); - // @ts-ignore axios.response = {data: {title: 'hoge', descriptions: ['fuga', 'piyo']}}; From 17642d4c7ef781475c359fbe073a874e75e3ed52 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Mon, 9 Mar 2020 19:12:21 +0900 Subject: [PATCH 41/43] scrapbox: remove dummy data not used in test --- scrapbox/index.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/scrapbox/index.test.ts b/scrapbox/index.test.ts index 945286ea..63cbb451 100644 --- a/scrapbox/index.test.ts +++ b/scrapbox/index.test.ts @@ -3,9 +3,6 @@ import axios from 'axios'; // @ts-ignore import Slack from '../lib/slackMock.js'; -// @ts-ignore -axios.response = {data: {title: 'hoge', descriptions: ['fuga', 'piyo']}}; - let slack: Slack = null; const projectName = 'PROJECTNAME'; From 2c20353b0b4a68d6cff088fb340ffc420d562bb8 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Mon, 9 Mar 2020 19:19:18 +0900 Subject: [PATCH 42/43] scrapbox/mute: better variable name --- scrapbox/mute.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scrapbox/mute.test.ts b/scrapbox/mute.test.ts index e1dce289..68b3b8fa 100644 --- a/scrapbox/mute.test.ts +++ b/scrapbox/mute.test.ts @@ -81,9 +81,13 @@ describe('mute notification', () => { text: gen.get('text'), images: [gen.get('img')], }]; - const splittedAttachments = splitAttachments(attachments); - expect(splittedAttachments).toMatchObject(expected); - splittedAttachments.forEach(({images}) => images.forEach(a => expect(a.text).toBe(''))); + const actual = splitAttachments(attachments); + expect(actual).toMatchObject(expected); + for (const {images} of actual) { + for (const attachment of images) { + expect(attachment.text).toBe(''); + } + } }); }); From c3f17a0da7a485cd7de1777a019090e31c45a155 Mon Sep 17 00:00:00 2001 From: pizzacat83 <17941141+pizzacat83@users.noreply.github.com> Date: Mon, 9 Mar 2020 19:24:52 +0900 Subject: [PATCH 43/43] small refactoring --- lib/fastify.ts | 2 +- welcome/index.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/fastify.ts b/lib/fastify.ts index 6c94e1b8..21444624 100644 --- a/lib/fastify.ts +++ b/lib/fastify.ts @@ -21,7 +21,7 @@ export const fastifyDevConstructor = (opts?: Parameters text).slice(1).join('\n'); return slack.chat.postMessage({