diff --git a/.gitignore b/.gitignore index 0307a32..43d0d51 100644 --- a/.gitignore +++ b/.gitignore @@ -94,8 +94,12 @@ typings/ # End of https://www.gitignore.io/api/node +# env dile .envrc + +# photo, video file *.png *.jpeg *.jpg -hls/ \ No newline at end of file +*.m3u8 +*.ts diff --git a/README.md b/README.md index e464ac0..bc608f5 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,11 @@ ngrok で得られる URL はは変動するので,[API Gateway](https://aws.a │ └─ GET ├─ /photo-viewer │ └─ GET + ├─ /slack + │ └ /{path+} + │ ├─ GET + │ └─ POST ├─ /stream - │ ├─ GET │ └ /{file+} │ └─ GET └─ /viewer @@ -77,6 +80,19 @@ Bot User メニューにて Redirect URLs は `https://[RESOURCE_ID].execute-api.[REGION].amazonaws.com/prod/oauth-redirect`のみに設定し, Scopes に`identity.basic`を追加してください。 +### Slack bot + +[Slash Commands](https://api.slack.com/slash-commands)に従い slack api ページにて, +Command: `/bushitsu-photo` +Request URL: `https://[AWS_REST_API_ID].execute-api.[REGION].amazonaws.com/prod/slack/photo` +Escape channels, users, and links sent to your app を有効 +に設定します。 + +### Slack interactive message + +[Making messages interactive](https://api.slack.com/interactive-messages) に従い設定します。 +Request URL は`https://[AWS_REST_API_ID].execute-api.[REGION].amazonaws.com/prod/slack/actions/`を設定してください。 + ### 環境変数 `NGROK_TOKEN`: Tunnel Authtoken, 無料プランでプランでも動作します @@ -86,6 +102,13 @@ Scopes に`identity.basic`を追加してください。 `SLACK_CLIENT_ID`: Slack Apps の Client ID `SLACK_CLIENT_SECRET`: Slack Apps の Client Secret +`SLACK_BOT_ACCESS_TOKEN`: Slack Apps の OAuth Access Token +`SLACK_SIGNING_SECRET`: Slack Apps の Signing Secret + +`CONTACT_CHANNEL`: Slack でのメッセージにのせる問い合せ先の channel ID + +> 参考: [Formatting text in messages](https://api.slack.com/messaging/composing/formatting#linking-channels) + `LIVE_PRIVATE_KEY`: live streaming に認証をかけるための key, 暗に用いるので頑強であれば何でも良い `WORKSTATION_ID`: Slack の WorkSpace の ID @@ -98,11 +121,19 @@ export AWS_REST_API_ID="h7c..." export SLACK_CLIENT_ID="179..." export SLACK_CLIENT_SECRET="38b..." +export SLACK_BOT_ACCESS_TOKEN="xoxb-3814..." +export SLACK_SIGNING_SECRET="fb36..." + +export CONTACT_CHANNEL="JCP..." + export PRIVATE_KEY="presetprivatekey" export WORKSTATION_ID="VOW38CP2D" ``` +> `IS_MAC`, `DEBUG` この2つの変数で flag を立てることもできます +> `true`, `false`, `0`, `1` で指定可能です。 + [direnv](https://direnv.net/)なら以上のように設定されているはずです。 ## ffmpeg diff --git a/app.js b/app.js index 4281bd2..1517e82 100644 --- a/app.js +++ b/app.js @@ -4,6 +4,7 @@ const Server = require('./server'); const aws = require('./aws'); const Stream = require('./stream'); const { daemon } = require('./utils'); +const slack = require('./slack'); const config = { restApiId: process.env.AWS_REST_API_ID, @@ -13,10 +14,13 @@ const config = { ngrokToken: process.env.NGROK_TOKEN, slackClientId: process.env.SLACK_CLIENT_ID, slackClientSecret: process.env.SLACK_CLIENT_SECRET, + slackBotAccessToken: process.env.SLACK_BOT_ACCESS_TOKEN, + slackSigningSecret: process.env.SLACK_SIGNING_SECRET, + contactChannel: process.env.CONTACT_CHANNEL, wsId: process.env.WORKSTATION_ID, privateKey: process.env.PRIVATE_KEY, - debug: Boolean(process.env.DEBUG), - isMac: Boolean(process.env.IS_MAC), + debug: process.env.DEBUG && Boolean(JSON.parse(process.env.DEBUG)), + isMac: process.env.IS_MAC && Boolean(JSON.parse(process.env.IS_MAC)), }; const liveServer = new RtmpServer(1935); @@ -36,14 +40,13 @@ disk console.log(`Please put HLS files in ${mountPath}`); let input; if (config.isMac) { - input = 'ffmpeg -f avfoundation -framerate 30 -re -i 0 -r 10'; + input = 'ffmpeg -f avfoundation -re -i 0 -r 10'; } else { input = 'ffmpeg -i /dev/video0'; } daemon( `${input} -vcodec libx264 -pix_fmt yuv420p -preset veryfast -tune zerolatency,stillimage,film -vb 2500k -vf "drawtext=text='%{localtime}':fontcolor=white@0.8:x=0:y=h-lh*1.2:fontsize=24" -f flv rtmp://localhost:${1935}/live/bushitsuchan`, ); - daemon( `ffmpeg -i rtmp://localhost:1935/live/bushitsuchan -hls_flags delete_segments -g 40 -f hls ${mountPath}/output.m3u8`, ); @@ -69,6 +72,16 @@ disk config, `rtmp://localhost:1935/live/${'bushitsuchan'}`, ); + server.app.use( + '/slack', + slack( + awsUrl, + config.contactChannel, + `rtmp://localhost:${1935}/live/bushitsuchan`, + config.slackBotAccessToken, + config.slackSigningSecret, + ), + ); server.run(3000).then(() => console.log(`Express app listening on port ${3000}`)); }) .catch((e) => { diff --git a/aws.js b/aws.js index 8ea578e..830e680 100644 --- a/aws.js +++ b/aws.js @@ -4,30 +4,38 @@ const util = require('util'); const exec = util.promisify(childProcess.exec); module.exports.run = async (config, url) => { - exec(`aws apigateway get-resources --rest-api-id ${config.restApiId}`) - .then((result) => { - const { stdout } = result; - return JSON.parse(stdout); - }) - .then(resources => Promise.all( - resources.items.map((item) => { - let command = `aws apigateway put-integration --rest-api-id ${config.restApiId} --resource-id ${item.id} --http-method GET --type HTTP_PROXY --integration-http-method GET`; - command += ` --uri ${url}${item.path.replace(/{([^}+]+)\+}/, '{$1}')}`; - const isMatch = item.path.match(/{([^}+]+)\+}/); + const resources = await exec( + `aws apigateway get-resources --rest-api-id ${config.restApiId}`, + ).then((result) => { + const { stdout } = result; + return JSON.parse(stdout); + }); + const commands = []; + + resources.items.forEach((resource) => { + if (!resource.resourceMethods) { + return; + } + Array.prototype.push.apply( + commands, + Object.keys(resource.resourceMethods).map((method) => { + let command = `aws apigateway put-integration --rest-api-id ${config.restApiId} --resource-id ${resource.id} --http-method ${method} --type HTTP_PROXY --integration-http-method ${method}`; + command += ` --uri ${url}${resource.path.replace(/{([^}+]+)\+}/, '{$1}')}`; + const isMatch = resource.path.match(/{([^}+]+)\+}/); if (isMatch) { command += ` --request-parameters integration.request.path.${ isMatch[1] }=method.request.path.${isMatch[1]}`; } - return exec(command); + return command; }), - )) - .then(() => { - exec(`aws apigateway create-deployment --rest-api-id ${config.restApiId} --stage-name prod`); - }) - .catch((e) => { - console.error(e); - }); + ); + }); + await Promise.all(commands.map(command => exec(command))); + + await exec( + `aws apigateway create-deployment --rest-api-id ${config.restApiId} --stage-name prod`, + ); const region = await exec('aws configure get region').then((result) => { const { stdout } = result; diff --git a/block_template.json b/block_template.json new file mode 100644 index 0000000..4cf6f63 --- /dev/null +++ b/block_template.json @@ -0,0 +1,104 @@ +[ + { + "type": "image", + "title": { + "type": "plain_text", + "text": "部室の写真" + }, + "image_url": "${photo_image}", + "alt_text": "部室の写真" + }, + { + "type": "actions", + "block_id": "action_block", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "削除" + }, + "value": "delete", + "action_id": "delete_button", + "style": "danger", + "confirm": { + "title": { + "type": "plain_text", + "text": "メッセージ削除" + }, + "text": { + "type": "plain_text", + "text": "削除しますか?" + }, + "confirm": { + "type": "plain_text", + "text": "削除実行" + }, + "deny": { + "type": "plain_text", + "text": "キャンセル" + } + } + } /** , + { + "type": "overflow", + "options": [ + { + "text": { + "type": "plain_text", + "text": "slection1" + }, + "value": "slection1" + }, + { + "text": { + "type": "plain_text", + "text": "slection2" + }, + "value": "slection2" + } + ], + "action_id": "overflow" + } */ + ] + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "${expired-time}" + } + ] + }, + { + "type": "divider" + }, + + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "📷 View photo with `/bushitsu-photo`" + } + ] + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "<${viewer-url}|WEB-配信動画版>" + }, + { + "type": "mrkdwn", + "text": "<${photo-viewer-url}|WEB-画像版>" + }, + { + "type": "mrkdwn", + "text": "*問い合せ*: <#${contact-channel}>" + } + ] + } +] diff --git a/package-lock.json b/package-lock.json index 7beb512..5be108e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,11 +24,106 @@ "js-tokens": "^4.0.0" } }, + "@slack/interactive-messages": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@slack/interactive-messages/-/interactive-messages-1.1.1.tgz", + "integrity": "sha512-FIyj/sBmCjQ4B4Tt1z6MNdFmgvyT5CK2U2VrWGcdqdynxQk8tlN/AA/C0SSc5wIr/WNYck1c90ZawQJTlw3YsQ==", + "requires": { + "axios": "^0.18.0", + "debug": "^3.1.0", + "lodash.isfunction": "^3.0.9", + "lodash.isplainobject": "^4.0.6", + "lodash.isregexp": "^4.0.1", + "lodash.isstring": "^4.0.1", + "raw-body": "^2.3.3", + "tsscmp": "^1.0.6" + }, + "dependencies": { + "axios": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz", + "integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==", + "requires": { + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" + } + } + } + }, + "@slack/logger": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-1.0.0.tgz", + "integrity": "sha512-QDYhQR/58xKfB5iquvQwfpxPsmKPP/5SuDp8hRhZeUluCHsP1qBCOc3sW2Xb3cydxK0PAEnkLbBJf/ezsGwtlA==", + "requires": { + "@types/node": ">=8.9.0" + } + }, + "@slack/types": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-1.0.0.tgz", + "integrity": "sha512-IktC4uD/CHfLQcSitKSmjmRu4a6+Nf/KzfS6dTgUlDzENhh26l8aESKAuIpvYD5VOOE6NxDDIAdPJOXBvUGxlg==" + }, + "@slack/web-api": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-5.0.1.tgz", + "integrity": "sha512-L2Nc8P+NjXH1yqnsNhqxsrbpW3Qv+//9X5PQqcM3bctDmvmwTuhuM1X208RVD2avhnC89aghY5PssyaayWj5sA==", + "requires": { + "@slack/logger": "^1.0.0", + "@slack/types": "^1.0.0", + "@types/form-data": "^2.2.1", + "@types/is-stream": "^1.1.0", + "@types/node": ">=8.9.0", + "@types/p-queue": "^2.3.2", + "axios": "^0.18.0", + "eventemitter3": "^3.1.0", + "form-data": "^2.3.3", + "is-stream": "^1.1.0", + "p-queue": "^2.4.2", + "p-retry": "^4.0.0" + }, + "dependencies": { + "axios": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz", + "integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==", + "requires": { + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" + } + } + } + }, + "@types/form-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-2.5.0.tgz", + "integrity": "sha512-23/wYiuckYYtFpL+4RPWiWmRQH2BjFuqCUi2+N3amB1a1Drv+i/byTrGvlLwRVLFNAZbwpbQ7JvTK+VCAPMbcg==", + "requires": { + "form-data": "*" + } + }, + "@types/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==", + "requires": { + "@types/node": "*" + } + }, "@types/node": { "version": "8.10.51", "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.51.tgz", "integrity": "sha512-cArrlJp3Yv6IyFT/DYe+rlO8o3SIHraALbBW/+CcCYW/a9QucpLI+n2p4sRxAvl2O35TiecpX2heSZtJjvEO+Q==" }, + "@types/p-queue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/p-queue/-/p-queue-2.3.2.tgz", + "integrity": "sha512-eKAv5Ql6k78dh3ULCsSBxX6bFNuGjTmof5Q/T6PiECDq0Yf8IIn46jCyp3RJvCi8owaEmm3DZH1PEImjBMd/vQ==" + }, + "@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -322,6 +417,17 @@ "delayed-stream": "~1.0.0" } }, + "comment-json": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-2.1.0.tgz", + "integrity": "sha512-OcO+nJnUtp29j9SwHVcP/F8t/9piiOQj7APXTj75cK/qFAgX7PtGV31ullzwZbBwiA5Rmj25C32FaFgwcu1Uwg==", + "requires": { + "core-util-is": "^1.0.2", + "esprima": "^4.0.1", + "has-own-prop": "^2.0.0", + "repeat-string": "^1.6.1" + } + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -790,8 +896,7 @@ "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "esquery": { "version": "1.0.1", @@ -828,6 +933,11 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, + "eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + }, "expect-ct": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/expect-ct/-/expect-ct-0.2.0.tgz", @@ -1122,6 +1232,11 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, + "has-own-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", + "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==" + }, "has-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", @@ -1369,6 +1484,11 @@ "has": "^1.0.1" } }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" + }, "is-symbol": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz", @@ -1494,6 +1614,26 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.14.tgz", "integrity": "sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==" }, + "lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isregexp": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isregexp/-/lodash.isregexp-4.0.1.tgz", + "integrity": "sha1-4T5kezDNVZdSoEzZEghvr32hwws=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -1780,6 +1920,20 @@ "p-limit": "^1.1.0" } }, + "p-queue": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-2.4.2.tgz", + "integrity": "sha512-n8/y+yDJwBjoLQe1GSJbbaYQLTI7QHNZI2+rpmCDbe++WLf9HC3gf6iqj5yfPAV71W4UF3ql5W1+UBPXoXTxng==" + }, + "p-retry": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.1.0.tgz", + "integrity": "sha512-oepllyG9gX1qH4Sm20YAKxg1GA7L7puhvGnTfimi31P07zSIj7SDV6YtuAx9nbJF51DES+2CIIRkXs8GKqWJxA==", + "requires": { + "@types/retry": "^0.12.0", + "retry": "^0.12.0" + } + }, "p-try": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", @@ -2000,6 +2154,11 @@ "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", "dev": true }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" + }, "request": { "version": "2.88.0", "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", @@ -2077,6 +2236,11 @@ "signal-exit": "^3.0.2" } }, + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=" + }, "rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", @@ -2279,6 +2443,11 @@ "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=" }, + "store": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/store/-/store-2.0.12.tgz", + "integrity": "sha1-jFNOKguDH3K3X8XxEZhXxE711ZM=" + }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", @@ -2434,6 +2603,11 @@ "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==", "dev": true }, + "tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==" + }, "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index a900925..cd7fd6e 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,12 @@ }, "homepage": "https://github.com/TUS-OSK/bushitsuchan-PC#readme", "dependencies": { + "@slack/interactive-messages": "^1.1.1", + "@slack/web-api": "^5.0.1", "axios": "^0.19.0", + "body-parser": "^1.19.0", "child_process": "^1.0.2", + "comment-json": "^2.1.0", "cookie-session": "^1.3.3", "cors": "^2.8.5", "express": "^4.17.1", @@ -29,6 +33,7 @@ "ngrok": "^3.2.3", "node-media-server": "^2.1.2", "rtmp-server": "^0.2.0", + "store": "^2.0.12", "util": "^0.12.1" }, "devDependencies": { diff --git a/server.js b/server.js index 3c8f9fe..9655667 100644 --- a/server.js +++ b/server.js @@ -59,6 +59,23 @@ const authorize = async (token, workstationId) => { return result.data.user.name; }; +const resPhoto = (rtmpAddress, ext = 'jpg') => (req, res) => { + const ffmpeg = childProcess.spawn('ffmpeg', [ + '-i', + `${rtmpAddress}`, + '-ss', + '0.7', + '-vframes', + '1', + '-f', + 'image2', + 'pipe:1', + ]); + + res.contentType(`image/${ext}`); + ffmpeg.stdout.pipe(res); +}; + module.exports = class { constructor(ngrokUrl, awsUrl, mountPath, config, rtmpAddress) { this.ngrokUrl = ngrokUrl; @@ -80,7 +97,7 @@ module.exports = class { }), ); - morgan.token('user', (req, res) => req.session.name || 'anonymous'); + morgan.token('user', (req, res) => req.session && (req.session.name || 'anonymous')); this.app.use( morgan( '<@:user> [:date[clf]] :method :url :status :res[content-length] - :response-time ms', @@ -211,24 +228,7 @@ module.exports = class { this.app.use('/stream', express.static(this.mountPath)); - this.app.get('/photo.jpg', async (req, res) => { - const ext = 'jpeg'; - - const ffmpeg = childProcess.spawn('ffmpeg', [ - '-i', - `${this.rtmpAddress}`, - '-ss', - '0.7', - '-vframes', - '1', - '-f', - 'image2', - 'pipe:1', - ]); - - res.contentType(`image/${ext}`); - ffmpeg.stdout.pipe(res); - }); + this.app.get('/photo.jpg', resPhoto(this.rtmpAddress)); } async run(port = 3000) { diff --git a/slack.js b/slack.js new file mode 100644 index 0000000..f4de81d --- /dev/null +++ b/slack.js @@ -0,0 +1,103 @@ +const express = require('express'); +const bodyParser = require('body-parser'); +const { WebClient } = require('@slack/web-api'); +const { createMessageAdapter } = require('@slack/interactive-messages'); +const store = require('store'); +const crypto = require('crypto'); +const expirePlugin = require('store/plugins/expire'); +const updatePlugin = require('store/plugins/update'); +const childProcess = require('child_process'); +const fs = require('fs'); +const commentJSON = require('comment-json'); +const { base64Encode, base64Decode } = require('./utils'); + +store.addPlugin(expirePlugin); +store.addPlugin(updatePlugin); + +module.exports = (awsUrl, contactChannel, rtmpAddress, slackBotAccessToken, slackSigningSecret) => { + const router = express.Router(); + const web = new WebClient(slackBotAccessToken); + const slackInteractions = createMessageAdapter(slackSigningSecret); + + router.use('/actions', slackInteractions.expressMiddleware()); + slackInteractions.action({ type: 'button' }, (payload, respond) => { + const { actions, message, channel } = payload; + const { ts } = message; + + if (actions[0].value === 'delete') { + web.chat.delete({ channel: channel.id, ts }).catch(e => console.error(e)); + } + }); + + router.use(bodyParser.urlencoded({ extended: false })); + router.use(bodyParser.json()); + + router.post('/photo', (req, res) => { + const expired = new Date(); + expired.setHours(expired.getHours() + 5); + + const key = crypto + .createHash('md5') + .update(`${req.body.user_id}-${expired.getTime()}`) + .digest('Base64'); + + const ffmpeg = childProcess.spawn('ffmpeg', [ + '-i', + `${rtmpAddress}`, + '-ss', + '0.7', + '-vframes', + '1', + '-f', + 'image2', + 'pipe:1', + ]); + + const chunks = []; + ffmpeg.stdout.on('data', (chunk) => { + chunks.push(chunk); + }); + ffmpeg.stdout.on('end', () => { + store.set(key, Buffer.concat(chunks), expired.getTime()); + let template = fs.readFileSync('./block_template.json', 'utf8'); + template = template.replace( + /\${photo_image}/g, + `${awsUrl}${req.baseUrl}/thumb.jpg?key=${base64Encode(key)}`, + ); + template = template.replace(/\${viewer-url}/g, `${awsUrl}/viewer`); + template = template.replace(/\${photo-viewer-url}/g, `${awsUrl}/photo-viewer`); + template = template.replace(/\${contact-channel}/g, contactChannel); + template = template.replace( + /\${expired-time}/g, + `写真は`, + ); + web.chat.postMessage({ + channel: req.body.channel_id, + text: '部室の様子', + icon_emoji: ':slack:', + blocks: commentJSON.parse(template), + }); + }); + + res.status(200).send('待ってね'); + }); + + router.get('/thumb.jpg', (req, res) => { + const { key } = req.query; + if (!key) { + res.sendStatus(404).end(); + return; + } + const chunks = store.get(base64Decode(key)); + if (!chunks) { + res.sendStatus(204).end(); + return; + } + res.contentType('image/jpg'); + res.send(Buffer.from(chunks.data)); + }); + + return router; +}; diff --git a/utils.js b/utils.js index c8961a8..034bc4f 100644 --- a/utils.js +++ b/utils.js @@ -2,7 +2,7 @@ const childProcess = require('child_process'); const util = require('util'); const fs = require('fs'); -const exec = util.promisify(childProcess.exec); +module.exports.exec = util.promisify(childProcess.exec); const wait = ms => new Promise(reolve => setTimeout(() => reolve(), ms)); @@ -15,7 +15,7 @@ module.exports.daemon = async (command, ms = 1000 * 10, maxCount = 100) => { for (let i = 0; i < maxCount; i += 1) { try { // eslint-disable-next-line no-await-in-loop - await exec(command); + await module.exports.exec(command); } catch (e) { logger.error(e); // eslint-disable-next-line no-await-in-loop @@ -23,3 +23,13 @@ module.exports.daemon = async (command, ms = 1000 * 10, maxCount = 100) => { } } }; + +module.exports.base64Encode = str => str + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + +module.exports.base64Decode = (str) => { + const replaces = str.replace(/-/g, '+').replace(/_/g, '/'); + return replaces + '='.repeat(4 - ((replaces.length % 4) % 4)); +};