├── .ci └── node10 │ └── Dockerfile.linux ├── .circleci └── config.yml ├── .cirrus.yml ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .template-lintrc.js ├── .travis.yml ├── LICENSE ├── README.md ├── config └── default.js ├── db ├── channels.json ├── index.js ├── messages │ ├── C12345678.json │ ├── C12345679.json │ └── index.js ├── sessions.json ├── teams.json └── users.json ├── docs ├── ABOUT_RU.md ├── CODECLIMATE.md ├── DOCKER.md ├── FOR_DEVELOPERS_EN.md ├── FOR_DEVELOPERS_RU.md ├── NPMCOMMANDS.md ├── ROADMAP.md └── images │ ├── demo.gif │ └── logo.png ├── examples └── rtmbot │ ├── __tests__ │ └── integration │ │ ├── actions.js │ │ ├── channel.test.js │ │ ├── direct.test.js │ │ ├── greeting.test.js │ │ ├── shared.js │ │ └── utils.js │ └── index.js ├── features ├── auth_test_api.feature ├── bold_message_formatting.feature ├── breaks_at_the_any_position_of_message.feature ├── code_message_formatting.feature ├── edit_last_message.feature ├── hide_message_header.feature ├── incoming_message_payload.feature ├── incoming_user_typing_payload.feature ├── inline_message_editor_linebreaks.feature ├── italic_message_formatting.feature ├── multiline_messages.feature ├── pagecontent.feature ├── preformatted_message_formatting.feature ├── quote_message_formatting.feature ├── receiving_messages.feature ├── sending_messages.feature ├── step_definitions │ ├── actions.js │ ├── hooks.js │ ├── pages.js │ ├── selectors.js │ ├── steps.js │ ├── support │ │ ├── fakeuser.js │ │ ├── scope.js │ │ ├── services.js │ │ └── validators.js │ └── world.js ├── strike_message_formatting.feature └── update_message_event.feature ├── helpers.js ├── jest-puppeteer.config.js ├── package-lock.json ├── package.json ├── public ├── css │ └── iconmonstr-iconic-font.min.css ├── fonts │ ├── iconmonstr-iconic-font.eot │ ├── iconmonstr-iconic-font.ttf │ ├── iconmonstr-iconic-font.woff │ └── iconmonstr-iconic-font.woff2 ├── images │ ├── loading_channel_pane@2x.png │ ├── meavatar.png │ ├── slack_user.png │ └── uslackbot-avatar-72.png └── js │ ├── formatters.js │ └── helpers.js ├── reports └── .keep ├── routes ├── api │ ├── factories.js │ ├── index.js │ └── utils.js ├── app │ └── index.js ├── constants.js ├── managers.js ├── responses │ ├── auth.test.json │ ├── cant_update_message.json │ ├── channels.list.json │ ├── chat.postMessage.json │ ├── index.js │ ├── invalid_auth.json │ ├── rtm.connect.json │ ├── team.info.json │ ├── user_not_found.json │ └── users.info.json ├── rtm │ └── index.js └── testapi │ └── index.js ├── screenshots └── .keep ├── scripts ├── codeclimate │ ├── check.js │ └── setup.sh ├── glitch.js ├── ntimes.sh └── volumes │ └── inspect.js ├── server.js └── views ├── layouts └── main.hbs ├── partials ├── appitem.hbs ├── appitemheader.hbs ├── channel.hbs ├── channelheader.hbs ├── directuser.hbs ├── inlinestyles.hbs ├── invite.hbs ├── mehref.hbs ├── message.hbs ├── message_edit.hbs ├── statustyping.hbs ├── submitform.hbs └── userchannelheader.hbs └── slackv2.hbs /.ci/node10/Dockerfile.linux: -------------------------------------------------------------------------------- 1 | FROM node:10.16.0 2 | 3 | RUN apt-get update && \ 4 | apt-get -y install xvfb gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 \ 5 | libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 \ 6 | libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 \ 7 | libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 \ 8 | libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget && \ 9 | rm -rf /var/lib/apt/lists/* 10 | 11 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 12 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 13 | && apt-get update \ 14 | && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst ttf-freefont \ 15 | --no-install-recommends \ 16 | && rm -rf /var/lib/apt/lists/* 17 | 18 | # Add user so we don't need --no-sandbox. 19 | RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \ 20 | && mkdir -p /home/pptruser/Downloads \ 21 | && chown -R pptruser:pptruser /home/pptruser 22 | 23 | # Run everything after as non-privileged user. 24 | USER pptruser -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:10.16.0 6 | working_directory: ~/mad-fake-slack 7 | steps: 8 | - checkout 9 | # Download and cache dependencies 10 | - restore_cache: 11 | keys: 12 | - v1-dependencies-{{ checksum "package.json" }} 13 | # fallback to using the latest cache if no exact match is found 14 | - v1-dependencies- 15 | - run: 16 | name: Install dependencies 17 | command: | 18 | sudo apt-get update 19 | sudo apt-get install -yq gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 \ 20 | libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 \ 21 | libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \ 22 | libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 \ 23 | ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget --fix-missing 24 | - run: #STABLE 25 | name: Install Chromedriver latest version 26 | command: | 27 | sudo apt-get update 28 | sudo apt-get install lsb-release libappindicator3-1 29 | curl -L -o google-chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb 30 | sudo dpkg -i google-chrome.deb 31 | sudo sed -i 's|HERE/chrome"|HERE/chrome" --no-sandbox|g' /opt/google/chrome/google-chrome 32 | rm google-chrome.deb 33 | - run: PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true npm i 34 | - save_cache: 35 | paths: 36 | - node_modules 37 | key: v1-dependencies-{{ checksum "package.json" }} 38 | - run: npm test 39 | - store_artifacts: 40 | path: ~/mad-fake-slack/screenshots 41 | -------------------------------------------------------------------------------- /.cirrus.yml: -------------------------------------------------------------------------------- 1 | env: 2 | DISPLAY: :99.0 3 | PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true 4 | USE_SANDBOX: true 5 | 6 | task: 7 | matrix: 8 | - name: Chromium (node10 + linux) 9 | container: 10 | dockerfile: .ci/node10/Dockerfile.linux 11 | xvfb_start_background_script: Xvfb :99 -ac -screen 0 1920x1080x24 12 | install_script: npm install --unsafe-perm 13 | test_script: npm test -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. 4 | #------------------------------------------------------------------------------------------------------------- 5 | 6 | FROM node:lts 7 | 8 | # Configure apt 9 | ENV DEBIAN_FRONTEND=noninteractive 10 | RUN apt-get update \ 11 | && apt-get -y install --no-install-recommends apt-utils 2>&1 12 | 13 | # Install and configure locales 14 | RUN apt-get install -y locales 15 | 16 | RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \ 17 | dpkg-reconfigure --frontend=noninteractive locales && \ 18 | update-locale LANG=en_US.UTF-8 19 | 20 | ENV LANG en_US.UTF-8 21 | 22 | # Verify git and needed tools are installed 23 | RUN apt-get install -y git procps 24 | 25 | # Remove outdated yarn from /opt and install via package 26 | # so it can be easily updated via apt-get upgrade yarn 27 | RUN rm -rf /opt/yarn-* \ 28 | && rm -f /usr/local/bin/yarn \ 29 | && rm -f /usr/local/bin/yarnpkg \ 30 | && apt-get install -y curl apt-transport-https lsb-release \ 31 | && curl -sS https://dl.yarnpkg.com/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/pubkey.gpg | apt-key add - 2>/dev/null \ 32 | && echo "deb https://dl.yarnpkg.com/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/ stable main" | tee /etc/apt/sources.list.d/yarn.list \ 33 | && apt-get update \ 34 | && apt-get -y install --no-install-recommends yarn 35 | 36 | # Install eslint 37 | RUN npm install -g eslint 38 | 39 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 40 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 41 | && apt-get update \ 42 | && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst ttf-freefont \ 43 | --no-install-recommends \ 44 | && rm -rf /var/lib/apt/lists/* 45 | 46 | RUN apt-get update \ 47 | && apt-get install -y apt-transport-https ca-certificates curl gnupg-agent software-properties-common lsb-release \ 48 | && curl -fsSL https://download.docker.com/linux/$(/usr/bin/lsb_release -is | tr '[:upper:]' '[:lower:]')/gpg | apt-key add - 2>&1 >/dev/null \ 49 | && add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/$(/usr/bin/lsb_release -is | tr '[:upper:]' '[:lower:]') $(lsb_release -cs) stable" \ 50 | && apt-get update \ 51 | && apt-get install -y docker-ce-cli 52 | 53 | # Uncomment to skip the chromium download when installing puppeteer. If you do, 54 | # you'll need to launch puppeteer with: 55 | # browser.launch({executablePath: 'google-chrome-stable'}) 56 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true 57 | 58 | # Clean up 59 | RUN apt-get autoremove -y \ 60 | && apt-get clean -y \ 61 | && rm -rf /var/lib/apt/lists/* 62 | ENV DEBIAN_FRONTEND=dialog 63 | 64 | VOLUME [ "/root/.vscode-server-insiders" ] 65 | 66 | # Set the default shell to bash instead of sh 67 | ENV SHELL /bin/bash -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details. 2 | { 3 | "name": "Node.js (latest LTS)", 4 | "dockerFile": "Dockerfile", 5 | 6 | // Uncomment the next line if you want to publish any ports. 7 | "appPort": [9001, 9222], 8 | "runArgs": [ 9 | "-v", "/var/run/docker.sock:/var/run/docker.sock", 10 | "-e", "CONTAINER_TIMEOUT_SECONDS=1800" 11 | ], 12 | // Uncomment the next line if you want to add in default container specific settings.json values 13 | // "settings": { "workbench.colorTheme": "Quiet Light" }, 14 | 15 | // Uncomment the next line to run commands after the container is created. 16 | "postCreateCommand": "npm i && npm run codeclimate:install", 17 | 18 | "extensions": [ 19 | "dbaeumer.vscode-eslint", 20 | "editorconfig.editorconfig", 21 | "ms-vscode.atom-keybindings", 22 | "alexkrechik.cucumberautocomplete", 23 | "ms-azuretools.vscode-docker", 24 | "stevejpurves.cucumber" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.js] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.hbs] 12 | charset = utf-8 13 | end_of_line = lf 14 | indent_size = 2 15 | indent_style = space 16 | insert_final_newline = false 17 | trim_trailing_whitespace = true 18 | 19 | [vcbuild.bat] 20 | end_of_line = crlf 21 | 22 | [Makefile] 23 | indent_size = 8 24 | indent_style = tab 25 | 26 | [{deps}/**] 27 | charset = ignore 28 | end_of_line = ignore 29 | indent_size = ignore 30 | indent_style = ignore 31 | trim_trailing_whitespace = ignore 32 | 33 | [{test/fixtures,deps,tools/node_modules,tools/gyp,tools/icu,tools/msvs}/**] 34 | insert_final_newline = false 35 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": ["airbnb-base/legacy"], 3 | "env": { 4 | "browser": true, 5 | "es6": true, 6 | "node": true, 7 | "jest": true 8 | }, 9 | "plugins": ["jest"], 10 | "globals": { 11 | "Atomics": "readonly", 12 | "SharedArrayBuffer": "readonly", 13 | "page": true, 14 | "browser": true, 15 | "context": true, 16 | "jestPuppeteer": true, 17 | }, 18 | "parserOptions": { 19 | "ecmaVersion": 2018, 20 | "sourceType": "module" 21 | }, 22 | "rules": { 23 | "jest/no-disabled-tests": "warn", 24 | "jest/no-focused-tests": "error", 25 | "jest/no-identical-title": "error", 26 | "jest/prefer-to-have-length": "warn", 27 | "jest/valid-expect": "error", 28 | "no-restricted-syntax": "warn", 29 | "max-len": [ 30 | 2, 31 | 180, 32 | 8 33 | ], 34 | "linebreak-style": 0, 35 | "eslint linebreak-style": [0, "error", "windows"], 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | .DS_Store 64 | 65 | reports/* 66 | !reports/.keep 67 | 68 | screenshots/* 69 | !screenshots/.keep -------------------------------------------------------------------------------- /.template-lintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'recommended', 3 | ignore: [ 4 | 'views/layouts/main', 5 | 'views/slackv2' 6 | ], 7 | rules: { 8 | 'no-bare-strings': false, 9 | 'no-triple-curlies': false, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | dist: xenial 3 | addons: 4 | chrome: stable 5 | notifications: 6 | email: false 7 | cache: 8 | directories: 9 | - node_modules 10 | sudo: required 11 | node_js: 12 | - "10.15.3" 13 | services: 14 | - xvfb 15 | env: 16 | global: 17 | - PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true 18 | # allow headful tests 19 | before_install: 20 | # Enable user namespace cloning 21 | - "sysctl kernel.unprivileged_userns_clone=1" 22 | install: 23 | - npm i 24 | script: 25 | - npm test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mad Devs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
Your fake service #1!
8 |code
|
16 |
17 | Scenario: Two words in code
18 | And I type "`some code`"
19 | When I press the "Enter" keyboard button
20 | Then I should see "some code" in "Message body"
21 | And Message has the following HTML content at "last" position in "Message body":
22 | | html content |
23 | | some code
|
24 |
25 | Scenario: Two code blocks one by one
26 | And I type "`code1` `code2`"
27 | When I press the "Enter" keyboard button
28 | Then I should see "code1 code2" in "Message body"
29 | And Message has the following HTML content at "last" position in "Message body":
30 | | html content |
31 | | code1
code2
|
32 |
33 | Scenario: Two code blocks on separate lines
34 | And I type "`code1`"
35 | When I press the "Shift + Enter" keyboard button
36 | And I type "`code2`"
37 | When I press the "Enter" keyboard button
38 | Then I should see "last" multiline message with:
39 | | Message body | code1\ncode2 |
40 | And Message has the following HTML content at "last" position in "Message body":
41 | | html content |
42 | | code1
code2
|
43 |
44 | Scenario: Three code blocks on separate lines
45 | And I type "`code1`"
46 | When I press the "Shift + Enter" keyboard button
47 | And I type "`code2`"
48 | When I press the "Shift + Enter" keyboard button
49 | And I type "`code3`"
50 | When I press the "Enter" keyboard button
51 | Then I should see "last" multiline message with:
52 | | Message body | code1\ncode2\ncode3 |
53 | And Message has the following HTML content at "last" position in "Message body":
54 | | html content |
55 | | code1
code2
code3
|
56 |
57 | Scenario: Three code blocks on separate lines with more than one breaklines
58 | And I type "`code1`"
59 | When I press the "Shift + Enter" keyboard button
60 | When I press the "Shift + Enter" keyboard button
61 | And I type "`code2`"
62 | When I press the "Shift + Enter" keyboard button
63 | When I press the "Shift + Enter" keyboard button
64 | And I type "`code3`"
65 | When I press the "Shift + Enter" keyboard button
66 | When I press the "Enter" keyboard button
67 | Then I should see "last" multiline message with:
68 | | Message body | code1\ncode2\ncode3 |
69 | And Message has the following HTML content at "last" position in "Message body":
70 | """
71 | code1
code2
code3
72 | """
73 |
74 | Scenario: Include any count of ` from the end
75 | And I type "`code1````"
76 | When I press the "Enter" keyboard button
77 | Then I should see "code1```" in "Message body"
78 | And Message has the following HTML content at "last" position in "Message body":
79 | | html content |
80 | | code1```
|
81 |
82 | Scenario: Preserve spaces
83 | And I type "` code `"
84 | When I press the "Enter" keyboard button
85 | Then I should see "code" in "Message body"
86 | And Message has the following HTML content at "last" position in "Message body":
87 | | html content |
88 | | code
|
89 |
90 | Scenario: Format with excluding extra apostrophe at end
91 | And I type "`code``"
92 | When I press the "Enter" keyboard button
93 | Then I should see "code`" in "Message body"
94 | And Message has the following HTML content at "last" position in "Message body":
95 | | html content |
96 | | code`
|
97 |
98 | Scenario: Format with triple apostrophe at end
99 | And I type "`code```"
100 | When I press the "Enter" keyboard button
101 | Then I should see "code``" in "Message body"
102 | And Message has the following HTML content at "last" position in "Message body":
103 | | html content |
104 | | code``
|
105 |
106 | Scenario: Ignore strike formatting symbol inside
107 | And I type "`~code~`"
108 | When I press the "Enter" keyboard button
109 | Then I should see "~code~" in "Message body"
110 | And Message has the following HTML content at "last" position in "Message body":
111 | | html content |
112 | | ~code~
|
113 |
114 | Scenario: Ignore italic formatting symbol inside
115 | And I type "`_code_`"
116 | When I press the "Enter" keyboard button
117 | Then I should see "_code_" in "Message body"
118 | And Message has the following HTML content at "last" position in "Message body":
119 | | html content |
120 | | _code_
|
121 |
122 | Scenario: Ignore bold formatting symbol inside
123 | And I type "`*code*`"
124 | When I press the "Enter" keyboard button
125 | Then I should see "*code*" in "Message body"
126 | And Message has the following HTML content at "last" position in "Message body":
127 | | html content |
128 | | *code*
|
129 |
130 | Scenario: No formatting without end of code block
131 | And I type "`some code"
132 | When I press the "Enter" keyboard button
133 | Then I should see "`some code" in "Message body"
134 | And Message has the following HTML content at "last" position in "Message body":
135 | | html content |
136 | | `some code |
137 |
138 | Scenario: No formatting without begin of code block
139 | And I type "some code`"
140 | When I press the "Enter" keyboard button
141 | Then I should see "some code`" in "Message body"
142 | And Message has the following HTML content at "last" position in "Message body":
143 | | html content |
144 | | some code` |
145 |
146 | Scenario: No formatting for empty code block
147 | And I type "``"
148 | When I press the "Enter" keyboard button
149 | Then I should see "``" in "Message body"
150 | And Message has the following HTML content at "last" position in "Message body":
151 | | html content |
152 | | `` |
153 |
154 | Scenario: No formatting for code block with only spaces in it
155 | And I type "` `"
156 | When I press the "Enter" keyboard button
157 | Then I should see "` `" in "Message body"
158 | And Message has the following HTML content at "last" position in "Message body":
159 | | html content |
160 | | ` preformatted| 16 | 17 | Scenario: Two words as preformatted 18 | And I type "```one two```" 19 | When I press the "Enter" keyboard button 20 | Then I should see "one two" in "Message body" 21 | And Message has the following HTML content at "last" position in "Message body": 22 | | html content | 23 | |
one two| 24 | 25 | Scenario: Two words on different lines 26 | And I type "```one " 27 | And I press the "Shift + Enter" keyboard button 28 | And I type "two```" 29 | When I press the "Enter" keyboard button 30 | Then I should see "last" multiline message with: 31 | | Message body | one \ntwo | 32 | And Message has the following HTML content at "last" position in "Message body": 33 | | html content | 34 | |
one| 35 | 36 | Scenario: Two words on different lines with breaklines in the middle 37 | And I type "```one " 38 | And I press the "Shift + Enter" keyboard button 39 | And I press the "Shift + Enter" keyboard button 40 | And I press the "Shift + Enter" keyboard button 41 | And I type "two```" 42 | When I press the "Enter" keyboard button 43 | Then I should see "last" multiline message with: 44 | | Message body | one \n\ntwo | 45 | And Message has the following HTML content at "last" position in "Message body": 46 | | html content | 47 | |
two
one| 48 | 49 | Scenario: Two words on different lines as preformatted 50 | And I type "```one```" 51 | And I press the "Shift + Enter" keyboard button 52 | And I type "```two```" 53 | When I press the "Enter" keyboard button 54 | Then I should see "last" multiline message with: 55 | | Message body | one\ntwo | 56 | And Message has the following HTML content at "last" position in "Message body": 57 | | html content | 58 | |
two
one
two| 59 | 60 | Scenario: No breaklines immediately after a preformatted start block 61 | And I type "```" 62 | And I press the "Shift + Enter" keyboard button 63 | And I type "one```" 64 | When I press the "Enter" keyboard button 65 | Then I should see "last" multiline message with: 66 | | Message body | one | 67 | And Message has the following HTML content at "last" position in "Message body": 68 | | html content | 69 | |
one| 70 | 71 | Scenario: No breaklines before a preformatted end block 72 | And I type "```" 73 | And I press the "Shift + Enter" keyboard button 74 | And I type "one" 75 | And I press the "Shift + Enter" keyboard button 76 | And I type "```" 77 | When I press the "Enter" keyboard button 78 | Then I should see "last" multiline message with: 79 | | Message body | one | 80 | And Message has the following HTML content at "last" position in "Message body": 81 | | html content | 82 | |
one| 83 | 84 | Scenario: Display two preformatted blocks separated by text 85 | And I type "```" 86 | And I press the "Shift + Enter" keyboard button 87 | And I type "one" 88 | And I press the "Shift + Enter" keyboard button 89 | And I type "```" 90 | And I press the "Shift + Enter" keyboard button 91 | And I type "some text" 92 | And I press the "Shift + Enter" keyboard button 93 | And I type "```" 94 | And I press the "Shift + Enter" keyboard button 95 | And I type "two" 96 | And I press the "Shift + Enter" keyboard button 97 | And I type "```" 98 | When I press the "Enter" keyboard button 99 | Then I should see "last" multiline message with: 100 | | Message body | one\nsome text\n\ntwo | 101 | And Message has the following HTML content at "last" position in "Message body": 102 | | html content | 103 | |
onesome text
two| 104 | 105 | Scenario: Display two preformatted blocks separated by breaklines 106 | And I type "```" 107 | And I press the "Shift + Enter" keyboard button 108 | And I type "one" 109 | And I press the "Shift + Enter" keyboard button 110 | And I type "```" 111 | And I press the "Shift + Enter" keyboard button 112 | And I press the "Shift + Enter" keyboard button 113 | And I press the "Shift + Enter" keyboard button 114 | And I type "```" 115 | And I press the "Shift + Enter" keyboard button 116 | And I type "two" 117 | And I press the "Shift + Enter" keyboard button 118 | And I type "```" 119 | When I press the "Enter" keyboard button 120 | Then I should see "last" multiline message with: 121 | | Message body | one\ntwo | 122 | And Message has the following HTML content at "last" position in "Message body": 123 | """ 124 |
one
two125 | """ 126 | 127 | Scenario: Preserve spaces at start and at the end of block and between words 128 | And I type "``` one two three ```" 129 | When I press the "Enter" keyboard button 130 | Then I should see "last" multiline message with: 131 | | Message body | one two three | 132 | And Message has the following HTML content at "last" position in "Message body": 133 | | html content | 134 | |
one two three| 135 | 136 | Scenario: Exclude preformatting with only begin block 137 | And I type "```one two" 138 | When I press the "Enter" keyboard button 139 | Then I should see "```one two" in "Message body" 140 | And Message has the following HTML content at "last" position in "Message body": 141 | | html content | 142 | | ```one two | 143 | 144 | Scenario: Exclude preformatting with only end block 145 | And I type "one two```" 146 | When I press the "Enter" keyboard button 147 | Then I should see "one two```" in "Message body" 148 | And Message has the following HTML content at "last" position in "Message body": 149 | | html content | 150 | | one two``` | 151 | 152 | Scenario: Exclude preformatting with no content 153 | And I type "``````" 154 | When I press the "Enter" keyboard button 155 | Then I should see "``````" in "Message body" 156 | And Message has the following HTML content at "last" position in "Message body": 157 | | html content | 158 | | `````` | 159 | 160 | Scenario: Exclude preformatting with spaces content 161 | And I type "``` ```" 162 | When I press the "Enter" keyboard button 163 | Then I should see "``` ```" in "Message body" 164 | And Message has the following HTML content at "last" position in "Message body": 165 | | html content | 166 | | ``` ``` | 167 | 168 | Scenario: Exclude preformatting with breaklines content 169 | And I type "```" 170 | And I press the "Shift + Enter" keyboard button 171 | And I press the "Shift + Enter" keyboard button 172 | And I press the "Shift + Enter" keyboard button 173 | And I press the "Shift + Enter" keyboard button 174 | And I press the "Shift + Enter" keyboard button 175 | And I type "```" 176 | When I press the "Enter" keyboard button 177 | Then I should see "``````" in "Message body" 178 | And Message has the following HTML content at "last" position in "Message body": 179 | | html content | 180 | | `````` | -------------------------------------------------------------------------------- /features/quote_message_formatting.feature: -------------------------------------------------------------------------------- 1 | Feature: Quote message formatting 2 | As a user, I want to send some words or all message in quote 3 | 4 | Background: 5 | Given My timezone is "Asia/Bishkek" 6 | And Fake slack db is empty 7 | And I am on "fake slack ui" page 8 | 9 | Scenario: Only one word in blockquote 10 | And I type ">quote" 11 | When I press the "Enter" keyboard button 12 | Then I should see "quote" in "Message body" 13 | And Message has the following HTML content at "last" position in "Message body": 14 | | html content | 15 | |
quote| 16 | 17 | Scenario: More than one word in blockquote 18 | And I type ">quote with more than one word" 19 | When I press the "Enter" keyboard button 20 | Then I should see "quote with more than one word" in "Message body" 21 | And Message has the following HTML content at "last" position in "Message body": 22 | | html content | 23 | |
quote with more than one word| 24 | 25 | Scenario: Multiline blockquote formatting without breaklines 26 | And I type ">quote line 1" 27 | And I press the "Shift + Enter" keyboard button 28 | And I type ">quote line 2" 29 | And I press the "Shift + Enter" keyboard button 30 | And I type ">quote line 3" 31 | When I press the "Enter" keyboard button 32 | Then I should see "last" multiline message with: 33 | | Message body | quote line 1\nquote line 2\nquote line 3 | 34 | And Message has the following HTML content at "last" position in "Message body": 35 | | html content | 36 | |
quote line 1| 37 | 38 | Scenario: Multiline blockquote formatting with breaklines 39 | And I type ">quote line 1" 40 | And I press the "Shift + Enter" keyboard button 41 | And I type ">" 42 | And I press the "Shift + Enter" keyboard button 43 | And I type ">" 44 | And I press the "Shift + Enter" keyboard button 45 | And I type ">" 46 | And I press the "Shift + Enter" keyboard button 47 | And I type ">quote line 2" 48 | And I press the "Shift + Enter" keyboard button 49 | And I type ">quote line 3" 50 | When I press the "Enter" keyboard button 51 | Then I should see "last" multiline message with: 52 | | Message body | quote line 1\n\n\nquote line 2\nquote line 3 | 53 | And Message has the following HTML content at "last" position in "Message body": 54 | | html content | 55 | |
quote line 2
quote line 3
quote line 1| 56 | 57 | Scenario: Two blockquote lines separated with text 58 | And I type ">quote line 1" 59 | And I press the "Shift + Enter" keyboard button 60 | And I type "some text" 61 | And I press the "Shift + Enter" keyboard button 62 | And I type ">quote line 2" 63 | When I press the "Enter" keyboard button 64 | Then I should see "last" multiline message with: 65 | | Message body | quote line 1\nsome text\n\nquote line 2 | 66 | And Message has the following HTML content at "last" position in "Message body": 67 | | html content | 68 | |
quote line 2
quote line 3
quote line 1some text
quote line 2| 69 | 70 | Scenario: Do not render next lines as blockquote if starts with spaces 71 | And I type ">quote line 1" 72 | And I press the "Shift + Enter" keyboard button 73 | And I type " >not blockquote 1" 74 | And I press the "Shift + Enter" keyboard button 75 | And I type " >not blockquote 2" 76 | When I press the "Enter" keyboard button 77 | Then I should see "last" multiline message with: 78 | | Message body | quote line 1\n>not blockquote 1\n >not blockquote 2 | 79 | And Message has the following HTML content at "last" position in "Message body": 80 | | html content | 81 | |
quote line 1>not blockquote 1
| 91 | 92 | Scenario: Preserve spaces inside multiline quote 93 | And I type "> quote line 1" 94 | And I press the "Shift + Enter" keyboard button 95 | And I type "> quote line 2" 96 | When I press the "Enter" keyboard button 97 | Then I should see "last" multiline message with: 98 | | Message body | quote line 1\n quote line 2 | 99 | And Message has the following HTML content at "last" position in "Message body": 100 | | html content | 101 | |quote line 1
| -------------------------------------------------------------------------------- /features/receiving_messages.feature: -------------------------------------------------------------------------------- 1 | Feature: Receiving messages 2 | As a user, I want to receive messages from different channels 3 | 4 | Background: 5 | Given My timezone is "Asia/Bishkek" 6 | And Fake slack db is empty 7 | And I am on "fake slack ui" page 8 | And User "Valera" connected to fake slack using parameters: 9 | | token | xoxb-XXXXXXXXXXXX-TTTTTTTTTTTTTT | 10 | | url | http://localhost:9001/api/ | 11 | 12 | Scenario: Reciving "typing" status message in the "general" channel 13 | When User "Valera" send "user_typing" message to "general" channel 14 | Then I should see "Valera is typing" in "Notification bar item" 15 | 16 | Scenario: Reciving "typing" status message in the "random" channel 17 | And I click on "channel item" with text "random" 18 | When User "Valera" send "user_typing" message to "random" channel 19 | Then I should see "Valera is typing" in "Notification bar item" 20 | 21 | Scenario: Receiving simple text message from "general" channel 22 | When User "Valera" send message: 23 | | type | message | 24 | | text | Text from bot #1 | 25 | | channel | general | 26 | Then I should see "last" message with: 27 | | Message sender | Valera | 28 | | App badge | APP | 29 | | Message body | Text from bot #1 | 30 | 31 | Scenario: Receiving simple text message from "random" channel 32 | And I click on "channel item" with text "random" 33 | When User "Valera" send message: 34 | | type | message | 35 | | text | Text from bot #2 | 36 | | channel | random | 37 | Then I should see "last" message with: 38 | | Message sender | Valera | 39 | | App badge | APP | 40 | | Message body | Text from bot #2 | -------------------------------------------------------------------------------- /features/sending_messages.feature: -------------------------------------------------------------------------------- 1 | Feature: Sending messages 2 | As a user, I want to send messages to different channels 3 | 4 | Background: 5 | Given My timezone is "Asia/Bishkek" 6 | And Fake slack db is empty 7 | And I am on "fake slack ui" page 8 | And User "Valera" connected to fake slack using parameters: 9 | | token | xoxb-XXXXXXXXXXXX-TTTTTTTTTTTTTT | 10 | | url | http://localhost:9001/api/ | 11 | 12 | Scenario: Sending simple text message to "general" channel 13 | And I type "my simple text message" 14 | When I press the "Enter" keyboard button 15 | Then I should see "my simple text message" in "Message body" 16 | And User "Valera" should receive messages: 17 | | message | channel | 18 | | my simple text message | general | 19 | 20 | Scenario: Sending simple text message to "random" channel 21 | And I click on "channel item" with text "random" 22 | And I type "text message to random channel" 23 | When I press the "Enter" keyboard button 24 | Then I should see "text message to random channel" in "Message body" on the "last" position 25 | And User "Valera" should receive messages: 26 | | message | channel | 27 | | text message to random channel | random | 28 | @skip 29 | Scenario: Sending user_typing message to "random" channel 30 | And I click on "channel item" with text "random" 31 | When I type "m" 32 | And I'll wait a little 33 | Then User "Valera" should receive status messages: 34 | | type | channel | 35 | | user_typing | random | 36 | 37 | Scenario: Sending user_typing message to "general" channel 38 | When I type "m" 39 | And I'll wait a little 40 | Then User "Valera" should receive status messages: 41 | | type | channel | 42 | | user_typing | general | -------------------------------------------------------------------------------- /features/step_definitions/hooks.js: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | const { 3 | After, 4 | AfterAll, 5 | Before, 6 | BeforeAll, 7 | setDefaultTimeout, 8 | Status 9 | } = require('cucumber'); 10 | 11 | const path = require('path'); 12 | const Promise = require('bluebird'); 13 | const scope = require('./support/scope'); 14 | const services = require('./support/services'); 15 | setDefaultTimeout(2 * 60 * 1000); 16 | 17 | async function cleanupAppUsers() { 18 | if (scope.context.appUsers) { 19 | await Promise.mapSeries(Object.entries(scope.context.appUsers), ([, bot]) => bot && bot.close()); 20 | } 21 | scope.context.appUsers = {}; 22 | } 23 | 24 | async function cleanupPage() { 25 | // Here we check if a scenario has instantiated a browser and a current page 26 | if (scope.browser && scope.context.currentPage) { 27 | // if it has, find all the cookies, and delete them 28 | const cookies = await scope.context.currentPage.cookies(); 29 | if (cookies && cookies.length > 0) { 30 | await scope.context.currentPage.deleteCookie(...cookies); 31 | } 32 | await scope.context.currentPage.evaluate(() => { 33 | localStorage.clear(); 34 | }); 35 | // close the web page down 36 | await scope.context.currentPage.close(); 37 | // wipe the context's currentPage value 38 | scope.context.currentPage = null; 39 | } 40 | } 41 | 42 | BeforeAll(async () => { 43 | await services.ui.server.start(); 44 | }); 45 | 46 | Before(async () => { 47 | await cleanupAppUsers(); 48 | await cleanupPage(); 49 | }); 50 | 51 | After(async (testCase) => { 52 | if (scope.browser && testCase.result.status === Status.FAILED) { 53 | const pages = await scope.browser.pages(); 54 | await Promise.mapSeries(pages, 55 | p => p.screenshot( 56 | { 57 | path: path.join(process.cwd(), 'screenshots', `failed${Date.now()}.png`), 58 | fullPage: true 59 | } 60 | )); 61 | } 62 | await cleanupAppUsers(); 63 | await cleanupPage(); 64 | }); 65 | 66 | AfterAll(async () => { 67 | if (scope.browser) await scope.browser.close(); 68 | if (services.ui.server) await services.ui.server.close(); 69 | }); 70 | 71 | async function clearResources() { 72 | if (services.ui.server) await services.ui.server.close(); 73 | } 74 | 75 | process.on('exit', clearResources); 76 | process.on('SIGINT', clearResources); 77 | process.on('SIGTERM', clearResources); 78 | -------------------------------------------------------------------------------- /features/step_definitions/pages.js: -------------------------------------------------------------------------------- 1 | const pages = { 2 | 'fake slack ui': '/' 3 | }; 4 | 5 | module.exports = pages; 6 | -------------------------------------------------------------------------------- /features/step_definitions/selectors.js: -------------------------------------------------------------------------------- 1 | const mainSelectors = { 2 | 'Team name': '#team_name', 3 | 'Team user name': '#team_menu_user_name', 4 | 'Selected channel': '.p-channel_sidebar__channel--selected', 5 | 'Channel title': '#channel_title', 6 | Favorite: '.im.im-star-o', 7 | 'Channel header information': '#channel_header_info', 8 | Users: '.im.im-user-male', 9 | 'Channel members count': '#channel_members_toggle_count', 10 | 'Channel header topic': '#channel_topic_text', 11 | 'Channel header buttons': '.channel_header__buttons', 12 | 'Recent mentions button': 'button#recent_mentions_toggle > .im.im-link', 13 | 'Stars toggle button': 'button#stars_toggle > .im.im-star-o', 14 | 'Flex toggle button': 'button#flex_menu_toggle > .im.im-menu-dot-v', 15 | 'Messages container': '#messages_container', 16 | 'Message item': '.list__item', 17 | 'Input message container': '#msg_form', 18 | 'Input message': '#msg_input_text', 19 | 'File upload button': '#primary_file_button', 20 | 'Emoji button': '#main_emo_menu', 21 | 'User mentions button': '.msg_mentions_button', 22 | 'Notification bar container': '#notification_bar', 23 | 'Left notification section': '.p-notification_bar__section.p-notification_bar__section--left', 24 | 'Right notification section': '.p-notification_bar__section.p-notification_bar__section--right', 25 | 'Message body': '#messages_container .c-message__body', 26 | 'channel item': 'span.p-channel_sidebar__name', 27 | 'Notification bar': '.p-notification_bar__section--left', 28 | 'Notification bar item': '.p-notification_bar__section--left .p-notification_bar__typing', 29 | 'Message sender': '#messages_container .c-message__sender_link', 30 | 'App badge': '#messages_container .c-app_badge', 31 | button: '.c-button', 32 | 'Inline Message Editor': '.c-message__editor' 33 | }; 34 | 35 | const selectors = { 36 | 'fake slack ui': mainSelectors 37 | }; 38 | 39 | module.exports = selectors; 40 | -------------------------------------------------------------------------------- /features/step_definitions/support/fakeuser.js: -------------------------------------------------------------------------------- 1 | let mockDate = null; 2 | 3 | global.OrigDate = global.Date; 4 | global.Date = class MockDate extends global.Date { 5 | constructor(date) { 6 | if (!MockDate.mockedDate) { 7 | return super(date); 8 | } 9 | return super(MockDate.mockedDate.getTime()); 10 | } 11 | 12 | static mockDateIncreaseMinutes(minutes = 0) { 13 | if (MockDate.mockedDate) { 14 | MockDate.mockedDate.setMinutes(MockDate.mockedDate.getMinutes() + minutes); 15 | } 16 | } 17 | 18 | static now() { 19 | if (MockDate.mockedDate) { 20 | return MockDate.mockedDate.getTime(); 21 | } 22 | return global.OrigDate.now(); 23 | } 24 | 25 | static mockDate(date) { 26 | MockDate.mockedDate = date; 27 | } 28 | 29 | static unmockDate() { 30 | MockDate.mockedDate = null; 31 | } 32 | }; 33 | 34 | if (mockDate) { 35 | global.Date.mockDate(mockDate); 36 | } else { 37 | global.Date.unmockDate(); 38 | } 39 | 40 | const { WebClient } = require('@slack/web-api'); 41 | const { RTMClient } = require('@slack/rtm-api'); 42 | const API_CHANNELS_LIST = 'channels.list'; 43 | 44 | class FakeUser { 45 | constructor(token, slackApiUrl) { 46 | this.token = token; 47 | this.slackApiUrl = slackApiUrl; 48 | this.web = null; 49 | this.rtm = null; 50 | this.self = null; 51 | this.team = null; 52 | this.apiResponses = {}; 53 | this.incomingLastUpdate = null; 54 | this.rtmIncomingMessages = []; 55 | } 56 | 57 | async start() { 58 | this.web = new WebClient(this.token, { slackApiUrl: this.slackApiUrl }); 59 | this.rtm = new RTMClient(this.token, { slackApiUrl: this.slackApiUrl }); 60 | this.setupEvents(); 61 | const { self, team } = await this.rtm.start(); 62 | this.self = self; 63 | this.team = team; 64 | return new Promise(resolve => { 65 | this.resolveStart = resolve; 66 | }); 67 | } 68 | 69 | // eslint-disable-next-line class-methods-use-this 70 | setMockDate(isoDate) { 71 | global.Date.mockDate(isoDate); 72 | } 73 | 74 | // eslint-disable-next-line class-methods-use-this 75 | increaseMockDateByMinutes(minutes = 0) { 76 | global.Date.mockDateIncreaseMinutes(minutes); 77 | } 78 | 79 | getChannelIdByName(channelName) { 80 | const result = this.apiResponses[API_CHANNELS_LIST].channels.filter(({ name }) => name === channelName); 81 | return result.length ? result[0] : null; 82 | } 83 | 84 | setupEvents() { 85 | this.rtm.on('ready', async () => { 86 | const authTestResponse = await this.web.auth.test(); 87 | this.apiResponses['auth.test'] = authTestResponse; 88 | const channelsListResponse = await this.web.channels.list({ exclude_archived: 1 }); 89 | this.apiResponses['channels.list'] = channelsListResponse; 90 | if (typeof this.resolveStart === 'function') { 91 | this.resolveStart(); 92 | delete this.resolveStart; 93 | } 94 | }); 95 | 96 | this.rtm.on('message', async (event) => { 97 | this.rtmIncomingMessages.push(event); 98 | }); 99 | 100 | this.rtm.on('user_typing', async (event) => { 101 | this.rtmIncomingMessages.push(event); 102 | }); 103 | } 104 | 105 | sendTextMessageToChannel(channelName, message) { 106 | const channel = this.getChannelIdByName(channelName); 107 | return this.rtm.sendMessage(message, channel.id); 108 | } 109 | 110 | sendUserTypingToChannel(channelName) { 111 | const channel = this.getChannelIdByName(channelName); 112 | return this.rtm.sendTyping(channel.id); 113 | } 114 | 115 | getLastIncomingPayload(payloadType) { 116 | const lastItemIndex = this.rtmIncomingMessages.length - 1; 117 | const index = this.rtmIncomingMessages.slice().reverse().findIndex(m => m.type === payloadType); 118 | return index >= 0 && this.rtmIncomingMessages[lastItemIndex - index]; 119 | } 120 | 121 | getLastIncomingMessage() { 122 | return this.getLastIncomingPayload('message'); 123 | } 124 | 125 | getLastIncomingMessageByChannelId(channelId) { 126 | const filteredMessages = this.getLastIncomingMessagesByChannelId(channelId, 1); 127 | return filteredMessages[filteredMessages.length - 1]; 128 | } 129 | 130 | getLastIncomingMessagesByChannelId(channelId, limit = 10) { 131 | const filteredMessages = this.rtmIncomingMessages.filter(msg => msg.channel === channelId); 132 | return filteredMessages.slice(-1 * limit); 133 | } 134 | 135 | close() { 136 | if (this.rtm) { 137 | this.rtm.removeAllListeners(); 138 | return this.rtm.disconnect(); 139 | } 140 | return true; 141 | } 142 | } 143 | 144 | module.exports = FakeUser; 145 | -------------------------------------------------------------------------------- /features/step_definitions/support/scope.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /features/step_definitions/support/services.js: -------------------------------------------------------------------------------- 1 | const createUIServer = require('../../../server'); 2 | const FakeUser = require('./fakeuser'); 3 | 4 | module.exports = { 5 | ui: { 6 | server: createUIServer({ 7 | httpPort: 9001, 8 | httpHost: 'localhost' 9 | }) 10 | }, 11 | user: { 12 | create({ token, url }) { 13 | return new FakeUser(token, url); 14 | } 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /features/step_definitions/support/validators.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | CustomJSONSchemaValidator(Validator) { 3 | /* eslint-disable no-param-reassign */ 4 | Validator.prototype.customFormats.uuid = function uuid(input) { 5 | const v4uuid = new RegExp(/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i); 6 | return !!String(input).match(v4uuid); 7 | }; 8 | 9 | Validator.prototype.customFormats.slackts = function slackts(input) { 10 | return !!String(input).match(/^([0-9]{10})\.([0-9]{6})$/); 11 | }; 12 | 13 | Validator.prototype.customFormats.slackchid = function slackchid(input) { 14 | return !!String(input).match(/^C[0-9A-Z]{8}$/); 15 | }; 16 | 17 | Validator.prototype.customFormats.slackuid = function slackuid(input) { 18 | return !!String(input).match(/^(U|W)[0-9A-Z]{8}$/); 19 | }; 20 | 21 | Validator.prototype.customFormats.slacktid = function slacktid(input) { 22 | return !!String(input).match(/^T[0-9A-Z]{8}$/); 23 | }; 24 | 25 | Validator.prototype.customFormats.slackmtypes = function slackmtypes(input) { 26 | return ['message', 'user_typing'].includes(String(input)); 27 | }; 28 | 29 | Validator.prototype.customFormats.slackmsubtypes = function slackmtypes(input) { 30 | return ['message_changed'].includes(String(input)); 31 | }; 32 | 33 | /* eslint-enable no-param-reassign */ 34 | return Validator; 35 | }, 36 | CustomJSONSchemaValidatorAddRefs(validatorInstance) { 37 | const ChatUpdateMessageEditedSchema = { 38 | id: '/ChatUpdateMessageEdited', 39 | type: 'object', 40 | properties: { 41 | user: { type: 'string', format: 'slackuid' }, 42 | ts: { type: 'string', format: 'slackts' } 43 | }, 44 | required: ['user', 'ts'] 45 | }; 46 | validatorInstance.addSchema(ChatUpdateMessageEditedSchema, ChatUpdateMessageEditedSchema.id); 47 | 48 | const ChatUpdateMessageSchema = { 49 | id: '/ChatUpdateMessage', 50 | type: 'object', 51 | properties: { 52 | client_msg_id: { type: 'string', format: 'uuid' }, 53 | type: { type: 'string', format: 'slackmtypes' }, 54 | user: { type: 'string', format: 'slackuid' }, 55 | team: { type: 'string', format: 'slacktid' }, 56 | edited: { $ref: '/ChatUpdateMessageEdited' }, 57 | user_team: { type: 'string', format: 'slacktid' }, 58 | source_team: { type: 'string', format: 'slacktid' }, 59 | channel: { type: 'string', format: 'slackchid' }, 60 | text: { type: 'string' }, 61 | ts: { type: 'string', format: 'slackts' } 62 | }, 63 | required: ['client_msg_id', 'type', 'user', 'team', 'edited', 'user_team', 'source_team', 'channel', 'text', 'ts'] 64 | }; 65 | validatorInstance.addSchema(ChatUpdateMessageSchema, ChatUpdateMessageSchema.id); 66 | 67 | const ChatUpdatePreviousMessageSchema = { 68 | id: '/ChatUpdatePreviousMessage', 69 | type: 'object', 70 | properties: { 71 | client_msg_id: { type: 'string', format: 'uuid' }, 72 | type: { type: 'string', format: 'slackmtypes' }, 73 | user: { type: 'string', format: 'slackuid' }, 74 | team: { type: 'string', format: 'slacktid' }, 75 | text: { type: 'string' }, 76 | ts: { type: 'string', format: 'slackts' } 77 | }, 78 | required: ['client_msg_id', 'type', 'user', 'team', 'ts', 'text'] 79 | }; 80 | validatorInstance.addSchema(ChatUpdatePreviousMessageSchema, ChatUpdatePreviousMessageSchema.id); 81 | 82 | return validatorInstance; 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /features/step_definitions/world.js: -------------------------------------------------------------------------------- 1 | const { 2 | setWorldConstructor 3 | } = require('cucumber'); 4 | 5 | const puppeteer = require('puppeteer'); 6 | const scope = require('./support/scope'); 7 | 8 | // eslint-disable-next-line func-names 9 | function World() { 10 | scope.host = 'http://localhost:9001'; 11 | scope.driver = puppeteer; 12 | scope.context = {}; 13 | scope.memo = {}; 14 | scope.interceptRequests = {}; 15 | scope.locale = {}; 16 | scope.locale.languages = ['en-US', 'en']; 17 | scope.locale.language = ['en-US']; 18 | scope.locale.timeZone = 'Asia/Almaty'; 19 | scope.mockDate = null; 20 | } 21 | 22 | setWorldConstructor(World); 23 | -------------------------------------------------------------------------------- /features/strike_message_formatting.feature: -------------------------------------------------------------------------------- 1 | Feature: Strike message formatting 2 | As a user, I want to send some words or all message in strikethrough style 3 | 4 | Background: 5 | Given My timezone is "Asia/Bishkek" 6 | And Fake slack db is empty 7 | And I am on "fake slack ui" page 8 | 9 | Scenario: Only one word in strike 10 | And I type "~strike~" 11 | When I press the "Enter" keyboard button 12 | Then I should see "strike" in "Message body" 13 | And Message has the following HTML content at "last" position in "Message body": 14 | | html content | 15 | |quote line 1
quote line 2
' : ''; 136 | return nextIsEmpty ? trimBr(line) : `${trimBr(line)}${lineBreaker}`; 137 | } 138 | return index > 0 ? '' : ''; 139 | }; 140 | message = trimBr(message).split('\n').map(format).join(''); 141 | return new Handlebars.SafeString(message); 142 | }, 143 | formatInlineMessage(text) { 144 | let message = escapeExpression(text.trim()); 145 | message = message.split('\n').map(line => (line === '' ? '' : `
${line}
`)).join(''); 146 | return new Handlebars.SafeString(message); 147 | }, 148 | getTsDiffInSeconds(firstTs, secondTs) { 149 | const firstUnixTs = Number(firstTs.split('.')[0]); 150 | const secondUnixTs = Number(secondTs.split('.')[0]); 151 | const maxTs = Math.max(firstUnixTs, secondUnixTs); 152 | const minTs = Math.min(firstUnixTs, secondUnixTs); 153 | return Math.round(maxTs - minTs); 154 | }, 155 | canHideHeader(currentMessage, baseMessage, interval = 0) { 156 | const diffInSeconds = helpers.getTsDiffInSeconds(currentMessage.ts, baseMessage.ts); 157 | return String(baseMessage.user_id) === String(currentMessage.user_id) && diffInSeconds <= interval; 158 | }, 159 | findFirstMessageByUser(messages, userId) { 160 | const keys = Object.keys(messages); 161 | let firstMessageFromUser = null; 162 | for (let i = keys.length - 1; i >= 0; i -= 1) { 163 | const message = messages[keys[i]]; 164 | if (message.user_id !== userId) { 165 | break; 166 | } else { 167 | firstMessageFromUser = message; 168 | } 169 | } 170 | return firstMessageFromUser; 171 | }, 172 | measureMessageItems(prevItem, item) { 173 | const intervalInSeconds = getConstants().HIDE_HEADER_TIME_INTERVAL_IN_SECONDS; 174 | if (prevItem && prevItem.user_id === item.user_id) { 175 | const messageDiffInSeconds = helpers.getTsDiffInSeconds(prevItem.ts, item.ts); 176 | if (messageDiffInSeconds >= intervalInSeconds) { 177 | return [item, { ...item, hideHeader: false }]; 178 | } 179 | const hideHeader = helpers.canHideHeader(item, prevItem, intervalInSeconds); 180 | return [prevItem, { ...item, hideHeader }]; 181 | } 182 | return [item, { ...item, hideHeader: false }]; 183 | }, 184 | eachMessage(context, options) { 185 | let currentContext = context; 186 | let prevItem; 187 | const intervalInSeconds = getConstants().HIDE_HEADER_TIME_INTERVAL_IN_SECONDS; 188 | if (!options) { 189 | throw new Error('Must pass iterator to #eachMessage'); 190 | } 191 | 192 | let fn = options.fn; 193 | let inverse = options.inverse; 194 | let data; 195 | 196 | if (typeof currentContext === 'function') { 197 | currentContext = currentContext.call(this); 198 | } 199 | 200 | if (options.data) { 201 | data = Handlebars.createFrame(options.data); 202 | } 203 | 204 | function execIteration(field, index, last) { 205 | if (data) { 206 | data.key = field; 207 | data.index = index; 208 | data.first = index === 0; 209 | data.last = !!last; 210 | } 211 | let item = currentContext[field]; 212 | [prevItem, item] = helpers.measureMessageItems(prevItem, item, intervalInSeconds); 213 | return fn(item, { 214 | data: data, 215 | blockParams: [item, field] 216 | }); 217 | } 218 | 219 | if (currentContext && typeof currentContext === 'object') { 220 | return Object.keys(currentContext).reduce((acc, key, index, arr) => { 221 | return [acc, execIteration(key, index, arr.length - 1 === index)].join(''); 222 | }, ''); 223 | } 224 | 225 | return inverse(this); 226 | } 227 | }; 228 | 229 | if (isServer) { 230 | module.exports = helpers; 231 | } else if (typeof window.Handlebars !== 'undefined') { 232 | Object.entries(helpers).forEach(([name, fn]) => window.Handlebars.registerHelper(name, fn)); 233 | } 234 | -------------------------------------------------------------------------------- /reports/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maddevsio/mad-fake-slack/50548aada8cd98e2fedc5138560eaab96f0cee12/reports/.keep -------------------------------------------------------------------------------- /routes/api/factories.js: -------------------------------------------------------------------------------- 1 | const responses = require('../responses'); 2 | const utils = require('./utils'); 3 | const faker = require('faker'); 4 | let generationId = 1; 5 | let eventIdTracker = 1; 6 | 7 | function createMessageResponse({ 8 | type, 9 | ts, 10 | text, 11 | channel 12 | }, { user, team }) { 13 | const response = utils.copyObject(responses['chat.postMessage']); 14 | const msgTs = typeof ts === 'undefined' ? utils.createTs(generationId) : ts; 15 | generationId += 1; 16 | response.ts = msgTs; 17 | response.channel = channel; 18 | 19 | const overrideProperties = { 20 | client_msg_id: faker.random.uuid(), 21 | text, 22 | type, 23 | user: user.id, 24 | team: team.id, 25 | user_team: team.id, 26 | source_team: team.id, 27 | channel, 28 | event_ts: msgTs, 29 | ts: msgTs 30 | }; 31 | 32 | response.message = { 33 | ...response.message, 34 | ...overrideProperties 35 | }; 36 | return response; 37 | } 38 | 39 | function createUpdateMessageEvent({ channel, message, previousMessage }, { user, team }) { 40 | const eventTs = utils.createTs(eventIdTracker); 41 | eventIdTracker += 1; 42 | const clientMsgId = faker.random.uuid(); 43 | 44 | const overrideMessageProps = { 45 | client_msg_id: clientMsgId, 46 | type: 'message', 47 | user: user.id, 48 | team: team.id, 49 | edited: { user: user.id, ts: eventTs }, 50 | user_team: team.id, 51 | source_team: team.id, 52 | channel 53 | }; 54 | 55 | const overridePevMessageProps = { 56 | client_msg_id: clientMsgId, 57 | type: 'message', 58 | user: user.id, 59 | team: team.id 60 | }; 61 | 62 | const messagePayload = { 63 | ...utils.copyObject(message), 64 | ...overrideMessageProps 65 | }; 66 | 67 | const previousMessagePayload = { 68 | ...utils.copyObject(previousMessage), 69 | ...overridePevMessageProps 70 | }; 71 | 72 | ['user_id'].forEach(unwantedProp => { 73 | delete messagePayload[unwantedProp]; 74 | delete previousMessagePayload[unwantedProp]; 75 | }); 76 | 77 | return { 78 | type: 'message', 79 | subtype: 'message_changed', 80 | hidden: false, 81 | message: messagePayload, 82 | channel, 83 | previous_message: previousMessagePayload, 84 | event_ts: eventTs, 85 | ts: eventTs 86 | }; 87 | } 88 | 89 | module.exports = { 90 | createMessageResponse, 91 | createUpdateMessageEvent 92 | }; 93 | -------------------------------------------------------------------------------- /routes/api/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const crypto = require('crypto'); 4 | const multer = require('multer'); 5 | 6 | const { dbManager, wsManager } = require('../managers'); 7 | const responses = require('../responses'); 8 | const utils = require('./utils'); 9 | const factories = require('./factories'); 10 | 11 | const storage = multer.diskStorage({ 12 | destination: (req, file, cb) => { 13 | cb(null, '/tmp/uploads'); 14 | }, 15 | filename: (req, file, cb) => { 16 | cb(null, `${file.fieldname}-${Date.now()}`); 17 | } 18 | }); 19 | 20 | const upload = multer({ storage }); 21 | 22 | function beforeAllHandler(req, res, next) { 23 | if (utils.isUrlEncodedForm(req)) { 24 | express.urlencoded()(req, res, next); 25 | } else if (utils.isMultipartForm(req)) { 26 | upload.none()(req, res, next); 27 | } else { 28 | express.json()(req, res, next); 29 | } 30 | } 31 | 32 | function authTestHandler(req, res) { 33 | const token = (req.body && req.body.token) || req.headers.Authorization || req.headers.authorization; 34 | if (!token) { 35 | res.json(responses.invalid_auth); 36 | return; 37 | } 38 | 39 | const uid = crypto 40 | .createHash('md5') 41 | .update(token) 42 | .digest('hex'); 43 | 44 | const users = dbManager.users().findById(dbManager.db.sessions[uid]); 45 | if (!users.length) { 46 | res.json(responses.invalid_auth); 47 | } else { 48 | const user = users[0]; 49 | const team = dbManager.db.teams.filter(tm => tm.id === user.team_id)[0]; 50 | const exampleResponse = responses['auth.test']; 51 | exampleResponse.team_id = team.id; 52 | exampleResponse.user_id = user.id; 53 | const schema = process.env.URL_SCHEMA || 'http'; 54 | exampleResponse.url = `${schema}://${team.domain}/`; 55 | exampleResponse.team = team.name || exampleResponse.team; 56 | exampleResponse.user = user.name || exampleResponse.team; 57 | res.json(exampleResponse); 58 | } 59 | } 60 | 61 | function createResponse({ 62 | user, 63 | team, 64 | ...other 65 | }) { 66 | return factories.createMessageResponse( 67 | { 68 | type: 'message', 69 | ...other 70 | }, 71 | { user, team } 72 | ); 73 | } 74 | 75 | function broadcastResponse({ payload, channel, userId }) { 76 | wsManager.broadcast(JSON.stringify(payload), userId); 77 | if (utils.isOpenChannel(channel)) { 78 | wsManager.broadcastToBots(JSON.stringify(payload), userId); 79 | } 80 | if (utils.isBot(channel, dbManager)) { 81 | wsManager.broadcastToBot(JSON.stringify(payload), channel); 82 | } 83 | } 84 | 85 | async function postMessageHandler(req, res) { 86 | const message = dbManager.channel(req.body.channel).createMessage(dbManager.slackUser().id, req.body); 87 | if (utils.isUrlEncodedForm(req) || utils.isMultipartForm(req)) { 88 | const channelId = utils.getChannelId(req.body.channel); 89 | res.redirect(`/messages/${channelId && channelId[0]}`); 90 | } else { 91 | const channel = utils.getChannelId(req.body.channel); 92 | const user = dbManager.slackUser(); 93 | const team = dbManager.slackTeam(); 94 | const response = createResponse({ 95 | user, 96 | team, 97 | ts: message.ts, 98 | channel, 99 | text: req.body.text.trim(), 100 | hideHeader: message.hideHeader 101 | }); 102 | broadcastResponse({ payload: response.message, userId: user.id, channel }); 103 | res.json(response); 104 | } 105 | } 106 | 107 | function rtmConnectHandler(req, res) { 108 | const token = (req.body && req.body.token) || req.headers.Authorization; 109 | const tokenHash = crypto 110 | .createHash('md5') 111 | .update(token) 112 | .digest('hex'); 113 | const successResponse = responses['rtm.connect']; 114 | 115 | const response = { 116 | ...successResponse 117 | }; 118 | const { id: userId, name: userName } = dbManager.slackUser(); 119 | response.self.id = userId; 120 | response.self.name = userName; 121 | 122 | const { id: teamId, domain, name: teamName } = dbManager.slackTeam(); 123 | response.team.id = teamId; 124 | response.team.domain = domain; 125 | response.team.name = teamName; 126 | const schema = process.env.URL_SCHEMA || 'http'; 127 | response.url = `${schema === 'http' ? 'ws' : 'wss'}://${domain}/ws?uid=${tokenHash}`; 128 | res.json(response); 129 | } 130 | 131 | function channelsListHandler(req, res) { 132 | const successResponse = { 133 | ...responses['channels.list'] 134 | }; 135 | 136 | successResponse.channels = dbManager.db.channels; 137 | res.json(successResponse); 138 | } 139 | 140 | function conversationsListHandler(req, res) { 141 | let { types } = req.query; 142 | const channelTypes = new Set(); 143 | if (!types) { 144 | types = ['public_channel', 'private_channel']; 145 | } 146 | 147 | if (Array.isArray(types)) { 148 | types.forEach(type => channelTypes.add(type)); 149 | } else { 150 | channelTypes.add(types); 151 | } 152 | 153 | res.json({ ok: true }); 154 | } 155 | 156 | function userInfoHandler(req, res) { 157 | let { user: userId } = req.query; 158 | const users = dbManager.users().findById(userId); 159 | if (!users.length) { 160 | res.json(responses.user_not_found); 161 | } else { 162 | const user = users[0]; 163 | const response = responses['users.info']; 164 | res.json({ 165 | ...response, 166 | user 167 | }); 168 | } 169 | } 170 | 171 | function chatUpdateHandler(req, res) { 172 | const { channel, ts, text } = req.body; 173 | const previousMessage = dbManager.channel(req.body.channel).findMessageByTs(ts); 174 | const user = dbManager.slackUser(); 175 | const team = dbManager.db.teams.filter(tm => tm.id === user.team_id)[0]; 176 | 177 | if (!previousMessage || previousMessage.user_id !== user.id || !text) { 178 | res.json(responses.cant_update_message); 179 | return; 180 | } 181 | 182 | const message = dbManager.channel(req.body.channel).updateMessage({ 183 | ...previousMessage, 184 | text 185 | }); 186 | 187 | const payload = factories.createUpdateMessageEvent({ 188 | channel, 189 | message, 190 | previousMessage 191 | }, { user, team }); 192 | 193 | broadcastResponse({ payload, userId: user.id, channel }); 194 | 195 | res.json({ 196 | ok: true, 197 | channel, 198 | ts, 199 | text 200 | }); 201 | } 202 | 203 | router.use('*', beforeAllHandler); 204 | 205 | router.post('/auth.test', authTestHandler); 206 | router.post('/chat.postMessage', postMessageHandler); 207 | router.post('/channels.list', channelsListHandler); 208 | router.post('/rtm.connect', rtmConnectHandler); 209 | router.post('/rtm.start', rtmConnectHandler); 210 | router.post('/chat.update', chatUpdateHandler); 211 | 212 | router.get('/rtm.connect', rtmConnectHandler); 213 | router.get('/rtm.start', rtmConnectHandler); 214 | router.get('/users.info', userInfoHandler); 215 | router.get('/conversations.list', conversationsListHandler); 216 | 217 | module.exports = router; 218 | -------------------------------------------------------------------------------- /routes/api/utils.js: -------------------------------------------------------------------------------- 1 | const helpers = require('../../public/js/helpers'); 2 | 3 | function copyObject(obj) { 4 | return JSON.parse(JSON.stringify(obj)); 5 | } 6 | 7 | function getChannelId(channel) { 8 | const result = /^[CWDU][A-Z0-9]{8}$/.exec(channel); 9 | return (Array.isArray(result) && result[0]) || null; 10 | } 11 | 12 | function isOpenChannel(channel) { 13 | return channel && channel.startsWith('C'); 14 | } 15 | 16 | function isBot(channel, dbManager) { 17 | const users = dbManager.users().findById(channel, u => u.is_app_user || u.is_bot); 18 | return users.length; 19 | } 20 | 21 | function isUrlEncodedForm(req) { 22 | return req.headers['content-type'] === 'application/x-www-form-urlencoded'; 23 | } 24 | 25 | function isMultipartForm(req) { 26 | return req.headers['content-type'] && req.headers['content-type'].startsWith('multipart/form-data'); 27 | } 28 | 29 | module.exports = { 30 | copyObject, 31 | getChannelId, 32 | isOpenChannel, 33 | isBot, 34 | isUrlEncodedForm, 35 | isMultipartForm, 36 | createTs: helpers.createTs 37 | }; 38 | -------------------------------------------------------------------------------- /routes/app/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { dbManager } = require('../managers'); 4 | 5 | const constants = require('../constants'); 6 | 7 | router.get('/', (req, res) => { 8 | const selectedChannel = dbManager.db.channels.filter(ch => ch.name === 'general')[0]; 9 | const messages = dbManager.channel(selectedChannel.id).messages(constants.MESSAGES_MAX_COUNT); 10 | res.render(constants.MAIN_PAGE, { 11 | selectedChannel, 12 | selectedUser: null, 13 | selectedApp: null, 14 | channels: dbManager.db.channels, 15 | users: dbManager.db.users.filter(su => !su.is_bot && !su.is_app_user), 16 | bots: dbManager.db.users.filter(su => su.is_bot || su.is_app_user), 17 | team: dbManager.slackTeam(), 18 | me: dbManager.slackUser(), 19 | messages, 20 | constants 21 | }); 22 | }); 23 | 24 | router.get('/messages/:id', (req, res) => { 25 | const selectedChannel = dbManager.db.channels.filter(ch => ch.id === req.params.id); 26 | const selectedUser = dbManager.users().findById(req.params.id, u => !u.is_bot && !u.is_app_user); 27 | const selectedApp = dbManager.users().findById(req.params.id, u => u.is_bot || u.is_app_user); 28 | const messages = (selectedChannel.length && dbManager.channel(selectedChannel[0].id).messages(constants.MESSAGES_MAX_COUNT)) 29 | || (selectedUser.length && dbManager.channel(selectedUser[0].id).messages(constants.MESSAGES_MAX_COUNT)) 30 | || (selectedApp.length && dbManager.channel(selectedApp[0].id).messages(constants.MESSAGES_MAX_COUNT)) 31 | || []; 32 | res.render(constants.MAIN_PAGE, { 33 | selectedChannel: selectedChannel.length && selectedChannel[0], 34 | selectedUser: selectedUser.length && selectedUser[0], 35 | selectedApp: selectedApp.length && selectedApp[0], 36 | channels: dbManager.db.channels, 37 | users: dbManager.db.users.filter(su => !su.is_bot && !su.is_app_user), 38 | bots: dbManager.db.users.filter(su => su.is_bot || su.is_app_user), 39 | team: dbManager.slackTeam(), 40 | me: dbManager.slackUser(), 41 | messages, 42 | constants 43 | }); 44 | }); 45 | 46 | module.exports = router; 47 | -------------------------------------------------------------------------------- /routes/constants.js: -------------------------------------------------------------------------------- 1 | const WS_OPEN_STATE = 1; 2 | const MAIN_PAGE = process.env.MAIN_PAGE; 3 | const MESSAGES_MAX_COUNT = Number(process.env.MESSAGES_MAX_COUNT); 4 | const HIDE_HEADER_TIME_INTERVAL_IN_SECONDS = Number(process.env.DEFAULT_HEADER_HIDE_TIME_INTERVAL_IN_MIN) * 60; 5 | 6 | module.exports = { 7 | OPEN: WS_OPEN_STATE, 8 | MAIN_PAGE, 9 | MESSAGES_MAX_COUNT, 10 | HIDE_HEADER_TIME_INTERVAL_IN_SECONDS 11 | }; 12 | -------------------------------------------------------------------------------- /routes/managers.js: -------------------------------------------------------------------------------- 1 | const { createDbManager } = require('../db'); 2 | const { OPEN } = require('./constants'); 3 | 4 | class WsManager { 5 | constructor() { 6 | this.slackWss = new Set(); 7 | this.slackBots = new Set(); 8 | this.deleteActions = {}; 9 | 10 | this.deleteActions.bot = (client) => { 11 | this.slackBots.delete(client); 12 | }; 13 | this.deleteActions.ui = (client) => { 14 | this.slackWss.delete(client); 15 | }; 16 | } 17 | 18 | static sendByCondition(clients, msg, conditionFn = () => false) { 19 | clients.forEach(client => { 20 | if (client.readyState === OPEN && conditionFn(client)) { 21 | client.send(msg); 22 | } 23 | }); 24 | } 25 | 26 | sendJson(client, msg) { 27 | if (client.readyState === OPEN) { 28 | client.send(JSON.stringify(msg)); 29 | } else { 30 | this.deleteActions[client.clientType](client); 31 | } 32 | } 33 | 34 | broadcastToBot(msg, botId) { 35 | WsManager.sendByCondition(this.slackBots, msg, client => client.user.id === botId); 36 | } 37 | 38 | broadcast(msg, except) { 39 | WsManager.sendByCondition(this.slackWss, msg, client => client.user.id !== except); 40 | } 41 | 42 | broadcastToBots(msg, except) { 43 | WsManager.sendByCondition(this.slackBots, msg, client => client.user.id !== except); 44 | } 45 | } 46 | 47 | const dbManager = createDbManager(); 48 | const wsManager = new WsManager(); 49 | 50 | module.exports = { 51 | dbManager, 52 | wsManager 53 | }; 54 | -------------------------------------------------------------------------------- /routes/responses/auth.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": true, 3 | "url": "PLACEHOLDER_WS_URL", 4 | "team": "PLACEHOLDER_TEAM_NAME", 5 | "user": "PLACEHOLDER_USER_NAME", 6 | "team_id": "PLACEHOLDER_TEAM_ID", 7 | "user_id": "PLACEHOLDER_USER_ID" 8 | } -------------------------------------------------------------------------------- /routes/responses/cant_update_message.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": false, 3 | "error": "cant_update_message" 4 | } -------------------------------------------------------------------------------- /routes/responses/channels.list.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": true, 3 | "channels": [ 4 | { 5 | "id": "C0G9QF9GW", 6 | "name": "random", 7 | "is_channel": true, 8 | "created": 1449709280, 9 | "creator": "U0G9QF9C6", 10 | "is_archived": false, 11 | "is_general": false, 12 | "name_normalized": "random", 13 | "is_shared": false, 14 | "is_org_shared": false, 15 | "is_member": true, 16 | "is_private": false, 17 | "is_mpim": false, 18 | "members": [ 19 | "U0G9QF9C6", 20 | "U0G9WFXNZ" 21 | ], 22 | "topic": { 23 | "value": "Other stuff", 24 | "creator": "U0G9QF9C6", 25 | "last_set": 1449709352 26 | }, 27 | "purpose": { 28 | "value": "A place for non-work-related flimflam, faffing, hodge-podge or jibber-jabber you'd prefer to keep out of more focused work-related channels.", 29 | "creator": "", 30 | "last_set": 0 31 | }, 32 | "previous_names": [], 33 | "num_members": 2 34 | }, 35 | { 36 | "id": "C0G9QKBBL", 37 | "name": "general", 38 | "is_channel": true, 39 | "created": 1449709280, 40 | "creator": "U0G9QF9C6", 41 | "is_archived": false, 42 | "is_general": true, 43 | "name_normalized": "general", 44 | "is_shared": false, 45 | "is_org_shared": false, 46 | "is_member": true, 47 | "is_private": false, 48 | "is_mpim": false, 49 | "members": [ 50 | "U0G9QF9C6", 51 | "U0G9WFXNZ" 52 | ], 53 | "topic": { 54 | "value": "Talk about anything!", 55 | "creator": "U0G9QF9C6", 56 | "last_set": 1449709364 57 | }, 58 | "purpose": { 59 | "value": "To talk about anything!", 60 | "creator": "U0G9QF9C6", 61 | "last_set": 1449709334 62 | }, 63 | "previous_names": [], 64 | "num_members": 2 65 | } 66 | ], 67 | "response_metadata": { 68 | "next_cursor": "dGVhbTpDMUg5UkVTR0w=" 69 | } 70 | } -------------------------------------------------------------------------------- /routes/responses/chat.postMessage.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": true, 3 | "channel": "[PLACEHOLDER]", 4 | "ts": "[PLACEHOLDER]", 5 | "message": { 6 | "client_msg_id": "[PLACEHOLDER]", 7 | "suppress_notification": false, 8 | "type": "message", 9 | "text": "[PLACEHOLDER]", 10 | "user": "[PLACEHOLDER]", 11 | "team": "[PLACEHOLDER]", 12 | "user_team": "[PLACEHOLDER]", 13 | "source_team": "[PLACEHOLDER]", 14 | "channel": "[PLACEHOLDER]", 15 | "event_ts": "[PLACEHOLDER]", 16 | "ts": "[PLACEHOLDER]" 17 | } 18 | } -------------------------------------------------------------------------------- /routes/responses/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const modules = {}; 4 | require('fs') 5 | .readdirSync(__dirname) 6 | .forEach((file) => { 7 | if (file.match(/\.json$/) !== null && file !== 'index.js') { 8 | const name = file.replace('.json', ''); 9 | modules[name] = JSON.parse(fs.readFileSync(path.join(__dirname, file))); 10 | } 11 | }); 12 | 13 | module.exports = modules; 14 | -------------------------------------------------------------------------------- /routes/responses/invalid_auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": false, 3 | "error": "invalid_auth" 4 | } -------------------------------------------------------------------------------- /routes/responses/rtm.connect.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": true, 3 | "self": { 4 | "id": "W12345678", 5 | "name": "valera.petrov" 6 | }, 7 | "team": { 8 | "domain": "botfactory", 9 | "id": "T12345678", 10 | "name": "BotFactory" 11 | }, 12 | "url": "WS_CONNECTION_PLACEHOLDER" 13 | } -------------------------------------------------------------------------------- /routes/responses/team.info.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": true, 3 | "team": { 4 | "id": "T12345678", 5 | "name": "BotFactory", 6 | "domain": "example", 7 | "email_domain": "example.com", 8 | "icon": { 9 | "image_34": "/images/slack_user.png", 10 | "image_44": "/images/slack_user.png", 11 | "image_68": "/images/slack_user.png", 12 | "image_88": "/images/slack_user.png", 13 | "image_102": "/images/slack_user.png", 14 | "image_132": "/images/slack_user.png", 15 | "image_default": true 16 | }, 17 | "enterprise_id": "E123456789", 18 | "enterprise_name": "Umbrella Corporation" 19 | } 20 | } -------------------------------------------------------------------------------- /routes/responses/user_not_found.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": false, 3 | "error": "user_not_found" 4 | } -------------------------------------------------------------------------------- /routes/responses/users.info.json: -------------------------------------------------------------------------------- 1 | { 2 | "ok": true, 3 | "user": {} 4 | } -------------------------------------------------------------------------------- /routes/rtm/index.js: -------------------------------------------------------------------------------- 1 | 2 | const express = require('express'); 3 | const router = express.Router(); 4 | const { dbManager, wsManager } = require('../managers'); 5 | require('express-ws')(router); 6 | 7 | function setupMessageHandler(ws, handlers) { 8 | ws.on('message', (msg) => { 9 | const jsonMsg = JSON.parse(msg); 10 | if (handlers[jsonMsg.type]) { 11 | handlers[jsonMsg.type](ws, jsonMsg); 12 | } 13 | }); 14 | } 15 | 16 | const handlers = { 17 | ping: (ws, msg) => wsManager.sendJson(ws, { 18 | reply_to: msg.id, 19 | type: 'pong', 20 | time: msg.time 21 | }), 22 | message: (ws, msg) => { 23 | const response = { 24 | ok: true, 25 | channel: msg.channel, 26 | text: msg.text 27 | }; 28 | 29 | if (msg.id !== undefined) { 30 | response.reply_to = msg.id; 31 | } 32 | 33 | const message = dbManager.channel(msg.channel).createMessage(ws.user.id, msg); 34 | message.user = ws.user; 35 | message.team = ws.team; 36 | message.channel = msg.channel; 37 | response.message = message; 38 | response.ts = message.ts; 39 | response.channel = message.channel; 40 | 41 | const jsonPayload = JSON.stringify(response.message); 42 | 43 | wsManager.sendJson(ws, response); 44 | wsManager.broadcast(jsonPayload, ws.user.id); 45 | wsManager.broadcastToBots(jsonPayload, ws.user.id); 46 | }, 47 | typing: (ws, msg) => { 48 | handlers.user_typing(ws, msg); 49 | }, 50 | user_typing: (ws, msg) => { 51 | const message = JSON.stringify({ 52 | ...msg, 53 | channel: msg.channel.id || msg.channel, 54 | user: ws.user.id 55 | }); 56 | wsManager.broadcast(message, ws.user.id); 57 | wsManager.broadcastToBots(message, ws.user.id); 58 | } 59 | }; 60 | 61 | router.ws('/ws', (ws, req) => { 62 | /* eslint-disable no-param-reassign */ 63 | ws.clientType = 'bot'; 64 | const uid = req.query && req.query.uid; 65 | ws.user = dbManager.users().findById(dbManager.db.sessions[uid])[0]; 66 | ws.team = dbManager.teams().findById(ws.user.team_id)[0]; 67 | /* eslint-enable no-param-reassign */ 68 | 69 | wsManager.slackBots.add(ws); 70 | 71 | wsManager.sendJson(ws, { 72 | type: 'hello' 73 | }); 74 | 75 | setupMessageHandler(ws, handlers); 76 | 77 | ws.on('close', () => { 78 | wsManager.slackBots.delete(ws); 79 | }); 80 | }); 81 | 82 | router.ws('/slack', (ws) => { 83 | /* eslint-disable no-param-reassign */ 84 | ws.clientType = 'ui'; 85 | ws.user = dbManager.slackUser(); 86 | ws.team = dbManager.slackTeam(); 87 | /* eslint-enable no-param-reassign */ 88 | 89 | wsManager.slackWss.add(ws); 90 | 91 | setupMessageHandler(ws, handlers); 92 | 93 | ws.on('close', () => { 94 | wsManager.slackWss.delete(ws); 95 | }); 96 | }); 97 | 98 | module.exports = router; 99 | -------------------------------------------------------------------------------- /routes/testapi/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { dbManager } = require('../managers'); 4 | 5 | router.get('/reset', (req, res) => { 6 | dbManager.reset(); 7 | res.json({ ok: true }); 8 | }); 9 | 10 | router.get('/current/team', (req, res) => { 11 | if (typeof req.query.domain !== 'undefined') { 12 | dbManager.slackTeam().domain = req.query.domain; 13 | res.json({ 14 | ok: true 15 | }); 16 | } else { 17 | res.json({ 18 | ok: false 19 | }); 20 | } 21 | }); 22 | 23 | module.exports = router; 24 | -------------------------------------------------------------------------------- /screenshots/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maddevsio/mad-fake-slack/50548aada8cd98e2fedc5138560eaab96f0cee12/screenshots/.keep -------------------------------------------------------------------------------- /scripts/codeclimate/check.js: -------------------------------------------------------------------------------- 1 | const util = require('util'); 2 | const exec = util.promisify(require('child_process').exec); 3 | const expect = require('expect'); 4 | const fs = require('fs'); 5 | const chalk = require('chalk'); 6 | // eslint-disable-next-line no-console 7 | const log = console.log; 8 | const success = chalk.bold.green; 9 | const error = chalk.bold.red; 10 | const warning = chalk.keyword('orange'); 11 | 12 | async function isImagesInstalled() { 13 | const COMMAND = 'docker images | grep codeclimate/'; 14 | const codeClimateImages = [ 15 | 'codeclimate/codeclimate-structure', 16 | 'codeclimate/codeclimate-duplication', 17 | 'codeclimate/codeclimate' 18 | ]; 19 | const { stdout, stderr } = await exec(COMMAND); 20 | if (!stderr.length > 0) { 21 | const imageNames = stdout.split('\n').map(line => line.split(' ')[0].trim()).filter(Boolean); 22 | try { 23 | expect(imageNames).toStrictEqual(codeClimateImages); 24 | log(success('[+] All checks passed!')); 25 | } catch (e) { 26 | log(warning('You need to pull manually the following images:'), codeClimateImages); 27 | throw new Error('Needed images not installed manually'); 28 | } 29 | } else { 30 | log(error(stderr)); 31 | throw new Error(stderr.toString()); 32 | } 33 | } 34 | 35 | async function isInDocker() { 36 | const DOCKERENV = '/.dockerenv'; 37 | if (!fs.existsSync(DOCKERENV)) throw new Error('Not in Docker'); 38 | } 39 | 40 | isImagesInstalled() 41 | .then(isInDocker) 42 | .then(() => process.exit(0)) 43 | .catch((e) => { 44 | log(error(e)); 45 | process.exit(1); 46 | }); 47 | -------------------------------------------------------------------------------- /scripts/codeclimate/setup.sh: -------------------------------------------------------------------------------- 1 | cd /tmp \ 2 | && curl -L https://github.com/codeclimate/codeclimate/archive/master.tar.gz | tar xz \ 3 | && cd codeclimate-master \ 4 | && /bin/bash -l -c "make install" \ 5 | && /bin/bash -l -c "codeclimate engines:install" -------------------------------------------------------------------------------- /scripts/glitch.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const archiver = require('archiver'); 3 | const output = fs.createWriteStream(`glitch_release_${+new Date()}.zip`); 4 | const archive = archiver('zip', { 5 | zlib: { level: 9 } // Sets the compression level. 6 | }); 7 | 8 | const DomainHost = 'mad-fake-slack.glitch.me'; 9 | 10 | output.on('close', () => { 11 | // eslint-disable-next-line no-console 12 | console.log(`${archive.pointer()} total bytes`); 13 | // eslint-disable-next-line no-console 14 | console.log('archiver has been finalized and the output file descriptor has closed.'); 15 | }); 16 | 17 | output.on('end', () => { 18 | // eslint-disable-next-line no-console 19 | console.log('Data has been drained'); 20 | }); 21 | 22 | archive.on('warning', (err) => { 23 | if (err.code === 'ENOENT') { 24 | // log warning 25 | // eslint-disable-next-line no-console 26 | console.warn(err); 27 | } else { 28 | // throw error 29 | throw err; 30 | } 31 | }); 32 | 33 | archive.on('error', (err) => { 34 | throw err; 35 | }); 36 | 37 | archive.pipe(output); 38 | 39 | archive.directory('views/', 'views'); 40 | archive.glob('db/**/*', { ignore: ['db/teams.json'] }); 41 | archive.directory('public/', 'public'); 42 | archive.directory('routes/', 'routes'); 43 | archive.directory('config/', 'config'); 44 | archive.file('examples/rtmbot/index.js', { name: 'bot.js' }); 45 | archive.file('package-lock.json', { name: 'package-lock.json' }); 46 | archive.file('README.md', { name: 'README.md' }); 47 | archive.file('server.js', { name: 'server.js' }); 48 | archive.file('helpers.js', { name: 'helpers.js' }); 49 | 50 | const teams = JSON.parse(fs.readFileSync('./db/teams.json', 'utf8')); 51 | teams[0].domain = DomainHost; 52 | teams[0].email_domain = DomainHost; 53 | archive.append(JSON.stringify(teams, ' ', 2), { name: 'db/teams.json' }); 54 | 55 | const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); 56 | packageJson.scripts.start = `URL_SCHEMA=https ${packageJson.scripts.start}`; 57 | archive.append(JSON.stringify(packageJson, ' ', 2), { name: 'package.json' }); 58 | 59 | archive.finalize(); 60 | -------------------------------------------------------------------------------- /scripts/ntimes.sh: -------------------------------------------------------------------------------- 1 | for run in {1..10}; do npm run test:bdd && sleep 5; done -------------------------------------------------------------------------------- /scripts/volumes/inspect.js: -------------------------------------------------------------------------------- 1 | const util = require('util'); 2 | const exec = util.promisify(require('child_process').exec); 3 | const Promise = require('bluebird'); 4 | const MadFakeSlack = 'mad-fake-slack'; 5 | 6 | const DOCKER_VOLUME_LS = 'docker ps'; 7 | async function getRealPath() { 8 | const { stdout, stderr } = await exec(DOCKER_VOLUME_LS); 9 | if (stderr) throw new Error(stderr); 10 | 11 | const volumes = stdout.split('\n').map(line => line.split(' ').filter(Boolean)).filter(line => line.length).slice(1); 12 | const sources = await Promise.mapSeries(volumes, async ([id]) => { 13 | const { stdout: result } = await exec(`docker inspect -f "{{ json .Mounts }}" ${id}`); 14 | return result; 15 | }); 16 | const madFakeSlackData = sources.filter(line => line.indexOf('mad-fake-slack') !== -1); 17 | if (madFakeSlackData.length) { 18 | const foundVolumes = JSON.parse(madFakeSlackData[0]).filter(item => item.Source && item.Source.indexOf(MadFakeSlack) !== -1); 19 | if (foundVolumes.length) { 20 | return foundVolumes[0].Source; 21 | } 22 | } 23 | throw new Error('Real path to workspace not found'); 24 | } 25 | 26 | // eslint-disable-next-line no-console 27 | getRealPath().then(path => console.log(path)).catch(() => process.exit(1)); 28 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require('./config/default')(); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const express = require('express'); 5 | const exphbs = require('express-handlebars'); 6 | const morgan = require('morgan'); 7 | const http = require('http'); 8 | const handlebarsHelpers = require('./helpers.js'); 9 | const apiRouter = require('./routes/api'); 10 | const appRouter = require('./routes/app'); 11 | const testApiRouter = require('./routes/testapi'); 12 | const rtmRouter = require('./routes/rtm'); 13 | const { spawn } = require('child_process'); 14 | 15 | const port = process.env.PORT; 16 | const host = process.env.HOST; 17 | 18 | /* eslint-disable-next-line */ 19 | const format = 20 | ':remote-addr - :remote-user [:date[clf]] ":method ' 21 | + ':url HTTP/:http-version" :type :status :res[content-length] ":referrer" ":user-agent"'; 22 | morgan.format('full', format); 23 | 24 | const app = express(); 25 | require('express-ws')(app); 26 | 27 | app.use(express.static('public')); 28 | app.use('/assets', express.static('node_modules')); 29 | app.engine('.hbs', exphbs({ extname: '.hbs', helpers: handlebarsHelpers })); 30 | app.set('view engine', '.hbs'); 31 | morgan.token('type', (req) => { 32 | return req.headers['content-type']; 33 | }); 34 | app.use( 35 | morgan('dev', { 36 | skip: (_, res) => { 37 | return res.statusCode < 400 || res.statusCode === 404; 38 | } 39 | }) 40 | ); 41 | app.use( 42 | morgan('full', { 43 | stream: fs.createWriteStream(path.join('/tmp', 'access.log'), { flags: 'a' }) 44 | }) 45 | ); 46 | app.use((req, res, next) => { 47 | const token = (req.body && req.body.token) || req.headers.Authorization; 48 | req.token = token; 49 | req.requestTime = Date.now(); 50 | next(); 51 | }); 52 | 53 | app.use('/', rtmRouter); 54 | app.use('/', appRouter); 55 | app.use('/api/db', testApiRouter); 56 | app.use('/api', apiRouter); 57 | 58 | function createUIServer({ httpPort, httpHost }) { 59 | const server = http.createServer(app); 60 | let serverPort = httpPort; 61 | let serverHost = httpHost; 62 | // eslint-disable-next-line global-require 63 | require('express-ws')(app, server); 64 | return { 65 | start(env) { 66 | if (env && typeof env === 'object') { 67 | process.env = { 68 | ...process.env, 69 | ...env 70 | }; 71 | serverPort = process.env.PORT; 72 | serverHost = process.env.HOST; 73 | } 74 | return new Promise(resolve => { 75 | server.listen(serverPort, serverHost, () => { 76 | resolve(server); 77 | }); 78 | }); 79 | }, 80 | close() { 81 | server.close(); 82 | } 83 | }; 84 | } 85 | 86 | if (require.main === module) { 87 | app.listen(port, host, () => { 88 | if (fs.existsSync('bot.js')) { 89 | spawn('npm', ['run', 'example:rtmbot:glitch']); 90 | } 91 | /* eslint-disable-next-line */ 92 | console.log(`Example app listening on port ${port}!`); 93 | }); 94 | } else { 95 | module.exports = createUIServer; 96 | } 97 | -------------------------------------------------------------------------------- /views/layouts/main.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |Mad Fake Slack App 6 | 7 | {{> inlinestyles}} 8 | 9 | 10 | 11 | 12 | 13 | 14 | {{{body}}} 15 | 16 | -------------------------------------------------------------------------------- /views/partials/appitem.hbs: -------------------------------------------------------------------------------- 1 | {{! template-lint-disable no-inline-styles }} 2 |3 |-------------------------------------------------------------------------------- /views/partials/appitemheader.hbs: -------------------------------------------------------------------------------- 1 | 16 |2 |24 | -------------------------------------------------------------------------------- /views/partials/channel.hbs: -------------------------------------------------------------------------------- 1 | {{! template-lint-disable no-inline-styles }} 2 |3 |12 |4 |
11 |5 | 8 |9 | {{app.real_name}} 10 |13 |23 |14 | 18 | 21 |22 |3 | 4 | 9 | 8 |-------------------------------------------------------------------------------- /views/partials/channelheader.hbs: -------------------------------------------------------------------------------- 1 |2 |-------------------------------------------------------------------------------- /views/partials/directuser.hbs: -------------------------------------------------------------------------------- 1 | {{! template-lint-disable no-bare-strings no-inline-styles }} 2 |3 | 4 | 5 | 6 |10 |7 | 8 |9 |11 |38 |12 | 15 |16 |17 | 21 |22 |23 | 27 |28 |29 | 30 | 31 | 32 | {{channel.topic.value}} 33 | 34 | 35 | 36 |37 |3 |-------------------------------------------------------------------------------- /views/partials/invite.hbs: -------------------------------------------------------------------------------- 1 | {{! template-lint-disable no-inline-styles }} 2 | 19 |3 |-------------------------------------------------------------------------------- /views/partials/mehref.hbs: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /views/partials/message.hbs: -------------------------------------------------------------------------------- 1 | {{! template-lint-disable no-inline-styles }} 2 | 71 |3 |41 | -------------------------------------------------------------------------------- /views/partials/message_edit.hbs: -------------------------------------------------------------------------------- 1 | {{! template-lint-disable no-inline-styles }} 2 | 40 |3 |-------------------------------------------------------------------------------- /views/partials/statustyping.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /views/partials/userchannelheader.hbs: -------------------------------------------------------------------------------- 1 | 39 |2 |44 | --------------------------------------------------------------------------------3 | 4 | 10 | 11 |15 |12 | 13 |14 |16 |43 |17 | 20 |21 |22 | 23 | {{#if (eq user.id "USLACKBOT") }} 24 | 25 | {{else}} 26 | 27 | {{/if}} 28 | 29 | active 30 |31 | {{#if (eq user.id "USLACKBOT") }} 32 |33 | 34 | 35 | 36 | {{user.real_name}} 37 | 38 | 39 | 40 |41 | {{/if}} 42 |