├── .gitignore ├── .dockerignore ├── config ├── unicorn.rb └── initializers │ └── slack.rb ├── Procfile ├── app ├── mixins │ └── slack_api_callerble.rb ├── registry.rb ├── libs │ ├── redis_connection.rb │ └── slack_auth.rb ├── models │ ├── start.rb │ ├── store.rb │ ├── comemback.rb │ ├── lunch.rb │ ├── afk.rb │ ├── finish.rb │ ├── base.rb │ └── stats.rb └── api.rb ├── config.ru ├── app.rb ├── Dockerfile ├── manifests ├── service.yml ├── cron.yml └── deployment.yml ├── Gemfile ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── automerge.yml ├── Makefile ├── README.md ├── bot.rb ├── ai_worker.rb ├── presence.rb ├── join.rb └── Gemfile.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | .git 3 | migrate.rb 4 | -------------------------------------------------------------------------------- /config/unicorn.rb: -------------------------------------------------------------------------------- 1 | stderr_path '/dev/stderr' 2 | stdout_path '/dev/stdout' 3 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | console: bundle exec ruby bot.rb 2 | web: bundle exec unicorn -p 8080 -c ./config/unicorn.rb 3 | -------------------------------------------------------------------------------- /app/mixins/slack_api_callerble.rb: -------------------------------------------------------------------------------- 1 | module SlackApiCallerble 2 | def bot_token_client 3 | App::Registry.bot_token_client 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require_relative 'app' 3 | require_relative 'app/api' 4 | use Rack::RewindableInput::Middleware 5 | run App::Api 6 | -------------------------------------------------------------------------------- /config/initializers/slack.rb: -------------------------------------------------------------------------------- 1 | require 'slack-ruby-bot' 2 | SlackRubyBot::Client.logger.level = Logger::INFO 3 | SlackRubyBot.configure do |config| 4 | config.allow_message_loops = false 5 | end 6 | 7 | App::Registry.register(:bot_token_client, Slack::Web::Client.new(token: ENV["SLACK_API_TOKEN"])) 8 | -------------------------------------------------------------------------------- /app/registry.rb: -------------------------------------------------------------------------------- 1 | module App 2 | class Registry 3 | 4 | @@registry = {} 5 | 6 | def self.find(key) 7 | @@registry[key] 8 | end 9 | 10 | def self.register(key, instance) 11 | @@registry[key] = instance 12 | end 13 | 14 | def self.bot_token_client 15 | @@registry[:bot_token_client] 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | require 'dotenv' 2 | require 'config' 3 | require './app/registry' 4 | Dotenv.load 5 | 6 | Dir[File.join(File.dirname(__FILE__), './config/initializers/*.rb')].sort.each do |file| 7 | require file 8 | end 9 | 10 | %w[ 11 | mixins 12 | models 13 | libs 14 | ].each do |subdir| 15 | Dir[File.join(File.dirname(__FILE__), './app', subdir, '**/*.rb')].sort.each do |file| 16 | require file 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.1 2 | ADD Gemfile /opt/away-from-keyboard/Gemfile 3 | ADD Gemfile.lock /opt/away-from-keyboard/Gemfile.lock 4 | 5 | WORKDIR /opt/away-from-keyboard 6 | RUN gem install bundler -N 7 | RUN bundle install --deployment --without development,test -j4 8 | 9 | ADD . /opt/away-from-keyboard 10 | RUN apt-get update -qqy && apt upgrade -qqy && apt-get clean&& rm -rf /var/lib/apt/lists/* 11 | ENTRYPOINT ["bundle", "exec"] 12 | CMD ["away-from-keyboard"] 13 | -------------------------------------------------------------------------------- /manifests/service.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: away-from-keyboard 5 | annotations: 6 | service.beta.kubernetes.io/openstack-internal-load-balancer: "true" # you should delete when you don't use openstack 7 | loadbalancer.openstack.org/manage-security-groups: "false" 8 | spec: 9 | type: LoadBalancer 10 | selector: 11 | name: away-from-keyboard 12 | ports: 13 | - protocol: TCP 14 | port: 80 15 | targetPort: 8080 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | gem 'activesupport' 8 | gem 'async-websocket', '~> 0.8.0' 9 | gem 'config' 10 | gem 'connection_pool' 11 | gem 'dotenv' 12 | gem 'foreman' 13 | gem 'redis' 14 | gem 'sinatra' 15 | gem 'sinatra-contrib' 16 | gem 'slack-ruby-bot' 17 | gem 'slack-ruby-client' 18 | gem 'unicorn' 19 | 20 | gem 'ruby-openai' 21 | gem 'terminal-table' 22 | -------------------------------------------------------------------------------- /app/libs/redis_connection.rb: -------------------------------------------------------------------------------- 1 | require 'redis' 2 | 3 | module RedisConnection 4 | def self.pool 5 | return @pool if @pool 6 | 7 | ENV['REDIS_URL'] ||= 'redis://localhost:6379' 8 | 9 | opt = { 10 | url: ENV['REDIS_URL'] 11 | } 12 | opt[:password] = ENV['REDIS_PASSWORD'] if ENV['REDIS_PASSWORD'] 13 | opt[:db] = ENV['REDIS_DB'] if ENV['REDIS_DB'] 14 | 15 | @pool = ConnectionPool::Wrapper.new({ size: 5, timeout: 5 }) do 16 | Redis.new(opt) 17 | end 18 | end 19 | 20 | def self.create_namespace(ns) 21 | Redis::Namespace.new(ns, redis: pool) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "bundler" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | ignore: 13 | - dependency-name: "async-websocket" 14 | 15 | -------------------------------------------------------------------------------- /app/models/start.rb: -------------------------------------------------------------------------------- 1 | module App 2 | module Model 3 | class Start < Base 4 | def add_list? 5 | true 6 | end 7 | 8 | def bot_run(uid, params) 9 | user_presence = App::Model::Store.get(uid) 10 | user_presence['today_begin'] = Time.now.to_s 11 | App::Model::Store.set(uid, user_presence) 12 | bot_token_client.chat_postMessage(channel: params['channel_id'], text: "#{params['user_name']}が始業しました。", as_user: true) 13 | (RedisConnection.pool.get("start_#{Date.today}") + "\n\n" || ENV['AFK_START_MESSAGE'] || 'おはようございます、今日も自分史上最高の日にしましょう!!1') 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/store.rb: -------------------------------------------------------------------------------- 1 | module App 2 | module Model 3 | class Store 4 | def self.get(uid) 5 | redis_key = "#{uid}-store" 6 | raw_user_presence = RedisConnection.pool.get(redis_key) 7 | if raw_user_presence 8 | JSON.parse(raw_user_presence) 9 | else 10 | { 11 | 'last_active_start_time' => Time.now.to_s 12 | } 13 | end 14 | end 15 | 16 | def self.set(uid, val, expire = 86_400 * 30) 17 | redis_key = "#{uid}-store" 18 | RedisConnection.pool.set(redis_key, val.to_json) 19 | RedisConnection.pool.expire(redis_key, expire) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Docker Image 2 | on: 3 | push: 4 | branches: 5 | - master 6 | tags: 7 | - "v*" 8 | schedule: 9 | - cron: '55 0 * * *' 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: '0' 17 | token: ${{secrets.GITHUB_TOKEN}} 18 | - run: sudo apt update -qqy && sudo apt install -qqy wget 19 | - run: make releasedeps 20 | - run: git-semv now 21 | - uses: docker/login-action@v1 22 | with: 23 | username: ${{ secrets.DOCKERHUB_USERNAME }} 24 | password: ${{ secrets.DOCKERHUB_TOKEN }} 25 | - run: make build 26 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: 3 | pull_request_target: 4 | 5 | jobs: 6 | automerge: 7 | runs-on: ubuntu-latest 8 | if: ${{ github.actor == 'dependabot[bot]' }} 9 | steps: 10 | - name: Dependabot metadata 11 | uses: dependabot/fetch-metadata@v1 12 | id: metadata 13 | - name: Auto-merge for Dependabot PRs 14 | if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch'}} 15 | run: gh pr merge --auto --merge "$PR_URL" 16 | env: 17 | PR_URL: ${{github.event.pull_request.html_url}} 18 | GH_TOKEN: ${{ secrets.MATZBOT_GITHUB_TOKEN }} 19 | GITHUB_TOKEN: ${{ secrets.MATZBOT_GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /app/models/comemback.rb: -------------------------------------------------------------------------------- 1 | module App 2 | module Model 3 | class Comeback < Base 4 | def bot_run(uid, params) 5 | user_presence = App::Model::Store.get(uid) 6 | if user_presence['mention_histotry'] 7 | history = user_presence['mention_histotry'].map do |h| 8 | <<~EOS 9 | <@#{h['user']}>: 10 | 内容: #{h['text']} 11 | EOS 12 | end 13 | end 14 | 15 | bot_token_client.chat_postMessage(channel: params['channel_id'], text: "#{params['user_name']}が戻ってきました。 I'll be back!!1", as_user: true) 16 | history ||= [] 17 | if history.empty? 18 | 'おかえりなさい!!1特にいない間にメンションは飛んでこなかったみたいです。' 19 | else 20 | "おかえりなさい!!1\nいない間に飛んできたメンションです\n" + history.join("\n") 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/models/lunch.rb: -------------------------------------------------------------------------------- 1 | module App 2 | module Model 3 | class Lunch < Base 4 | def add_list? 5 | true 6 | end 7 | 8 | def bot_run(uid, params) 9 | unless params["text"].empty? 10 | RedisConnection.pool.set(uid, "#{params["user_name"]} はランチに行っています。「#{params["text"]}」") 11 | else 12 | RedisConnection.pool.set(uid, "#{params["user_name"]} はランチに行っています。反応が遅れるかもしれません。") 13 | end 14 | RedisConnection.pool.expire(uid, 3600) 15 | bot_token_client.chat_postMessage(channel: params["channel_id"], text: "#{params["user_name"]}がランチに行きました。何食べるんでしょうね?", as_user: true) 16 | return_message "行ってらっしゃい!!1 #{(Time.now + 3600).strftime("%H:%M")}に自動で解除します" 17 | 18 | user_presence = App::Model::Store.get(uid) 19 | user_presence["last_lunch_date"] = Time.now.to_s 20 | App::Model::Store.set(uid, user_presence) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION := $(shell git-semv now | sed -e 's/v//g')-$(shell git show --format='%h' --no-patch) 2 | build: 3 | docker build --platform linux/amd64 -t pyama/away-from-keyboard:$(VERSION) . 4 | docker push pyama/away-from-keyboard:$(VERSION) 5 | 6 | .PHONY: releasedeps 7 | releasedeps: git-semv 8 | 9 | .PHONY: git-semv 10 | git-semv: 11 | ifeq ($(shell uname),Linux) 12 | which git-semv || (wget https://github.com/linyows/git-semv/releases/download/v1.2.0/git-semv_linux_x86_64.tar.gz && tar zxvf git-semv_linux_x86_64.tar.gz && sudo mv git-semv /usr/bin/) 13 | else 14 | which git-semv > /dev/null || brew tap linyows/git-semv 15 | which git-semv > /dev/null || brew install git-semv 16 | endif 17 | 18 | ## release_major: release nke (major) 19 | release_major: releasedeps 20 | git semv major --bump 21 | 22 | .PHONY: release_minor 23 | ## release_minor: release nke (minor) 24 | release_minor: releasedeps 25 | git semv minor --bump 26 | 27 | .PHONY: release_patch 28 | ## release_patch: release nke (patch) 29 | release_patch: releasedeps 30 | git semv patch --bump 31 | 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slack-afk 2 | 3 | Slackで不在時に代理応答します。 4 | 5 | ## 概要 6 | Slack App経由でRedisに情報を登録して、ボットがそれらの情報を基にチャンネルを監視して、メンションを検知したら代理応答します。 7 | 8 | ## セットアップ 9 | ### アプリの作成 10 | 1. [Slack API](https://api.slack.com/apps) の画面でCreate Appを押下してください。 11 | 2. アプリを作成したら、 `Slack Commands` に下記のように登録します。 12 | 1. `/afk` リクエストエンドポイント `https:///register` 13 | 2. `/afk_(number)` リクエストエンドポイント `https:///register` (指定した数字分経過で自動解除) 14 | 3. `/lunch` リクエストエンドポイント `https:///register`(60分経過で自動解除) 15 | 4. `/finish` リクエストエンドポイント `https:///register`(翌日9時に自動解除) 16 | 5. `/comeback` リクエストエンドポイント `https:///delete` 17 | 18 | ### ボットユーザーの作成 19 | 20 | ボットユーザーを作成して、トークンを取得してください 21 | 22 | ### k8sへのデプロイ 23 | `SLACK_API_TOKEN`はRTMAPIが実行できるボットユーザーのトークン、`SLACK_USER_API_TOKEN` は一般ユーザーのトークンです。一般ユーザーのトークンは、ボットユーザーを公開チャンネルに自動でinviteするのに利用します。 24 | 25 | ``` 26 | $ kubectl create secret generic away-from-keyboard-secret --from-literal=slack-api-token=$SLACK_API_TOKEN --from-literal=slack-user-api-token=$SLACK_USER_API_TOKEN 27 | $ kubectl apply -f manifests 28 | ``` 29 | -------------------------------------------------------------------------------- /app/models/afk.rb: -------------------------------------------------------------------------------- 1 | require_relative 'base' 2 | module App 3 | module Model 4 | class Afk < Base 5 | def add_list? 6 | true 7 | end 8 | 9 | def bot_run(uid, params) 10 | unless params["text"].empty? 11 | RedisConnection.pool.set(uid, "#{params["user_name"]} は席を外しています。「#{params["text"]}」") 12 | bot_token_client.chat_postMessage(channel: params["channel_id"], text: "#{params["user_name"]}が離席しました。「#{params["text"]}」", as_user: true) 13 | else 14 | RedisConnection.pool.set(uid, "#{params["user_name"]} は席を外しています。反応が遅れるかもしれません。") 15 | bot_token_client.chat_postMessage(channel: params["channel_id"], text: "#{params["user_name"]}が離席しました。代わりに不在をお伝えします", as_user: true) 16 | end 17 | 18 | # ボットがDMとかで投稿できなくてもexpireはいれる 19 | unless params["minute"].empty? 20 | diff = params["minute"].to_i * 60 21 | RedisConnection.pool.expire(uid, diff.to_i) 22 | end 23 | 24 | unless params["minute"].empty? 25 | return_message "行ってらっしゃい!!1 #{(Time.now + diff).strftime("%H:%M")}に自動で解除します" 26 | else 27 | return_message "行ってらっしゃい!!1" 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/models/finish.rb: -------------------------------------------------------------------------------- 1 | module App 2 | module Model 3 | class Finish < Base 4 | def add_list? 5 | true 6 | end 7 | 8 | def bot_run(uid, params) 9 | if params['text'].empty? 10 | RedisConnection.pool.set(uid, "#{params['user_name']} は退勤しました。反応が遅れるかもしれません。") 11 | else 12 | RedisConnection.pool.set(uid, "#{params['user_name']} は退勤しました。「#{params['text']}」") 13 | end 14 | tomorrow = Time.now.beginning_of_day + 3600 * 33 15 | RedisConnection.pool.expire(uid, (tomorrow - Time.now).to_i) 16 | 17 | user_presence = App::Model::Store.get(uid) 18 | begin_time = user_presence['today_begin'] 19 | user_presence['today_end'] = Time.now.to_s 20 | App::Model::Store.set(uid, user_presence) 21 | 22 | bot_token_client.chat_postMessage(channel: params['channel_id'], text: "#{params['user_name']}が退勤しました。お疲れさまでした!!1", as_user: true) 23 | (RedisConnection.pool.get("finish_#{Date.today}") + "\n\n" || ENV['AFK_FINISH_MESSAGE'] || 'お疲れさまでした!!1') + 24 | (begin_time ? "始業時刻:#{Time.parse(begin_time).strftime('%H:%M')}\n" : '') + 25 | " 明日の#{tomorrow.strftime('%H:%M')}に自動で解除します" 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/models/base.rb: -------------------------------------------------------------------------------- 1 | module App 2 | module Model 3 | class Base 4 | include SlackApiCallerble 5 | def reset(uid) 6 | RedisConnection.pool.lrem("registered", 0, uid) 7 | RedisConnection.pool.del(uid) 8 | end 9 | 10 | def add_list? 11 | false 12 | end 13 | 14 | 15 | def return_message(message) 16 | message + tips 17 | end 18 | 19 | def tips 20 | [ 21 | "\nちなみに通知も止めたいときは `/dnd 15min` とか `/dnd 1h` とかで通知も止められるし、 `/dnd off` で元に戻せるよ、知ってるとは思ったんだけど、念の為ね、知ってるとは思ったんだけどね", 22 | "\nちなみに、リマインドって知ってる? `/remind @pyama この内容を15分後に@pyamaに通知するよ in 15min` とか `/remind @pyama この内容を1時間後に通知するよ in 1h` とかすると、15分後とか1時間後に @pyama さんに通知できるんだ、相手を気遣って時間ずらしてメンションしたいときとか使ってみてよ、いや、知ってるとは思ったんだけどね。 `/remind @pyama この内容を明日の10時に通知するよ in tomorrow at 10am` とかは流石に知らなかったでしょ?僕もさっき知ったしね、まあ知ってるとは思ったんだけどね。自分に設定したいときは `/remind` だけ押してエンター押してみてよ。" 23 | ].sample 24 | end 25 | 26 | def bot_run(uid, params) 27 | raise "bot run isn't defined" 28 | end 29 | 30 | def run(uid, params) 31 | reset(uid) 32 | if add_list? 33 | RedisConnection.pool.lpush("registered", uid) 34 | user_presence = App::Model::Store.get(uid) 35 | user_presence["mention_histotry"] = [] 36 | App::Model::Store.set(uid, user_presence) 37 | end 38 | 39 | bot_run(uid, params) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /bot.rb: -------------------------------------------------------------------------------- 1 | require 'slack-ruby-bot' 2 | require './app' 3 | server = SlackRubyBot::Server.new( 4 | token: ENV['SLACK_API_TOKEN'], 5 | hook_handlers: { 6 | message: [lambda { |client, data| 7 | return if data.subtype == 'channel_join' 8 | return if data.subtype == 'bot_message' 9 | return if data.text =~ /\+\+|is up to [0-9]+ points!/ 10 | 11 | entries = RedisConnection.pool.lrange('registered', 0, -1) 12 | uids = entries.select do |entry| 13 | data.text =~ /<@#{entry}>/ 14 | end 15 | 16 | cid = data.channel 17 | c = App::Model::Store.get(cid) 18 | 19 | uids.each do |uid| 20 | message = RedisConnection.pool.get(uid) 21 | next unless message && c.fetch('enable', 1) == 1 22 | 23 | user_presence = App::Model::Store.get(uid) 24 | user_presence['mention_histotry'] ||= [] 25 | user_presence['mention_histotry'] = [] if user_presence['mention_histotry'].is_a?(Hash) 26 | user_presence['mention_histotry'] << { 27 | channel: data.channel, 28 | user: data.user, 29 | text: data.text && data.text.gsub(/<@#{uid}>/, ''), 30 | event_ts: data.event_ts 31 | } 32 | App::Model::Store.set(uid, user_presence) 33 | 34 | client.say(text: "自動応答: #{message}", channel: data.channel, 35 | thread_ts: data.thread_ts) 36 | end 37 | }], 38 | ping: [lambda { |client, data| 39 | }], 40 | pong: [lambda { |client, data| 41 | }] 42 | } 43 | ) 44 | 45 | Thread.new do 46 | server = TCPServer.new('0.0.0.0', 1234) 47 | loop do 48 | sock = server.accept 49 | line = sock.gets 50 | sock.write("HTTP/1.0 200 OK\n\nok") 51 | sock.close 52 | end 53 | end 54 | 55 | server.run 56 | -------------------------------------------------------------------------------- /manifests/cron.yml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1beta1 2 | kind: CronJob 3 | metadata: 4 | name: away-from-keyboard-autojoin 5 | spec: 6 | schedule: "0 * * * *" 7 | jobTemplate: 8 | spec: 9 | template: 10 | spec: 11 | containers: 12 | - name: away-from-keyboard-autojoin 13 | image: pyama/away-from-keyboard:0.0.1 14 | imagePullPolicy: Always 15 | env: 16 | - name: SLACK_USER 17 | value: "afk" 18 | - name: LANG 19 | value: C.UTF-8 20 | - name: SLACK_API_TOKEN 21 | valueFrom: 22 | secretKeyRef: 23 | name: away-from-keyboard-secret 24 | key: slack-user-api-token 25 | args: ["ruby", "join.rb"] 26 | image: pyama/away-from-keyboard:0.0.1 27 | imagePullPolicy: Always 28 | restartPolicy: Never 29 | --- 30 | apiVersion: batch/v1beta1 31 | kind: CronJob 32 | metadata: 33 | name: away-from-keyboard-presence 34 | spec: 35 | schedule: "*/15 * * * *" 36 | jobTemplate: 37 | spec: 38 | template: 39 | spec: 40 | containers: 41 | - name: away-from-keyboard-presence 42 | image: pyama/away-from-keyboard:0.0.1 43 | imagePullPolicy: Always 44 | env: 45 | - name: SLACK_USER 46 | value: "afk" 47 | - name: LANG 48 | value: C.UTF-8 49 | - name: SLACK_API_TOKEN 50 | valueFrom: 51 | secretKeyRef: 52 | name: away-from-keyboard-secret 53 | key: slack-user-api-token 54 | args: ["ruby", "presence.rb"] 55 | image: pyama/away-from-keyboard:0.0.1 56 | imagePullPolicy: Always 57 | restartPolicy: Never 58 | -------------------------------------------------------------------------------- /app/libs/slack_auth.rb: -------------------------------------------------------------------------------- 1 | module App 2 | class SlackAuth 3 | attr_accessor :slack_secret, :version, :paths 4 | 5 | def initialize(app, slack_secret, path: nil, version: 'v0') 6 | @app = app 7 | @slack_secret = slack_secret 8 | @version = version 9 | @paths = path 10 | end 11 | 12 | def call(env) 13 | request = Rack::Request.new(env) 14 | return @app.call(env) unless path_match?(request) 15 | 16 | return unauthorized unless authorized?(request) 17 | 18 | @app.call(env) 19 | end 20 | 21 | private 22 | 23 | def authorized?(request) 24 | timestamp = request.env['HTTP_X_SLACK_REQUEST_TIMESTAMP'] 25 | 26 | # check that the timestamp is recent (~5 mins) to prevent replay attacks 27 | return false if Time.at(timestamp.to_i) < Time.now - (60 * 5) 28 | 29 | # generate hash 30 | request_body = request.body.read 31 | request.body.rewind 32 | computed_signature = generate_hash(timestamp, request_body) 33 | 34 | # compare generated hash with slack signature 35 | slack_signature = request.env['HTTP_X_SLACK_SIGNATURE'] 36 | computed_signature == slack_signature 37 | end 38 | 39 | def generate_hash(timestamp, request_body) 40 | sig_basestring = "#{version}:#{timestamp}:#{request_body}" 41 | digest = OpenSSL::Digest.new('SHA256') 42 | hex_hash = OpenSSL::HMAC.hexdigest(digest, slack_secret, sig_basestring) 43 | 44 | "#{version}=#{hex_hash}" 45 | end 46 | 47 | def path_match?(request) 48 | if paths 49 | paths.any? { |path| request.env['PATH_INFO'] == path } 50 | else 51 | true 52 | end 53 | end 54 | 55 | def unauthorized 56 | [401, 57 | { 'Content_Type' => 'text/plain', 58 | 'Content' => '0' }, 59 | []] 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /ai_worker.rb: -------------------------------------------------------------------------------- 1 | require_relative 'app' 2 | require 'openai' 3 | return unless ENV['OPENAI_API_KEY'] 4 | 5 | openai = ::OpenAI::Client.new( 6 | access_token: ENV['OPENAI_API_KEY'] 7 | ) 8 | if ENV['AFK_START_MESSAGE'] && RedisConnection.pool.get("start_#{Date.today}").nil? 9 | response = openai.chat( 10 | parameters: { 11 | model: 'gpt-4', 12 | messages: [{ role: 'user', content: <<~EOS 13 | これから入力するテキストは従業員の始業時に通知するメッセージです。 14 | モチベーションが上がるような内容に修正してください。 15 | 作成してもらう文章は下記を要点とします。 16 | 17 | 1. 今日は#{Date.today}です。従業員に対して、日付にちなんだ面白い興味を引く文章にしてください。 18 | 2. あなたに作成していただいたメッセージはSlackで送信するので返信に件名は不要です。 19 | 3. 謎の関西人でニックネームはまうりんという人間になりきっておもしろおかしくしてください。 20 | 21 | テキスト:#{ENV['AFK_START_MESSAGE']} 22 | EOS 23 | }] # Required. 24 | } 25 | ) 26 | 27 | r = response.dig('choices', 0, 'message', 'content') 28 | puts "start_message: #{r}" 29 | RedisConnection.pool.set("start_#{Date.today}", r) 30 | RedisConnection.pool.expire("start_#{Date.today}", 86_400) 31 | end 32 | 33 | if ENV['AFK_FINISH_MESSAGE'] && RedisConnection.pool.get("finish_#{Date.today}").nil? 34 | response = openai.chat( 35 | parameters: { 36 | model: 'gpt-4', 37 | messages: [{ role: 'user', content: <<~EOS 38 | これから入力するテキストは従業員の終業時に通知するメッセージです。 39 | 労を労い、自己肯定感が上がるような内容に修正してください。 40 | 作成してもらう文章は下記を要点とします。 41 | 42 | 1. 今日は#{Date.today}です。従業員に対して、日付にちなんだ面白い興味を引く文章にしてください。 43 | 2. あなたに作成していただいたメッセージはSlackで送信するので返信に件名は不要です。 44 | 3. 謎の関西人でニックネームはまうりんという人間になりきっておもしろおかしくしてください。 45 | 46 | テキスト:#{ENV['AFK_FINISH_MESSAGE']} 47 | EOS 48 | }] # Required. 49 | } 50 | ) 51 | 52 | r = response.dig('choices', 0, 'message', 'content') 53 | puts "finish_message: #{r}" 54 | RedisConnection.pool.set("finish_#{Date.today}", r) 55 | RedisConnection.pool.expire("finish_#{Date.today}", 86_400) 56 | 57 | end 58 | -------------------------------------------------------------------------------- /presence.rb: -------------------------------------------------------------------------------- 1 | require './app' 2 | require 'json' 3 | require 'time' 4 | 5 | slack = Slack::Web::Client.new(token: ENV["SLACK_API_TOKEN"]) 6 | 7 | members = [] 8 | next_cursor = nil 9 | loop do 10 | t = slack.users_list({limit: 1000, cursor: next_cursor}) 11 | members << t['members'] 12 | next_cursor = t['response_metadata']['next_cursor'] 13 | break if next_cursor.empty? 14 | end 15 | members.flatten! 16 | RedisConnection.pool.set("members", members.to_json) 17 | 18 | groups = [] 19 | next_cursor = nil 20 | 21 | t = slack.usergroups_list({limit: 1000}) 22 | groups << t['usergroups'] 23 | groups.flatten! 24 | 25 | groups.each do |g| 26 | begin 27 | t = slack.usergroups_users_list({usergroup: g["id"]}) 28 | rescue Slack::Web::Api::Errors::TooManyRequestsError 29 | sleep 5 30 | retry 31 | end 32 | g["users"] = t['users'] 33 | end 34 | 35 | RedisConnection.pool.set("groups", groups.to_json) 36 | 37 | members.each do |m| 38 | uid = m["id"] 39 | redis_key = "#{uid}-store" 40 | next if m["is_bot"] || m["deleted"] 41 | begin 42 | presence = slack.users_getPresence({user: m["id"]})["presence"] 43 | rescue Slack::Web::Api::Errors::TooManyRequestsError 44 | sleep 5 45 | retry 46 | end 47 | user_presence = App::Model::Store.get(uid) 48 | if user_presence['status'] != presence 49 | # awayになったときにactiveだった時間を記録する 50 | if presence == 'away' 51 | user_presence['history'] ||= [] 52 | user_presence['history'] << { 53 | start: user_presence['last_active_start_time'], 54 | end: Time.now.to_s 55 | } 56 | else 57 | user_presence['last_active_start_time'] = Time.now.to_s 58 | end 59 | end 60 | user_presence['status'] = presence 61 | user_presence['name'] = m['name'] 62 | 63 | # 1ヶ月前のデータは削除する 64 | user_presence['history'].reject! do |h| 65 | !h['start'] || Time.parse(h['start']) < Time.now - 30 * 86400 66 | end if user_presence['history'] && !user_presence['history'].empty? 67 | App::Model::Store.set(uid, user_presence) 68 | end 69 | -------------------------------------------------------------------------------- /app/models/stats.rb: -------------------------------------------------------------------------------- 1 | require 'terminal-table' 2 | module App 3 | module Model 4 | class Stats < Base 5 | def run(uid, params) 6 | mentions = params["text"].scan(/@[\w-]+/) 7 | return "該当者がいないみたいです" if mentions.empty? 8 | mention = mentions[0].gsub(/@/, '') 9 | members = JSON.parse RedisConnection.pool.get("members") 10 | target = members.find do|m| 11 | m["name"] == mention 12 | end 13 | 14 | unless target 15 | groups = JSON.parse RedisConnection.pool.get("groups") 16 | target = groups.find do|m| 17 | m["name"] == mention 18 | end 19 | return "対象のチームメンションが見つかりませんでした" unless target 20 | users = target["users"] 21 | else 22 | users = [target["id"]] 23 | end 24 | 25 | 26 | entries = RedisConnection.pool.lrange("registered", 0, -1) 27 | begin 28 | result = [] 29 | users.each do |uid| 30 | us = App::Model::Store.get(uid) 31 | s = us["today_begin"] && DateTime.parse(us["today_begin"]) 32 | l = us["last_lunch_date"] && DateTime.parse(us["last_lunch_date"]) 33 | e = us["today_end"] && DateTime.parse(us["today_end"]) 34 | 35 | start = s && s.today? ? s.strftime("%H:%M") : "本日の登録なし" 36 | lunch = l && l.today? ? l.strftime("%H:%M") : "本日の登録なし" 37 | _end = e && e.today? ? e.strftime("%H:%M") : "本日の登録なし" 38 | is_here = !entries.find {|entry| entry == uid } || !RedisConnection.pool.get(uid) 39 | now = is_here ? "在席" : "離席" 40 | leave_message = is_here ? "" : RedisConnection.pool.get(uid) 41 | result << [us["name"], now, start, lunch, _end, leave_message] 42 | end 43 | 44 | table = Terminal::Table.new :title => "AFKサマリー", :headings => ['Slack Name', '在籍状況', '始業', 'ランチ', '退勤','離席理由'], :rows => result 45 | "#{table.to_s}\n※表がずれるのは、いつか・・・" 46 | rescue => e 47 | pp e 48 | "何かがだめでした" 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /join.rb: -------------------------------------------------------------------------------- 1 | require 'slack-ruby-client' 2 | slack = Slack::Web::Client.new(token: ENV['SLACK_API_TOKEN']) 3 | 4 | members = [] 5 | next_cursor = nil 6 | 7 | loop do 8 | slack_users = slack.users_list({ limit: 1000, cursor: next_cursor }) 9 | members << slack_users['members'] 10 | next_cursor = slack_users['response_metadata']['next_cursor'] 11 | break if next_cursor.empty? 12 | end 13 | members.flatten! 14 | 15 | channels = [] 16 | next_cursor = nil 17 | loop do 18 | slack_channels = slack.conversations_list({ limit: 1000, cursor: next_cursor }) 19 | channels << slack_channels['channels'] 20 | next_cursor = slack_channels['response_metadata']['next_cursor'] 21 | break if next_cursor.empty? 22 | end 23 | channels.flatten! 24 | 25 | channels.each do |c| 26 | next_cursor = nil 27 | loop do 28 | c['members'] ||= [] 29 | break if c['num_members'] == 0 || c['is_archived'] 30 | 31 | channel_members = slack.conversations_members({ channel: c['id'], limit: 1000, cursor: next_cursor }) 32 | c['members'] << channel_members['members'] 33 | next_cursor = channel_members['response_metadata']['next_cursor'] 34 | break if next_cursor.empty? 35 | end 36 | end 37 | 38 | user = members.find { |m| m['name'] == ENV['SLACK_USER'] } 39 | exclude_channels = ENV['SLACK_EXCLUDE_CHANNELS'] ? ENV['SLACK_EXCLUDE_CHANNELS'].split(/,/) : [] 40 | channels.each do |c| 41 | next if c['is_archived'] 42 | next if c['members'].empty? || c['members'].flatten.find { |m| m == user['id'] } 43 | next if exclude_channels.include?(c['name']) 44 | next if c['is_ext_shared'] 45 | 46 | begin 47 | slack.conversations_invite({ channel: c['id'], users: user['id'] }) 48 | rescue Slack::Web::Api::Errors::AlreadyInChannel 49 | next 50 | rescue Slack::Web::Api::Errors::NotInChannel 51 | slack.conversations_join({ channel: c['id'] }) 52 | begin 53 | slack.conversations_invite({ channel: c['id'], users: user['id'] }) 54 | rescue Slack::Web::Api::Errors::AlreadyInChannel 55 | next 56 | ensure 57 | slack.conversations_leave({ channel: c['id'] }) 58 | end 59 | rescue StandardError => e 60 | pp c 61 | pp e 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /manifests/deployment.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1beta1 2 | kind: Deployment 3 | metadata: 4 | name: away-from-keyboard 5 | spec: 6 | replicas: 1 7 | template: 8 | metadata: 9 | labels: 10 | name: away-from-keyboard 11 | spec: 12 | containers: 13 | - name: away-from-keyboard-api 14 | imagePullPolicy: Always 15 | image: pyama/away-from-keyboard:0.0.1 16 | tty: true 17 | args: ["unicorn", "-c", "config/unicorn.rb", "-p", "8080"] 18 | securityContext: 19 | readOnlyRootFilesystem: true 20 | runAsUser: 65534 21 | ports: 22 | - containerPort: 8080 23 | livenessProbe: 24 | initialDelaySeconds: 10 25 | periodSeconds: 10 26 | httpGet: 27 | path: / 28 | port: 8080 29 | readinessProbe: 30 | initialDelaySeconds: 10 31 | periodSeconds: 10 32 | httpGet: 33 | path: / 34 | port: 8080 35 | env: 36 | - name: TZ 37 | value: Asia/Tokyo 38 | - name: LANG 39 | value: C.UTF-8 40 | - name: APP_ENV 41 | value: "production" 42 | - name: SLACK_API_TOKEN 43 | valueFrom: 44 | secretKeyRef: 45 | name: away-from-keyboard-secret 46 | key: slack-api-token 47 | - name: away-from-keyboard-bot 48 | args: ["ruby", "bot.rb"] 49 | image: pyama/away-from-keyboard:0.0.1 50 | imagePullPolicy: Always 51 | tty: true 52 | securityContext: 53 | readOnlyRootFilesystem: true 54 | runAsUser: 65534 # nobody 55 | runAsNonRoot: true 56 | readinessProbe: 57 | tcpSocket: 58 | port: 1234 59 | initialDelaySeconds: 3 60 | periodSeconds: 10 61 | livenessProbe: 62 | httpGet: 63 | path: / 64 | port: 1234 65 | initialDelaySeconds: 3 66 | periodSeconds: 10 67 | env: 68 | - name: TZ 69 | value: Asia/Tokyo 70 | - name: LANG 71 | value: C.UTF-8 72 | - name: APP_ENV 73 | value: "production" 74 | - name: SLACK_API_TOKEN 75 | valueFrom: 76 | secretKeyRef: 77 | name: away-from-keyboard-secret 78 | key: slack-api-token 79 | - name: redis 80 | image: redis 81 | tty: true 82 | ports: 83 | - containerPort: 6379 84 | -------------------------------------------------------------------------------- /app/api.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra/reloader' if development? 2 | require 'sinatra/custom_logger' 3 | require 'time' 4 | require 'json' 5 | require 'active_support/time' 6 | 7 | module App 8 | class Api < Sinatra::Base 9 | include SlackApiCallerble 10 | use SlackAuth, ENV['SLACK_SIGNING_SECRET'], path: %w[/message /register /delete /stats] 11 | helpers Sinatra::CustomLogger 12 | configure :development, :staging, :production do 13 | logger = Logger.new(STDERR) 14 | logger.level = Logger::DEBUG if development? 15 | set :logger, logger 16 | set :public_folder, 'assets/public' 17 | end 18 | 19 | # for livenessProbe 20 | get '/' do 21 | content_type 'text/plain; charset=utf8' 22 | 'ok' 23 | end 24 | 25 | post '/channel' do 26 | content_type 'text/plain; charset=utf8' 27 | cid = params['channel_id'] 28 | case params['command'] 29 | when '/enable_afk', '/afk_enable' 30 | c = App::Model::Store.get(cid) 31 | c['enable'] = 1 32 | App::Model::Store.set(cid, c) 33 | 'このチャンネルでの代理応答を有効にしました' 34 | when '/disable_afk', '/afk_disable' 35 | c = App::Model::Store.get(cid) 36 | c['enable'] = 0 37 | App::Model::Store.set(cid, c) 38 | 'このチャンネルでの代理応答を無効にしました' 39 | end 40 | rescue Slack::Web::Api::Errors::ChannelNotFound 41 | pp params 42 | 'ボットがチャンネルで投稿できないみたいです。DMとかは無理です。' 43 | end 44 | 45 | post '/message' do 46 | content_type 'text/plain; charset=utf8' 47 | payload = JSON.parse(request.body.read) 48 | payload['challenge'] 49 | end 50 | 51 | post '/register' do 52 | content_type 'text/plain; charset=utf8' 53 | uid = params['user_id'] 54 | case params['command'] 55 | when '/start', '/afk_start' 56 | App::Model::Start.new.run(uid, params) 57 | when '/finish', '/afk_end' 58 | App::Model::Finish.new.run(uid, params) 59 | when '/lunch', '/afk_lunch' 60 | App::Model::Lunch.new.run(uid, params) 61 | when %r{^/afk_*([0-9]*)} 62 | params['minute'] = ::Regexp.last_match(1) 63 | App::Model::Afk.new.run(uid, params) 64 | end 65 | rescue Slack::Web::Api::Errors::ChannelNotFound 66 | pp params 67 | 'ボットがチャンネルで投稿できないみたいです。DMとかは無理です。' 68 | end 69 | 70 | post '/delete' do 71 | content_type 'text/plain; charset=utf8' 72 | uid = params['user_id'] 73 | App::Model::Comeback.new.run(uid, params) 74 | rescue Slack::Web::Api::Errors::ChannelNotFound 75 | pp params 76 | 'ボットがチャンネルで投稿できないみたいです。DMとかは無理です。' 77 | end 78 | 79 | post '/stats' do 80 | content_type 'text/plain; charset=utf8' 81 | uid = params['user_id'] 82 | App::Model::Stats.new.run(uid, params) 83 | rescue Slack::Web::Api::Errors::ChannelNotFound 84 | pp params 85 | 'ボットがチャンネルで投稿できないみたいです。DMとかは無理です。' 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (8.0.2) 5 | base64 6 | benchmark (>= 0.3) 7 | bigdecimal 8 | concurrent-ruby (~> 1.0, >= 1.3.1) 9 | connection_pool (>= 2.2.5) 10 | drb 11 | i18n (>= 1.6, < 2) 12 | logger (>= 1.4.2) 13 | minitest (>= 5.1) 14 | securerandom (>= 0.3) 15 | tzinfo (~> 2.0, >= 2.0.5) 16 | uri (>= 0.13.1) 17 | async (2.8.0) 18 | console (~> 1.10) 19 | fiber-annotation 20 | io-event (~> 1.1) 21 | timers (~> 4.1) 22 | async-io (1.41.0) 23 | async 24 | async-websocket (0.8.0) 25 | async-io 26 | websocket-driver (~> 0.7.0) 27 | base64 (0.2.0) 28 | benchmark (0.4.0) 29 | bigdecimal (3.1.9) 30 | concurrent-ruby (1.3.5) 31 | config (5.5.2) 32 | deep_merge (~> 1.2, >= 1.2.1) 33 | ostruct 34 | connection_pool (2.5.0) 35 | console (1.23.3) 36 | fiber-annotation 37 | fiber-local 38 | deep_merge (1.2.2) 39 | dotenv (3.1.7) 40 | drb (2.2.1) 41 | event_stream_parser (1.0.0) 42 | faraday (2.13.0) 43 | faraday-net_http (>= 2.0, < 3.5) 44 | json 45 | logger 46 | faraday-mashify (1.0.0) 47 | faraday (~> 2.0) 48 | hashie 49 | faraday-multipart (1.1.0) 50 | multipart-post (~> 2.0) 51 | faraday-net_http (3.4.0) 52 | net-http (>= 0.5.0) 53 | fiber-annotation (0.2.0) 54 | fiber-local (1.0.0) 55 | foreman (0.88.1) 56 | gli (2.22.2) 57 | ostruct 58 | hashie (5.0.0) 59 | i18n (1.14.7) 60 | concurrent-ruby (~> 1.0) 61 | io-event (1.4.2) 62 | json (2.10.2) 63 | kgio (2.11.4) 64 | logger (1.7.0) 65 | minitest (5.25.5) 66 | multi_json (1.15.0) 67 | multipart-post (2.4.1) 68 | mustermann (3.0.3) 69 | ruby2_keywords (~> 0.0.1) 70 | net-http (0.6.0) 71 | uri 72 | ostruct (0.6.0) 73 | rack (3.1.12) 74 | rack-protection (4.1.1) 75 | base64 (>= 0.1.0) 76 | logger (>= 1.6.0) 77 | rack (>= 3.0.0, < 4) 78 | rack-session (2.0.0) 79 | rack (>= 3.0.0) 80 | raindrops (0.20.1) 81 | redis (5.3.0) 82 | redis-client (>= 0.22.0) 83 | redis-client (0.22.2) 84 | connection_pool 85 | ruby-openai (8.1.0) 86 | event_stream_parser (>= 0.3.0, < 2.0.0) 87 | faraday (>= 1) 88 | faraday-multipart (>= 1) 89 | ruby2_keywords (0.0.5) 90 | securerandom (0.4.1) 91 | sinatra (4.1.1) 92 | logger (>= 1.6.0) 93 | mustermann (~> 3.0) 94 | rack (>= 3.0.0, < 4) 95 | rack-protection (= 4.1.1) 96 | rack-session (>= 2.0.0, < 3) 97 | tilt (~> 2.0) 98 | sinatra-contrib (4.1.1) 99 | multi_json (>= 0.0.2) 100 | mustermann (~> 3.0) 101 | rack-protection (= 4.1.1) 102 | sinatra (= 4.1.1) 103 | tilt (~> 2.0) 104 | slack-ruby-bot (0.16.1) 105 | activesupport 106 | hashie 107 | slack-ruby-client (>= 0.14.0) 108 | slack-ruby-client (2.5.2) 109 | faraday (>= 2.0) 110 | faraday-mashify 111 | faraday-multipart 112 | gli 113 | hashie 114 | logger 115 | terminal-table (4.0.0) 116 | unicode-display_width (>= 1.1.1, < 4) 117 | tilt (2.4.0) 118 | timers (4.3.5) 119 | tzinfo (2.0.6) 120 | concurrent-ruby (~> 1.0) 121 | unicode-display_width (3.1.4) 122 | unicode-emoji (~> 4.0, >= 4.0.4) 123 | unicode-emoji (4.0.4) 124 | unicorn (6.1.0) 125 | kgio (~> 2.6) 126 | raindrops (~> 0.7) 127 | uri (1.0.3) 128 | websocket-driver (0.7.6) 129 | websocket-extensions (>= 0.1.0) 130 | websocket-extensions (0.1.5) 131 | 132 | PLATFORMS 133 | ruby 134 | 135 | DEPENDENCIES 136 | activesupport 137 | async-websocket (~> 0.8.0) 138 | config 139 | connection_pool 140 | dotenv 141 | foreman 142 | redis 143 | ruby-openai 144 | sinatra 145 | sinatra-contrib 146 | slack-ruby-bot 147 | slack-ruby-client 148 | terminal-table 149 | unicorn 150 | 151 | BUNDLED WITH 152 | 2.4.4 153 | --------------------------------------------------------------------------------