├── .gitignore ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── docker-compose-prod.yml ├── docker-compose.yml ├── src ├── config.rb ├── hideit_bot.rb ├── long_polling.rb └── server.ru └── tokens.env.sample /.gitignore: -------------------------------------------------------------------------------- 1 | Tokenfile 2 | tokens.env 3 | .data/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.3 2 | 3 | WORKDIR /usr/src/bot 4 | COPY Gemfile Gemfile.lock ./ 5 | RUN bundle 6 | 7 | COPY . . 8 | CMD ["ruby","src/long_polling.rb"] -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'puma' 4 | gem 'rack' 5 | 6 | gem 'oj' 7 | gem 'multi_json' 8 | 9 | gem 'telegram-bot-ruby' 10 | gem 'mongo' -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | axiom-types (0.1.1) 5 | descendants_tracker (~> 0.0.4) 6 | ice_nine (~> 0.11.0) 7 | thread_safe (~> 0.3, >= 0.3.1) 8 | bson (4.1.1) 9 | coercible (1.0.0) 10 | descendants_tracker (~> 0.0.1) 11 | daemons (1.2.3) 12 | descendants_tracker (0.0.4) 13 | thread_safe (~> 0.3, >= 0.3.1) 14 | equalizer (0.0.11) 15 | faraday (0.9.2) 16 | multipart-post (>= 1.2, < 3) 17 | ice_nine (0.11.2) 18 | mongo (2.2.5) 19 | bson (~> 4.0) 20 | multipart-post (2.0.0) 21 | telegram-bot-ruby (0.5.1) 22 | faraday 23 | virtus 24 | thread_safe (0.3.5) 25 | virtus (1.0.5) 26 | axiom-types (~> 0.1) 27 | coercible (~> 1.0) 28 | descendants_tracker (~> 0.0, >= 0.0.3) 29 | equalizer (~> 0.0, >= 0.0.9) 30 | 31 | PLATFORMS 32 | ruby 33 | 34 | DEPENDENCIES 35 | daemons 36 | mongo 37 | telegram-bot-ruby 38 | 39 | BUNDLED WITH 40 | 1.10.6 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Guillermo Guridi 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hide It Bot 2 | 3 | This bot will hide the content of a message by replacing non-blank characters with black blocks, also adding a button underneath the message to reveal the content. 4 | 5 | ### Possible usages 6 | 7 | * Sending a book/series/movie spoiler in a group chat so only those users who choose to see it get potentially spoiled. 8 | * Sending a message to someone in wich the text in the notification will be hidden (useful if you fear someone is looking at their screen and maybe reading their notifications). 9 | 10 | ### How to use it: 11 | 12 | Use it inline starting your message with @hideitBot 13 | 14 | ## Development 15 | 16 | You can try to run the code for this bot directly but I suggest you use docker, concretely docker-composer. To install docker and docker-composer go to: [docker compose installation guide](https://docs.docker.com/compose/install/). 17 | 18 | #### Set up 19 | 20 | The only thing you need to get started is to create a *tokens.env* file. A good way to create one is to copy *tokens.env.sample* and replace the fields inside. 21 | 22 | Parameters in *tokens.env*: 23 | 24 | * `TELEGRAM_TOKEN`: (mandatory) The token given by telegram for your bot (ask [@botfather](https://telegram.me/botfather) for one) 25 | * `BOTAN_TOKEN`: (optional) If you want to use [botan](http://botan.io/) for traking your users place here your token. 26 | * `WEBHOOK_DOMAIN`: (optional) If you are going to use the bot with webhooks you have to specify the domain the bot will be running in. See [webhooks section](#webhooks) for more details. 27 | 28 | #### Running with docker 29 | 30 | You can launch the dockerized bot with just one command. This will launch the database first and then the bot and connect them together. 31 | 32 | `docker-compose up --build` 33 | 34 | Refer to the [docker-compose command documentation](https://docs.docker.com/compose/reference/up/) for more options. 35 | 36 | In case you want to change the command you want to run or see *stdout* in the terminal try running `docker-compose build bot` and `docker-compose run -it bot` 37 | 38 | #### Running manually 39 | 40 | First you need to have a functionning mongodb installation accessible at the url *mongodb* (try modifying *etc/hosts*), without password. 41 | 42 | Then you need all of the ruby dependencies: `bundle install` 43 | 44 | After you can run the bot if at the root directory you run the command `ruby src/long_polling.rb`, or `puma -b tcp://0.0.0.0:80 src/server.ru`. 45 | 46 | #### Production 47 | 48 | For running in a server there is a different *docker-compose* file called *docker-compose-prod.yml*. By default it will use webhooks. You can run all the docker commands mentioned before but adding `-f docker-compose-prod.yml` just after `docker-compose` in every command. 49 | 50 | #### Webhooks 51 | 52 | The server this bot will create doesn't handle the https connections needed for telegram. You should put this bot behind a proxy that will handle that. What the bot will do is generating a token (placed in the bot's url) that will be sent to telegram upon startup, activating webhooks (in case they were not) at the same time. 53 | 54 | Here is a sample vhost configuration file for apache2 that will provide the bot endpoint with authentication for telegram. The certificates have been obtained via [let's encrypt](http://letsencrypt.org/). 55 | 56 | ``` 57 | 58 | Listen 88 59 | 60 | ServerName example.com 61 | ProxyPreserveHost On 62 | ProxyPass / http://localhost:8801/ 63 | ProxyPassReverse / http://localhost:8801 64 | SSLEngine On 65 | SSLCertificateFile /etc/letsencrypt/path/to/cert.pem 66 | SSLCertificateKeyFile /etc/letsencrypt/path/to/privkey.pem 67 | SSLCertificateChainFile /etc/letsencrypt/path/to/chain.pem 68 | Include /etc/letsencrypt/options-ssl-apache.conf 69 | 70 | SSLRequireSSL On 71 | SSLVerifyClient optional 72 | SSLVerifyDepth 1 73 | SSLOptions +StdEnvVars +StrictRequire 74 | 75 | ErrorLog "/var/log/apache2/telegram-error.log" 76 | CustomLog "/var/log/apache2/telegram-access.log" common 77 | 78 | 79 | 80 | ``` 81 | 82 | In this case I have used the dafult port for the bot (which is 8801 and can be changed in *docker-compose-prod.yml*) and port 88 as outside port. If you try to do this too you should specify the port 88 in the domain name in *tokens.env*. 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /docker-compose-prod.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | mongodb: 4 | image: mongo 5 | volumes: 6 | - "./.data/db:/data/db" 7 | bot: 8 | build: . 9 | links: 10 | - mongodb 11 | env_file: tokens.env 12 | command: puma -b tcp://0.0.0.0:80 -e production src/server.ru 13 | ports: 14 | - "8801:80" -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | mongodb: 4 | image: mongo 5 | bot: 6 | build: . 7 | links: 8 | - mongodb 9 | dns: 8.8.8.8 10 | env_file: tokens.env 11 | ports: 12 | - "8800:80" -------------------------------------------------------------------------------- /src/config.rb: -------------------------------------------------------------------------------- 1 | module BotConfig 2 | 3 | Telegram_token = ENV['TELEGRAM_TOKEN'] 4 | Botan_token = ENV['BOTAN_TOKEN'] 5 | Webhook_domain = ENV['WEBHOOK_DOMAIN'] 6 | 7 | def BotConfig.require_tokens(server:false) 8 | if not BotConfig.has_telegram_token 9 | STDERR.puts "Telegram token not provided. Write your token to tokens.env file." 10 | exit 11 | end 12 | if not BotConfig.has_webhook_domain and server 13 | STDERR.puts "Webhook domain not provided. Write your domain to tokens.env file." 14 | exit 15 | end 16 | end 17 | 18 | def BotConfig.has_telegram_token() 19 | return (Telegram_token and Telegram_token != 'placeholder') 20 | end 21 | 22 | def BotConfig.has_botan_token() 23 | return (Botan_token and Botan_token != 'placeholder') 24 | end 25 | 26 | def BotConfig.has_webhook_domain() 27 | return (Webhook_domain and Webhook_domain != 'placeholder') 28 | end 29 | 30 | end 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/hideit_bot.rb: -------------------------------------------------------------------------------- 1 | require 'telegram/bot' 2 | require 'telegram/bot/botan' 3 | require 'mongo' 4 | require_relative 'config' 5 | 6 | module Hideit_bot 7 | 8 | class HideItBot 9 | RegExpParcial = /(^|[^\\])\*(([^\*]|\\\*)*([^\*\\]|\\\*))\*/ 10 | Welcome_message = "Hello world!" 11 | 12 | def self.start() 13 | Mongo::Logger.logger.level = ::Logger::FATAL 14 | 15 | @@database_cleaner = Thread.new do 16 | # clean unused data 17 | mongoc = Mongo::Client.new("mongodb://mongodb:27017/hideitbot") 18 | counter = 0 # Only run every 30 seconds but sleep one second at a time 19 | loop do 20 | sleep 1 21 | counter = (counter + 1) % 30 22 | if counter == 29 23 | mongoc[:messages].delete_many(:used => false, :created_date => {:$lte => (Time.now - 30).utc}) 24 | end 25 | end 26 | end 27 | end 28 | 29 | def initialize() 30 | @bot = Telegram::Bot::Client.new(BotConfig::Telegram_token) 31 | @messages = Mongo::Client.new("mongodb://mongodb:27017/hideitbot")[:messages] 32 | 33 | rootMessage = @messages.find(:text => Welcome_message) 34 | if rootMessage.count == 0 35 | @rootMessageId = save_message(0, Welcome_message, used:true) 36 | else 37 | @rootMessageId = rootMessage.to_a[0]["_id"].to_s 38 | end 39 | 40 | if BotConfig.has_botan_token 41 | @bot.enable_botan!(BotConfig::Botan_token) 42 | end 43 | end 44 | 45 | def listen(&block) 46 | @bot.listen &block 47 | end 48 | 49 | def process_update(message) 50 | begin 51 | 52 | case message 53 | when Telegram::Bot::Types::InlineQuery 54 | id = handle_inline_query(message) 55 | if BotConfig.has_botan_token 56 | @bot.track('inline_query', message.from.id, {message_length: message.query.length, db_id: id}) 57 | end 58 | 59 | when Telegram::Bot::Types::CallbackQuery 60 | res = message.data 61 | begin 62 | res = @messages.find("_id" => BSON::ObjectId(message.data)).to_a[0][:text] 63 | rescue 64 | res = "Message not found in database. Sorry!" 65 | end 66 | @bot.api.answer_callback_query( 67 | callback_query_id: message.id, 68 | text: res, 69 | show_alert: true) 70 | if BotConfig.has_botan_token 71 | @bot.track('callback_query', message.from.id, {db_id: message.data}) 72 | end 73 | 74 | when Telegram::Bot::Types::ChosenInlineResult 75 | message_type, message_id = message.result_id.split(':') 76 | @messages.find("_id" => BSON::ObjectId(message_id)) 77 | .update_one(:$set => {used: true}) 78 | if BotConfig.has_botan_token 79 | @bot.track('chosen_inline', message.from.id, {db_id: message_id, chosen_type: message_type}) 80 | end 81 | 82 | 83 | when Telegram::Bot::Types::Message 84 | if message.left_chat_member or message.new_chat_member or message.new_chat_title or message.delete_chat_photo or message.group_chat_created or message.supergroup_chat_created or message.channel_chat_created or message.migrate_to_chat_id or message.migrate_from_chat_id or message.pinned_message 85 | return 86 | end 87 | 88 | 89 | if message.text == "/start toolong" 90 | @bot.api.send_message(chat_id: message.chat.id, text: "Unfortunately, due to telegram's api restrictions we cannot offer this functionality with messages over 200 characters. We'll try to find more options and contact telegram. Sorry for the inconvenience.") 91 | if BotConfig.has_botan_token 92 | @bot.track('message', message.from.id, message_type: 'toolong') 93 | end 94 | elsif message.text == "/start" 95 | @bot.api.send_message(chat_id: message.chat.id, text: "Hello, #{message.from.first_name}!\nThis bot should be used inline.\nType @hideItBot to start") 96 | @bot.api.send_message(chat_id: message.chat.id, text: "You can use it to send a spoiler in a group conversation.\nOr to send a message that won't be readable in notifications!\nYou can hide only *parts of the message* enclosing them in asterisks.\nExample:\n") 97 | @bot.api.send_message( 98 | chat_id: message.chat.id, 99 | text: message_to_blocks(Welcome_message), 100 | reply_markup: Telegram::Bot::Types::InlineKeyboardMarkup.new( 101 | inline_keyboard: [ 102 | Telegram::Bot::Types::InlineKeyboardButton.new( 103 | text: 'Read', 104 | callback_data: @rootMessageId 105 | ) 106 | ] 107 | ) 108 | ) 109 | if BotConfig.has_botan_token 110 | @bot.track('message', message.from.id, message_type: 'hello') 111 | end 112 | end 113 | 114 | end 115 | rescue Telegram::Bot::Exceptions::ResponseError => e 116 | puts "Telegram answered with error #{e}. Continuing" 117 | end 118 | end 119 | 120 | def set_webhook(url) 121 | @bot.api.set_webhook(url: url) 122 | end 123 | 124 | private 125 | 126 | def save_message(from, text, used: false) 127 | return @messages.insert_one({user: from, text: text, used: used, created_date: Time.now.utc}).inserted_id.to_s 128 | end 129 | 130 | def message_to_blocks(message) 131 | return message.gsub(/[^\s]/i, "\u2588") 132 | end 133 | 134 | def message_to_blocks_parcial(message) 135 | return message.gsub(RegExpParcial) {|s| $1+message_to_blocks($2.gsub(/\*/, ""))} 136 | end 137 | 138 | def message_clear_parcial(message) 139 | return message.gsub(RegExpParcial) {|s| $1+$2.gsub(/\\\*/, "*")} 140 | end 141 | 142 | def handle_inline_query(message) 143 | 144 | default_params = {} 145 | id = nil 146 | 147 | if message.query == "" 148 | results = [] 149 | default_params = { 150 | switch_pm_text: 'How to use this bot', 151 | switch_pm_parameter: 'howto' 152 | } 153 | elsif message.query.length > 200 154 | results = [] 155 | default_params = { 156 | switch_pm_text: 'Sorry, this message is too long, split it to send.', 157 | switch_pm_parameter: 'toolong' 158 | } 159 | else 160 | 161 | id = save_message(message.from.id, message.query) 162 | results = [ 163 | [id, 'cover:'+id, 'Send covered text', message_to_blocks(message.query), message_to_blocks(message.query)], 164 | [id, 'generic:'+id, 'Send generic message', '❓❓❓','❓❓❓'] 165 | ] 166 | 167 | if message.query.index(RegExpParcial) 168 | id_covered = save_message(message.from.id, message_clear_parcial(message.query)) 169 | results.insert(1, 170 | [id_covered, 'partial:'+id_covered, 'Send partially covered text', message_to_blocks_parcial(message.query), message_to_blocks_parcial(message.query)], 171 | ) 172 | end 173 | 174 | results = results.map do |arr| 175 | Telegram::Bot::Types::InlineQueryResultArticle.new( 176 | id: arr[1], 177 | title: arr[2], 178 | description: arr[3], 179 | input_message_content: Telegram::Bot::Types::InputTextMessageContent.new(message_text: arr[4]), 180 | reply_markup: Telegram::Bot::Types::InlineKeyboardMarkup.new( 181 | inline_keyboard: [ 182 | Telegram::Bot::Types::InlineKeyboardButton.new( 183 | text: 'Read', 184 | callback_data: arr[0] 185 | ) 186 | ] 187 | ), 188 | ) 189 | end 190 | end 191 | 192 | @bot.api.answer_inline_query({ 193 | inline_query_id: message.id, 194 | results: results, 195 | cache_time: 0, 196 | is_personal: true 197 | }.merge!(default_params)) 198 | return id 199 | end 200 | end 201 | 202 | end 203 | -------------------------------------------------------------------------------- /src/long_polling.rb: -------------------------------------------------------------------------------- 1 | require 'telegram/bot' 2 | require_relative 'hideit_bot' 3 | require_relative 'config' 4 | 5 | BotConfig::require_tokens() 6 | 7 | error_count = 0 8 | 9 | Hideit_bot::HideItBot.start() 10 | 11 | begin 12 | bot = Hideit_bot::HideItBot.new() 13 | 14 | bot.set_webhook("") 15 | 16 | bot.listen do |message| 17 | bot.process_update message 18 | error_count = 0 19 | end 20 | 21 | 22 | rescue => e 23 | error_count += 1 24 | puts e.to_s 25 | open('hideit_server_log.txt', 'a') do |f| 26 | f.puts e.to_s 27 | end 28 | if error_count < 5 29 | sleep(1) 30 | retry 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /src/server.ru: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | 3 | require 'oj' 4 | require 'multi_json' 5 | require 'telegram/bot' 6 | require_relative 'hideit_bot' 7 | require_relative 'config' 8 | 9 | BotConfig.require_tokens(server: true) 10 | 11 | Webhook_token = SecureRandom.hex 12 | url = 'https://' + BotConfig::Webhook_domain + '/' + Webhook_token 13 | 14 | puts "telegram webhook url: ",url 15 | 16 | Hideit_bot::HideItBot.start() 17 | 18 | bot = Hideit_bot::HideItBot.new() 19 | bot.set_webhook(url) 20 | 21 | def extract_message(update) 22 | update.inline_query || 23 | update.chosen_inline_result || 24 | update.callback_query || 25 | update.message 26 | end 27 | 28 | app = Proc.new do |env| 29 | request = Rack::Request.new(env) 30 | 31 | if request.post? 32 | data = MultiJson.load request.body.read 33 | 34 | token = request.path[1..-1] 35 | if token != Webhook_token 36 | [403, {'Content-Type' => 'text/html'}, ['Invalid token!']] 37 | else 38 | update = Telegram::Bot::Types::Update.new(data) 39 | bot.process_update extract_message(update) 40 | 41 | [200, {}, []] 42 | end 43 | else 44 | [200, {'Content-Type' => 'text/html'}, ['hello! bot here. You are not using a token nor a POST request']] 45 | end 46 | end 47 | 48 | 49 | run app 50 | -------------------------------------------------------------------------------- /tokens.env.sample: -------------------------------------------------------------------------------- 1 | TELEGRAM_TOKEN=placeholder 2 | BOTAN_TOKEN=placeholder 3 | WEBHOOK_DOMAIN=placeholder --------------------------------------------------------------------------------