1
0
зеркало из https://github.com/glebtv/telegram-bot.git synced 2026-08-28 15:26:18 +03:00

Сравнить коммиты

...

63 Коммитов

Автор SHA1 Сообщение Дата
Max Melentiev
384d203b59 Fix spec helpers for callback queries 2017-12-04 09:47:17 +03:00
Max Melentiev
9e396cabc4 New methods from bot api v3.5
Collect all api helper-methods in Client::ApiHelper module.
Add bin/fetch-telegram-methods to update methods list from website.
2017-11-24 11:08:12 +03:00
Max Melentiev
81c18034a5 Add some new files after appraisal install 2017-11-24 11:04:38 +03:00
printercu
f332d27ec8 Update README.md 2017-11-18 11:27:24 +03:00
printercu
fa9b01d9d2 Merge pull request #49 from telegram-bot-rb/update_rubocop
Update rubocop
2017-11-17 09:59:43 +03:00
Max Melentiev
e06882f7af Release rails dependencies 2017-11-17 09:53:10 +03:00
Max Melentiev
1a30b430df Update rubocop, add appraisal gem 2017-11-17 09:46:49 +03:00
Max Melentiev
da93abaaf7 v0.12.1 2017-10-06 11:23:56 +03:00
printercu
0ae17fb34e Merge pull request #45 from telegram-bot-rb/update_sessions
Update sessions
2017-10-06 11:19:01 +03:00
Max Melentiev
7e41bd4f9b Allow use different sessions for MessageContext 2017-10-06 11:10:05 +03:00
Max Melentiev
e529a79339 Don't use Rails.cache as fallback for session_store
It's NullStore in development by default so session does not work,
and indeed should not be dependent.
2017-10-06 11:07:11 +03:00
Max Melentiev
ef4f3caa5f Make #session raise error when store is not configured
Add more explanation on sessions in readme
2017-10-06 11:05:25 +03:00
Max Melentiev
df7039326d Run set_webhook task always in sync way
Fixes telegram-bot-rb/telegram_bot_app#7
2017-10-05 22:11:22 +03:00
Max Melentiev
4f47506e22 Fix typo in readme 2017-08-11 15:10:05 +03:00
Max Melentiev
6abbe5489d v0.12.0 2017-08-11 15:04:41 +03:00
Max Melentiev
ff7de976d0 Add specs for #edit_message 2017-08-11 15:04:19 +03:00
Max Melentiev
aaba27eb70 New API methods and payload types (up to Bot API 3.2) 2017-08-11 15:04:02 +03:00
printercu
88a4a9aab3 Merge pull request #37 from zuf/master
update rails dependency (<5.2 instead <=5.1)
2017-08-11 13:43:13 +03:00
Egor
9387043ec1 fix rails dependency (<5.2 instead <=5.1) 2017-08-10 18:07:21 +03:00
Max Melentiev
3dca35ba9a callback_query context for RSpec 2017-07-25 10:58:18 +03:00
Max Melentiev
6879618c00 edit_message helper 2017-07-25 10:49:39 +03:00
Max Melentiev
6e4a69b6b1 Fix storing token in ClientStub when config hash is given 2017-05-17 17:45:21 +06:00
printercu
b8db576452 Merge pull request #31 from telegram-bot-rb/integration_test
Integration test with rails app and two bots
2017-05-15 22:20:26 +06:00
Max Melentiev
2fffdeb475 Integration test with rails app and two bots 2017-05-15 22:11:52 +06:00
Max Melentiev
9defa42f40 Add notes on testing multiple bots to readme
Fixes #29
2017-05-11 14:01:42 +06:00
Max Melentiev
0320507abe Telegram.bot raises error when there is no default config 2017-05-11 13:57:29 +06:00
Max Melentiev
cd3d3739a4 v0.11.3 2017-05-05 17:30:49 +06:00
Max Melentiev
a054ac704c Build with rails 5.1 on travis 2017-05-05 17:28:59 +06:00
printercu
23aa92e614 Merge pull request #28 from ironbrains/master
Increase dependencies version
2017-05-05 17:19:05 +06:00
Bulat F
a49dca66fa Increase dependencies version
- Add support `activesupport 5.1`
- Add support `actionpack 5.1`
2017-05-05 10:16:30 +03:00
Max Melentiev
2d5ca981ce Rails 5.1 deep symbolized secrets support
Fixes #26
2017-04-22 12:02:59 +03:00
Max Melentiev
cf9bd87cf2 Add notice about how to require gem to Readme
Fixes #24
2017-03-11 21:33:20 +03:00
Max Melentiev
48ff7a4f43 Fix poller for typed responses
Fixes: #22
2017-03-05 17:56:32 +03:00
Max Melentiev
17260cb5a9 v0.11.0 2017-02-10 09:34:15 +03:00
Max Melentiev
817af95369 Remove Bot::StaleChat in favor of Bot::Forbidden 2017-02-10 09:33:41 +03:00
Max Melentiev
99fbe41b16 Fixed support for *channel_post updates 2016-12-09 13:33:55 +03:00
Max Melentiev
88178113d7 Support (edited_)channel_post updates, add api methods from 2.3, 2.3.1 2016-12-09 13:23:58 +03:00
printercu
1a2ac0dd83 Update README.md 2016-11-16 21:31:02 +03:00
Max Melentiev
fa6e385ca1 v0.10.0 2016-11-16 20:51:41 +03:00
Max Melentiev
1098118826 Fix integration spec for Rails 4 2016-11-16 14:34:42 +03:00
Max Melentiev
0c9a4a5fa0 Inegration context for RSpec
ActionDispatch::Testing::Integration requires smth else and
defines Rails, so had to change `defined?(Rails)` all over the code.
2016-11-16 14:18:45 +03:00
printercu
5c299887bb Merge pull request #18 from bob-frost/ruby2.2.0
Error "can't dup Fixnum (TypeError)"
2016-11-13 19:16:59 +03:00
bob-frost
9f2234c132 Fix "can't dup Fixnum (TypeError)" for Ruby 2.2.0 2016-11-13 09:47:01 +06:00
Max Melentiev
ec1d5bad7d v0.9.0 2016-11-07 20:24:01 +03:00
Max Melentiev
194fc78202 RSpec matchers 2016-11-07 20:17:34 +03:00
Max Melentiev
ececfa660e [Docs] Note about queue adapters 2016-10-19 14:20:39 +03:00
printercu
9e67122465 Merge pull request #2 from telegram-bot-rb/async
Async requests
2016-10-19 14:00:26 +03:00
Max Melentiev
caee9809d0 More docs on async mode 2016-10-19 13:59:54 +03:00
Max Melentiev
4b511f51a7 update changelog 2016-10-19 13:36:03 +03:00
Max Melentiev
d2d6202fa7 API methods from 2016-10-03 update
https://core.telegram.org/bots/api-changelog#october-3-2016
2016-10-17 17:43:13 +03:00
Max Melentiev
dd38cfea93 Take chat from message for callback queries 2016-10-07 12:45:16 +03:00
Max Melentiev
3cb6e930b8 Fix typo in module name: CallbackQueyContext -> CallbackQueryContext 2016-09-22 01:12:19 +03:00
Max Melentiev
e42a293804 edit_message_* methods 2016-09-21 22:53:06 +03:00
Max Melentiev
2e405acfb1 0.9.0.alpha2 2016-08-15 21:51:42 +06:00
Max Melentiev
b2eca2d37f Thread-safety for async config methods 2016-08-15 21:50:42 +06:00
Max Melentiev
6d687e459a 0.9.0.alpha1 2016-08-09 18:25:54 +06:00
Max Melentiev
71f736e0a2 Async requests with ActiveJob 2016-08-09 18:20:56 +06:00
Max Melentiev
85ff07de9b Extract Botan-related code from Client to Botan::ClientHelpers 2016-08-09 16:07:44 +06:00
Max Melentiev
27602b20bd Simplified .wrap method, added for Botan 2016-08-09 16:07:21 +06:00
Max Melentiev
91e0aa2096 Moved Botan helpers to its namespace 2016-08-06 18:06:50 +06:00
Max Melentiev
4e539e1867 One more description for StaleChat error 2016-08-01 15:52:59 +06:00
printercu
33f0049f6e Add link to sample standalone app to README 2016-07-30 19:34:16 +06:00
Max Melentiev
207754499e telegram_webhooks doesn't crash when bot's token is nil
Fixes #11
2016-07-30 19:03:24 +06:00
66 изменённых файлов: 2252 добавлений и 425 удалений

3
.gitignore поставляемый
Просмотреть файл

@@ -1,9 +1,12 @@
/.bundle/
/.yardoc
/Gemfile.lock
/gemfiles/*.lock
/gemfiles/.bundle
/_yardoc/
/coverage/
/doc/
/pkg/
/spec/reports/
/log/
/tmp/

Просмотреть файл

@@ -1,29 +1,52 @@
Rails: {Enabled: true}
Style/Alias: {Enabled: false}
Style/AlignParameters:
Layout/AlignParameters:
# Disable, till rubocop supports combination of styles.
# Use one of this styles where appropriate, keep it clean, compact and readable.
Enabled: false
# EnforcedStyle:
# - with_first_parameter
# - with_fixed_indentation
# Breaks
#
# I18n.t(key,
# param: val,
# # ...
# )
Layout/ClosingParenthesisIndentation: {Enabled: false}
Layout/DotPosition: {EnforcedStyle: trailing}
Layout/FirstParameterIndentation: {EnforcedStyle: consistent}
# Same as Layout/ClosingParenthesisIndentation
Layout/MultilineMethodCallBraceLayout: {Enabled: false}
Layout/MultilineMethodCallIndentation: {EnforcedStyle: indented}
Layout/MultilineOperationIndentation: {EnforcedStyle: indented}
Layout/SpaceInsideHashLiteralBraces: {EnforcedStyle: no_space}
# Offences named scopes and `expect {}.to change {}`.
Lint/AmbiguousBlockAssociation: {Enabled: false}
Naming/PredicateName: {Enabled: false}
Naming/VariableNumber: {EnforcedStyle: snake_case}
Style/Alias: {Enabled: false}
Style/AndOr: {EnforcedStyle: conditionals}
Style/ClosingParenthesisIndentation: {Enabled: false}
Style/Documentation: {Enabled: false}
Style/DotPosition: {EnforcedStyle: trailing}
Style/FirstParameterIndentation: {EnforcedStyle: consistent}
Style/IfUnlessModifier: {Enabled: false}
# Consistent to other definitions.
Style/EmptyMethod: {EnforcedStyle: expanded}
Style/ModuleFunction: {Enabled: false}
Style/MultilineOperationIndentation: {EnforcedStyle: indented}
Style/NestedParenthesizedCalls: {Enabled: false}
Style/PredicateName: {Enabled: false}
Style/SignalException: {EnforcedStyle: only_raise}
Style/SpaceInsideHashLiteralBraces: {EnforcedStyle: no_space}
Style/TrailingCommaInArguments: {Enabled: false}
Style/TrailingCommaInLiteral: {EnforcedStyleForMultiline: comma}
Metrics/AbcSize: {Max: 21}
# Other metrics are just enough.
# This one offences all specs, routes and some initializers.
Metrics/BlockLength: {Enabled: false}
Metrics/LineLength: {Max: 100}
Metrics/MethodLength: {Max: 30}
Metrics/CyclomaticComplexity: {Max: 8}

Просмотреть файл

@@ -2,8 +2,9 @@ language: ruby
cache: bundler
rvm:
- 2.2.3
env:
- RAILS=4
- RAILS=5
gemfile:
- gemfiles/rails_42.gemfile
- gemfiles/rails_50.gemfile
- gemfiles/rails_51.gemfile
notifications:
email: false

14
Appraisals Обычный файл
Просмотреть файл

@@ -0,0 +1,14 @@
appraise 'rails-51' do
gem 'actionpack', '~> 5.1'
gem 'railties', '~> 5.1'
end
appraise 'rails-50' do
gem 'actionpack', '~> 5.0'
gem 'railties', '~> 5.0'
end
appraise 'rails-42' do
gem 'actionpack', '~> 4.2'
gem 'railties', '~> 4.2'
end

Просмотреть файл

@@ -1,3 +1,69 @@
# Unreleased
# 0.12.4
- Fix spec helpers for callback queries.
# 0.12.3
- New methods from Bot API v3.5
- Collect all api helper-methods in Client::ApiHelper module.
- Add `bin/fetch-telegram-methods` to update API methods list from website.
# 0.12.1
- Fix `set_webhook` rake task for async bots with self-issued certificates.
- Make `#session` raise error when store is not configured,
don't use Rails.cache as fallback for session_store.
- Allow use different sessions for MessageContext.
# 0.12.0
- New API methods and payload types (up to Bot API 3.2).
- Fix rails 5.1.x support.
- RSpec context for callback_query updates.
- `edit_message` helper.
- ClientStub saves and returns token. Fixes testing multiple bots.
- Raise descriptive error when accessing not-configured bot.
# 0.11.3
- Release dependencies for Rails 5.1.
# 0.11.2
- Rails 5.1 deep symbolized secrets support.
# 0.11.1
- Fixed poller for typed response.
# 0.11.0
- Remove Bot::StaleChat in favor of Bot::Forbidden, as Telegram adds more
and more new descriptions.
Please open an issue if you face a problem.
# 0.10.2
- Support `(edited_)channel_post` updates.
- New methods from 2.3, 2.3.1 API updates.
# 0.10.0
- Integration helpers for RSpec.
- Fix for ruby 2.2.0 bug by @bob-frost.
# 0.9.0
- Async API requests.
- One more description for StaleChat error.
- edit_message_* methods.
- API methods from 2016-10-03 update
- Fix typo in module name: CallbackQueyContext -> CallbackQueryContext.
- Take `chat` from `message` for callback queries
- RSpec matchers.
# 0.8.0
- Fixed `#reply_with`, now it sets `reply_to_message_id` as it's supposed to.

18
Gemfile
Просмотреть файл

@@ -1,24 +1,20 @@
source 'https://rubygems.org'
gemspec
case ENV['RAILS']
when '5'
gem 'actionpack', '5.0.0.rc1'
when '4'
gem 'actionpack', '~> 4.2'
end
group :development do
gem 'sdoc', '~> 0.4.1'
gem 'appraisal', '~> 2.2'
gem 'pry', '~> 0.10.1'
gem 'pry-byebug', '~> 3.2.0'
gem 'sdoc', '~> 0.4.1'
gem 'telegram-bot-types', '~> 0.2.0'
gem 'telegram-bot-types', '~> 0.3.0'
gem 'rspec', '~> 3.3.0'
gem 'rspec', '~> 3.5.0'
gem 'rspec-its', '~> 1.1.0'
gem 'rspec-rails', '~> 3.5.0'
gem 'rubocop', '~> 0.37.0'
gem 'rubocop', '~> 0.51.0'
gem 'coveralls', '~> 0.8.2', require: false
end

372
README.md
Просмотреть файл

@@ -4,23 +4,31 @@
[![Code Climate](https://codeclimate.com/github/telegram-bot-rb/telegram-bot/badges/gpa.svg)](https://codeclimate.com/github/telegram-bot-rb/telegram-bot)
[![Build Status](https://travis-ci.org/telegram-bot-rb/telegram-bot.svg)](https://travis-ci.org/telegram-bot-rb/telegram-bot)
Tools for developing bot for Telegram. Best used with Rails, but can be be used in
standalone app. Supposed to be used in webhook-mode in production, and poller-mode
Tools for developing Telegram bots. Best used with Rails, but can be used in
[standalone app](https://github.com/telegram-bot-rb/telegram-bot/wiki/Not-rails-application).
Supposed to be used in webhook-mode in production, and poller-mode
in development, but you can use poller in production if you want.
Package contains:
- Ligthweight client for bot API (with fast and thread-safe
[httpclient](https://github.com/nahi/httpclient) under the hood).
- Controller with message parser. Allows to write separate methods for each command.
- Controller with message parser: define methods for commands, not `case` branches.
- Middleware and routes helpers for production env.
- Poller with automatic source-reloader for development env.
- Rake tasks to update webhook urls.
- __[Async mode](#async-mode)__ for Telegram and/or Botan API.
Let the queue adapter handle network errors!
Here is sample [telegram_bot_app](https://github.com/telegram-bot-rb/telegram_bot_app)
with session, keyboards and inline queries.
Run it on your local machine in 1 minute!
And here is [app template](https://github.com/telegram-bot-rb/rails_template)
to generate clean app in seconds.
Examples and cookbook in [wiki](https://github.com/telegram-bot-rb/telegram-bot/wiki).
## Installation
Add this line to your application's Gemfile:
@@ -37,51 +45,70 @@ Or install it yourself as:
$ gem install telegram-bot
Require if necessary:
```ruby
require 'telegram/bot'
```
## Usage
### Configuration
### Configuration in Rails app
Add `telegram` section into `secrets.yml`:
```yml
telegram:
bots:
# just set the token
chat: TOKEN_1
# or add username to support commands with mentions (/help@ChatBot)
auction:
token: TOKEN_2
username: ChatBot
development:
telegram:
# Single bot can be specified like this
bot: TOKEN
# or
bot:
token: TOKEN
username: SomeBot
# Single bot can be specified like this
bot: TOKEN
# or
bot:
token: TOKEN
username: SomeBot
# For multiple bots in single app use hash of `internal_bot_id => settings`
bots:
# just set the bot token
chat: TOKEN_1
# or add username to support commands with mentions (/help@ChatBot)
auction:
token: TOKEN_2
username: ChatBot
```
### Client
From now clients will be accessible with `Telegram.bots[:chat]` or `Telegram.bots[:auction]`.
Single bot can be accessed with `Telegram.bot` or `Telegram.bots[:default]`.
You can create clients manually with `Telegram::Bot::Client.new(token, username)`.
### Client
Client is instantiated with `Telegram::Bot::Client.new(token, username)`.
Username is optional and used only to parse commands with mentions.
There is `request(path_suffix, body)` method to perform any query.
And there are also shortcuts for available queries in underscored style
(`answer_inline_query` instead of `answerInlineQuery`).
All this methods just post given params to specific URL.
And there are shortcuts for all available requests in underscored style
(`answer_inline_query(params)` instead of `answerInlineQuery`).
```ruby
bot.request(:getMe) or bot.get_me
bot.request(:getupdates, offset: 1) or bot.get_updates(offset: 1)
bot.send_message chat_id: chat_id, text: 'Test'
bot.send_message(chat_id: chat_id, text: 'Test')
```
There is no magic, they just pass params as is and set `path_suffix`.
See [`Client`](https://github.com/telegram-bot-rb/telegram-bot/blob/master/lib/telegram/bot/client.rb)
class for list of available methods. Please open PR or issue if it misses methods from
new API versions.
Any API request error will raise `Telegram::Bot::Error` with description in its message.
Special `Telegram::Bot::Forbidden` is raised when bot can't post messages to the chat anymore.
#### Typed responses
By default client will return parsed json responses. You can enable
response typecasting to virtus models using `telegram-bot-types` gem:
response typecasting to virtus models using
[`telegram-bot-types`](https://github.com/telegram-bot-rb/telegram-bot-types) gem:
```ruby
# Add to your gemfile:
gem 'telegram-bot-types', '~> x.x.x'
@@ -93,18 +120,23 @@ bot.extend Telegram::Bot::Client::TypedResponse
bot.get_me.class # => Telegram::Bot::Types::User
```
Any API request error will raise `Telegram::Bot::Error` with description in its message.
Special `Telegram::Bot::StaleChat` is raised when bot can't post messages to the chat anymore.
### Controller
Controller makes it easy to keep bot's code readable.
It does nothing more than finding out action name for update and invoking it.
So there is almost no overhead comparing to large `switch`, while you
can represent actions as separate methods keeping source much more readable and supportable.
New instance of controller is instantiated for each update.
This way every update is processed in isolation from others.
```ruby
class Telegram::WebhookController < Telegram::Bot::UpdatesController
# use callbacks like in any other controllers
around_action :with_locale
# Every update can have one of: message, inline_query, chosen_inline_result,
# callback_query.
# callback_query, etc.
# Define method with same name to respond to this updates.
def message(message)
# message can be also accessed via instance method
@@ -130,6 +162,7 @@ class Telegram::WebhookController < Telegram::Bot::UpdatesController
# do_smth_with(data)
# There are `chat` & `from` shortcut methods.
# For callback queries `chat` if taken from `message` when it's available.
response = from ? "Hello #{from['username']}!" : 'Hi there!'
# There is `respond_with` helper to set `chat_id` from received message:
respond_with :message, text: response
@@ -153,6 +186,20 @@ class Telegram::WebhookController < Telegram::Bot::UpdatesController
end
```
#### Reply helpers
There are helpers to respond for basic actions. They just set chat/message/query
identifiers from update. See [`ReplyHelpers`](https://github.com/telegram-bot-rb/telegram-bot/blob/master/lib/telegram/bot/updates_controller/reply_helpers.rb) module for more information.
Here are this methods signatures:
```ruby
def respond_with(type, params); end
def reply_with(type, params); end
def answer_inline_query(results, params = {}); end
def answer_callback_query(text, params = {}); end
def edit_message(type, params = {}); end
```
#### Optional typecasting
You can enable typecasting of `update` with `telegram-bot-types` by including
@@ -170,35 +217,56 @@ end
#### Session
There is support for sessions using `ActiveSupport::Cache` stores.
This API is very close to ActiveController's session API, but works different
under the hood. Cookies can not be used to store session id or
whole session (like CookieStore does). So it uses key-value store and `session_key`
method to build identifier from update.
Store can be one of numerous `ActiveSupport::Cache` stores.
While `:file_store` is suitable for development and single-server deployments
without heavy load, it doesn't scale well. Key-value databases with persistance
like Redis are more appropriate for production use.
```ruby
# configure store in env files:
# In rails app store can be configured in env files:
config.telegram_updates_controller.session_store = :redis_store, {expires_in: 1.month}
# In other app it can be done for all controllers with:
Telegram::Bot::UpdatesController.session_store = :redis_store, {expires_in: 1.month}
# or for specific one:
OneOfUpdatesController.session_store = :redis_store, {expires_in: 1.month}
```
Default session id is made from bot's username and `(from || chat)['id']`.
It means that session will be the same for updates from user in every chat,
and different for every user in the same group chat.
To change this behavior you can override `session_key` method, or even
define [multiple sessions](https://github.com/telegram-bot-rb/telegram-bot/wiki/Multiple-session-objects)
in single controller. For details see `Session` module.
```ruby
class Telegram::WebhookController < Telegram::Bot::UpdatesController
include Telegram::Bot::UpdatesController::Session
# or just shortcut:
use_session!
# You can override global config
# You can override global config for this controller.
self.session_store = :file_store
def write(text = nil, *)
session[:text] = text
end
def read
def read(*)
respond_with :message, text: session[:text]
end
private
# By default it uses bot's username and user's id as a session key.
# Chat's id is used only when `from` field is empty.
# Override `session_key` method to change this behavior.
# In this case session will persist for user only in specific chat.
# Same user in other chat will have different session.
def session_key
# In this case session will persist for user only in specific chat:
"#{bot.username}:#{chat['id']}:#{from['id']}"
"#{bot.username}:#{chat['id']}:#{from['id']}" if chat && from
end
end
```
@@ -238,77 +306,80 @@ class Telegram::WebhookController < Telegram::Bot::UpdatesController
# This will call #rename like if it is called with message '/rename %text%'
context_handler :rename
# If you have a lot of such methods you can use
# If you have a lot of such methods you can call this method
# to use context value as action name for all contexts which miss handlers:
context_to_action!
# It'll use context value as action name for all contexts which miss handlers.
end
```
You can use `CallbackQueyContext` in the similar way to split `#callback_query` into
several specific methods. It doesn't require session support, and takes context from
data. If data has a prefix with colon like this `my_ctx:smth...` it'll call
`my_ctx_callback_query('smth...')` when there is such action method. Otherwise
it'll call `callback_query('my_ctx:smth...')` as usual.
#### Callback queries
You can include `CallbackQueryContext` module to split `#callback_query` into
several methods. It doesn't require session support, and takes context from
data: if data has a prefix with colon like this `my_ctx:smth...` it invokes
`my_ctx_callback_query('smth...')` when such action method is defined. Otherwise
it invokes `callback_query('my_ctx:smth...')` as usual.
Callback queries without prefix stay untouched.
```ruby
# This one handles `set_value:%{something}`.
def set_value_callback_query(new_value = nil, *)
save_this(value)
answer_callback_query('Saved!)
end
# And this one is for `make_cool:%{something}`
def make_cool_callback_query(thing = nil, *)
do_it(thing)
answer_callback_query("#{thing} is cool now! Like a callback query context.")
end
```
### Routes in Rails app
There is `telegram_webhooks` helper for rails app to define routes for webhooks.
It defines routes at `telegram/#{bot.token}` and connects bots with controller.
For more options see [examples in wiki](https://github.com/telegram-bot-rb/telegram-bot/wiki/Routes-helpers-in-details).
```ruby
# Create routes for all Telegram.bots using single controller:
telegram_webhooks TelegramController
# Use different controllers for each bot:
telegram_webhooks chat: TelegramChatController,
auction: TelegramAuctionController
```
#### Processesing updates
To process update run:
To process update with controller call `.dispatch(bot, update)` on it.
There are several options to run it automatically:
- Use webhooks with routes helper (described above).
- Use `Telegram::Bot::Middleware` with rack ([example in wiki](https://github.com/telegram-bot-rb/telegram-bot/wiki/Not-rails-application)).
- Use poller (described in the next section).
To run action without update (ex., send notifications from jobs),
you can call `#process` directly. In this case controller can be initialized
with `:from` and/or `:chat` options instead of `update` object:
```ruby
ControllerClass.dispatch(bot, update)
```
There is also ability to run action without update:
```ruby
# Most likely you'll want to pass :from and :chat
controller = ControllerClass.new(bot, from: telegram_user, chat: telegram_chat)
controller.process(:help, *args)
controller.process(:welcome, *args)
```
### Routes
Use `telegram_webhooks` helper to add routes. It will create routes for bots
at "telegram/#{bot.token}" path.
```ruby
# Create routes for all Telegram.bots to use same controller:
telegram_webhooks TelegramController
# Or pass custom bots usin any of supported config options:
telegram_webhooks TelegramController,
bot,
{token: token, username: username},
other_bot_token
# Use different controllers for each bot:
telegram_webhooks bot => TelegramChatController,
other_bot => TelegramAuctionController
# telegram_webhooks creates named routes.
# Route name depends on `Telegram.bots`.
# When there is single bot it will use 'telegram_webhook'.
# When there are it will use bot's key in the `Telegram.bots` as prefix
# (eg. `chat_telegram_webhook`).
# You can override this options or specify others:
telegram_webhooks TelegramController, as: :my_webhook
telegram_webhooks bot => [TelegramChatController, as: :chat_webhook],
other_bot => TelegramAuctionController,
admin_chat: TelegramAdminChatController
```
For Rack applications you can also use `Telegram::Bot::Middleware` or just
call `.dispatch(bot, update)` on controller.
### Development & Debugging
Use `rake telegram:bot:poller` to run poller. It'll automatically load
changes without restart in development env. Optionally specify bot to run poller for
with `BOT` envvar (`BOT=chat`).
Use `rake telegram:bot:poller` to run poller in rails app. It automatically loads
changes without restart in development env.
Optionally pass bot id in `BOT` envvar (`BOT=chat`) to specify bot to run poller for.
This task will not work if you don't use `telegram_webhooks`.
You can run poller manually with
`Telegram::Bot::UpdatesPoller.start(bot, controller_class)`.
This task requires `telegram_webhooks` helper to be used as it connects bots with controller.
To run poller in other cases use:
```ruby
Telegram::Bot::UpdatesPoller.start(bot, controller_class)
```
### Testing
@@ -316,26 +387,73 @@ There is `Telegram::Bot::ClientStub` class to stub client for tests.
Instead of performing API requests it stores them in `requests` hash.
To stub all possible clients use `Telegram::Bot::ClientStub.stub_all!` before
initializing clients. Most likely you'll want something like this:
initializing clients. Here is template for RSpec:
```ruby
# environments/test.rb
# Make sure to run it before defining routes or accessing any bot in the app!
Telegram.reset_bots
Telegram::Bot::ClientStub.stub_all!
# rails_helper.rb
RSpec.configure do |config|
# ...
Telegram.reset_bots
Telegram::Bot::ClientStub.stub_all!
config.after { Telegram.bot.reset }
# or for multiple bots:
config.after { Telegram.bots.each_value(&:reset) }
# ...
end
```
There are also some helpers for controller tests.
Check out `telegram/bot/updates_controller/rspec_helpers` and
`telegram/bot/updates_controller/testing`.
There are integration and controller contexts for RSpec and some built-in matchers:
### Deploying
```ruby
# spec/requests/telegram_webhooks_spec.rb
require 'telegram/bot/rspec/integration'
Use `rake telegram:bot:set_webhook` to update webhook url for all configured bots.
Certificate can be specified with `CERT=path/to/cert`.
RSpec.describe TelegramWebhooksController, :telegram_bot do
# for old rspec add:
# include_context 'telegram/bot/integration'
describe '#start' do
subject { -> { dispatch_command :start } }
it { should respond_with_message 'Hi there!' }
end
# There is context for callback queries with related matchers.
describe '#hey_callback_query', :callback_query do
let(:data) { "hey:#{name}" }
let(:name) { 'Joe' }
it { should answer_callback_query('Hey Joe') }
it { should edit_current_message :text, text: 'Done' }
end
# For controller specs use
require 'telegram/bot/updates_controller/rspec_helpers'
RSpec.describe TelegramWebhooksController, type: :telegram_bot_controller do
# for old rspec add:
# include_context 'telegram/bot/updates_controller'
end
# Matchers are available for custom specs:
include Telegram::Bot::RSpec::ClientMatchers
expect(&process_update).to send_telegram_message(bot, /msg regexp/, some: :option)
expect(&process_update).
to make_telegram_request(bot, :sendMessage, hash_including(text: 'msg text'))
```
Place integration tests inside `spec/requests`
when using RSpec's `infer_spec_type_from_file_location!`,
or just add `type: :request` to `describe`.
See sample app for more examples.
### Deployment
While webhooks-mode is prefered, poller still can be used in production.
See [comparison and examples](https://github.com/telegram-bot-rb/telegram-bot/wiki/Deployment)
for details.
### Botan.io metrics
@@ -352,11 +470,11 @@ or just add `botan` key in `secrets.yml`:
Access to Botan client with `bot.botan`.
Use `bot.botan.track(event, uid, payload)` to track events.
There are some helpers for controllers in `Telegram::Bot::UpdatesController::Botan`:
There are some helpers for controllers in `Telegram::Bot::Botan::ControllerHelpers`:
```ruby
class Telegram::WebhookController < Telegram::Bot::UpdatesController
include Telegram::Bot::UpdatesController::Botan
include Telegram::Bot::Botan::ControllerHelpers
# This will track with event: action_name & data: payload
before_action :botan_track_action
@@ -373,10 +491,40 @@ end
There is no stubbing for botan clients, so don't set botan token in tests.
### Async mode
There is built in support for async requests using ActiveJob. Without Rails
you can implement your own worker class to handle such requests. This allows:
- Process updates very fast, without waiting for telegram and botan responses.
- Handle and retry network and other errors with queue adapter.
- ???
Instead of performing request instantly client serializes it, pushes to queue,
and immediately return control back. The job is then fetched with a worker
and real API request is performed. And this all is absolutely transparent for the app.
To enable this mode add `async: true` to bot's and botan's config.
For more information and custom configuration check out
[docs](http://www.rubydoc.info/github/telegram-bot-rb/telegram-bot/master/Telegram/Bot/Async) or
[source](https://github.com/telegram-bot-rb/telegram-bot/blob/master/lib/telegram/bot/async.rb).
If you want async mode, but don't want to setup queue, know that Rails 5 are shipped
with Async adapter by default, and there is
[Sucker Punch](https://github.com/brandonhilkert/sucker_punch) for Rails 4.
To disable async mode for the block of code use `bot.async(false) { bot.send_photo }`.
Yes, it's threadsafe too.
#### Limitations
- Client will not return API response.
- Sending files is not available in async mode, because they can not be serialized.
## Development
After checking out the repo, run `bin/setup` to install dependencies.
Then, run `rake spec` to run the tests.
Then, run `appraisal rake spec` to run the tests.
You can also run `bin/console` for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run `bundle exec rake install`.
@@ -384,15 +532,7 @@ To release a new version, update the version number in `version.rb`,
and then run `bundle exec rake release`, which will create a git tag for the version,
push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
### Different Rails versions
To setup development for specific major Rails version use:
```
RAILS=5 bundle install
# or
RAILS=5 bundle update
```
Use `bin/fetch-telegram-methods` to update API methods list from Telegram website.
## Contributing

41
bin/fetch-telegram-methods Исполняемый файл
Просмотреть файл

@@ -0,0 +1,41 @@
#!/usr/bin/env ruby
#
# Fetch list of methods from Telegram docs.
# Use it to update client.rb.
require 'net/http'
require 'nokogiri'
DOCS_URL = 'https://core.telegram.org/bots/api'.freeze
page_html = Net::HTTP.get(URI(DOCS_URL))
doc = Nokogiri::HTML(page_html)
# Select `h4`s, but use `h3`s to group them.
headers = doc.css('h3, h4').
chunk_while { |_, x| x.name == 'h4' }.
map { |g| g.select { |x| x.name == 'h4' } }.
map { |g| g.map(&:text) }
# Method starts with lowercase and does not have spaces.
NOT_METHOD_REGEXP = /(\A[^a-z])|\s/
# Filter method names.
method_list = headers.
map { |g| g.reject { |x| x.match?(NOT_METHOD_REGEXP) } }.
reject(&:empty?)
api_version = doc.text.match(/^(Bot API ([\d\.]+))\.?$/)
result = ['# Generated with bin/fetch-telegram-methods']
result << "# #{api_version[1]}" if api_version
result << ''
result << method_list.map { |g| g.join("\n") }.join("\n\n")
result << ''
result_txt = result.join("\n")
puts result_txt
API_METHODS_FILE = File.expand_path('../lib/telegram/bot/client/api_methods.txt', __dir__).freeze
File.write(API_METHODS_FILE, result_txt)
puts '', "Updated #{API_METHODS_FILE}"

Просмотреть файл

@@ -3,6 +3,7 @@ set -euo pipefail
IFS=$'\n\t'
bundle install
appraisal install
bin/install_git_hooks
# Do any other automated setup that you need to do here

21
gemfiles/rails_42.gemfile Обычный файл
Просмотреть файл

@@ -0,0 +1,21 @@
# This file was generated by Appraisal
source "https://rubygems.org"
gem "actionpack", "~> 4.2"
gem "railties", "~> 4.2"
group :development do
gem "appraisal", "~> 2.2"
gem "pry", "~> 0.10.1"
gem "pry-byebug", "~> 3.2.0"
gem "sdoc", "~> 0.4.1"
gem "telegram-bot-types", "~> 0.3.0"
gem "rspec", "~> 3.5.0"
gem "rspec-its", "~> 1.1.0"
gem "rspec-rails", "~> 3.5.0"
gem "rubocop", "~> 0.51.0"
gem "coveralls", "~> 0.8.2", require: false
end
gemspec path: "../"

21
gemfiles/rails_50.gemfile Обычный файл
Просмотреть файл

@@ -0,0 +1,21 @@
# This file was generated by Appraisal
source "https://rubygems.org"
gem "actionpack", "~> 5.0"
gem "railties", "~> 5.0"
group :development do
gem "appraisal", "~> 2.2"
gem "pry", "~> 0.10.1"
gem "pry-byebug", "~> 3.2.0"
gem "sdoc", "~> 0.4.1"
gem "telegram-bot-types", "~> 0.3.0"
gem "rspec", "~> 3.5.0"
gem "rspec-its", "~> 1.1.0"
gem "rspec-rails", "~> 3.5.0"
gem "rubocop", "~> 0.51.0"
gem "coveralls", "~> 0.8.2", require: false
end
gemspec path: "../"

21
gemfiles/rails_51.gemfile Обычный файл
Просмотреть файл

@@ -0,0 +1,21 @@
# This file was generated by Appraisal
source "https://rubygems.org"
gem "actionpack", "~> 5.1"
gem "railties", "~> 5.1"
group :development do
gem "appraisal", "~> 2.2"
gem "pry", "~> 0.10.1"
gem "pry-byebug", "~> 3.2.0"
gem "sdoc", "~> 0.4.1"
gem "telegram-bot-types", "~> 0.3.0"
gem "rspec", "~> 3.5.0"
gem "rspec-its", "~> 1.1.0"
gem "rspec-rails", "~> 3.5.0"
gem "rubocop", "~> 0.51.0"
gem "coveralls", "~> 0.8.2", require: false
end
gemspec path: "../"

Просмотреть файл

@@ -21,7 +21,7 @@ namespace :telegram do
route_name = Telegram::Bot::RoutesHelper.route_name_for_bot(bot)
url = routes.send("#{route_name}_url")
puts "Setting webhook for #{key}..."
bot.set_webhook(url: url, certificate: cert)
bot.async(false) { bot.set_webhook(url: url, certificate: cert) }
end
end
end

Просмотреть файл

@@ -6,30 +6,16 @@ module Telegram
module Bot
class Error < StandardError; end
class NotFound < Error; end
class Forbidden < Error; end
# Error class for events when chat is not available anymore for bot.
# While Telegram has same error codes for different messages and there is no
# official docs for this error codes it uses `description` to
# check response.
class StaleChat < Error
DESCRIPTIONS = [
'bot was kicked',
"can't write to",
'group chat is deactivated',
].freeze
class << self
def match_response?(response)
description = response['description'].to_s
DESCRIPTIONS.any? { |x| description[x] }
end
end
end
autoload :Async, 'telegram/bot/async'
autoload :Botan, 'telegram/bot/botan'
autoload :Client, 'telegram/bot/client'
autoload :ClientStub, 'telegram/bot/client_stub'
autoload :DebugClient, 'telegram/bot/debug_client'
autoload :Initializers, 'telegram/bot/initializers'
autoload :Middleware, 'telegram/bot/middleware'
autoload :RSpec, 'telegram/bot/rspec'
autoload :UpdatesController, 'telegram/bot/updates_controller'
autoload :UpdatesPoller, 'telegram/bot/updates_poller'
end

162
lib/telegram/bot/async.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,162 @@
module Telegram
module Bot
# Telegram & Botan clients can perform requests in async way with
# any job adapter (ActiveJob by default). Using Rails you don't need any
# additional configuration. However you may want to enable async requests
# by default with `async: true` in `secrets.yml`. Botan client doesn't inherit
# async setting from client and must be configured separately.
#
# telegram:
# bots:
# chat_async:
# token: secret
# async: true # enable async mode for client
# botan: botan_token # in this way botan will not be async
# botan: # in this way - it's in async mode
# token: botan_token
# async: true
#
# Without Rails To start using async requests
# initialize client with `id` kwarg and make sure the client is
# accessible via `Teletgram.bots[id]` in job worker. Or just use
# `Telegram.bots_config=` for configuration.
#
# Being in async mode `#request` enqueues job instead to perform
# http request instead of performing it directly.
# Async behavior is controlled with `#async=` writer
# and can be enabled/disabled for the block with `#async`:
#
# client = Telegram::Bot::Client.new(**config, async: true)
# client.send_message(message)
# client.async(false) { client.send_message(other_one) }
#
# `#async=` sets global value for all threads,
# while `#async(val, &block)` is thread-safe.
#
# It can be set with custom job class or classname. By default it defines
# job classes for every client class, inherited from ApplicationRecord, which
# can be accessed via `.default_async_job`. You can integrate it with any
# other job provider by defining a class with `.perform_later(bot_id, *args)`
# method. See Async::Job for implemetation.
module Async
# Used to track missing key in a hash in local variable.
MISSING_VALUE = Object.new.freeze
module Job
class << self
def included(base)
base.singleton_class.send :attr_accessor, :client_class
end
end
def perform(client_id, *args)
client = self.class.client_class.wrap(client_id.to_sym)
client.async(false) { client.request(*args) }
end
end
module ClassMethods
def default_async_job
@default_async_job ||= begin
begin
ApplicationJob
rescue NameError
raise 'Define ApplicationJob class or setup #async= with custom job class'
end
klass = Class.new(ApplicationJob) { include Job }
klass.client_class = self
const_set(:AsyncJob, klass)
end
end
# This is used in specs.
def default_async_job=(val)
@default_async_job = val
remove_const(:AsyncJob) if const_defined?(:AsyncJob, false)
end
# Prepares argments for async job. ActiveJob doesn't support
# Symbol in argumens. Also we can encode json bodies only once here,
# so it would not be unnecessarily serialized-deserialized.
#
# This is stub method, which returns input. Every client class
# must prepare args itself.
def prepare_async_args(*args)
args
end
# Returns default_async_job if `true` is given,
# treats String as a constant name, or bypasses any other values.
def prepare_async_val(val)
case val
when true then default_async_job
when String then Object.const_get(val)
else val
end
end
end
class << self
def prepended(base)
base.extend(ClassMethods)
end
# Transforms symbols to strings in hash values.
def prepare_hash(hash)
return hash unless hash.is_a?(Hash)
hash = hash.dup
hash.each { |key, val| hash[key] = val.to_s if val.is_a?(Symbol) }
end
# Thread-local hash to store async config for every client.
def thread_store
Thread.current[:telegram_bot_async] ||= {}
end
end
attr_reader :id
def initialize(*, id: nil, async: nil, **options)
@id = id
self.async = async
super
end
# Sets default async value for all threads.
# Uses `self.class.prepare_async_val` to prepare value.
def async=(val)
@async = self.class.prepare_async_val(val)
end
# Sets async value in a thread-safe way for the block.
# Uses `self.class.prepare_async_val` to prepare value.
#
# If no block is given returns previously set value or the global one,
# set by #async=.
def async(val = true)
thread_key = object_id
thread_store = Async.thread_store
return thread_store.fetch(thread_key) { @async } unless block_given?
begin
old_val = thread_store.fetch(thread_key) { MISSING_VALUE }
thread_store[thread_key] = self.class.prepare_async_val(val)
yield
ensure
if MISSING_VALUE == old_val
thread_store.delete(thread_key)
else
thread_store[thread_key] = old_val
end
end
end
# Uses job if #async is set.
def request(*args)
job_class = async
return super unless job_class
raise 'Can not enqueue job without client id' unless id
job_class.perform_later(id.to_s, *self.class.prepare_async_args(*args))
end
end
end
end

Просмотреть файл

@@ -3,26 +3,39 @@ module Telegram
class Botan
TRACK_URI = 'https://api.botan.io/track'.freeze
autoload :ClientHelpers, 'telegram/bot/botan/client_helpers'
autoload :ControllerHelpers, 'telegram/bot/botan/controller_helpers'
class Error < Bot::Error; end
extend Initializers
prepend Async
include DebugClient
class << self
def by_id(id)
Telegram.botans[id]
end
def prepare_async_args(method, uri, query = {}, body = nil)
[method.to_s, uri.to_s, Async.prepare_hash(query), body]
end
end
attr_reader :client, :token
def initialize(token)
def initialize(token = nil, **options)
@client = HTTPClient.new
@token = token
@token = token || options[:token]
end
def track(event, uid, payload = {})
res = http_request(
:post,
TRACK_URI,
{token: token, name: event, uid: uid},
payload.to_json,
)
request(:post, TRACK_URI, {name: event, uid: uid}, payload.to_json)
end
def request(method, uri, query = {}, body = nil)
res = http_request(method, uri, query.merge(token: token), body)
status = res.status
return JSON.parse(res.body) if 300 > status
return JSON.parse(res.body) if status < 300
result = JSON.parse(res.body) rescue nil # rubocop:disable RescueModifier
err_msg = "#{res.reason}: #{result && result['info'] || '-'}"
raise Error, err_msg
@@ -31,6 +44,10 @@ module Telegram
def http_request(method, uri, query, body)
client.request(method, uri, query, body)
end
def inspect
"#<#{self.class.name}##{object_id}(#{@id})>"
end
end
end
end

15
lib/telegram/bot/botan/client_helpers.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,15 @@
module Telegram
module Bot
class Botan
# Helpers for botan.io metrics.
module ClientHelpers
attr_reader :botan
def initialize(*, botan: nil, **options)
super
@botan = Botan.wrap(botan, id: id) if botan
end
end
end
end
end

Просмотреть файл

@@ -1,8 +1,8 @@
module Telegram
module Bot
class UpdatesController
class Botan
# Helpers for botan.io metrics.
module Botan
module ControllerHelpers
class MissingFrom < Error; end
protected

Просмотреть файл

@@ -1,8 +1,5 @@
require 'json'
require 'httpclient'
require 'active_support/core_ext/string/inflections'
require 'active_support/core_ext/hash/keys'
require 'telegram/bot/debug_client'
module Telegram
module Bot
@@ -10,23 +7,17 @@ module Telegram
URL_TEMPLATE = 'https://api.telegram.org/bot%s/'.freeze
autoload :TypedResponse, 'telegram/bot/client/typed_response'
extend Initializers
prepend Async
prepend Botan::ClientHelpers
include DebugClient
require 'telegram/bot/client/api_helper'
include ApiHelper
class << self
# Accepts different options to initialize bot.
def wrap(input)
case input
when self then input
when Array then input.map(&method(__callee__))
when Hash then
input = input.stringify_keys
new input['token'], input['username'], botan: input['botan']
when Symbol
Telegram.bots[input] or
raise "Bot #{input} not configured, check Telegram.bots_config."
else
new(input)
end
def by_id(id)
Telegram.bots[id]
end
# Prepend TypedResponse module.
@@ -41,61 +32,35 @@ module Telegram
body[k] = val.to_json if val.is_a?(Hash) || val.is_a?(Array)
end
end
def prepare_async_args(action, body = {})
[action.to_s, Async.prepare_hash(prepare_body(body))]
end
end
attr_reader :client, :token, :username, :base_uri, :botan
attr_reader :client, :token, :username, :base_uri
def initialize(token, username = nil, botan: nil)
def initialize(token = nil, username = nil, **options)
@client = HTTPClient.new
@token = token
@username = username
@base_uri = format URL_TEMPLATE, token
@botan = Botan.new(botan) if botan
@token = token || options[:token]
@username = username || options[:username]
@base_uri = format URL_TEMPLATE, self.token
end
def request(action, body = {}) # rubocop:disable PerceivedComplexity
def request(action, body = {})
res = http_request("#{base_uri}#{action}", self.class.prepare_body(body))
status = res.status
return JSON.parse(res.body) if 300 > status
return JSON.parse(res.body) if status < 300
result = JSON.parse(res.body) rescue nil # rubocop:disable RescueModifier
err_msg = "#{res.reason}: #{result && result['description'] || '-'}"
err_msg = result && result['description'] || '-'
if result
# NotFound is raised only for valid responses from Telegram
raise NotFound, err_msg if 404 == status
raise StaleChat, err_msg if StaleChat.match_response?(result)
# This errors are raised only for valid responses from Telegram
case status
when 403 then raise Forbidden, err_msg
when 404 then raise NotFound, err_msg
end
end
raise Error, err_msg
end
%w(
answerCallbackQuery
answerInlineQuery
forwardMessage
getChat
getChatAdministrators
getChatMember
getChatMembersCount
getFile
getMe
getUpdates
getUserProfilePhotos
kickChatMember
leaveChat
sendAudio
sendChatAction
sendContact
sendDocument
sendLocation
sendMessage
sendPhoto
sendSticker
sendVenue
sendVideo
sendVoice
setWebhook
unbanChatMember
).each do |method|
define_method(method.underscore) { |*args| request(method, *args) }
raise Error, "#{res.reason}: #{err_msg}"
end
# Endpoint for low-level request. For easy host highjacking & instrumentation.

31
lib/telegram/bot/client/api_helper.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,31 @@
require 'active_support/core_ext/string/inflections'
module Telegram
module Bot
class Client
module ApiHelper
METHODS_LIST_FILE = File.expand_path('../api_methods.txt', __FILE__)
class << self
def methods_list(file = METHODS_LIST_FILE)
File.read(file).lines.
map(&:strip).
reject { |x| x.empty? || x.start_with?('#') }
end
# Defines method with underscored name to post to specific endpoint:
#
# define_method :getMe
# # defines #get_me
def define_helpers(*list)
list.map(&:to_s).each do |method|
define_method(method.underscore) { |*args| request(method, *args) }
end
end
end
define_helpers(*methods_list)
end
end
end
end

68
lib/telegram/bot/client/api_methods.txt Обычный файл
Просмотреть файл

@@ -0,0 +1,68 @@
# Generated with bin/fetch-telegram-methods
# Bot API 3.5.
getUpdates
setWebhook
deleteWebhook
getWebhookInfo
getMe
sendMessage
forwardMessage
sendPhoto
sendAudio
sendDocument
sendVideo
sendVoice
sendVideoNote
sendMediaGroup
sendLocation
editMessageLiveLocation
stopMessageLiveLocation
sendVenue
sendContact
sendChatAction
getUserProfilePhotos
getFile
kickChatMember
unbanChatMember
restrictChatMember
promoteChatMember
exportChatInviteLink
setChatPhoto
deleteChatPhoto
setChatTitle
setChatDescription
pinChatMessage
unpinChatMessage
leaveChat
getChat
getChatAdministrators
getChatMembersCount
getChatMember
setChatStickerSet
deleteChatStickerSet
answerCallbackQuery
editMessageText
editMessageCaption
editMessageReplyMarkup
deleteMessage
sendSticker
getStickerSet
uploadStickerFile
createNewStickerSet
addStickerToSet
setStickerPositionInSet
deleteStickerFromSet
answerInlineQuery
sendInvoice
answerShippingQuery
answerPreCheckoutQuery
sendGame
setGameScore
getGameHighScores

Просмотреть файл

@@ -9,7 +9,7 @@ module Telegram
if self == ClientStub || !ClientStub.stub_all?
super
else
ClientStub.new(args[1])
ClientStub.new(*args)
end
end
end
@@ -34,8 +34,9 @@ module Telegram
end
end
def initialize(username = nil)
@username = username
def initialize(token = nil, username = nil, **options)
@token = token || options[:token]
@username = username || options[:username] || token
reset
end
@@ -43,7 +44,7 @@ module Telegram
@requests = Hash.new { |h, k| h[k] = [] }
end
def request(action, body)
def request(action, body = {})
requests[action.to_sym] << body
end
end

Просмотреть файл

@@ -1,5 +1,6 @@
require 'active_support/core_ext/hash/keys'
require 'active_support/core_ext/hash/transform_values'
require 'active_support/core_ext/hash/indifferent_access'
module Telegram
module Bot
@@ -20,18 +21,29 @@ module Telegram
def bot_poller_mode?
return @bot_poller_mode if defined?(@bot_poller_mode)
@bot_poller_mode = ENV.fetch('BOT_POLLER_MODE') do
Rails.env.development? if defined?(Rails)
Rails.env.development? if defined?(Rails.env)
end
end
# Hash of bots made with bots_config.
def bots
@bots ||= bots_config.transform_values(&Client.method(:wrap))
@bots ||= bots_config.each_with_object({}) do |(id, config), h|
h[id] = Client.wrap(config, id: id)
end
end
# Default bot.
def bot
@bot ||= bots[:default]
@bot ||= bots.fetch(:default) do
raise 'Default bot is not configured.' \
' Add :default to bots_config' \
' or use telegram.bot/telegram.bots.default section in secrets.yml.'
end
end
# Hash of botan clients made from #bots.
def botans
@botans ||= bots.transform_values(&:botan)
end
# Returns config for .bots method. By default uses `telegram['bots']` section
@@ -39,12 +51,17 @@ module Telegram
#
# Can be overwritten with .bots_config=
def bots_config
return @bots_config if @bots_config
telegram_config = Rails.application.secrets[:telegram]
(telegram_config['bots'] || {}).symbolize_keys.tap do |config|
default = telegram_config['bot']
config[:default] = default if default
end
@bots_config ||=
if defined?(Rails.application)
secrets = Rails.application.secrets.
fetch(:telegram, {}).with_indifferent_access
secrets.fetch(:bots, {}).symbolize_keys.tap do |config|
default = secrets[:bot]
config[:default] = default if default
end
else
{}
end
end
# Resets all cached bots and their configs.
@@ -52,6 +69,7 @@ module Telegram
@bots = nil
@bot = nil
@bots_config = nil
@botans = nil
end
end
end

21
lib/telegram/bot/initializers.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,21 @@
require 'active_support/core_ext/hash/keys'
module Telegram
module Bot
module Initializers
# Accepts different options to initialize bot.
def wrap(input, **options)
case input
when Symbol then by_id(input) or raise "#{name} #{input.inspect} not configured"
when self then input
when Hash then new(**input.symbolize_keys, **options)
else new(input, **options)
end
end
def by_id(_id)
raise 'Not implemented'
end
end
end
end

Просмотреть файл

@@ -18,7 +18,7 @@ module Telegram
ActiveSupport.on_load('telegram.bot.updates_controller') do
self.logger = options.logger || Rails.logger
self.session_store = options.session_store || Rails.cache
self.session_store = options.session_store if options.session_store
end
end

Просмотреть файл

@@ -20,7 +20,7 @@ module Telegram
# Replaces colon with underscore so rails don't treat it as
# route parameter.
def escape_token(token)
token.tr(':', '_')
token && token.tr(':', '_')
end
end

7
lib/telegram/bot/rspec.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,7 @@
module Telegram
module Bot
module RSpec
autoload :ClientMatchers, 'telegram/bot/rspec/client_matchers'
end
end
end

151
lib/telegram/bot/rspec/client_matchers.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,151 @@
module Telegram
module Bot
module RSpec
# Proxy that uses RSpec::Mocks::ArgListMatcher when it's available.
# Otherwise just performs `#==` match.
#
# Also allows to check argumets with custom block.
class ArgListMatcher
attr_reader :expected, :expected_proc
def initialize(*args, &block)
@expected_proc = block if block_given?
@expected =
if mocks_matcher?
::RSpec::Mocks::ArgumentListMatcher.new(*args)
else
args
end
end
def args_match?(*actual)
if expected_proc
expected_proc[*actual]
true
elsif mocks_matcher?
expected.args_match?(*actual)
else
expected == actual
end
end
def args
mocks_matcher? ? expected.args : expected
end
def mocks_matcher?
defined?(::RSpec::Mocks::ArgumentListMatcher)
end
def to_s
if mocks_matcher?
expected.expected_args.inspect
elsif expected_proc
'(proc matcher)'
else
expected.inspect
end
end
end
# Matchers to test requests to Telegram API.
#
# Complex matchers requires `rspec-mocks` to be installed.
module ClientMatchers
class MakeTelegramRequest < ::RSpec::Matchers::BuiltIn::BaseMatcher
EXPECTATION_TYPES = {
exactly: :==,
at_most: :>=,
at_least: :<=,
}.freeze
attr_reader :performed_requests, :description
def initialize(bot, action, description: nil)
@bot = bot
@action = action
@description = description || "make #{action} telegram request"
exactly(1)
end
def matches?(proc) # rubocop:disable AbcSize
raise ArgumentError, 'matcher only supports block expectations' unless proc.is_a?(Proc)
original_requests_count = bot.requests[action].count
proc.call
@performed_requests = bot.requests[action].drop(original_requests_count)
@matching_requests_count = performed_requests.count do |request|
!arg_list_matcher || arg_list_matcher.args_match?(request)
end
expectation_method = EXPECTATION_TYPES[expectation_type]
expected_number.public_send(expectation_method, matching_requests_count)
end
def with(*args, &block)
@arg_list_matcher = ArgListMatcher.new(*args, &block)
self
end
EXPECTATION_TYPES.each_key do |type|
define_method type do |count|
@expectation_type = type
@expected_number = Integer(count)
self
end
end
def times
self
end
def failure_message
"expected to #{base_message}"
end
def failure_message_when_negated
"expected not to #{base_message}"
end
def supports_block_expectations?
true
end
private
attr_reader :bot, :action, :expectation_type, :expected_number,
:arg_list_matcher, :matching_requests_count
def base_message
"make #{expectation_type.to_s.tr('_', ' ')} #{expected_number} " \
"#{bot.inspect}.#{action} requests,".tap do |msg|
msg << " with #{arg_list_matcher}," if arg_list_matcher
msg << " but made #{matching_requests_count}"
if performed_requests
actual_args = performed_requests.map(&:inspect).join(', ')
msg << ", and #{performed_requests.count} with #{actual_args}"
end
end
end
end
# Check that bot performed request to telegram API:
#
# expect { dispatch_message('Hi!') }.
# to make_telegram_request(bot, :sendMessage).
# with(text: 'Hello!', chat_id: chat_id)
def make_telegram_request(bot, action)
MakeTelegramRequest.new(bot, action)
end
# Helper for asserting message is sent. Note that options are checked
# with `hash_including`. For strict checks use #make_telegram_request.
def send_telegram_message(bot, text = nil, options = {})
description = "send telegram message #{text.inspect}"
text = a_string_matching(text) if text.is_a?(Regexp)
options = options.merge(text: text) if text
MakeTelegramRequest.new(bot, :sendMessage, description: description).
with(hash_including(options))
end
end
end
end
end

85
lib/telegram/bot/rspec/integration.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,85 @@
RSpec.shared_context 'telegram/bot/integration' do
let(:bot) { Telegram.bot }
let(:default_message_options) { {from: from, chat: chat} }
let(:from) { {id: from_id} }
let(:from_id) { 123 }
let(:chat) { {id: chat_id} }
let(:chat_id) { 456 }
let(:controller_path) do
route_name = Telegram::Bot::RoutesHelper.route_name_for_bot(bot)
Rails.application.routes.url_helpers.public_send("#{route_name}_path")
end
let(:request_headers) do
{
'ACCEPT' => 'application/json',
'Content-Type' => 'application/json',
}
end
let(:clear_session?) { described_class.respond_to?(:session_store) }
before { described_class.session_store.try!(:clear) if clear_session? }
include Telegram::Bot::RSpec::ClientMatchers
def dispatch(update)
if ActionPack::VERSION::MAJOR >= 5
post(controller_path, params: update.to_json, headers: request_headers)
else
post(controller_path, update.to_json, request_headers)
end
end
def dispatch_message(text, options = {})
dispatch message: default_message_options.merge(options).merge(text: text)
end
def dispatch_command(*args)
options = args.last.is_a?(Hash) ? args.pop : {}
dispatch_message("/#{args.join ' '}", options)
end
# Matcher to check response. Make sure to define `let(:chat_id)`.
def respond_with_message(expected = Regexp.new(''))
raise 'Define chat_id to use respond_with_message' unless defined?(chat_id)
send_telegram_message(bot, expected, chat_id: chat_id)
end
end
RSpec.shared_context 'telegram/bot/callback_query', callback_query: true do
include_context 'telegram/bot/integration'
subject { -> { dispatch callback_query: payload } }
let(:payload) { {id: 11, from: from, message: message, data: data} }
let(:message) { {message_id: 22, chat: chat, text: 'message text'} }
# Matcher to check that origin message got edited.
def edit_current_message(type, options = {})
description = 'edit current message'
options = options.merge(
message_id: message[:message_id],
chat_id: chat_id,
)
Telegram::Bot::RSpec::ClientMatchers::MakeTelegramRequest.new(
bot, :"editMessage#{type.to_s.camelize}", description: description
).with(hash_including(options))
end
# Matcher to check that callback query is answered.
def answer_callback_query(text = Regexp.new(''), options = {})
description = "answer callback query with #{text.inspect}"
text = a_string_matching(text) if text.is_a?(Regexp)
options = options.merge(
callback_query_id: payload[:id],
text: text,
)
Telegram::Bot::RSpec::ClientMatchers::MakeTelegramRequest.new(
bot, :answerCallbackQuery, description: description
).with(hash_including(options))
end
end
RSpec.configure do |config|
if config.respond_to?(:include_context)
config.include_context 'telegram/bot/integration', :telegram_bot
config.include_context 'telegram/bot/callback_query', :telegram_bot, :callback_query
end
end

Просмотреть файл

@@ -57,9 +57,8 @@ module Telegram
require 'telegram/bot/updates_controller/log_subscriber'
require 'telegram/bot/updates_controller/instrumentation'
require 'telegram/bot/updates_controller/reply_helpers'
autoload :CallbackQueyContext, 'telegram/bot/updates_controller/callback_query_context'
autoload :CallbackQueryContext, 'telegram/bot/updates_controller/callback_query_context'
autoload :MessageContext, 'telegram/bot/updates_controller/message_context'
autoload :Botan, 'telegram/bot/updates_controller/botan'
include AbstractController::Callbacks
# Redefine callbacks with default terminator.
@@ -79,13 +78,17 @@ module Telegram
autoload :TypedUpdate, 'telegram/bot/updates_controller/typed_update'
PAYLOAD_TYPES = %w(
PAYLOAD_TYPES = %w[
message
edited_message
channel_post
edited_channel_post
inline_query
chosen_inline_result
callback_query
edited_message
).freeze
shipping_query
pre_checkout_query
].freeze
CMD_REGEX = %r{\A/([a-z\d_]{,31})(@(\S+))?(\s|$)}i
CONFLICT_CMD_REGEX = Regexp.new("^(#{PAYLOAD_TYPES.join('|')}|\\d)")
@@ -141,10 +144,12 @@ module Telegram
@_payload, @_payload_type = payload_data
end
# Accessor to `'chat'` field of payload. Can be overriden with `chat` option
# for #initialize.
# Accessor to `'chat'` field of payload. Also tries `'chat'` in `'message'`
# when there is no such field in payload.
#
# Can be overriden with `chat` option for #initialize.
def chat
@_chat ||= payload && payload['chat']
@_chat ||= payload.try! { |x| x['chat'] || x['message'] && x['message']['chat'] }
end
# Accessor to `'from'` field of payload. Can be overriden with `from` option
@@ -166,25 +171,27 @@ module Telegram
# Returns array `[is_command?, action, args]`.
def action_for_payload
if payload_type
send("action_for_#{payload_type}") || [false, payload_type, [payload]]
send("action_for_#{payload_type}") || action_for_default_payload
else
[false, :unsupported_payload_type, []]
end
end
def action_for_default_payload
[false, payload_type, [payload]]
end
# If payload is a message with command, then returned action is an
# action for this command.
# Separate method, so it can be easily overriden (ex. MessageContext).
#
# This is not used for edited messages/posts. It process them as basic updates.
def action_for_message
cmd, args = self.class.command_from_text(payload['text'], bot_username)
cmd &&= self.class.action_for_command(cmd)
[true, cmd, args] if cmd
end
# It doesn't extract commands from edited messages. Just process
# them as usual ones.
def action_for_edited_message
end
alias_method :action_for_channel_post, :action_for_message
def action_for_inline_query
[false, payload_type, [payload['query'], payload['offset']]]
@@ -203,6 +210,11 @@ module Telegram
def action_missing(*)
end
PAYLOAD_TYPES.each do |type|
method = :"action_for_#{type}"
alias_method method, :action_for_default_payload unless instance_methods.include?(method)
end
ActiveSupport.run_load_hooks('telegram.bot.updates_controller', self)
end
end

Просмотреть файл

@@ -3,7 +3,7 @@ module Telegram
class UpdatesController
# Use separate actions for different callback queries.
# It doesn't require session support. Simply add `%{context}:` prefix to data.
module CallbackQueyContext
module CallbackQueryContext
protected
# Uses #context_from_callback_query as context name.
@@ -15,7 +15,6 @@ module Telegram
# because `data` param is controlled by client.
def action_for_callback_query
context, new_data = context_from_callback_query
# binding.pry
if context
action_name = "#{context}_callback_query"
[false, action_name, [new_data]] if action_method?(action_name)

Просмотреть файл

@@ -39,7 +39,7 @@ module Telegram
Instrumentation.instrument(:respond_with, type: type) { super }
end
%i(answer_callback_query answer_inline_query).each do |type|
%i[answer_callback_query answer_inline_query].each do |type|
define_method(type) do |*args|
Instrumentation.instrument(:respond_with, type: type) { super(*args) }
end

Просмотреть файл

@@ -17,7 +17,7 @@ module Telegram
payload = event.payload
additions = UpdatesController.log_process_action(payload)
message = "Completed in #{event.duration.round}ms"
message << " (#{additions.join(' | ')})" unless additions.blank?
message << " (#{additions.join(' | ')})" if additions.present?
message
end
end

Просмотреть файл

@@ -7,14 +7,11 @@ module Telegram
include Session
included do
# As we use before_action context is cleared anyway,
# no matter we used it or not.
singleton_class.send :attr_reader, :context_handlers, :context_to_action
@context_handlers = {}
end
module ClassMethods
def context_handlers
@_context_handlers ||= {}
end
# Registers handler for context.
#
# context_handler :rename do |*|
@@ -39,6 +36,8 @@ module Telegram
context_handlers[context] = action || context
end
attr_reader :context_to_action
# Use it to use context value as action name for all contexts
# which miss handlers.
# For security reasons it supports only action methods and will
@@ -59,10 +58,16 @@ module Telegram
# according to previous request.
attr_reader :context
# Controller may have multiple sessions, let it be possible
# to select session for message context.
def message_context_session
session
end
# Fetches context and finds handler for it. If message has new command,
# it has higher priority than contextual action.
def action_for_message
val = session.delete(:context)
val = message_context_session.delete(:context)
@context = val && val.to_sym
super || context && begin
handler = handler_for_context
@@ -72,7 +77,7 @@ module Telegram
# Save context for the next request.
def save_context(context)
session[:context] = context
message_context_session[:context] = context
end
def handler_for_context

Просмотреть файл

@@ -38,6 +38,19 @@ module Telegram
)
bot.answer_callback_query(params)
end
# Edit message from callback query.
def edit_message(type, params = {})
params =
if message_id = payload['inline_message_id'] # rubocop:disable AssignmentInCondition
params.merge(inline_message_id: message_id)
elsif message = payload['message'] # rubocop:disable AssignmentInCondition
params.merge(chat_id: message['chat']['id'], message_id: message['message_id'])
else
raise 'Can not edit message without `inline_message_id` or `message`'
end
bot.public_send("edit_message_#{type}", params)
end
end
end
end

Просмотреть файл

@@ -13,13 +13,18 @@ RSpec.shared_context 'telegram/bot/updates_controller' do
let(:bot) { Telegram::Bot::ClientStub.new(bot_name) }
let(:bot_name) { 'bot' }
let(:session) { controller.send(:session) }
let(:from_id) { 123 }
let(:chat_id) { 456 }
let(:default_message_options) { {from: {id: from_id}, chat: {id: chat_id}} }
include Telegram::Bot::RSpec::ClientMatchers
def dispatch(bot = self.bot, update = self.update)
controller.dispatch_again(bot, update)
end
def dispatch_message(text, options = {})
update = build_update :message, options.merge(text: text)
update = build_update :message, default_message_options.merge(options).merge(text: text)
dispatch bot, update
end
@@ -34,4 +39,15 @@ RSpec.shared_context 'telegram/bot/updates_controller' do
else input
end
end
# Matcher to check response. Make sure to define `let(:chat_id)`.
def respond_with_message(expected)
send_telegram_message(bot, expected, chat_id: chat_id)
end
end
RSpec.configure do |config|
if config.respond_to?(:include_context)
config.include_context 'telegram/bot/updates_controller', type: :telegram_bot_controller
end
end

Просмотреть файл

@@ -8,19 +8,26 @@ module Telegram
module Session
extend ActiveSupport::Concern
module ClassMethods
# Builds session with given key and optional store (default to session_store).
# This way it's easier to define multiple custom sessions,
# ex. one for group chat and one for user.
def build_session(key, store = session_store)
raise 'session_store is not configured' unless store
key ? SessionHash.new(store, key) : NullSessionHash.new
end
end
def process_action(*)
super
ensure
session.commit
session.commit if @_session
end
protected
def session
@_session ||= begin
key = session_key
key ? SessionHash.new(self.class.session_store, key) : NullSessionHash.new
end
@_session ||= self.class.build_session(session_key)
end
def session_key

Просмотреть файл

@@ -2,7 +2,7 @@ module Telegram
module Bot
class UpdatesController
module Testing
IVARS_TO_KEEP = %i(@_session).freeze
IVARS_TO_KEEP = %i[@_session].freeze
# Perform multiple dispatches on same instance.
def dispatch_again(bot = nil, update = nil)

Просмотреть файл

@@ -27,12 +27,12 @@ module Telegram
attr_reader :bot, :controller, :timeout, :offset, :logger, :running, :reload
def initialize(bot, controller, **options)
@logger = options.fetch(:logger) { defined?(Rails) && Rails.logger }
@logger = options.fetch(:logger) { defined?(Rails.logger) && Rails.logger }
@bot = bot
@controller = controller
@timeout = options.fetch(:timeout) { DEFAULT_TIMEOUT }
@offset = options[:offset]
@reload = options.fetch(:reload) { defined?(Rails) && Rails.env.development? }
@reload = options.fetch(:reload) { defined?(Rails.env) && Rails.env.development? }
end
def log(&block)
@@ -50,7 +50,7 @@ module Telegram
end
rescue Interrupt
@running = false
rescue => e
rescue StandardError => e
logger.error { ([e.message] + e.backtrace).join("\n") } if logger
end
end
@@ -64,15 +64,17 @@ module Telegram
end
def fetch_updates
response = bot.get_updates(offset: offset, timeout: timeout)
return unless response['ok'] && response['result'].any?
response = bot.async(false) { bot.get_updates(offset: offset, timeout: timeout) }
updates = response.is_a?(Array) ? response : response['result']
return unless updates && updates.any?
reload! do
response['result'].each do |update|
updates.each do |update|
@offset = update['update_id'] + 1
yield update
end
end
rescue Timeout::Error # rubocop:disable HandleExceptions
rescue Timeout::Error
log { 'Fetch timeout' }
end
def reload!
@@ -85,7 +87,7 @@ module Telegram
end
end
if defined?(Rails) && Rails.application.respond_to?(:reloader)
if defined?(Rails.application) && Rails.application.respond_to?(:reloader)
def reloading_code
Rails.application.reloader.wrap do
yield

Просмотреть файл

@@ -1,6 +1,6 @@
module Telegram
module Bot
VERSION = '0.8.0'.freeze
VERSION = '0.12.4'.freeze
def self.gem_version
Gem::Version.new VERSION

Просмотреть файл

@@ -0,0 +1,13 @@
require 'integration_helper'
RSpec.describe DefaultBotController, :telegram_bot, type: :request do
describe '#start' do
subject { -> { dispatch_command :start } }
it { should respond_with_message 'from default' }
end
describe '#load_session' do
subject { -> { dispatch_command :load_session } }
it { should_not raise_error }
end
end

Просмотреть файл

@@ -0,0 +1,9 @@
require 'integration_helper'
RSpec.describe NamedBotController, :telegram_bot, type: :request do
let(:bot) { Telegram.bots[:named] }
describe '#start' do
subject { -> { dispatch_command :start } }
it { should respond_with_message 'from named' }
end
end

14
spec/integration/requests/other_bot_spec.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,14 @@
require 'integration_helper'
RSpec.describe OtherBotController, :telegram_bot, type: :request do
let(:bot) { Telegram.bots[:other] }
describe '#start' do
subject { -> { dispatch_command :start } }
it { should respond_with_message 'from other' }
end
describe '#load_session' do
subject { -> { dispatch_command :load_session } }
it { should raise_error(/session_store is not configured/) }
end
end

76
spec/integration_helper.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,76 @@
require 'telegram/bot/rspec/integration'
require 'action_controller'
require 'action_dispatch'
require 'action_dispatch/testing/integration'
require 'rails'
require 'telegram/bot/railtie'
require 'rspec/rails/adapters'
require 'rspec/rails/fixture_support'
require 'rspec/rails/example/rails_example_group'
require 'rspec/rails/example/request_example_group'
ENV['RAILS_ENV'] = 'test'
class TestApplication < Rails::Application
config.eager_load = false
config.log_level = :debug
config.action_dispatch.show_exceptions = false
secrets[:secret_key_base] = 'test'
secrets[:telegram] = {
bot: 'default_token',
bots: {
other: {token: 'other_token'},
named: {token: 'named_token', username: 'TestBot'},
},
}
end
Rails.application.initialize!
# # Controllers
%w[default other named].each do |bot_name|
controller = Class.new(Telegram::Bot::UpdatesController) do
define_method :start do |*|
respond_with :message, text: "from #{bot_name}"
end
end
Object.const_set("#{bot_name}_bot_controller".camelize, controller)
end
[DefaultBotController, OtherBotController].each do |klass|
klass.class_eval do
use_session!
define_method :load_session do |*|
session[:test]
end
end
end
DefaultBotController.session_store = :memory_store
RSpec.configure do |config|
config.include RSpec::Rails::RequestExampleGroup, type: :request
config.around type: :request do |ex|
begin
Telegram.reset_bots
Telegram::Bot::ClientStub.stub_all!
ex.run
ensure
Telegram.reset_bots
Telegram::Bot::ClientStub.stub_all!(false)
end
end
config.before type: :request do
# Redefine routes before every example, so it does not depent on order.
Rails.application.routes.draw do
require 'telegram/bot/routes_helper'
extend Telegram::Bot::RoutesHelper
telegram_webhooks default: DefaultBotController,
other: OtherBotController,
named: NamedBotController
end
end
end

Просмотреть файл

@@ -16,6 +16,9 @@ $LOAD_PATH.unshift GEM_ROOT.join('lib')
require 'telegram/bot'
require 'telegram/bot/updates_controller/rspec_helpers'
require 'telegram/bot/types'
require 'active_support/core_ext/object/json'
Dir[GEM_ROOT.join('spec/support/**/*.rb')].each { |f| require f }
RSpec.configure do |config|
config.expect_with :rspec do |expectations|

143
spec/support/examples/async.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,143 @@
RSpec.shared_examples 'async' do |request_args: -> {}|
let(:instance) { described_class.new(token: token, id: id, async: async) }
let(:id) { :default_bot }
let(:async) { true }
let!(:application_job_class) do
klass = Class.new do
def self.perform_later(*)
end
end
klass.tap { |x| stub_const('ApplicationJob', x) }
end
after { described_class.default_async_job = nil }
describe '#async' do
subject { ->(*args, &block) { instance.async(*args, &block) } }
its(:call) { should eq described_class.default_async_job }
context 'when async is disabled' do
let(:async) { false }
its(:call) { should eq false }
end
context 'when using with block' do
it 'sets value inside block' do
expect do
expect do
subject.call(false) do
expect do
subject.call(nil) { expect(subject[]).to eq nil }
end.to_not change(&subject).from false
raise 'TestError'
end
end.to raise_error(/TestError/)
end.to_not change(instance, :async).from(described_class.default_async_job)
end
end
context 'in multi-threaded environment' do
it 'changes only in current thread' do
thread = nil
expect do
thread = Thread.new do
expect do
subject.call(false) do
expect(subject[]).to eq false
end
end.to_not change(instance, :async).from(described_class.default_async_job)
sleep 0.2
end
end.to_not change(instance, :async).from(described_class.default_async_job)
thread.join
end
it 'uses default value after block' do
thread = Thread.new do
subject.call(false) {}
expect { sleep 0.2 }.to change(instance, :async).
from(described_class.default_async_job).to(nil)
end
sleep 0.1
instance.async = nil
thread.join
end
end
end
describe '#async=' do
subject { ->(val = new_val) { instance.async = val } }
let(:async) {}
context 'when true is given' do
let(:new_val) { true }
it { should change(instance, :async).to described_class.default_async_job }
end
context 'when false is given' do
let(:new_val) { false }
it { should change(instance, :async).to false }
end
context 'when string is given' do
let(:new_val) { 'Telegram::Bot::Client' }
it { should change(instance, :async).to Object.const_get(new_val) }
end
context 'in multi-threaded environment' do
subject { ->(val = new_val) { Thread.new { instance.async = val }.join } }
let(:new_val) { true }
it { should change(instance, :async).to described_class.default_async_job }
end
end
describe '.default_async_job' do
subject { described_class.default_async_job }
its(:superclass) { should eq application_job_class }
it { should include Telegram::Bot::Async::Job }
its(:client_class) { should eq described_class }
context 'when ApplicationJob is not defined' do
let(:application_job_class) {}
it { expect { subject }.to raise_error(/Define ApplicationJob/) }
end
end
describe '#request' do
subject { ->(*args) { instance.request(*(args.empty? ? self.args : args)) } }
let(:args, &request_args)
let(:result) { double(:result) }
shared_examples 'enqueues job' do
it 'enqueues job' do
expect(instance).to_not receive(:http_request)
expect(described_class).to receive(:prepare_async_args).with(*args) { args }
expect(instance.async).to receive(:perform_later).with(id.to_s, *args) { result }
expect(subject.call).to eq result
end
end
include_examples 'enqueues job'
context 'with custom job class' do
let(:async) { double(:job_class) }
include_examples 'enqueues job'
end
context 'when id is not set' do
let(:id) {}
it { should raise_error(/Can not enqueue/) }
end
context 'when async is disabled' do
let(:async) { false }
let(:result) { double(status: 200, body: '{"test":"ok"}') }
let(:args, &request_args)
it 'performs request immediately' do
expect(instance).to receive(:request).with(*args).and_call_original
expect(instance).to receive(:http_request) { result }
expect(subject[]).to eq 'test' => 'ok'
end
end
end
end

65
spec/support/examples/initializers.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,65 @@
RSpec.shared_examples 'initializers' do |config_method = :bots|
describe '.wrap' do
subject { described_class.wrap(input, **options) }
let(:options) { {} }
let(:result) { double(:result) }
let(:username) { 'username' }
context 'when input is a string' do
let(:input) { token }
it 'treats string as token' do
expect(described_class).to receive(:new).with(token, {}) { result }
should eq result
end
context 'and additional options are given' do
let(:options) { {id: :test} }
it 'passes them to initializer' do
expect(described_class).to receive(:new).with(input, **options) { result }
should eq result
end
end
end
context 'when input is a hash' do
let(:input) { {token: token, 'username' => username, other: :options} }
it 'passes it with symbolized keys' do
expect(described_class).to receive(:new).with(**input.symbolize_keys) { result }
should eq result
end
context 'and additional options are given' do
let(:options) { {id: :test} }
it 'passes them to initializer' do
expect(described_class).to receive(:new).
with(**input.symbolize_keys, **options) { result }
should eq result
end
end
end
context 'when input is an instance of described_class' do
let!(:input) { instance }
it 'returns input' do
expect(described_class).to_not receive(:new)
should eq input
end
end
context 'when input is a Symbol' do
let(:input) { :client_1 }
before { allow(Telegram).to receive(config_method) { {client_1: instance} } }
it { should eq Telegram.send(config_method)[:client_1] }
context 'and there is no such bot' do
let(:input) { :invalid }
it { expect { subject }.to raise_error(/not configured/) }
end
end
end
end

28
spec/telegram/bot/async_spec.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,28 @@
RSpec.describe Telegram::Bot::Async::Job do
let(:job_class) do
described_class = self.described_class
client_class = self.client_class
Class.new do
include described_class
self.client_class = client_class
end
end
let(:client_class) { Telegram::Bot::Client }
let(:instance) { job_class.new }
describe '#perform' do
subject { instance.perform(id, *args) }
let(:id) { 'bot_id' }
let(:args) { [double(:action), {body: :content}] }
let(:client) { Telegram::Bot::Client.new(async: custom_job_class) }
let(:custom_job_class) { Class.new }
let(:result) { double(status: 200, body: '{"test":"ok"}') }
it 'finds client and performs request' do
expect(client_class).to receive(:wrap).with(id.to_sym) { client }
expect(client).to receive(:request).with(*args).and_call_original
expect(client).to receive(:http_request) { result }
should eq 'test' => 'ok'
end
end
end

Просмотреть файл

@@ -0,0 +1,33 @@
RSpec.describe Telegram::Bot::Client do
let(:instance) { described_class.new(*client_args) }
let(:token) { 'token' }
let(:botan_token) { double(:botan_token) }
let(:client_id) { 'client_id' }
let(:client_args) { [token] }
describe '#botan' do
subject { instance.botan }
it { should eq nil }
context 'when botan token is set' do
let(:client_args) { [token, id: client_id, async: Class.new, botan: botan_token] }
it { should be_instance_of Telegram::Bot::Botan }
its(:token) { should eq botan_token }
its(:id) { should eq client_id }
it 'doesnt inherit async from client' do
expect(instance.async).to be
expect(subject.async).to_not be
end
end
context 'when botan is configured with hash' do
let(:client_args) { [token, id: client_id, async: Class.new, botan: botan_config] }
let(:botan_config) { {token: botan_token, async: Class.new} }
it { should be_instance_of Telegram::Bot::Botan }
its(:token) { should eq botan_token }
its(:id) { should eq client_id }
its(:async) { should eq botan_config[:async] }
end
end
end

Просмотреть файл

@@ -1,4 +1,4 @@
RSpec.describe Telegram::Bot::UpdatesController::Botan do
RSpec.describe Telegram::Bot::Botan::ControllerHelpers do
include_context 'telegram/bot/updates_controller'
let(:controller_class) do
described_class = self.described_class

27
spec/telegram/bot/botan_spec.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,27 @@
RSpec.describe Telegram::Bot::Botan do
let(:instance) { described_class.new 'token' }
let(:token) { 'token' }
include_examples 'initializers', :botans
it_behaves_like 'async', request_args: -> { [double(:method), double(:url)] }
describe '.new' do
subject { described_class.new(*args) }
context 'when usual args are given' do
let(:args) { ['secret'] }
its(:token) { should eq args[0] }
end
context 'when options are given' do
let(:args) { [token: 'secret'] }
its(:token) { should eq args[0][:token] }
end
end
describe '.prepare_async_args' do
subject { described_class.prepare_async_args(*input) }
let(:input) { [:post, :uri, {a: 1, b: :sym, 'd' => 'str'}, 'body'] }
it { should eq ['post', 'uri', {a: 1, b: 'sym', 'd' => 'str'}, 'body'] }
end
end

Просмотреть файл

@@ -3,71 +3,8 @@ RSpec.describe Telegram::Bot::Client do
let(:token) { 'token' }
let(:botan_token) { double(:botan_token) }
describe '.wrap' do
subject { described_class.wrap(input) }
let(:result) { double(:result) }
let(:username) { 'username' }
context 'when input is a string' do
let(:input) { token }
it 'treats string as token' do
expect(described_class).to receive(:new).with(token) { result }
should eq result
end
end
context 'when input is a hash' do
let(:input) { {token: token, username: username, ignore: :ignore} }
it 'extracts token & username' do
expect(described_class).to receive(:new).
with(token, username, botan: nil) { result }
should eq result
end
context 'when `botan` is given' do
let(:input) { super().merge(botan: botan_token) }
it 'passes it to initializer' do
expect(described_class).to receive(:new).
with(token, username, botan: botan_token) { result }
should eq result
end
end
end
context 'when input is an instance of described_class' do
let!(:input) { instance }
it 'returns input' do
expect(described_class).to_not receive(:new)
should eq input
end
end
context 'when input is a Symbol' do
let(:input) { :bot_1 }
before { allow(Telegram).to receive(:bots) { {bot_1: instance} } }
it { should eq Telegram.bots[:bot_1] }
context 'and there is no such bot' do
let(:input) { :invalid }
it { expect { subject }.to raise_error(/not configured/) }
end
end
context 'when input is an array' do
let!(:input) { ['other_token', instance, token: token, username: username] }
let(:result_2) { double(:result_2) }
it 'calls wrap for every element' do
expect(described_class).to receive(:new).with('other_token') { result }
expect(described_class).to receive(:new).with(token, username, botan: nil) { result_2 }
should eq [result, instance, result_2]
end
end
end
include_examples 'initializers'
it_behaves_like 'async', request_args: -> { [double(:action), {body: :content}] }
describe '.prepare_body' do
subject { described_class.prepare_body(input) }
@@ -82,20 +19,78 @@ RSpec.describe Telegram::Bot::Client do
it 'encodes nested hashes to json' do
expected = input.dup
%i(c d e).each { |x| expected[x] = expected[x].to_json }
%i[c d e].each { |x| expected[x] = expected[x].to_json }
should eq expected
end
end
end
describe '#botan' do
subject { instance.botan }
it { should eq nil }
describe '.prepare_async_args' do
subject { described_class.prepare_async_args(*input) }
let(:input) { [:action, a: 1, b: :sym, c: [:other], 'd' => 'str'] }
it { should eq ['action', a: 1, b: 'sym', c: '["other"]', 'd' => 'str'] }
end
context 'when botan token is set' do
let(:instance) { described_class.new token, botan: botan_token }
it { should be_instance_of Telegram::Bot::Botan }
its(:token) { should eq botan_token }
describe '.new' do
subject { described_class.new(*args) }
context 'when multiple args are given' do
let(:args) { %w[secret superbot] }
its(:token) { should eq args[0] }
its(:username) { should eq args[1] }
its(:base_uri) { should include args[0] }
end
context 'when hash is given' do
let(:args) { [token: 'secret', username: 'superbot'] }
its(:token) { should eq args[0][:token] }
its(:username) { should eq args[0][:username] }
its(:base_uri) { should include args[0][:token] }
end
end
describe '#request' do
subject { -> { instance.request(action, request_body) } }
let(:action) { :some_action }
let(:url) { "#{format described_class::URL_TEMPLATE, token}#{action}" }
let(:request_body) { double(:body) }
let(:prepared_body) { double(:prepared_body) }
let(:response) { HTTP::Message.new_response(body).tap { |x| x.status = status } }
let(:status) { 200 }
let(:body) { body_json.to_json }
let(:body_json) { {'param' => 'val', 'description' => 'some description'} }
before do
expect(described_class).to receive(:prepare_body).with(request_body) { prepared_body }
expect(instance).to receive(:http_request).with(url, prepared_body) { response }
end
around { |ex| Telegram::Bot::ClientStub.stub_all!(false) { ex.run } }
shared_examples 'invalid body' do |error = Telegram::Bot::Error|
context 'when body is not json' do
let(:body) { '{' }
it { should raise_error error }
end
end
its(:call) { should eq body_json }
include_examples 'invalid body', JSON::ParserError
context 'when status is 403' do
let(:status) { 403 }
it { should raise_error Telegram::Bot::Forbidden, body_json['description'] }
include_examples 'invalid body'
end
context 'when status is 404' do
let(:status) { 404 }
it { should raise_error Telegram::Bot::NotFound, body_json['description'] }
include_examples 'invalid body'
end
context 'when status is other' do
let(:status) { 500 }
it { should raise_error Telegram::Bot::Error, /#{body_json['description']}/ }
end
end
end

Просмотреть файл

@@ -1,7 +1,7 @@
RSpec.describe Telegram::Bot::ClientStub do
describe '#stub_all!' do
let(:client) { Telegram::Bot::Client.new('token', 'bot_name') }
let(:clients) { Telegram::Bot::Client.wrap(['token', token: 'token2']) }
let(:clients) { ['token', token: 'token2'].map(&Telegram::Bot::Client.method(:wrap)) }
shared_examples 'constructors' do |expected_class|
it 'makes Client.new return ClientStub' do
@@ -9,7 +9,7 @@ RSpec.describe Telegram::Bot::ClientStub do
expect(client.username).to eq 'bot_name'
end
it 'makes Client.wrap raturn ClientStub' do
it 'makes Client.wrap return ClientStub' do
expect(clients).to contain_exactly instance_of(expected_class),
instance_of(expected_class)
end
@@ -35,4 +35,25 @@ RSpec.describe Telegram::Bot::ClientStub do
include_examples 'constructors', Telegram::Bot::Client
end
end
describe '#new' do
subject { described_class.new(*args) }
context 'when only username is given' do
let(:args) { 'superbot' }
its(:username) { should eq args }
end
context 'when username and token are given' do
let(:args) { %w[token superbot] }
its(:token) { should eq args[0] }
its(:username) { should eq args[1] }
end
context 'when hash config is given' do
let(:args) { [token: 'token', username: 'superbot'] }
its(:token) { should eq args[0][:token] }
its(:username) { should eq args[0][:username] }
end
end
end

106
spec/telegram/bot/config_methods_spec.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,106 @@
RSpec.describe Telegram::Bot::ConfigMethods do
let(:registry) do
Object.new.tap do |x|
x.extend described_class
x.bots_config = config
end
end
let(:config) do
{
default: 'default_token',
chat: {
token: 'chat_token',
username: 'Chat',
botan: 'chat_botan_token',
},
other_chat: {
'token' => 'other_chat_token',
'username' => 'OtherChat',
'botan' => 'other_chat_botan_token',
},
}
end
describe '#bot' do
subject { -> { registry.bot } }
its(:call) { should eq registry.bots[:default] }
context 'when bot is not configured' do
let(:config) { super().except(:default) }
it { should raise_error(/bot is not configured/) }
end
end
describe '#bots' do
context 'configured by token' do
subject { registry.bots[:default] }
its(:id) { should eq :default }
its(:token) { should eq config[:default] }
end
context 'configured by hash' do
subject { registry.bots[:chat] }
its(:id) { should eq :chat }
its(:token) { should eq config[:chat][:token] }
its(:username) { should eq config[:chat][:username] }
its('botan.token') { should eq config[:chat][:botan] }
end
context 'configured by hash with stringified keys' do
subject { registry.bots[:other_chat] }
its(:id) { should eq :other_chat }
its(:token) { should eq config[:other_chat]['token'] }
its(:username) { should eq config[:other_chat]['username'] }
its('botan.token') { should eq config[:other_chat]['botan'] }
end
end
describe '#botans' do
subject { registry.botans }
it do
should eq(
default: nil,
chat: registry.bots[:chat].botan,
other_chat: registry.bots[:other_chat].botan,
)
end
end
describe '#bots_config' do
subject { registry.bots_config }
it { should eq config }
context 'when not configured' do
let(:registry) { Object.new.tap { |x| x.extend described_class } }
before { hide_const('Rails') }
it { should eq({}) }
context 'in rails environment' do
before { stub_const('Rails', double(application: double(secrets: secrets))) }
let(:secrets) { {} }
it { should eq({}) }
context 'when there is telegram section in secrets' do
let(:secrets) { {telegram: config.stringify_keys} }
let(:config) do
{
bot: double(:bot_config),
bots: {
chat: double(:chat_config),
other_chat: double(:other_chat_config),
},
}
end
it { should include default: config[:bot] }
it { should include config[:bots] }
context 'on rails >5.1 (deep symbolized keys)' do
let(:secrets) { {telegram: config.deep_symbolize_keys} }
it { should include default: config[:bot] }
it { should include config[:bots] }
end
end
end
end
end
end

Просмотреть файл

@@ -50,8 +50,9 @@ RSpec.describe Telegram::Bot::RoutesHelper do
def assert_routes(*expected) # rubocop:disable AbcSize
expected.each do |(bot, controller, route_name, options)|
expected_path = options.delete(:path) || "telegram/#{bot.token}"
expect(mapper).to receive(:post) do |path, params|
expect(path).to eq "telegram/#{bot.token}"
expect(path).to eq expected_path
middleware = params[:to]
expect(middleware.controller).to eq(controller)
expect(middleware.bot.token).to eq(bot.token)
@@ -70,6 +71,27 @@ RSpec.describe Telegram::Bot::RoutesHelper do
assert_routes [bot, controller, 'default_telegram_webhook', option: :val],
[other_bot, controller, 'other_telegram_webhook', option: :val]
end
context 'and bot does not have configured token' do
let(:bot) { create_bot(nil) }
it 'creates routes for every bot and this controller' do
assert_routes [bot, controller, 'default_telegram_webhook', option: :val],
[other_bot, controller, 'other_telegram_webhook', option: :val]
end
end
context 'and bot has colon in token' do
let(:bot) { create_bot('some:token') }
it 'replaces colon with underscore' do
assert_routes [
bot,
controller,
'default_telegram_webhook',
option: :val,
path: 'telegram/some_token',
], [other_bot, controller, 'other_telegram_webhook', option: :val]
end
end
end
context 'when called with hash' do

Просмотреть файл

@@ -0,0 +1,87 @@
RSpec.describe Telegram::Bot::RSpec::ClientMatchers do
let(:bot) { Telegram::Bot::ClientStub.new('TestBot') }
let(:other_bot) { Telegram::Bot::ClientStub.new('OtherTestBot') }
include described_class
describe '#make_telegram_request' do
context 'without args' do
it 'works for matching' do
block = ->(*) { bot.send_message(text: 'test') }
expect(&block).to make_telegram_request(bot, :sendMessage)
expect { 3.times(&block) }.to make_telegram_request(bot, :sendMessage).exactly(3).times
expect { 3.times(&block) }.to make_telegram_request(bot, :sendMessage).at_least(2).times
expect { 3.times(&block) }.to make_telegram_request(bot, :sendMessage).at_most(4).times
end
it 'works for not-matching' do
block = ->(*) { bot.get_me }
expect {}.to_not make_telegram_request(bot, :getMe)
expect { other_bot.get_me }.to_not make_telegram_request(bot, :getMe)
expect { 3.times(&block) }.to_not make_telegram_request(bot, :getMe)
expect { 3.times(&block) }.to_not make_telegram_request(bot, :getMe).exactly(2).times
expect { 3.times(&block) }.to_not make_telegram_request(bot, :getMe).exactly(4).times
expect { 3.times(&block) }.to_not make_telegram_request(bot, :getMe).at_least(4).times
expect { 3.times(&block) }.to_not make_telegram_request(bot, :getMe).at_most(2).times
end
end
context 'with args' do
it 'works for exact matching' do
args = {text: 'test', parseMode: :Markdown}
block = ->(*) { bot.send_message(args) }
expect(&block).to make_telegram_request(bot, :sendMessage).with(args)
expect(&block).to_not make_telegram_request(bot, :sendMessage).with(args.except(:text))
expect { 3.times(&block) }.to make_telegram_request(bot, :sendMessage).
with(args).exactly(3).times
end
it 'works for block matchers' do
args = {text: 'test', parseMode: :Markdown}
block = ->(*) { bot.send_message(args) }
expect(&block).to make_telegram_request(bot, :sendMessage).
with { |actual| expect(actual).to eq(args) }
# It ignores block's result! Custom expectations must be used inside block.
expect(&block).to make_telegram_request(bot, :sendMessage).
with { |actual| actual == {} }
expect do
expect(&block).to_not make_telegram_request(bot, :sendMessage).
with { |actual| expect(actual).to eq({}) }
end.to raise_error RSpec::Expectations::ExpectationNotMetError
n = -1
expect { 3.times { |i| bot.send_message text: i } }.
to make_telegram_request(bot, :sendMessage).exactly(3).times.
with { |actual| expect(actual).to eq(text: n += 1) }
end
it 'works for RSpec::Mocks matchers' do
args = {text: 'test', parseMode: :Markdown}
block = ->(*) { bot.send_message(args) }
expect(&block).to make_telegram_request(bot, :sendMessage).with(hash_including(args))
expect(&block).to make_telegram_request(bot, :sendMessage).with(
text: a_string_matching(/est$/),
parseMode: :Markdown,
)
end
end
end
describe '#send_telegram_message' do
let(:args) { {text: 'test', parseMode: :Markdown} }
let(:block) { ->(*) { bot.send_message(args) } }
it 'works for matching' do
expect(&block).to send_telegram_message(bot, 'test')
expect(&block).to send_telegram_message(bot, /^tes/)
expect(&block).to send_telegram_message(bot, 'test', parseMode: :Markdown)
expect(&block).to send_telegram_message(bot, a_string_including('es'))
end
it 'works for not-matching' do
expect(&block).to_not send_telegram_message(bot, 'test!')
expect(&block).to_not send_telegram_message(bot, /tes$/)
expect(&block).to_not send_telegram_message(bot, 'test', parseMode: :HTML)
expect(&block).to_not send_telegram_message(bot, a_string_including('smth'))
end
end
end

112
spec/telegram/bot/rspec/integration_spec.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,112 @@
require 'telegram/bot/rspec/integration'
require 'action_controller'
require 'action_dispatch'
require 'action_dispatch/testing/integration'
RSpec.describe 'Integrations helper', :telegram_bot do
include ActionDispatch::Integration::Runner
def reset_template_assertion
end
let(:app) do
app = Telegram::Bot::Middleware.new(bot, controller)
if ActionPack::VERSION::MAJOR >= 5
app
else
require 'action_dispatch/middleware/params_parser'
ActionDispatch::ParamsParser.new(app)
end
end
let(:bot) { Telegram::Bot::ClientStub.new('token') }
let(:controller_path) { '/' }
let(:controller) do
Class.new(Telegram::Bot::UpdatesController) do
def start(*args)
respond_with :message, text: "Start: #{args.inspect}, option: #{payload[:option]}"
end
end
end
describe '#default_message_options' do
subject { default_message_options }
it { should eq from: {id: from_id}, chat: {id: chat_id} }
end
describe '#dispatch' do
subject { -> { dispatch message: {text: '/start', **default_message_options} } }
it { should respond_with_message 'Start: [], option: ' }
end
describe '#dispatch_message' do
subject { -> { dispatch_message "/start #{args.join ' '}", options } }
let(:args) { %w[asd qwe] }
let(:options) { {} }
it { should respond_with_message "Start: #{args.inspect}, option: " }
context 'with options' do
let(:options) { {option: 1} }
it { should respond_with_message "Start: #{args.inspect}, option: 1" }
context 'and chat_id is not set' do
let(:options) { super().merge(chat: nil) }
it { should raise_error(/chat is not present/) }
end
end
end
describe '#dispatch_command' do
subject { -> { dispatch_command :start, *args } }
let(:args) { [] }
it { should respond_with_message "Start: #{args.inspect}, option: " }
context 'with args' do
let(:args) { %w[asd qwe] }
it { should respond_with_message "Start: #{args.inspect}, option: " }
end
context 'with options' do
let(:args) { ['asd', 'qwe', option: 1] }
it { should respond_with_message "Start: #{args[0...-1].inspect}, option: 1" }
end
end
describe 'callback queries', :callback_query do
let(:controller) do
Class.new(Telegram::Bot::UpdatesController) do
include Telegram::Bot::UpdatesController::CallbackQueryContext
def callback_query(data = nil, *)
answer_callback_query "data: #{data}"
end
def context_callback_query(data = nil, *)
answer_callback_query "data: #{data}", extra: :param
end
def answer_and_edit_callback_query(data = nil, *)
answer_callback_query "data: #{data}"
edit_message :text, text: 'edited-text', extra: :param
end
end
end
describe '#callback_query' do
let(:data) { 'unknown:command' }
it { should answer_callback_query("data: #{data}") }
end
describe '#context_callback_query' do
let(:data) { 'context:test:payload' }
it { should answer_callback_query('data: test:payload', extra: :param) }
it { should_not edit_current_message(:text) }
end
describe '#answer_and_edit_callback_query' do
let(:data) { 'answer_and_edit:test:payload' }
it do
should answer_callback_query(/test:payload/).
and edit_current_message(:text, text: /edited/, extra: :param)
end
end
end
end

Просмотреть файл

@@ -1,4 +1,4 @@
RSpec.describe Telegram::Bot::UpdatesController::CallbackQueyContext do
RSpec.describe Telegram::Bot::UpdatesController::CallbackQueryContext do
include_context 'telegram/bot/updates_controller'
let(:controller_class) do
described_class = self.described_class

Просмотреть файл

@@ -65,7 +65,7 @@ RSpec.describe Telegram::Bot::UpdatesController::MessageContext do
context 'when message has new command' do
let(:text) { '/action a s d' }
its(:call) { should eq [:action_result, *%w(a s d)] }
its(:call) { should eq [:action_result, 'a', 's', 'd'] }
it { should change { session[:context] }.to nil }
end
end

Просмотреть файл

@@ -48,4 +48,32 @@ RSpec.describe Telegram::Bot::UpdatesController do
end
end
end
describe '#edit_message' do
subject { controller.edit_message(type, params) }
let(:type) { :reply_markup }
it { expect { subject }.to raise_error(/Can not edit message without/) }
context 'when inline_message_id is present' do
let(:payload) { {inline_message_id: double(:message_id)} }
it 'sets inline_message_id' do
expect(bot).to receive("edit_message_#{type}").with(params.merge(
inline_message_id: payload[:inline_message_id],
)) { result }
should eq result
end
end
context 'when message is present' do
let(:payload) { {message: super().merge(chat: chat)} }
it 'sets chat_id & message_id' do
expect(bot).to receive("edit_message_#{type}").with(params.merge(
message_id: payload[:message][:message_id],
chat_id: payload[:message][:chat]['id'],
)) { result }
should eq result
end
end
end
end

Просмотреть файл

@@ -54,4 +54,45 @@ RSpec.describe Telegram::Bot::UpdatesController::Session do
end
end
end
describe '.build_session' do
subject { controller_class.build_session(key, *args) }
let(:key) {}
let(:args) { [] }
it { expect { subject }.to raise_error(/session_store is not configured/) }
shared_examples 'NullSessionHash when key is not present' do |store_proc|
it { should be_instance_of(described_class::NullSessionHash) }
context 'and key is present' do
let(:key) { :test_key }
it 'is valid SessionHash' do
expect(subject).to be_instance_of(described_class::SessionHash)
expect(subject.id).to eq key
expect(subject.instance_variable_get(:@store)).to be(instance_exec(&store_proc))
end
end
end
context 'when store configured' do
before { controller_class.session_store = nil }
include_examples 'NullSessionHash when key is not present',
-> { controller_class.session_store }
end
context 'when store is given' do
let(:args) { [double(:store)] }
include_examples 'NullSessionHash when key is not present', -> { args[0] }
end
end
describe '.session_store=' do
subject { ->(val) { controller_class.session_store = val } }
it 'casts to AS::Cache' do
expect { subject[:null_store] }.to change(controller_class, :session_store).
to(instance_of(ActiveSupport::Cache::NullStore))
expect { subject[nil] }.to change(controller_class, :session_store).
to(instance_of(ActiveSupport::Cache::MemoryStore))
end
end
end

Просмотреть файл

@@ -9,11 +9,11 @@ RSpec.describe Telegram::Bot::UpdatesController::TypedUpdate do
context 'when `update` is a virtus model' do
subject { controller }
%w(
%w[
message
inline_query
chosen_inline_result
).each do |type|
].each do |type|
context "with #{type}" do
type_class = Telegram::Bot::Types.const_get(type.camelize)
let(:payload_type) { type }

Просмотреть файл

@@ -102,41 +102,59 @@ RSpec.describe Telegram::Bot::UpdatesController do
it { should eq [false, payload_type, payload.values_at(:data)] }
end
context 'when payload is edited_message' do
let(:payload_type) { 'edited_message' }
it { should eq [false, payload_type, [payload]] }
end
context 'when payload is not supported' do
let(:payload_type) { '_unsupported_' }
it { should eq [false, :unsupported_payload_type, []] }
end
context 'when payload is message' do
let(:payload_type) { 'message' }
let(:payload) { {'text' => text} }
let(:text) { 'test' }
it { should eq [false, payload_type, [payload]] }
context 'with command' do
let(:text) { "/test#{"@#{mention}" if mention} arg 1 2" }
let(:mention) {}
it { should eq [true, 'test', %w(arg 1 2)] }
context 'with mention' do
let(:mention) { bot.username }
it { should eq [true, 'test', %w(arg 1 2)] }
end
context 'with mention for other bot' do
let(:mention) { other_bot_name }
it { should eq [false, 'message', [payload]] }
end
%w[message channel_post].each do |type|
context "when payload is edited_#{type}" do
let(:payload_type) { "edited_#{type}" }
it { should eq [false, payload_type, [payload]] }
end
context 'without text' do
let(:payload) { {'audio' => {'file_id' => 123}} }
context 'when payload is message' do
let(:payload_type) { type }
let(:payload) { {'text' => text} }
let(:text) { 'test' }
it { should eq [false, payload_type, [payload]] }
context 'with command' do
let(:text) { "/test#{"@#{mention}" if mention} arg 1 2" }
let(:mention) {}
it { should eq [true, 'test', %w[arg 1 2]] }
context 'with mention' do
let(:mention) { bot.username }
it { should eq [true, 'test', %w[arg 1 2]] }
end
context 'with mention for other bot' do
let(:mention) { other_bot_name }
it { should eq [false, payload_type, [payload]] }
end
end
context 'without text' do
let(:payload) { {'audio' => {'file_id' => 123}} }
it { should eq [false, payload_type, [payload]] }
end
end
end
custom_payload_types = %w[
message
edited_message
channel_post
edited_channel_post
inline_query
chosen_inline_result
callback_query
]
(described_class::PAYLOAD_TYPES - custom_payload_types).each do |type|
context "when payload is #{type}" do
let(:payload_type) { type }
it { should eq [false, payload_type, [payload]] }
end
end
@@ -145,11 +163,11 @@ RSpec.describe Telegram::Bot::UpdatesController do
context 'when `update` is a virtus model' do
subject { controller }
let(:update) { Telegram::Bot::Types::Update.new(super()) }
%w(
%w[
message
inline_query
chosen_inline_result
).each do |type|
].each do |type|
context "with #{type}" do
type_class = Telegram::Bot::Types.const_get(type.camelize)
let(:payload_type) { type }
@@ -176,7 +194,7 @@ RSpec.describe Telegram::Bot::UpdatesController do
describe '#process' do
subject { -> { controller.process(:action, *args) } }
let(:args) { [:arg1, :arg2] }
let(:args) { %i[arg1 arg2] }
let(:controller_class) do
Class.new(described_class) do
attr_reader :acted, :hooked
@@ -293,4 +311,49 @@ RSpec.describe Telegram::Bot::UpdatesController do
end
end
end
describe '#chat' do
subject { controller.chat }
let(:payload_type) { :message }
let(:payload) { {chat: 'test_value'} }
it { should eq payload[:chat] }
context 'when payload is not set' do
let(:payload) {}
it { should eq nil }
end
context 'when payload has no such field' do
let(:payload) { {smth: 'other'} }
it { should eq nil }
context 'but has `message`' do
let(:payload) { {message: message} }
let(:message) { {text: 'Hello bot!'} }
it { should eq nil }
context 'with `chat` set' do
let(:message) { super().merge(chat: 'test value') }
it { should eq message[:chat] }
end
end
end
end
describe '#from' do
subject { controller.from }
let(:payload_type) { :message }
let(:payload) { {from: 'test_value'} }
it { should eq payload[:from] }
context 'when payload is not set' do
let(:payload) {}
it { should eq nil }
end
context 'when payload has no such field' do
let(:payload) { {smth: 'other'} }
it { should eq nil }
end
end
end

Просмотреть файл

@@ -1,9 +1,39 @@
RSpec.describe Telegram::Bot::UpdatesPoller do
describe '#initialize' do
subject { described_class.new bot, controller }
let(:bot) { double }
let(:controller) { double }
let(:instance) { described_class.new(bot, controller) }
let(:bot) { Telegram::Bot::Client.new('token') }
let(:controller) { double(:controller) }
describe '#initialize' do
subject { instance }
it { should be }
end
describe '#fetch_updates' do
subject { -> { instance.fetch_updates(&block) } }
let(:block) { ->(x) { expect(x).to eq expected_results.shift } }
let(:results) { [{update_id: 12}, {update_id: 34}] }
let(:expected_results) { results.as_json }
let(:request_result) { {ok: true, result: results}.as_json }
before do
allow(bot).to receive(:get_updates) do
expect(bot.async).to be_falsy
request_result
end
end
it { should change(instance, :offset).to(results.last[:update_id] + 1) }
it { should change { expected_results }.to([]) }
context 'with typed response' do
let(:request_result) { results.as_json.map { |x| Telegram::Bot::Types::Update.new(x) } }
let(:expected_results) { request_result.dup }
it { should change(instance, :offset).to(results.last[:update_id] + 1) }
it { should change { expected_results }.to([]) }
end
context 'when bot is in async mode' do
let(:bot) { Telegram::Bot::Client.new('token', async: Class.new) }
it { should change { expected_results }.to([]) }
end
end
end

Просмотреть файл

@@ -2,22 +2,4 @@ RSpec.describe Telegram::Bot do
it 'has a version number' do
expect(described_class::VERSION).not_to be nil
end
describe described_class::StaleChat do
describe '.match_response?' do
subject { ->(val) { described_class.match_response?(val) } }
it 'returns true for specific errors' do
expect(subject.call({})).to eq false
expect(subject.call('description' => 'test')).to eq false
expect(subject.call('description' => 'Error: bot was kicked from')).to eq true
expect(subject.call(
'description' => "Forbidden: can't write to private chat with deleted user"
)).to eq true
expect(subject.call(
'description' => 'Bad request: group chat is deactivated'
)).to eq true
end
end
end
end

Просмотреть файл

@@ -1,4 +1,3 @@
# coding: utf-8
lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'telegram/bot/version'
@@ -20,9 +19,10 @@ Gem::Specification.new do |spec|
spec.required_ruby_version = '~> 2.0'
spec.add_dependency 'activesupport', '>= 4.0', '< 5.1'
spec.add_dependency 'actionpack', '>= 4.0', '< 5.1'
spec.add_dependency 'actionpack', '>= 4.0', '< 6.0'
spec.add_dependency 'activesupport', '>= 4.0', '< 6.0'
spec.add_dependency 'httpclient', '~> 2.7'
spec.add_development_dependency 'bundler', '~> 1.11'
spec.add_development_dependency 'rake', '~> 10.0'
end