├── images └── ikku.png ├── .travis.yml ├── config.example.json.ls ├── .gitattributes ├── .github └── workflows │ └── automerge.yml ├── package.json ├── README.md ├── test ├── rtm-start-response.json.ls └── test.ls ├── .gitignore └── index.ls /images/ikku.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hakatashi/slack-ikku/HEAD/images/ikku.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | - 6 5 | branches: 6 | except: 7 | - /^v\d+\.\d+\.\d+$/ 8 | notifications: 9 | webhooks: http://webhook.hakatashi.com/travis 10 | -------------------------------------------------------------------------------- /config.example.json.ls: -------------------------------------------------------------------------------- 1 | slack-token: 'xxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxx-xxxxxxxxxx' 2 | ikku-emoji: 'flower_playing_cards' 3 | jiamari-emoji: '' 4 | jitarazu-emoji: '' 5 | channels: <[]> 6 | max-jiamari: 1 7 | max-jitarazu: 0 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: automerge 2 | on: pull_request_target 3 | 4 | jobs: 5 | dependabot: 6 | runs-on: ubuntu-latest 7 | if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'hakatashi/slack-ikku' 8 | steps: 9 | - name: Dependabot metadata 10 | id: metadata 11 | uses: dependabot/fetch-metadata@v2 12 | with: 13 | github-token: "${{ secrets.USER_GITHUB_TOKEN }}" 14 | - name: Enable auto-merge for Dependabot PRs 15 | run: gh pr merge --auto --merge "$PR_URL" 16 | env: 17 | PR_URL: ${{ github.event.pull_request.html_url }} 18 | GH_TOKEN: ${{ secrets.USER_GITHUB_TOKEN }} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-ikku", 3 | "version": "1.0.0", 4 | "description": "Slackと一句で韻を踏んでいる", 5 | "private": true, 6 | "scripts": { 7 | "start": "lsc index.ls", 8 | "test": "mocha --compilers ls:livescript" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/hakatashi/slack-ikku.git" 13 | }, 14 | "keywords": [], 15 | "author": "Koki Takahashi (https://hakatashi.com/)", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/hakatashi/slack-ikku/issues" 19 | }, 20 | "homepage": "https://github.com/hakatashi/slack-ikku#readme", 21 | "dependencies": { 22 | "@slack/client": "^3.5.0", 23 | "kuromojin": "^1.3.1", 24 | "livescript": "^1.5.0", 25 | "prelude-ls": "^1.1.2", 26 | "unorm": "^1.4.1" 27 | }, 28 | "devDependencies": { 29 | "chai": "^4.0.0", 30 | "mocha": "^5.0.0", 31 | "mock-socket": "^7.0.0", 32 | "mockery": "^2.0.0", 33 | "nock": "^9.0.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | slack-ikku [![Build Status][travis-image]][travis-url] [![Dependency Status][david-image]][david-url] [![devDependency Status][david-dev-image]][david-dev-url] [![Greenkeeper badge](https://badges.greenkeeper.io/hakatashi/slack-ikku.svg)](https://greenkeeper.io/) 2 | ========== 3 | 4 | [travis-image]: https://travis-ci.org/hakatashi/slack-ikku.svg?branch=master 5 | [travis-url]: https://travis-ci.org/hakatashi/slack-ikku 6 | [david-image]: https://david-dm.org/hakatashi/slack-ikku.svg 7 | [david-url]: https://david-dm.org/hakatashi/slack-ikku 8 | [david-dev-image]: https://david-dm.org/hakatashi/slack-ikku/dev-status.svg 9 | [david-dev-url]: https://david-dm.org/hakatashi/slack-ikku#info=devDependencies 10 | 11 | ![slack-ikku](images/ikku.png) 12 | 13 | slack-ikkuだよ。一句を見つけて自動でReactionをつけるすごいやつだよ。 14 | 15 | [ブログ記事](http://inside.pixiv.net/entry/2016/07/05/194025) 16 | 17 | ## インストール 18 | 19 | ```sh 20 | git clone https://github.com/hakatashi/slack-ikku.git 21 | cd slack-ikku 22 | npm install 23 | cp config.example.json.ls config.json.ls 24 | vi config.json.ls 25 | ``` 26 | 27 | ## 起動 28 | 29 | ```sh 30 | npm start 31 | ``` 32 | -------------------------------------------------------------------------------- /test/rtm-start-response.json.ls: -------------------------------------------------------------------------------- 1 | ok: true 2 | self: 3 | id: 'UTESTUSER' 4 | name: 'test' 5 | prefs: {} 6 | created: 1455071219 7 | manual_presence: 'active' 8 | team: 9 | id: 'TTESTTEAM' 10 | name: 'test' 11 | email_domain: 'example.com' 12 | domain: 'test' 13 | msg_edit_window_mins: 5 14 | prefs: {} 15 | icon: {} 16 | over_storage_limit: false 17 | plan: 'std' 18 | latest_event_ts: '1467267799.000000' 19 | channels: [] 20 | groups: [] 21 | ims: [] 22 | cache_ts: 1467268399 23 | subteams: 24 | self: [] 25 | all: [] 26 | dnd: 27 | dnd_enabled: false 28 | next_dnd_start_ts: 1 29 | next_dnd_end_ts: 1 30 | snooze_enabled: false 31 | users: 32 | * id: 'UTESTUSER' 33 | name: 'test' 34 | deleted: false 35 | status: null 36 | color: 'e7392d' 37 | real_name: 'Test User' 38 | tz: 'Asia/Tokyo' 39 | tz_label: 'Japan Standard Time' 40 | tz_offset: 32400 41 | profile: {} 42 | is_admin: false 43 | is_owner: false 44 | is_primary_owner: false 45 | is_restricted: false 46 | is_ultra_restricted: false 47 | is_bot: false 48 | has_2fa: false 49 | presence: 'active' 50 | ... 51 | cache_version: 'v13-tiger' 52 | cache_ts_version: 'v1-cat' 53 | bots: [] 54 | url: 'wss://test.slack-msgs.com/websocket/xxxxx' 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directories 27 | node_modules 28 | jspm_packages 29 | 30 | # Optional npm cache directory 31 | .npm 32 | 33 | # Optional REPL history 34 | .node_repl_history 35 | 36 | config.json.ls 37 | 38 | # ========================= 39 | # Operating System Files 40 | # ========================= 41 | 42 | # OSX 43 | # ========================= 44 | 45 | .DS_Store 46 | .AppleDouble 47 | .LSOverride 48 | 49 | # Thumbnails 50 | ._* 51 | 52 | # Files that might appear in the root of a volume 53 | .DocumentRevisions-V100 54 | .fseventsd 55 | .Spotlight-V100 56 | .TemporaryItems 57 | .Trashes 58 | .VolumeIcon.icns 59 | 60 | # Directories potentially created on remote AFP share 61 | .AppleDB 62 | .AppleDesktop 63 | Network Trash Folder 64 | Temporary Items 65 | .apdisk 66 | 67 | # Windows 68 | # ========================= 69 | 70 | # Windows image file caches 71 | Thumbs.db 72 | ehthumbs.db 73 | 74 | # Folder config file 75 | Desktop.ini 76 | 77 | # Recycle Bin used on file shares 78 | $RECYCLE.BIN/ 79 | 80 | # Windows Installer files 81 | *.cab 82 | *.msi 83 | *.msm 84 | *.msp 85 | 86 | # Windows shortcuts 87 | *.lnk 88 | -------------------------------------------------------------------------------- /index.ls: -------------------------------------------------------------------------------- 1 | require! { 2 | util 3 | './config.json.ls' 4 | 'prelude-ls': {fold1, zip-with, max, map} 5 | kuromojin: {get-tokenizer} 6 | unorm: {nfkc} 7 | '@slack/client': { 8 | Rtm-client 9 | Web-client 10 | RTM_EVENTS: {MESSAGE} 11 | CLIENT_EVENTS: { 12 | RTM: {DISCONNECT} 13 | } 14 | } 15 | } 16 | 17 | tokenizer <- get-tokenizer!then 18 | 19 | rtm-client = new Rtm-client config.slack-token 20 | rtm-client.start! 21 | 22 | web-client = new Web-client config.slack-token 23 | 24 | rtm-client.on DISCONNECT, -> process.exit 1 25 | 26 | message <- rtm-client.on MESSAGE 27 | text = message.file?.initial_comment?.comment or message.text 28 | return unless text? 29 | 30 | return unless config.channels.length is 0 or message.channel in config.channels 31 | 32 | text .= replace /^<.+?>:?/ '' 33 | 34 | tokens = tokenizer.tokenize nfkc text 35 | 36 | target-regions = [5 7 5] 37 | regions = [0] 38 | 39 | for token in tokens 40 | if token.pos is \記号 or token.surface_form in <[、 ! ?]> 41 | if regions.length < target-regions.length and regions[* - 1] >= target-regions[regions.length - 1] 42 | regions.push 0 43 | continue 44 | 45 | pronunciation = token.pronunciation or token.surface_form 46 | return unless pronunciation.match /^[ぁ-ゔァ-ヺー…]+$/ 47 | 48 | region-length = pronunciation.replace /[ぁぃぅぇぉゃゅょァィゥェォャュョ…]/g, '' .length 49 | 50 | if token.pos in <[助詞 助動詞]> or token.pos_detail_1 in <[接尾 非自立]> 51 | regions[* - 1] += region-length 52 | else if regions[* - 1] < target-regions[regions.length - 1] or regions.length is 3 53 | regions[* - 1] += region-length 54 | else 55 | regions.push region-length 56 | 57 | if regions[* - 1] is 0 58 | regions.pop! 59 | 60 | return if regions.length isnt target-regions.length 61 | 62 | jitarazu = regions |> zip-with (-), target-regions |> map max 0 |> fold1 (+) 63 | jiamari = target-regions |> zip-with (-), regions |> map max 0 |> fold1 (+) 64 | 65 | return if jitarazu > config.max-jitarazu or jiamari > config.max-jiamari 66 | 67 | add-reaction = (emoji) -> 68 | | message.file? => 69 | web-client.reactions.add emoji, { 70 | file: message.file.id 71 | file_comment: message.file.initial_comment.id 72 | } 73 | | otherwise => 74 | web-client.reactions.add emoji, { 75 | message.channel 76 | timestamp: message.ts 77 | } 78 | 79 | add-reaction config.ikku-emoji 80 | 81 | if jiamari > 0 and config.jiamari-emoji?length > 0 82 | add-reaction config.jiamari-emoji 83 | 84 | if jitarazu > 0 and config.jitarazu-emoji?length > 0 85 | add-reaction config.jitarazu-emoji 86 | 87 | console.log "[#{Date!}] Found ikku: #{util.inspect message}" 88 | -------------------------------------------------------------------------------- /test/test.ls: -------------------------------------------------------------------------------- 1 | require! { 2 | nock 3 | path 4 | mockery 5 | chai: {expect} 6 | 'mock-socket': {Server, Web-socket} 7 | './rtm-start-response.json.ls' 8 | } 9 | 10 | It = global.it 11 | 12 | fake-token = 'xxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxx-xxxxxxxxxx' 13 | fake-user = 'DFAKEUSER' 14 | fake-channel = 'DFAKECHAN' 15 | fake-ts = '1111111111.000000' 16 | 17 | describe 'slack-ikku' -> 18 | before -> 19 | # Mock of config file 20 | mockery.register-mock './config.json.ls' do 21 | slack-token: fake-token 22 | ikku-emoji: 'test_ikku' 23 | channels: <[]> 24 | max-jiamari: 1 25 | max-jitarazu: 0 26 | 27 | # Polyfill lacking 'on' feature 28 | Web-socket::on = (name, listener) -> 29 | switch name 30 | | \open => @onopen = listener 31 | | \message => @onmessage = (raw) ~> listener.call this, raw.data 32 | | \close => @onclose = listener 33 | | \error => @onerror = listener 34 | 35 | mockery.register-mock 'ws' Web-socket 36 | 37 | before-each -> 38 | # Enable require mocks 39 | mockery.enable {-warn-on-unregistered} 40 | 41 | after-each -> 42 | # Purge cache of the app 43 | delete require.cache[path.resolve __dirname, '../index.ls'] 44 | 45 | # Purge nock 46 | nock.clean-all! 47 | 48 | # Disable require mocks 49 | mockery.disable! 50 | 51 | @timeout 10000 52 | 53 | It 'adds a specified reaction when it received 575-style message' (done) -> 54 | # First, execute the app 55 | require '../index.ls' 56 | 57 | # Mock rtm.start API request 58 | rtm-start = nock 'https://slack.com' 59 | .post '/api/rtm.start' token: fake-token 60 | .reply 200 rtm-start-response 61 | <- rtm-start.on \replied 62 | 63 | # Execute server and wait for connection 64 | mock-server = new Server rtm-start-response.url 65 | server, web-socket <- mock-server.on \connection 66 | 67 | # Send message that matches 575 68 | mock-server.send JSON.stringify do 69 | type: \message 70 | ts: fake-ts 71 | channel: fake-channel 72 | user: fake-user 73 | text: '古池や蛙飛び込む水の音' 74 | 75 | # Mock reactions.add API request 76 | reactions-add = nock 'https://slack.com' 77 | .post '/api/reactions.add' do 78 | token: fake-token 79 | channel: fake-channel 80 | timestamp: fake-ts 81 | name: 'test_ikku' 82 | .reply 200 {+ok} 83 | request <- reactions-add.on \replied 84 | 85 | # OK! 86 | mock-server.stop! 87 | done! 88 | 89 | It 'doesn\'t add reaction when the message doesn\'t match 575' (done) -> 90 | # First, execute the app 91 | require '../index.ls' 92 | 93 | # Mock rtm.start API request 94 | rtm-start = nock 'https://slack.com' 95 | .post '/api/rtm.start' token: fake-token 96 | .reply 200 rtm-start-response 97 | <- rtm-start.on \replied 98 | 99 | # Execute server and wait for connection 100 | mock-server = new Server rtm-start-response.url 101 | server, web-socket <- mock-server.on \connection 102 | 103 | # Send message that doesn't matches 575 104 | mock-server.send JSON.stringify do 105 | type: \message 106 | ts: fake-ts 107 | channel: fake-channel 108 | user: fake-user 109 | text: '咳をしても一人' 110 | 111 | # Mock reactions.add API request 112 | reactions-add = nock 'https://slack.com' 113 | .post '/api/reactions.add' 114 | .reply 200 {+ok} 115 | reactions-add.on \replied -> done new Error 'should not add reaction' 116 | 117 | <- set-timeout _, 2000 118 | 119 | # OK! 120 | mock-server.stop! 121 | done! 122 | 123 | context 'when jiamari-emoji and jitarazu-emoji are specified' -> 124 | before -> 125 | # Mock of config file 126 | mockery.deregister-mock './config.json.ls' 127 | mockery.register-mock './config.json.ls' do 128 | slack-token: fake-token 129 | ikku-emoji: 'test_ikku' 130 | jiamari-emoji: 'test_jiamari' 131 | jitarazu-emoji: 'test_jitarazu' 132 | channels: <[]> 133 | max-jiamari: 2 134 | max-jitarazu: 1 135 | 136 | It 'adds a specified jiamari reaction when it received 595-style message' (done) -> 137 | # First, execute the app 138 | require '../index.ls' 139 | 140 | # Mock rtm.start API request 141 | rtm-start = nock 'https://slack.com' 142 | .post '/api/rtm.start' token: fake-token 143 | .reply 200 rtm-start-response 144 | <- rtm-start.on \replied 145 | 146 | # Execute server and wait for connection 147 | mock-server = new Server rtm-start-response.url 148 | server, web-socket <- mock-server.on \connection 149 | 150 | # Send message that matches 575 151 | mock-server.send JSON.stringify do 152 | type: \message 153 | ts: fake-ts 154 | channel: fake-channel 155 | user: fake-user 156 | text: '枯枝に烏のとまりけり秋の暮' # 5 9 5 157 | 158 | # Mock reactions.add API request 159 | reactions-add = nock 'https://slack.com' 160 | .post '/api/reactions.add' do 161 | token: fake-token 162 | channel: fake-channel 163 | timestamp: fake-ts 164 | name: 'test_jiamari' 165 | .reply 200 {+ok} 166 | request <- reactions-add.on \replied 167 | 168 | # OK! 169 | mock-server.stop! 170 | done! 171 | 172 | It 'adds a specified jitarazu reaction when it received 574-style message' (done) -> 173 | # First, execute the app 174 | require '../index.ls' 175 | 176 | # Mock rtm.start API request 177 | rtm-start = nock 'https://slack.com' 178 | .post '/api/rtm.start' token: fake-token 179 | .reply 200 rtm-start-response 180 | <- rtm-start.on \replied 181 | 182 | # Execute server and wait for connection 183 | mock-server = new Server rtm-start-response.url 184 | server, web-socket <- mock-server.on \connection 185 | 186 | # Send message that matches 575 187 | mock-server.send JSON.stringify do 188 | type: \message 189 | ts: fake-ts 190 | channel: fake-channel 191 | user: fake-user 192 | text: '古池や蛙飛び込む水音' # 5 7 4 193 | 194 | # Mock reactions.add API request 195 | reactions-add = nock 'https://slack.com' 196 | .post '/api/reactions.add' do 197 | token: fake-token 198 | channel: fake-channel 199 | timestamp: fake-ts 200 | name: 'test_jitarazu' 201 | .reply 200 {+ok} 202 | request <- reactions-add.on \replied 203 | 204 | # OK! 205 | mock-server.stop! 206 | done! 207 | --------------------------------------------------------------------------------