将wechat放到项目里面

This commit is contained in:
guange 2016-04-26 10:50:54 +08:00
parent 5a60702412
commit cc9825e6a2
59 changed files with 5628 additions and 3 deletions

View File

@ -6,7 +6,7 @@ unless RUBY_PLATFORM =~ /w32/
gem 'iconv'
end
gem 'wechat',git: 'https://github.com/guange2015/wechat.git'
gem 'wechat',path: 'lib/wechat'
gem 'grack', path:'lib/grack'
gem 'gitlab', path: 'lib/gitlab-cli'
gem 'rest-client'

View File

@ -1,4 +1,4 @@
source "http://rubygems.org"
source "https://rubygems.org"
gemspec

View File

@ -0,0 +1,6 @@
# Save as .codeclimate.yml (note leading .) in project root directory
languages:
Ruby: true
exclude_paths:
- "lib/generators/wechat/templates/app/controllers/wechats_controller.rb"

17
lib/wechat/.rubocop.yml Normal file
View File

@ -0,0 +1,17 @@
Documentation:
Enabled: false
Metrics/LineLength:
Max: 150
Metrics/AbcSize:
Max: 37
Metrics/ClassLength:
Max: 150
Metrics/MethodLength:
Max: 15
Style/NumericLiterals:
MinDigits: 7

22
lib/wechat/.travis.yml Normal file
View File

@ -0,0 +1,22 @@
language: ruby
sudo: false
rvm:
- 2.0.0
- 2.1
- 2.2
- 2.3
matrix:
allow_failures:
- rvm: 2.3
install:
- "travis_retry bundle config build.nokogiri --use-system-libraries"
- "travis_retry bundle install --retry 3"
script: bundle exec rake
git:
depth: 10

121
lib/wechat/CHANGELOG.md Normal file
View File

@ -0,0 +1,121 @@
# Changelog
## v0.7.1 (released at 1/11/2016)
* Fix after using http, upload file function break. #78
* Add callback function after_wechat_response support. by @zfben #79
* Should using department_id instead of departmentid at enterprise api: user_simplelist/user_list.
## v0.7.0 (released at 1/1/2016)
* Using [http](https://github.com/httprb/http) instead of rest-client for performance reason. (not support upload file yet)
## v0.6.9 (released at 1/6/2016)
* Fix token refresh bug on multi worker. #76
* Rewrite the token relative code to add more storage support in future.
## v0.6.8 (released at 12/25/2015)
* Support Rails 5.0.0.beta1.
* English README available
* Fix oauth2_url calling error, fix #75
## v0.6.7 (released at 12/18/2015)
* Add timeout configuration option, close #74
* New getuserinfo and oauth2_url to support getting FromUserName from web page.
## v0.6.6 (released at 12/15/2015)
* Add jsapi_ticket support for Enterprise Account
* Default generated WechatsController < ActionController::Base, as many Rails application may having #authenticate_user or #set_current_user in ApplicationController, so easily affect the first time using experience.
* New syntax `on :view, with: 'VIEW_URL'` support.
* New command `upload_replaceparty` which combine three sub command to make uploading department easier.
* New command `upload_replaceuser` which combine three sub command to make uploading user easier.
## v0.6.5 (released at 11/24/2015)
* Handle 48001 error if token is expire/not valid, close #71
* ApiLoader will do config reading and initialize the api instead of spreading the logic.
## v0.6.4 (released at 11/16/2015)
* Command mode now display different command set based on enterprise/public account setting
* Move config logic in command/wechat to ApiLoader class
* Unsubscribe can only reply plain text 'success' #68
* Fix 404 qrcode download problem, by @huangxiangdan #69
## v0.6.3 (released at 11/14/2015)
* Official testing and support public encrypt mode, also fix one cipher bug, many thanks to @hlltc #67
* hlltc report public account FILE_BASE no longer needs, clean code #67
* Media command line reflect recent Tecent json schema change. #67
## v0.6.2 (released at 11/05/2015)
* Tecent report location API changed, so change wechat gems also. #64
## v0.6.1 (released at 10/20/2015)
* Handle 40001, invalid credential, access_token is invalid or not latest hint # 57
* Support at Rails 4.2.1 wechat can not run #58
## v0.6.0 (released at 10/08/2015)
### Scan and Batch job are BREAK CHANGE!
* Scan 2D barcode using new syntax `on :scan, with: 'BINDING_QR_CODE' ` instead of `on :event, with: 'BINDING_QR_CODE' ` in previous version #55
Which will fix can not using `on :event, with: "scan" ` problem
* Batch job using new syntax `on :batch_job, with: 'replace_user' `
instead of previous `on :event, with: 'replace_user' `.
* Click menu support new syntax `on :click, with: 'BOOK_LUNCH' `, but `on :event, with: 'BOOK_LUNCH' ` still supported. perfer `on :click` because it running faster and more nature expression.
* Wechat::Responder using Hash for new :client and :batch_job event, avoid time consuming Array match responder
* Fix refresh token not working problem under ruby 2.0.0 #54
* New department_update, user_batchdelete, convert_to_openid API
## v0.5.0 (released at 9/25/2015)
* Only relay on activesupport on run time, so will greatly improve wechat cli startup time
* Add rails generator support `rails g wechat:install`
* Add batch job support for enterprise account like batch create user/department, both API, callback responder and CLI
* Add material management API and CLI
* Add tag API and CLI for enterprise account
* Add QR code scene function for public account
## v0.4.2 (released at 9/7/2015)
* Fix wrong number of arguments at Wechat::Responder.on by using arity #47
* Fix can not access wechat method after using instance level context.
* Fix skip_verify_ssl parameter error.
## v0.4.1 (released at 9/6/2015)
* Limit news articles collection to 10, close #5
* Resolve the conflict with gem "responders" by @seamon #45
## v0.4.0 (released at 9/5/2015)
* Enable the verify SSL for enterprise mode by default, as security is more importent than speed, but still can switch off by configure
* Support scancode_push/scancode_waitmsg event.
* New API method can get wechat server IP list
* New API to query/create department/media/material
* Fix can not read token_file in mingw bug, which introduce at #43
## v0.3.0 (released at 8/30/2015)
* New user group management API
* Allow transfer to customer service on fallback. #42
* Read and write access_token properly using file locking, #43
## v0.2.0 (released at 8/27/2015)
* Add wechat enterprise account support
* Make responder execute in action context, by @lazing #15
* jsapi_ticket support, by @feitian124 #27
* Rename gems to wechat and ambitious to being #1 gems about development wechat. thanks Xiaoning transfer this gem name.
* Original gem `wechat-rails` author skinnyworm trasfer to Eric-Guo as maintainer
## v0.1.1
* Initial release from [wechat-rails](https://github.com/skinnyworm/wechat-rails).

11
lib/wechat/Gemfile Normal file
View File

@ -0,0 +1,11 @@
source 'https://rubygems.org'
gemspec
# jquery-rails is used by the dummy application
gem 'jquery-rails'
gem 'rake', '~> 10.4.2'
gem 'codeclimate-test-reporter', group: :test, require: nil
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

21
lib/wechat/LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 skinnyworm
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

591
lib/wechat/README-CN.md Normal file
View File

@ -0,0 +1,591 @@
WeChat [![Gem Version][version-badge]][rubygems] [![Build Status][travis-badge]][travis] [![Code Climate][codeclimate-badge]][codeclimate] [![Code Coverage][codecoverage-badge]][codecoverage]
======
[![Join the chat][gitter-badge]][gitter] [![Issue Stats][issue-badge]][issuestats] [![PR Stats][pr-badge]][issuestats]
WeChat gem 可以帮助开发者方便地在Rails环境中集成微信[公众平台](https://mp.weixin.qq.com/)和[企业平台](https://qy.weixin.qq.com)提供的服务,包括:
- 微信公众/企业平台[主动消息](http://qydev.weixin.qq.com/wiki/index.php?title=%E5%8F%91%E9%80%81%E6%B6%88%E6%81%AF)API命令行和Web环境都可以使用
- [回调消息](http://qydev.weixin.qq.com/wiki/index.php?title=%E6%8E%A5%E6%94%B6%E6%B6%88%E6%81%AF%E4%B8%8E%E4%BA%8B%E4%BB%B6)必须运行Web服务器
- [微信JS-SDK](http://qydev.weixin.qq.com/wiki/index.php?title=%E5%BE%AE%E4%BF%A1JS%E6%8E%A5%E5%8F%A3) config接口注入权限验证
- OAuth 2.0认证机制
命令行工具`wechat`可以调用各种无需web环境的API。同时也提供了Rails Controller的responder DSL, 可以帮助开发者方便地在Rails应用中集成微信的消息处理包括主动推送的和被动响应的消息。
如果你的App还需要集成微信OAuth2.0, 你可以考虑[omniauth-wechat-oauth2](https://github.com/skinnyworm/omniauth-wechat-oauth2), 以便和devise集成提供完整的用户认证。
如果你对如何制作微信网页UI没有灵感可以参考官方的[weui](https://github.com/weui/weui)针对Rails的Gem是[weui-rails](https://github.com/Eric-Guo/weui-rails)。
## 安装
Using `gem install`
```
gem install "wechat"
```
Or add to your app's `Gemfile`:
```
gem 'wechat'
```
Run the following command to install it:
```console
bundle install
```
Run the generator:
```console
rails generate wechat:install
```
运行`rails g wechat:install`后会自动生成wechat.yml配置还有wechat controller及相关路由配置到当前Rails项目。
## 配置
#### 命令行程序的配置
要使用命令行程序需要在home目录中创建一个`~/.wechat.yml`,包含以下内容。其中`access_token`是存放access_token的文件位置。
```
appid: "my_appid"
secret: "my_secret"
access_token: "/var/tmp/wechat_access_token"
```
Windows或者使用企业号需要存放在`C:/Users/[user_name]/`下其中corpid和corpsecret可以从企业号管理界面的设置->权限管理,通过新建任意一个管理组后获取。
```
corpid: "my_appid"
corpsecret: "my_secret"
agentid: 1 # 企业应用的id整型。可在应用的设置页面查看
access_token: "C:/Users/[user_name]/wechat_access_token"
```
#### Rails 全局配置
Rails应用程序中需要将配置文件放在`config/wechat.yml`可以为不同environment创建不同的配置。
公众号配置示例:
```
default: &default
appid: "app_id"
secret: "app_secret"
token: "app_token"
access_token: "/var/tmp/wechat_access_token"
production:
appid: <%= ENV['WECHAT_APPID'] %>
secret: <%= ENV['WECHAT_APP_SECRET'] %>
token: <%= ENV['WECHAT_TOKEN'] %>
access_token: <%= ENV['WECHAT_ACCESS_TOKEN'] %>
development:
<<: *default
test:
<<: *default
```
公众号可选安全模式(加密模式),通过添加如下配置可开启加密模式。
```
default: &default
encrypt_mode: true
encoding_aes_key: "my_encoding_aes_key"
```
企业号配置下必须使用加密模式其中token和encoding_aes_key可以从企业号管理界面的应用中心->某个应用->模式选择,选择回调模式后获得。
```
default: &default
corpid: "corpid"
corpsecret: "corpsecret"
agentid: 1
access_token: "C:/Users/[user_name]/wechat_access_token"
token: ""
encoding_aes_key: ""
jsapi_ticket: "C:/Users/[user_name]/wechat_jsapi_ticket"
production:
corpid: <%= ENV['WECHAT_CORPID'] %>
corpsecret: <%= ENV['WECHAT_CORPSECRET'] %>
agentid: <%= ENV['WECHAT_AGENTID'] %>
access_token: <%= ENV['WECHAT_ACCESS_TOKEN'] %>
token: <%= ENV['WECHAT_TOKEN'] %>
timeout: 30,
skip_verify_ssl: true
encoding_aes_key: <%= ENV['WECHAT_ENCODING_AES_KEY'] %>
jsapi_ticket: <%= ENV['WECHAT_JSAPI_TICKET'] %>
development:
<<: *default
test:
<<: *default
```
##### 配置优先级
注意在Rails项目根目录下运行`wechat`命令行工具会优先使用`config/wechat.yml`中的`default`配置,如果失败则使用`~\.wechat.yml`中的配置,以便于在生产环境下管理多个微信账号应用。
##### 配置微信服务器超时
微信服务器有时请求会花很长时间如果不配置默认为20秒可视情况配置。
##### 配置跳过SSL认证
Wechat服务器有报道曾出现[RestClient::SSLCertificateNotVerified](http://qydev.weixin.qq.com/qa/index.php?qa=11037)错误此时可以选择关闭SSL验证。`skip_verify_ssl: true`
#### 为每个Responder配置不同的appid和secret
在个别情况下单个Rails应用可能需要处理来自多个账号的消息此时可以配置多个responder controller。
```ruby
class WechatFirstController < ActionController::Base
wechat_responder appid: "app1", secret: "secret1", token: "token1", access_token: Rails.root.join("tmp/access_token1")
on :text, with:"help", respond: "help content"
end
```
#### jssdk 支持
jssdk 使用前需通过config接口注入权限验证配置, 所需参数可以通过 signature 方法获取:
```ruby
WechatsController.wechat.jsapi_ticket.signature(request.original_url)
```
## 关于接口权限
wechat gems 内部不会检查权限。但因公众号类型不同,和微信服务器端通讯时,可能会被拒绝,详细权限控制可参考[官方文档](http://mp.weixin.qq.com/wiki/7/2d301d4b757dedc333b9a9854b457b47.html)。
## 使用命令行
根据企业号和公众号配置不同wechat提供了的命令行命令。
#### 公众号命令行
```
$ wechat
Wechat commands:
wechat callbackip # 获取微信服务器IP地址
wechat custom_image [OPENID, IMAGE_PATH] # 发送图片客服消息
wechat custom_music [OPENID, THUMBNAIL_PATH, MUSIC_URL] # 发送音乐客服消息
wechat custom_news [OPENID, NEWS_YAML_PATH] # 发送图文客服消息
wechat custom_text [OPENID, TEXT_MESSAGE] # 发送文字客服消息
wechat custom_video [OPENID, VIDEO_PATH] # 发送视频客服消息
wechat custom_voice [OPENID, VOICE_PATH] # 发送语音客服消息
wechat group_create [GROUP_NAME] # 创建分组
wechat group_delete [GROUP_ID] # 删除分组
wechat group_update [GROUP_ID, NEW_GROUP_NAME] # 修改分组名
wechat groups # 查询所有分组
wechat material [MEDIA_ID, PATH] # 永久媒体下载
wechat material_add [MEDIA_TYPE, PATH] # 永久媒体上传
wechat material_count # 获取永久素材总数
wechat material_delete [MEDIA_ID] # 删除永久素材
wechat material_list [TYPE, OFFSET, COUNT] # 获取永久素材列表
wechat media [MEDIA_ID, PATH] # 媒体下载
wechat media_create [MEDIA_TYPE, PATH] # 媒体上传
wechat menu # 当前菜单
wechat menu_create [MENU_YAML_PATH] # 创建菜单
wechat menu_delete # 删除菜单
wechat oauth2_url [REDIRECT_URI] # 生成OAuth2.0验证URL
wechat qrcode_create_limit_scene [SCENE_ID_OR_STR] # 请求永久二维码
wechat qrcode_create_scene [SCENE_ID, EXPIRE_SECONDS] # 请求临时二维码
wechat qrcode_download [TICKET, QR_CODE_PIC_PATH] # 通过ticket下载二维码
wechat template_message [OPENID, TEMPLATE_YAML_PATH] # 模板消息接口
wechat user [OPEN_ID] # 获取用户基本信息
wechat user_change_group [OPEN_ID, TO_GROUP_ID] # 移动用户分组
wechat user_group [OPEN_ID] # 查询用户所在分组
wechat user_update_remark [OPEN_ID, REMARK] # 设置备注名
wechat users # 关注者列表
```
#### 企业号命令行
```
$ wechat
Wechat commands:
wechat agent [AGENT_ID] # 获取企业号应用详情
wechat agent_list # 获取应用概况列表
wechat batch_job_result [JOB_ID] # 获取异步任务结果
wechat batch_replaceparty [BATCH_PARTY_CSV_MEDIA_ID] # 全量覆盖部门
wechat batch_replaceuser [BATCH_USER_CSV_MEDIA_ID] # 全量覆盖成员
wechat batch_syncuser [SYNC_USER_CSV_MEDIA_ID] # 增量更新成员
wechat callbackip # 获取微信服务器IP地址
wechat convert_to_openid [USER_ID] # userid转换成openid
wechat custom_image [OPENID, IMAGE_PATH] # 发送图片客服消息
wechat custom_music [OPENID, THUMBNAIL_PATH, MUSIC_URL] # 发送音乐客服消息
wechat custom_news [OPENID, NEWS_YAML_PATH] # 发送图文客服消息
wechat custom_text [OPENID, TEXT_MESSAGE] # 发送文字客服消息
wechat custom_video [OPENID, VIDEO_PATH] # 发送视频客服消息
wechat custom_voice [OPENID, VOICE_PATH] # 发送语音客服消息
wechat department [DEPARTMENT_ID] # 获取部门列表
wechat department_create [NAME, PARENT_ID] # 创建部门
wechat department_delete [DEPARTMENT_ID] # 删除部门
wechat department_update [DEPARTMENT_ID, NAME] # 更新部门
wechat invite_user [USER_ID] # 邀请成员关注
wechat material [MEDIA_ID, PATH] # 永久媒体下载
wechat material_add [MEDIA_TYPE, PATH] # 永久媒体上传
wechat material_count # 获取永久素材总数
wechat material_delete [MEDIA_ID] # 删除永久素材
wechat material_list [TYPE, OFFSET, COUNT] # 获取永久素材列表
wechat media [MEDIA_ID, PATH] # 媒体下载
wechat media_create [MEDIA_TYPE, PATH] # 媒体上传
wechat menu # 当前菜单
wechat menu_create [MENU_YAML_PATH] # 创建菜单
wechat menu_delete # 删除菜单
wechat message_send [OPENID, TEXT_MESSAGE] # 发送文字消息
wechat oauth2_url [REDIRECT_URI] # 生成OAuth2.0验证URL
wechat qrcode_download [TICKET, QR_CODE_PIC_PATH] # 通过ticket下载二维码
wechat tag [TAG_ID] # 获取标签成员
wechat tag_add_department [TAG_ID, PARTY_IDS] # 增加标签部门
wechat tag_add_user [TAG_ID, USER_IDS] # 增加标签成员
wechat tag_create [TAGNAME, TAG_ID] # 创建标签
wechat tag_del_department [TAG_ID, PARTY_IDS] # 删除标签部门
wechat tag_del_user [TAG_ID, USER_IDS] # 删除标签成员
wechat tag_delete [TAG_ID] # 删除标签
wechat tag_update [TAG_ID, TAGNAME] # 更新标签名字
wechat tags # 获取标签列表
wechat template_message [OPENID, TEMPLATE_YAML_PATH] # 模板消息接口
wechat upload_replaceparty [BATCH_PARTY_CSV_PATH] # 上传文件方式全量覆盖部门
wechat upload_replaceuser [BATCH_USER_CSV_PATH] # 上传文件方式全量覆盖成员
wechat user [OPEN_ID] # 获取用户基本信息
wechat user_batchdelete [USER_ID_LIST] # 批量删除成员
wechat user_delete [USER_ID] # 删除成员
wechat user_list [DEPARTMENT_ID] # 获取部门成员详情
wechat user_simplelist [DEPARTMENT_ID] # 获取部门成员
wechat user_update_remark [OPEN_ID, REMARK] # 设置备注名
```
### 使用场景
以下是几种典型场景的使用方法
#####获取所有用户的OPENID
```
$ wechat users
{"total"=>4, "count"=>4, "data"=>{"openid"=>["oCfEht9***********", "oCfEhtwqa***********", "oCfEht9oMCqGo***********", "oCfEht_81H5o2***********"]}, "next_openid"=>"oCfEht_81H5o2***********"}
```
#####获取用户的信息
```
$ wechat user "oCfEht9***********"
{"subscribe"=>1, "openid"=>"oCfEht9***********", "nickname"=>"Nickname", "sex"=>1, "language"=>"zh_CN", "city"=>"徐汇", "province"=>"上海", "country"=>"中国", "headimgurl"=>"http://wx.qlogo.cn/mmopen/ajNVdqHZLLBd0SG8NjV3UpXZuiaGGPDcaKHebTKiaTyof*********/0", "subscribe_time"=>1395715239}
```
##### 获取当前菜单
```
$ wechat menu
{"menu"=>{"button"=>[{"type"=>"view", "name"=>"保护的", "url"=>"http://***/protected", "sub_button"=>[]}, {"type"=>"view", "name"=>"公开的", "url"=>"http://***", "sub_button"=>[]}]}}
```
##### 创建菜单
创建菜单需要一个定义菜单内容的yaml文件比如
menu.yaml
```
button:
-
name: "我要"
sub_button:
-
type: "scancode_waitmsg"
name: "绑定用餐二维码"
key: "BINDING_QR_CODE"
-
type: "click"
name: "预订午餐"
key: "BOOK_LUNCH"
-
type: "click"
name: "预订晚餐"
key: "BOOK_DINNER"
-
name: "查询"
sub_button:
-
type: "click"
name: "进出记录"
key: "BADGE_IN_OUT"
-
type: "click"
name: "年假余额"
key: "ANNUAL_LEAVE"
-
type: "view"
name: "关于"
url: "http://blog.cloud-mes.com/"
```
然后执行命令行,需确保设置,权限管理中有对此应用的管理权限,否则会报[60011](http://qydev.weixin.qq.com/wiki/index.php?title=%E5%85%A8%E5%B1%80%E8%BF%94%E5%9B%9E%E7%A0%81%E8%AF%B4%E6%98%8E)错。
```
$ wechat menu_create menu.yaml
```
##### 发送客服图文消息
需定义一个图文消息内容的yaml文件比如
articles.yaml
```
articles:
-
title: "习近平在布鲁日欧洲学院演讲"
description: "新华网比利时布鲁日4月1日电 国家主席习近平1日在比利时布鲁日欧洲学院发表重要演讲"
url: "http://news.sina.com.cn/c/2014-04-01/232629843387.shtml"
pic_url: "http://i3.sinaimg.cn/dy/c/2014-04-01/1396366518_bYays1.jpg"
```
然后执行命令行
```
$ wechat custom_news oCfEht9oM*********** articles.yml
```
##### 发送模板消息
需定义一个模板消息内容的yaml文件比如
template.yml
```
template:
template_id: "o64KQ62_xxxxxxxxxxxxxxx-Qz-MlNcRKteq8"
url: "http://weixin.qq.com/download"
topcolor: "#FF0000"
data:
first:
value: "你好,你已报名成功"
color: "#0A0A0A"
keynote1:
value: "XX活动"
color: "#CCCCCC"
keynote2:
value: "2014年9月16日"
color: "#CCCCCC"
keynote3:
value: "上海徐家汇xxx城"
color: "#CCCCCC"
remark:
value: "欢迎再次使用。"
color: "#173177"
```
然后执行命令行
```
$ wechat template_message oCfEht9oM*********** template.yml
```
## Rails Responder Controller DSL
为了在Rails app中响应用户的消息开发者需要创建一个wechat responder controller. 首先在router中定义
```ruby
resource :wechat, only:[:show, :create]
```
然后创建Controller class, 例如
```ruby
class WechatsController < ActionController::Base
wechat_responder
# 默认文字信息responder
on :text do |request, content|
request.reply.text "echo: #{content}" #Just echo
end
# 当请求的文字信息内容为'help'时, 使用这个responder处理
on :text, with: 'help' do |request|
request.reply.text 'help content' #回复帮助信息
end
# 当请求的文字信息内容为'<n>条新闻'时, 使用这个responder处理, 并将n作为第二个参数
on :text, with: /^(\d+)条新闻$/ do |request, count|
# 微信最多显示10条新闻大于10条将只取前10条
news = (1..count.to_i).each_with_object([]) { |n, memo| memo << { title: '新闻标题', content: "第#{n}条新闻的内容#{n.hash}" } }
request.reply.news(news) do |article, n, index| # 回复"articles"
article.item title: "#{index} #{n[:title]}", description: n[:content], pic_url: 'http://www.baidu.com/img/bdlogo.gif', url: 'http://www.baidu.com/'
end
end
# 当用户加关注
on :event, with: 'subscribe' do |request|
request.reply.text "User #{request[:FromUserName]} subscribe now"
end
# 公众号收到未关注用户扫描qrscene_xxxxxx二维码时。注意此次扫描事件将不再引发上条的用户加关注事件
on :scan, with: 'qrscene_xxxxxx' do |request, ticket|
request.reply.text "Unsubscribe user #{request[:FromUserName]} Ticket #{ticket}"
end
# 公众号收到已关注用户扫描创建二维码的scene_id事件时
on :scan, with: 'scene_id' do |request, ticket|
request.reply.text "Subscribe user #{request[:FromUserName]} Ticket #{ticket}"
end
# 当没有任何on :scan事件处理已关注用户扫描的scene_id时
on :event, with: 'scan' do |request|
if request[:EventKey].present?
request.reply.text "event scan got EventKey #{request[:EventKey]} Ticket #{request[:Ticket]}"
end
end
# 企业号收到EventKey 为二维码扫描结果事件时
on :scan, with: 'BINDING_QR_CODE' do |request, scan_result, scan_type|
request.reply.text "User #{request[:FromUserName]} ScanResult #{scan_result} ScanType #{scan_type}"
end
# 企业号收到EventKey 为CODE 39码扫描结果事件时
on :scan, with: 'BINDING_BARCODE' do |message, scan_result|
if scan_result.start_with? 'CODE_39,'
message.reply.text "User: #{message[:FromUserName]} scan barcode, result is #{scan_result.split(',')[1]}"
end
end
# 当用户点击菜单时
on :click, with: 'BOOK_LUNCH' do |request, key|
request.reply.text "User: #{request[:FromUserName]} click #{key}"
end
# 当用户点击菜单时
on :view, with: 'http://wechat.somewhere.com/view_url' do |request, view|
request.reply.text "#{request[:FromUserName]} view #{view}"
end
# 处理图片信息
on :image do |request|
request.reply.image(request[:MediaId]) #直接将图片返回给用户
end
# 处理语音信息
on :voice do |request|
request.reply.voice(request[:MediaId]) #直接语音音返回给用户
end
# 处理视频信息
on :video do |request|
nickname = wechat.user(request[:FromUserName])['nickname'] #调用 api 获得发送者的nickname
request.reply.video(request[:MediaId], title: '回声', description: "#{nickname}发来的视频请求") #直接视频返回给用户
end
# 处理上报地理位置事件
on :location do |request|
request.reply.text("Latitude: #{message[:Latitude]} Longitude: #{message[:Longitude]} Precision: #{message[:Precision]}")
end
# 当用户取消关注订阅
on :event, with: 'unsubscribe' do |request|
request.reply.success # user can not receive this message
end
# 成员进入应用的事件推送
on :event, with: 'enter_agent' do |request|
request.reply.text "#{request[:FromUserName]} enter agent app now"
end
# 当异步任务增量更新成员完成时推送
on :batch_job, with: 'sync_user' do |request, batch_job|
request.reply.text "job #{batch_job[:JobId]} finished, return code #{batch_job[:ErrCode]}, return message #{batch_job[:ErrMsg]}"
end
# 当异步任务全量覆盖成员完成时推送
on :batch_job, with: 'replace_user' do |request, batch_job|
request.reply.text "job #{batch_job[:JobId]} finished, return code #{batch_job[:ErrCode]}, return message #{batch_job[:ErrMsg]}"
end
# 当异步任务邀请成员关注完成时推送
on :batch_job, with: 'invite_user' do |request, batch_job|
request.reply.text "job #{batch_job[:JobId]} finished, return code #{batch_job[:ErrCode]}, return message #{batch_job[:ErrMsg]}"
end
# 当异步任务全量覆盖部门完成时推送
on :batch_job, with: 'replace_party' do |request, batch_job|
request.reply.text "job #{batch_job[:JobId]} finished, return code #{batch_job[:ErrCode]}, return message #{batch_job[:ErrMsg]}"
end
# 当无任何responder处理用户信息时,使用这个responder处理
on :fallback, respond: 'fallback message'
# 如果你要在微信回复后增加一些操作,可以用 after_wechat_response(req, res)
# private
#
# def after_wechat_response(req, res)
# WechatLog.create req: req, res: res
# end
end
```
在controller中使用`wechat_responder`引入Responder DSL, 之后可以用
```
on <message_type> do |message|
message.reply.text "some text"
end
```
来响应用户信息。
目前支持的message_type有如下几种
- :text 响应文字消息,可以用`:with`参数来匹配文本内容 `on(:text, with:'help'){|message, content| ...}`
- :image 响应图片消息
- :voice 响应语音消息
- :video 响应视频消息
- :link 响应链接消息
- :event 响应事件消息, 可以用`:with`参数来匹配事件类型
- :click 虚拟响应事件消息, 微信传入:event但gem内部会单独处理
- :view 虚拟响应事件消息, 微信传入:event但gem内部会单独处理
- :scan 虚拟响应事件消息
- :batch_job 虚拟响应事件消息
- :location 虚拟响应上报地理位置事件消息
- :fallback 默认响应当收到的消息无法被其他responder响应时会使用这个responder.
### 多客服消息转发
```ruby
class WechatsController < ActionController::Base
# 当无任何responder处理用户信息时转发至客服处理。
on :fallback do |message|
message.reply.transfer_customer_service
end
end
```
注意设置了[多客服消息转发](http://dkf.qq.com/)后,不能再添加`默认文字信息responder`,否则文字消息将得不到转发。
## 已知问题
* 企业号接受菜单消息时Wechat腾讯服务器无法解析部分域名请使用IP绑定回调URL用户的普通消息目前不受影响。
* 企业号全量覆盖成员使用的csv通讯录格式直接将下载的模板导入[是不工作的](http://qydev.weixin.qq.com/qa/index.php?qa=13978)必须使用Excel打开然后另存为csv格式才会变成合法格式。
[version-badge]: https://badge.fury.io/rb/wechat.svg
[rubygems]: https://rubygems.org/gems/wechat
[travis-badge]: https://travis-ci.org/Eric-Guo/wechat.svg
[travis]: https://travis-ci.org/Eric-Guo/wechat
[codeclimate-badge]: https://codeclimate.com/github/Eric-Guo/wechat.png
[codeclimate]: https://codeclimate.com/github/Eric-Guo/wechat
[codecoverage-badge]: https://codeclimate.com/github/Eric-Guo/wechat/coverage.png
[codecoverage]: https://codeclimate.com/github/Eric-Guo/wechat/coverage
[gitter-badge]: https://badges.gitter.im/Join%20Chat.svg
[gitter]: https://gitter.im/Eric-Guo/wechat?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge
[issue-badge]: http://issuestats.com/github/Eric-Guo/wechat/badge/issue
[pr-badge]: http://issuestats.com/github/Eric-Guo/wechat/badge/pr
[issuestats]: http://issuestats.com/github/Eric-Guo/wechat

605
lib/wechat/README.md Normal file
View File

@ -0,0 +1,605 @@
WeChat [![Gem Version][version-badge]][rubygems] [![Build Status][travis-badge]][travis] [![Code Climate][codeclimate-badge]][codeclimate] [![Code Coverage][codecoverage-badge]][codecoverage]
======
[![Join the chat][gitter-badge]][gitter] [![Issue Stats][issue-badge]][issuestats] [![PR Stats][pr-badge]][issuestats]
[中文文档 Chinese document](/README-CN.md)
[Wechat](http://www.wechat.com/) is a free messaging and calling app developed by [Tencent](http://tencent.com/en-us/index.shtml), after linked billion people, Wechat become a platform of application
WeChat gem trying to helping Rails developer to integrated [enterprise account](https://qy.weixin.qq.com) / [public account](https://mp.weixin.qq.com/) easily. Below feature is ready and no need writing adapter code talking to wechat server directly.
- [Sending message](http://qydev.weixin.qq.com/wiki/index.php?title=%E5%8F%91%E9%80%81%E6%B6%88%E6%81%AF) APICan access via console or in rails
- [Receiving message](http://qydev.weixin.qq.com/wiki/index.php?title=%E6%8E%A5%E6%94%B6%E6%B6%88%E6%81%AF%E4%B8%8E%E4%BA%8B%E4%BB%B6)You must running on rails server to receiving message
- [Wechat JS-SDK](http://qydev.weixin.qq.com/wiki/index.php?title=%E5%BE%AE%E4%BF%A1JS%E6%8E%A5%E5%8F%A3) config signature
- OAuth 2.0 authentication
`wechat` command share the same API in console, so you can interactive with wechat server quickly, without starting up web environment/code.
A responder DSL can used in Rails controller, so giving a event based interface to handler message sent by end user from wechat server.
Wechat provide OAuth2.0 as authentication service and possible to intergrated with devise/other authorization gems, [omniauth-wechat-oauth2](https://github.com/skinnyworm/omniauth-wechat-oauth2) is a good start
There is official [weui](https://github.com/weui/weui), which corresponding Rails gems called [weui-rails](https://github.com/Eric-Guo/weui-rails) available, if you prefer following the same UI design as wechat.
## Installation
Using `gem install`
```
gem install "wechat"
```
Or add to your app's `Gemfile`:
```
gem 'wechat'
```
Run the following command to install it:
```console
bundle install
```
Run the generator:
```console
rails generate wechat:install
```
`rails g wechat:install` will generated the initial `wechat.yml` configuration, example wechat controller and corresponding routes.
## Configuration
#### Configure for command line
You can using `wechat` command solely, you need created configure file `~/.wechat.yml` and including below content for public account. the access_token will be write as a file.
```
appid: "my_appid"
secret: "my_secret"
access_token: "/var/tmp/wechat_access_token"
```
For enterprise account, need using `corpid` instead of `appid` as enterprise account support multiply application (Tencent called agent) in one enterprise account. Obtain the `corpsecret` is a little tricky, must created at management mode->privilege setting and create any of management group to obtain. Due to Tencent currently only provide Chinese interface for they management console, it's highly recommend you find a college knowing Mandarin to help you to obtain.
Windows user need store `.wechat.yml` at `C:/Users/[user_name]/` (replace your user name), also be caution the direction of folder separator.
```
corpid: "my_appid"
corpsecret: "my_secret"
agentid: 1 # Integer, which can be obtain from application, settings
access_token: "C:/Users/[user_name]/wechat_access_token"
```
#### Configure for Rails
Rails configuration files support different environment similar to database.yml, after running `rails generate wechat:install` you can find configuration files at `config/wechat.yml`
Public account congfigure example
```
default: &default
appid: "app_id"
secret: "app_secret"
token: "app_token"
access_token: "/var/tmp/wechat_access_token"
production:
appid: <%= ENV['WECHAT_APPID'] %>
secret: <%= ENV['WECHAT_APP_SECRET'] %>
token: <%= ENV['WECHAT_TOKEN'] %>
access_token: <%= ENV['WECHAT_ACCESS_TOKEN'] %>
development:
<<: *default
test:
<<: *default
```
Although it's optional for public account, but highly recommend to enable encrypt mode by add below two items to `wechat.yml`
```
default: &default
encrypt_mode: true
encoding_aes_key: "my_encoding_aes_key"
```
Enterprise account must using encrypt mode (`encrypt_mode: true` is default on, no need configure).
The `token` and `encoding_aes_key` can be obtain from management console -> one of the agent application -> Mode selection, select callback mode and get/set.
```
default: &default
corpid: "corpid"
corpsecret: "corpsecret"
agentid: 1
access_token: "C:/Users/[user_name]/wechat_access_token"
token: ""
encoding_aes_key: ""
jsapi_ticket: "C:/Users/[user_name]/wechat_jsapi_ticket"
production:
corpid: <%= ENV['WECHAT_CORPID'] %>
corpsecret: <%= ENV['WECHAT_CORPSECRET'] %>
agentid: <%= ENV['WECHAT_AGENTID'] %>
access_token: <%= ENV['WECHAT_ACCESS_TOKEN'] %>
token: <%= ENV['WECHAT_TOKEN'] %>
timeout: 30,
skip_verify_ssl: true # not recommend
encoding_aes_key: <%= ENV['WECHAT_ENCODING_AES_KEY'] %>
jsapi_ticket: <%= ENV['WECHAT_JSAPI_TICKET'] %>
development:
<<: *default
test:
<<: *default
```
##### Configure priority
Running `wechat` command in the root folder of Rails application will using the Rails configuration first (`default` section), if can not find it, will relay on `~\.wechat.yml`, such behavior enable manage more wechat public account and enterprise account without changing your home `~\.wechat.yml` file.
##### Wechat server timeout setting
Stability various even for Tencent wechat server, so setting a long timeout may needed, default is 20 seconds if not set.
##### Skip the SSL verification
SSL Certification can also be corrupted by some reason in China, [it's reported](http://qydev.weixin.qq.com/qa/index.php?qa=11037) and if it's happen to you, can setting `skip_verify_ssl: true`. (not recommend)
#### Configure individual responder with different appid
Rare case, you may want to hosting more than one wechat enterprise/public account in one Rails application, so you can given those configuration info when calling `wechat_responder`
```ruby
class WechatFirstController < ActionController::Base
wechat_responder appid: "app1", secret: "secret1", token: "token1", access_token: Rails.root.join("tmp/access_token1")
on :text, with:"help", respond: "help content"
end
```
#### JS-SDK helper
JS-SDK enable you control wechat behavior in your web page, but need inject a config with signature methods first, you can obtain those signature hash via below
```ruby
WechatsController.wechat.jsapi_ticket.signature(request.original_url)
```
## The API privilege
wechat gems won't handle any privilege exception. (except token time out, but it's not important to you as it's auto retry/recovery in gems internally), but Tencent will control a lot of privilege based on your public account type and certification, more info, please reference [official document](http://mp.weixin.qq.com/wiki/7/2d301d4b757dedc333b9a9854b457b47.html).
## Command line mode
The available API is different between public account and enterprise account, so wechat gems provide different set of command.
Feel safe if you can not read Chinese in the comments, it's keep there in order to copy & find in official document more easier.
#### Public account command line
```
$ wechat
Wechat commands:
wechat callbackip # 获取微信服务器IP地址
wechat custom_image [OPENID, IMAGE_PATH] # 发送图片客服消息
wechat custom_music [OPENID, THUMBNAIL_PATH, MUSIC_URL] # 发送音乐客服消息
wechat custom_news [OPENID, NEWS_YAML_PATH] # 发送图文客服消息
wechat custom_text [OPENID, TEXT_MESSAGE] # 发送文字客服消息
wechat custom_video [OPENID, VIDEO_PATH] # 发送视频客服消息
wechat custom_voice [OPENID, VOICE_PATH] # 发送语音客服消息
wechat group_create [GROUP_NAME] # 创建分组
wechat group_delete [GROUP_ID] # 删除分组
wechat group_update [GROUP_ID, NEW_GROUP_NAME] # 修改分组名
wechat groups # 查询所有分组
wechat material [MEDIA_ID, PATH] # 永久媒体下载
wechat material_add [MEDIA_TYPE, PATH] # 永久媒体上传
wechat material_count # 获取永久素材总数
wechat material_delete [MEDIA_ID] # 删除永久素材
wechat material_list [TYPE, OFFSET, COUNT] # 获取永久素材列表
wechat media [MEDIA_ID, PATH] # 媒体下载
wechat media_create [MEDIA_TYPE, PATH] # 媒体上传
wechat menu # 当前菜单
wechat menu_create [MENU_YAML_PATH] # 创建菜单
wechat menu_delete # 删除菜单
wechat oauth2_url [REDIRECT_URI] # 生成OAuth2.0验证URL
wechat qrcode_create_limit_scene [SCENE_ID_OR_STR] # 请求永久二维码
wechat qrcode_create_scene [SCENE_ID, EXPIRE_SECONDS] # 请求临时二维码
wechat qrcode_download [TICKET, QR_CODE_PIC_PATH] # 通过ticket下载二维码
wechat template_message [OPENID, TEMPLATE_YAML_PATH] # 模板消息接口
wechat user [OPEN_ID] # 获取用户基本信息
wechat user_change_group [OPEN_ID, TO_GROUP_ID] # 移动用户分组
wechat user_group [OPEN_ID] # 查询用户所在分组
wechat user_update_remark [OPEN_ID, REMARK] # 设置备注名
wechat users # 关注者列表
```
#### Enterprise account command line
```
$ wechat
Wechat commands:
wechat agent [AGENT_ID] # 获取企业号应用详情
wechat agent_list # 获取应用概况列表
wechat batch_job_result [JOB_ID] # 获取异步任务结果
wechat batch_replaceparty [BATCH_PARTY_CSV_MEDIA_ID] # 全量覆盖部门
wechat batch_replaceuser [BATCH_USER_CSV_MEDIA_ID] # 全量覆盖成员
wechat batch_syncuser [SYNC_USER_CSV_MEDIA_ID] # 增量更新成员
wechat callbackip # 获取微信服务器IP地址
wechat convert_to_openid [USER_ID] # userid转换成openid
wechat custom_image [OPENID, IMAGE_PATH] # 发送图片客服消息
wechat custom_music [OPENID, THUMBNAIL_PATH, MUSIC_URL] # 发送音乐客服消息
wechat custom_news [OPENID, NEWS_YAML_PATH] # 发送图文客服消息
wechat custom_text [OPENID, TEXT_MESSAGE] # 发送文字客服消息
wechat custom_video [OPENID, VIDEO_PATH] # 发送视频客服消息
wechat custom_voice [OPENID, VOICE_PATH] # 发送语音客服消息
wechat department [DEPARTMENT_ID] # 获取部门列表
wechat department_create [NAME, PARENT_ID] # 创建部门
wechat department_delete [DEPARTMENT_ID] # 删除部门
wechat department_update [DEPARTMENT_ID, NAME] # 更新部门
wechat invite_user [USER_ID] # 邀请成员关注
wechat material [MEDIA_ID, PATH] # 永久媒体下载
wechat material_add [MEDIA_TYPE, PATH] # 永久媒体上传
wechat material_count # 获取永久素材总数
wechat material_delete [MEDIA_ID] # 删除永久素材
wechat material_list [TYPE, OFFSET, COUNT] # 获取永久素材列表
wechat media [MEDIA_ID, PATH] # 媒体下载
wechat media_create [MEDIA_TYPE, PATH] # 媒体上传
wechat menu # 当前菜单
wechat menu_create [MENU_YAML_PATH] # 创建菜单
wechat menu_delete # 删除菜单
wechat message_send [OPENID, TEXT_MESSAGE] # 发送文字消息
wechat oauth2_url [REDIRECT_URI] # 生成OAuth2.0验证URL
wechat qrcode_download [TICKET, QR_CODE_PIC_PATH] # 通过ticket下载二维码
wechat tag [TAG_ID] # 获取标签成员
wechat tag_add_department [TAG_ID, PARTY_IDS] # 增加标签部门
wechat tag_add_user [TAG_ID, USER_IDS] # 增加标签成员
wechat tag_create [TAGNAME, TAG_ID] # 创建标签
wechat tag_del_department [TAG_ID, PARTY_IDS] # 删除标签部门
wechat tag_del_user [TAG_ID, USER_IDS] # 删除标签成员
wechat tag_delete [TAG_ID] # 删除标签
wechat tag_update [TAG_ID, TAGNAME] # 更新标签名字
wechat tags # 获取标签列表
wechat template_message [OPENID, TEMPLATE_YAML_PATH] # 模板消息接口
wechat upload_replaceparty [BATCH_PARTY_CSV_PATH] # 上传文件方式全量覆盖部门
wechat upload_replaceuser [BATCH_USER_CSV_PATH] # 上传文件方式全量覆盖成员
wechat user [OPEN_ID] # 获取用户基本信息
wechat user_batchdelete [USER_ID_LIST] # 批量删除成员
wechat user_delete [USER_ID] # 删除成员
wechat user_list [DEPARTMENT_ID] # 获取部门成员详情
wechat user_simplelist [DEPARTMENT_ID] # 获取部门成员
wechat user_update_remark [OPEN_ID, REMARK] # 设置备注名
```
### Command line usage demo (partially)
##### Fetch all users open id
```
$ wechat users
{"total"=>4, "count"=>4, "data"=>{"openid"=>["oCfEht9***********", "oCfEhtwqa***********", "oCfEht9oMCqGo***********", "oCfEht_81H5o2***********"]}, "next_openid"=>"oCfEht_81H5o2***********"}
```
##### Fetch user info
```
$ wechat user "oCfEht9***********"
{"subscribe"=>1, "openid"=>"oCfEht9***********", "nickname"=>"Nickname", "sex"=>1, "language"=>"zh_CN", "city"=>"徐汇", "province"=>"上海", "country"=>"中国", "headimgurl"=>"http://wx.qlogo.cn/mmopen/ajNVdqHZLLBd0SG8NjV3UpXZuiaGGPDcaKHebTKiaTyof*********/0", "subscribe_time"=>1395715239}
```
##### Fetch menu
```
$ wechat menu
{"menu"=>{"button"=>[{"type"=>"view", "name"=>"保护的", "url"=>"http://***/protected", "sub_button"=>[]}, {"type"=>"view", "name"=>"公开的", "url"=>"http://***", "sub_button"=>[]}]}}
```
##### Menu create
menu content for a wechat application can be defined as a yaml files, like `menu.yaml`
```
button:
-
name: "Want"
sub_button:
-
type: "scancode_waitmsg"
name: "绑定用餐二维码"
key: "BINDING_QR_CODE"
-
type: "click"
name: "预订午餐"
key: "BOOK_LUNCH"
-
type: "click"
name: "预订晚餐"
key: "BOOK_DINNER"
-
name: "Query"
sub_button:
-
type: "click"
name: "进出记录"
key: "BADGE_IN_OUT"
-
type: "click"
name: "年假余额"
key: "ANNUAL_LEAVE"
-
type: "view"
name: "About"
url: "http://blog.cloud-mes.com/"
```
Caution: make sure you having management privilege for those application below running below command, other will got [60011](http://qydev.weixin.qq.com/wiki/index.php?title=%E5%85%A8%E5%B1%80%E8%BF%94%E5%9B%9E%E7%A0%81%E8%AF%B4%E6%98%8E) error.
```
$ wechat menu_create menu.yaml
```
##### Sent custom news
Sending custom_news should also defined as a yaml file, like `articles.yaml`
```
articles:
-
title: "习近平在布鲁日欧洲学院演讲"
description: "新华网比利时布鲁日4月1日电 国家主席习近平1日在比利时布鲁日欧洲学院发表重要演讲"
url: "http://news.sina.com.cn/c/2014-04-01/232629843387.shtml"
pic_url: "http://i3.sinaimg.cn/dy/c/2014-04-01/1396366518_bYays1.jpg"
```
After that, can running command:
```
$ wechat custom_news oCfEht9oM*********** articles.yml
```
##### Sent template message
Sending template message via yaml file similar too, defined `template.yml` and content is just the template content.
```
template:
template_id: "o64KQ62_xxxxxxxxxxxxxxx-Qz-MlNcRKteq8"
url: "http://weixin.qq.com/download"
topcolor: "#FF0000"
data:
first:
value: "Hello, you successfully registed"
color: "#0A0A0A"
keynote1:
value: "5km Health Running"
color: "#CCCCCC"
keynote2:
value: "2014-09-16"
color: "#CCCCCC"
keynote3:
value: "Centry Park, Pudong, Shanghai"
color: "#CCCCCC"
remark:
value: "Welcome back"
color: "#173177"
```
After that, can running command:
```
$ wechat template_message oCfEht9oM*********** template.yml
```
## Rails Responder Controller DSL
In order to responding the message user sent, Rails developer need create a wechat responder controller and defined the routing in `routes.rb`
```ruby
resource :wechat, only:[:show, :create]
```
So the ActionController should defined like below:
```ruby
class WechatsController < ActionController::Base
wechat_responder
# default text responder when no other match
on :text do |request, content|
request.reply.text "echo: #{content}" # Just echo
end
# When receive 'help', will trigger this responder
on :text, with: 'help' do |request|
request.reply.text 'help content'
end
# When receive '<n>news', will match and will got count as <n> as parameter
on :text, with: /^(\d+) news$/ do |request, count|
# Wechat article can only contain max 10 items, large than 10 will dropped.
news = (1..count.to_i).each_with_object([]) { |n, memo| memo << { title: 'News title', content: "No. #{n} news content" } }
request.reply.news(news) do |article, n, index| # article is return object
article.item title: "#{index} #{n[:title]}", description: n[:content], pic_url: 'http://www.baidu.com/img/bdlogo.gif', url: 'http://www.baidu.com/'
end
end
on :event, with: 'subscribe' do |request|
request.reply.text "#{request[:FromUserName]} subscribe now"
end
# When unsubscribe user scan qrcode qrscene_xxxxxx to subscribe in public account
# notice user will subscribe public account at same time, so wechat won't trigger subscribe event any more
on :scan, with: 'qrscene_xxxxxx' do |request, ticket|
request.reply.text "Unsubscribe user #{request[:FromUserName]} Ticket #{ticket}"
end
# When subscribe user scan scene_id in public account
on :scan, with: 'scene_id' do |request, ticket|
request.reply.text "Subscribe user #{request[:FromUserName]} Ticket #{ticket}"
end
# When no any on :scan responder can match subscribe user scaned scene_id
on :event, with: 'scan' do |request|
if request[:EventKey].present?
request.reply.text "event scan got EventKey #{request[:EventKey]} Ticket #{request[:Ticket]}"
end
end
# When enterprise user press menu BINDING_QR_CODE and success to scan bar code
on :scan, with: 'BINDING_QR_CODE' do |request, scan_result, scan_type|
request.reply.text "User #{request[:FromUserName]} ScanResult #{scan_result} ScanType #{scan_type}"
end
# Except QR code, wechat can also scan CODE_39 bar code in enterprise account
on :scan, with: 'BINDING_BARCODE' do |message, scan_result|
if scan_result.start_with? 'CODE_39,'
message.reply.text "User: #{message[:FromUserName]} scan barcode, result is #{scan_result.split(',')[1]}"
end
end
# When user click the menu button
on :click, with: 'BOOK_LUNCH' do |request, key|
request.reply.text "User: #{request[:FromUserName]} click #{key}"
end
# When user view URL in the menu button
on :view, with: 'http://wechat.somewhere.com/view_url' do |request, view|
request.reply.text "#{request[:FromUserName]} view #{view}"
end
# When user sent the imsage
on :image do |request|
request.reply.image(request[:MediaId]) # Echo the sent image to user
end
# When user sent the voice
on :voice do |request|
request.reply.voice(request[:MediaId]) # Echo the sent voice to user
end
# When user sent the video
on :video do |request|
nickname = wechat.user(request[:FromUserName])['nickname'] # Call wechat api to get sender nickname
request.reply.video(request[:MediaId], title: 'Echo', description: "Got #{nickname} sent video") # Echo the sent video to user
end
# When user sent location
on :location do |request|
request.reply.text("Latitude: #{message[:Latitude]} Longitude: #{message[:Longitude]} Precision: #{message[:Precision]}")
end
on :event, with: 'unsubscribe' do |request|
request.reply.success # user can not receive this message
end
# When user enter the app / agent app
on :event, with: 'enter_agent' do |request|
request.reply.text "#{request[:FromUserName]} enter agent app now"
end
# When batch job create/update user (incremental) finished.
on :batch_job, with: 'sync_user' do |request, batch_job|
request.reply.text "sync_user job #{batch_job[:JobId]} finished, return code #{batch_job[:ErrCode]}, return message #{batch_job[:ErrMsg]}"
end
# When batch job replace user (full sync) finished.
on :batch_job, with: 'replace_user' do |request, batch_job|
request.reply.text "replace_user job #{batch_job[:JobId]} finished, return code #{batch_job[:ErrCode]}, return message #{batch_job[:ErrMsg]}"
end
# When batch job invent user finished.
on :batch_job, with: 'invite_user' do |request, batch_job|
request.reply.text "invite_user job #{batch_job[:JobId]} finished, return code #{batch_job[:ErrCode]}, return message #{batch_job[:ErrMsg]}"
end
# When batch job replace department (full sync) finished.
on :batch_job, with: 'replace_party' do |request, batch_job|
request.reply.text "replace_party job #{batch_job[:JobId]} finished, return code #{batch_job[:ErrCode]}, return message #{batch_job[:ErrMsg]}"
end
# Any not match above will fail to below
on :fallback, respond: 'fallback message'
# If you need do something after response, you should add after_wechat_response(req, res)
# private
#
# def after_wechat_response(req, res)
# WechatLog.create req: req, res: res
# end
end
```
So the importent statement is only `wechat_responder`, all other is just a DSL:
```
on <message_type> do |message|
message.reply.text "some text"
end
```
The block code will be running to responding user's message.
Below is current supported message_type:
- :text text message, using `:with` to match text content like `on(:text, with:'help'){|message, content| ...}`
- :image image message
- :voice voice message
- :video video message
- :link link message
- :event event message, using `:with` to match particular event
- :click virtual event message, wechat still sent event messagebut gems will mapping to menu click event.
- :view virtual view message, wechat still sent event messagebut gems will mapping to menu view page event.
- :scan virtual scan message, wechat still sent event message, but gems will mapping on scan event
- :batch_job virtual batch job message
- :location virtual location message
- :fallback default message, when no other responder can handle incoming messsage, will using fallback to handle
### Transfer to customer service
```ruby
class WechatsController < ActionController::Base
# When no other responder can handle incoming message, will transfer to human customer service.
on :fallback do |message|
message.reply.transfer_customer_service
end
end
```
Caution: do not setting default text responder if you want to using [multiply human customer service](http://dkf.qq.com/), other will lead text message can not transfer.
## Known Issue
* Sometime, enterprise account can not receive the menu message due to Tencent server can not resolved the DNS, so using IP as a callback URL more stable, but it's never happen for user sent text message.
* Enterprise batch replace users using a CSV format file, but if you using the download template directly, it's [not working](http://qydev.weixin.qq.com/qa/index.php?qa=13978), must open the CSV file in excel first, then save as CSV format again, seems Tencent only support Excel save as CSV file format.
[version-badge]: https://badge.fury.io/rb/wechat.svg
[rubygems]: https://rubygems.org/gems/wechat
[travis-badge]: https://travis-ci.org/Eric-Guo/wechat.svg
[travis]: https://travis-ci.org/Eric-Guo/wechat
[codeclimate-badge]: https://codeclimate.com/github/Eric-Guo/wechat.png
[codeclimate]: https://codeclimate.com/github/Eric-Guo/wechat
[codecoverage-badge]: https://codeclimate.com/github/Eric-Guo/wechat/coverage.png
[codecoverage]: https://codeclimate.com/github/Eric-Guo/wechat/coverage
[gitter-badge]: https://badges.gitter.im/Join%20Chat.svg
[gitter]: https://gitter.im/Eric-Guo/wechat?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge
[issue-badge]: http://issuestats.com/github/Eric-Guo/wechat/badge/issue
[pr-badge]: http://issuestats.com/github/Eric-Guo/wechat/badge/pr
[issuestats]: http://issuestats.com/github/Eric-Guo/wechat

27
lib/wechat/Rakefile Normal file
View File

@ -0,0 +1,27 @@
#!/usr/bin/env rake
begin
require 'bundler/setup'
rescue LoadError
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
end
begin
require 'rdoc/task'
rescue LoadError
require 'rdoc/rdoc'
require 'rake/rdoctask'
RDoc::Task = Rake::RDocTask
end
RDoc::Task.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'Wechat'
rdoc.options << '--line-numbers'
rdoc.rdoc_files.include('README.rdoc')
rdoc.rdoc_files.include('lib/**/*.rb')
end
require File.join('bundler', 'gem_tasks')
require File.join('rspec', 'core', 'rake_task')
RSpec::Core::RakeTask.new(:spec)
task default: :spec

1
lib/wechat/VERSION Normal file
View File

@ -0,0 +1 @@
0.7.1

415
lib/wechat/bin/wechat Normal file
View File

@ -0,0 +1,415 @@
#!/usr/bin/env ruby
lib = File.expand_path(File.dirname(__FILE__) + '/../lib')
$LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
require 'thor'
require 'wechat'
require 'json'
require 'active_support' # To support Rails 4.2.1, see #17936
require 'active_support/dependencies/autoload'
require 'active_support/core_ext'
require 'active_support/json'
require 'fileutils'
require 'yaml'
require 'wechat/api_loader'
require 'cgi'
class App < Thor
package_name 'Wechat'
option :token_file, aliases: '-t', desc: 'File to store access token'
attr_reader :wechat_api_client
no_commands do
def wechat_api
@wechat_api_client ||= Wechat::ApiLoader.with(options)
end
end
desc 'callbackip', '获取微信服务器IP地址'
def callbackip
puts wechat_api.callbackip
end
desc 'qrcode_download [TICKET, QR_CODE_PIC_PATH]', '通过ticket下载二维码'
def qrcode_download(ticket, qr_code_pic_path)
tmp_file = wechat_api.qrcode(ticket)
FileUtils.mv(tmp_file.path, qr_code_pic_path)
puts 'File downloaded'
end
if Wechat::ApiLoader.with(options).is_a?(Wechat::CorpApi)
desc 'department_create [NAME, PARENT_ID]', '创建部门'
method_option :parentid, aliases: '-p', desc: '父亲部门id。根部门id为1'
def department_create(name)
api_opts = options.slice(:parentid)
puts wechat_api.department_create(name, api_opts[:parentid] || '1')
end
desc 'department_delete [DEPARTMENT_ID]', '删除部门'
def department_delete(departmentid)
puts wechat_api.department_delete(departmentid)
end
desc 'department_update [DEPARTMENT_ID, NAME]', '更新部门'
method_option :parentid, aliases: '-p', desc: '父亲部门id。根部门id为1', default: nil
method_option :order, aliases: '-o', desc: '在父部门中的次序值。order值小的排序靠前。', default: nil
def department_update(departmentid, name)
api_opts = options.slice(:parentid, :order)
puts wechat_api.department_update(departmentid, name, api_opts[:parentid], api_opts[:order])
end
desc 'department [DEPARTMENT_ID]', '获取部门列表'
def department(departmentid = 0)
r = wechat_api.department(departmentid)
puts "errcode: #{r['errcode']} errmsg: #{r['errmsg']}"
puts 'Or# pid id name'
r['department'].sort_by { |d| d['order'].to_i + d['parentid'].to_i * 1000 } .each do |i|
puts format('%3d %3d %3d %s', i['order'], i['parentid'], i['id'], i['name'])
end
end
desc 'user_delete [USER_ID]', '删除成员'
def user_delete(userid)
puts wechat_api.user_delete(userid)
end
desc 'user_batchdelete [USER_ID_LIST]', '批量删除成员'
def user_batchdelete(useridlist)
puts wechat_api.user_batchdelete(useridlist.split(','))
end
desc 'user_simplelist [DEPARTMENT_ID]', '获取部门成员'
method_option :fetch_child, aliases: '-c', desc: '是否递归获取子部门下面的成员', default: 1
method_option :status, aliases: '-s', desc: '0 获取全部成员1 获取已关注成员列表2 获取禁用成员列表4 获取未关注成员列表。status可叠加', default: 0
def user_simplelist(departmentid = 0)
api_opts = options.slice(:fetch_child, :status)
r = wechat_api.user_simplelist(departmentid, api_opts[:fetch_child], api_opts[:status])
puts "errcode: #{r['errcode']} errmsg: #{r['errmsg']}"
puts " userid Name #{' ' * 20} department_ids"
r['userlist'].sort_by { |d| d['userid'] } .each do |i|
puts format('%7s %-25s %-14s', i['userid'], i['name'], i['department'])
end
end
desc 'user_list [DEPARTMENT_ID]', '获取部门成员详情'
method_option :fetch_child, aliases: '-c', desc: '是否递归获取子部门下面的成员', default: 0
method_option :status, aliases: '-s', desc: '0 获取全部成员1 获取已关注成员列表2 获取禁用成员列表4 获取未关注成员列表。status可叠加', default: 0
def user_list(departmentid = 0)
api_opts = options.slice(:fetch_child, :status)
r = wechat_api.user_list(departmentid, api_opts[:fetch_child], api_opts[:status])
puts "errcode: #{r['errcode']} errmsg: #{r['errmsg']}"
puts " userid Name #{' ' * 15} department_ids position mobile #{' ' * 5}gender email #{' ' * 10}weixinid status extattr"
r['userlist'].sort_by { |d| d['userid'] } .each do |i|
puts format('%7s %-20s %-14s %-8s %-11s %-6s %-15s %-15s %-6s %s',
i['userid'], i['name'], i['department'], i['position'], i['mobile'],
i['gender'], i['email'], i['weixinid'], i['status'], i['extattr'])
end
end
desc 'invite_user [USER_ID]', '邀请成员关注'
def invite_user(userid)
puts wechat_api.invite_user(userid)
end
desc 'tag_create [TAGNAME, TAG_ID]', '创建标签'
method_option :tagid, aliases: '-id', desc: '整型指定此参数时新增的标签会生成对应的标签id不指定时则以目前最大的id自增'
def tag_create(name)
api_opts = options.slice(:tagid)
puts wechat_api.tag_create(name, api_opts[:tagid])
end
desc 'tag_update [TAG_ID, TAGNAME]', '更新标签名字'
def tag_update(tagid, tagname)
puts wechat_api.tag_update(tagid, tagname)
end
desc 'tag_delete [TAG_ID]', '删除标签'
def tag_delete(tagid)
puts wechat_api.tag_delete(tagid)
end
desc 'tag [TAG_ID]', '获取标签成员'
def tag(tagid)
puts wechat_api.tag(tagid)
end
desc 'tag_add_user [TAG_ID, USER_IDS]', '增加标签成员'
def tag_add_user(tagid, userids)
puts wechat_api.tag_add_user(tagid, userids.split(','))
end
desc 'tag_add_department [TAG_ID, PARTY_IDS]', '增加标签部门'
def tag_add_department(tagid, partyids)
puts wechat_api.tag_add_user(tagid, nil, partyids.split(','))
end
desc 'tag_del_user [TAG_ID, USER_IDS]', '删除标签成员'
def tag_del_user(tagid, userids)
puts wechat_api.tag_del_user(tagid, userids.split(','))
end
desc 'tag_del_department [TAG_ID, PARTY_IDS]', '删除标签部门'
def tag_del_department(tagid, partyids)
puts wechat_api.tag_del_user(tagid, nil, partyids.split(','))
end
desc 'tags', '获取标签列表'
def tags
puts wechat_api.tags
end
desc 'batch_job_result [JOB_ID]', '获取异步任务结果'
def batch_job_result(job_id)
puts wechat_api.batch_job_result(job_id)
end
desc 'batch_replaceparty [BATCH_PARTY_CSV_MEDIA_ID]', '全量覆盖部门'
def batch_replaceparty(batch_party_csv_media_id)
puts wechat_api.batch_replaceparty(batch_party_csv_media_id)
end
desc 'upload_replaceparty [BATCH_PARTY_CSV_PATH]', '上传文件方式全量覆盖部门'
def upload_replaceparty(batch_party_csv_path)
media_id = wechat_api.media_create('file', batch_party_csv_path)['media_id']
job_id = wechat_api.batch_replaceparty(media_id)['jobid']
puts "running job_id: #{job_id}"
puts wechat_api.batch_job_result(job_id)
end
desc 'batch_syncuser [SYNC_USER_CSV_MEDIA_ID]', '增量更新成员'
def batch_syncuser(sync_user_csv_media_id)
puts wechat_api.batch_syncuser(sync_user_csv_media_id)
end
desc 'batch_replaceuser [BATCH_USER_CSV_MEDIA_ID]', '全量覆盖成员'
def batch_replaceuser(batch_user_csv_media_id)
puts wechat_api.batch_replaceuser(batch_user_csv_media_id)
end
desc 'upload_replaceuser [BATCH_USER_CSV_PATH]', '上传文件方式全量覆盖成员'
def upload_replaceuser(batch_user_csv_path)
media_id = wechat_api.media_create('file', batch_user_csv_path)['media_id']
job_id = wechat_api.batch_replaceuser(media_id)['jobid']
puts "running job_id: #{job_id}"
puts wechat_api.batch_job_result(job_id)
end
desc 'convert_to_openid [USER_ID]', 'userid转换成openid'
def convert_to_openid(userid)
puts wechat_api.convert_to_openid(userid)
end
desc 'agent_list', '获取应用概况列表'
def agent_list
r = wechat_api.agent_list
puts "errcode: #{r['errcode']} errmsg: #{r['errmsg']}"
puts 'ag# name square_logo_url round_logo_url'
r['agentlist'].sort_by { |d| d['agentid'] } .each do |i|
puts format('%3d %s %s %s', i['agentid'], i['name'], i['square_logo_url'], i['round_logo_url'])
end
end
desc 'agent [AGENT_ID]', '获取企业号应用详情'
def agent(agentid)
r = wechat_api.agent(agentid)
puts "agentid: #{r['agentid']} errcode: #{r['errcode']} errmsg: #{r['errmsg']}"
puts "name: #{r['name']}"
puts "description: #{r['description']}"
puts " square_logo_url: #{r['square_logo_url']}"
puts " round_logo_url: #{r['round_logo_url']}"
puts "allow_userinfos: #{r['allow_userinfos']}"
puts "allow_partys: #{r['allow_partys']}"
puts "allow_tags: #{r['allow_tags']}"
puts "close: #{r['close']} redirect_domain: #{r['redirect_domain']}"
puts "report_location_flag: #{r['report_location_flag']} isreportuser: #{r['isreportuser']} isreportenter: #{r['isreportenter']}"
end
desc 'message_send [OPENID, TEXT_MESSAGE]', '发送文字消息'
def message_send(openid, text_message)
puts wechat_api.message_send openid, text_message
end
else
desc 'group_create [GROUP_NAME]', '创建分组'
def group_create(group_name)
puts wechat_api.group_create(group_name)
end
desc 'groups', '查询所有分组'
def groups
puts wechat_api.groups
end
desc 'user_group [OPEN_ID]', '查询用户所在分组'
def user_group(openid)
puts wechat_api.user_group(openid)
end
desc 'group_update [GROUP_ID, NEW_GROUP_NAME]', '修改分组名'
def group_update(groupid, new_group_name)
puts wechat_api.group_update(groupid, new_group_name)
end
desc 'user_change_group [OPEN_ID, TO_GROUP_ID]', '移动用户分组'
def user_change_group(openid, to_groupid)
puts wechat_api.user_change_group(openid, to_groupid)
end
desc 'group_delete [GROUP_ID]', '删除分组'
def group_delete(groupid)
puts wechat_api.group_delete(groupid)
end
desc 'users', '关注者列表'
def users
puts wechat_api.users
end
desc 'qrcode_create_scene [SCENE_ID, EXPIRE_SECONDS]', '请求临时二维码'
def qrcode_create_scene(scene_id, expire_seconds = 604800)
puts wechat_api.qrcode_create_scene(scene_id, expire_seconds)
end
desc 'qrcode_create_limit_scene [SCENE_ID_OR_STR]', '请求永久二维码'
def qrcode_create_limit_scene(scene_id_or_str)
puts wechat_api.qrcode_create_limit_scene(scene_id_or_str)
end
end
desc 'user [OPEN_ID]', '获取用户基本信息'
def user(open_id)
puts wechat_api.user(open_id)
end
desc 'oauth2_url [REDIRECT_URI]', '生成OAuth2.0验证URL'
def oauth2_url(redirect_uri)
appid = Wechat.config.corpid || Wechat.config.appid
puts wechat_api.oauth2_url(redirect_uri, appid)
end
desc 'user_update_remark [OPEN_ID, REMARK]', '设置备注名'
def user_update_remark(openid, remark)
puts wechat_api.user_update_remark(openid, remark)
end
desc 'menu', '当前菜单'
def menu
puts wechat_api.menu
end
desc 'menu_delete', '删除菜单'
def menu_delete
puts 'Menu deleted' if wechat_api.menu_delete
end
desc 'menu_create [MENU_YAML_PATH]', '创建菜单'
def menu_create(menu_yaml_path)
menu = YAML.load(File.read(menu_yaml_path))
puts 'Menu created' if wechat_api.menu_create(menu)
end
desc 'media [MEDIA_ID, PATH]', '媒体下载'
def media(media_id, path)
tmp_file = wechat_api.media(media_id)
FileUtils.mv(tmp_file.path, path)
puts 'File downloaded'
end
desc 'media_create [MEDIA_TYPE, PATH]', '媒体上传'
def media_create(type, path)
puts wechat_api.media_create(type, path)
end
desc 'material [MEDIA_ID, PATH]', '永久媒体下载'
def material(media_id, path)
tmp_file = wechat_api.material(media_id)
FileUtils.mv(tmp_file.path, path)
puts 'File downloaded'
end
desc 'material_add [MEDIA_TYPE, PATH]', '永久媒体上传'
def material_add(type, path)
puts wechat_api.material_add(type, path)
end
desc 'material_delete [MEDIA_ID]', '删除永久素材'
def material_delete(media_id)
puts wechat_api.material_delete(media_id)
end
desc 'material_count', '获取永久素材总数'
def material_count
puts wechat_api.material_count
end
desc 'material_list [TYPE, OFFSET, COUNT]', '获取永久素材列表'
def material_list(type, offset, count)
r = wechat_api.material_list(type, offset, count)
if %w(image voice video file).include?(type)
puts "errcode: #{r['errcode']} errmsg: #{r['errmsg']} total_count: #{r['total_count']} item_count: #{r['item_count']}"
if wechat_api.is_a?(Wechat::CorpApi)
r['itemlist'].each { |i| puts "#{i['media_id']} #{i['filename']} #{Time.at(i['update_time'].to_i)}" }
else
r['item'].each { |i| puts "#{i['media_id']} #{i['name']} #{Time.at(i['update_time'].to_i)}" }
end
else
puts r
end
end
desc 'custom_text [OPENID, TEXT_MESSAGE]', '发送文字客服消息'
def custom_text(openid, text_message)
puts wechat_api.custom_message_send Wechat::Message.to(openid).text(text_message)
end
desc 'custom_image [OPENID, IMAGE_PATH]', '发送图片客服消息'
def custom_image(openid, image_path)
api = wechat_api
media_id = api.media_create('image', image_path)['media_id']
puts api.custom_message_send Wechat::Message.to(openid).image(media_id)
end
desc 'custom_voice [OPENID, VOICE_PATH]', '发送语音客服消息'
def custom_voice(openid, voice_path)
api = wechat_api
media_id = api.media_create('voice', voice_path)['media_id']
puts api.custom_message_send Wechat::Message.to(openid).voice(media_id)
end
desc 'custom_video [OPENID, VIDEO_PATH]', '发送视频客服消息'
method_option :title, aliases: '-h', desc: '视频标题'
method_option :description, aliases: '-d', desc: '视频描述'
def custom_video(openid, video_path)
api = wechat_api
api_opts = options.slice(:title, :description)
media_id = api.media_create('video', video_path)['media_id']
puts api.custom_message_send Wechat::Message.to(openid).video(media_id, api_opts)
end
desc 'custom_music [OPENID, THUMBNAIL_PATH, MUSIC_URL]', '发送音乐客服消息'
method_option :title, aliases: '-h', desc: '音乐标题'
method_option :description, aliases: '-d', desc: '音乐描述'
method_option :HQ_music_url, aliases: '-u', desc: '高质量音乐URL链接'
def custom_music(openid, thumbnail_path, music_url)
api = wechat_api
api_opts = options.slice(:title, :description, :HQ_music_url)
thumb_media_id = api.media_create('thumb', thumbnail_path)['thumb_media_id']
puts api.custom_message_send Wechat::Message.to(openid).music(thumb_media_id, music_url, api_opts)
end
desc 'custom_news [OPENID, NEWS_YAML_PATH]', '发送图文客服消息'
def custom_news(openid, news_yaml_path)
articles = YAML.load(File.read(news_yaml_path))
puts wechat_api.custom_message_send Wechat::Message.to(openid).news(articles['articles'])
end
desc 'template_message [OPENID, TEMPLATE_YAML_PATH]', '模板消息接口'
def template_message(openid, template_yaml_path)
template = YAML.load(File.read(template_yaml_path))
puts wechat_api.template_message_send Wechat::Message.to(openid).template(template['template'])
end
end
App.start

View File

@ -0,0 +1,37 @@
module ActionController
module WechatResponder
def wechat_responder(opts = {})
include Wechat::Responder
self.corpid = opts[:corpid] || Wechat.config.corpid
self.agentid = opts[:agentid] || Wechat.config.agentid
self.encrypt_mode = opts[:encrypt_mode] || Wechat.config.encrypt_mode || corpid.present?
self.timeout = opts[:timeout] || 20
self.skip_verify_ssl = opts[:skip_verify_ssl]
self.token = opts[:token] || Wechat.config.token
self.encoding_aes_key = opts[:encoding_aes_key] || Wechat.config.encoding_aes_key
if opts.empty?
self.wechat = Wechat.api
else
if corpid.present?
self.wechat = Wechat::CorpApi.new(corpid, opts[:corpsecret], opts[:access_token], agentid, timeout, skip_verify_ssl, opts[:jsapi_ticket])
else
self.wechat = Wechat::Api.new(opts[:appid], opts[:secret], opts[:access_token], timeout, skip_verify_ssl, opts[:jsapi_ticket])
end
end
end
end
if defined? Base
class << Base
include WechatResponder
end
end
if defined? API
class << API
include WechatResponder
end
end
end

View File

@ -0,0 +1,32 @@
require 'rails/generators/active_record'
module Wechat
module Generators
class InstallGenerator < Rails::Generators::Base
include ::Rails::Generators::Migration
desc 'Install Wechat support files'
source_root File.expand_path('../templates', __FILE__)
def copy_config
template 'config/wechat.yml'
end
def add_wechat_route
route 'resource :wechat, only: [:show, :create]'
end
def copy_wechat_controller
template 'app/controllers/wechats_controller.rb'
end
def copy_model_migration
migration_template 'db/migration.rb', 'db/migrate/create_wechat_logs.rb'
end
def self.next_migration_number(dirname)
::ActiveRecord::Generators::Base.next_migration_number(dirname)
end
end
end
end

View File

@ -0,0 +1,123 @@
<% if defined? ActionController::API -%>
class WechatsController < ApplicationController
<% else -%>
class WechatsController < ActionController::Base
<% end -%>
wechat_responder
# default text responder when no other match
on :text do |request, content|
request.reply.text "echo: #{content}" # Just echo
end
# When receive 'help', will trigger this responder
on :text, with: 'help' do |request|
request.reply.text 'help content'
end
# When receive '<n>news', will match and will got count as <n> as parameter
on :text, with: /^(\d+) news$/ do |request, count|
# Wechat article can only contain max 10 items, large than 10 will dropped.
news = (1..count.to_i).each_with_object([]) { |n, memo| memo << { title: 'News title', content: "No. #{n} news content" } }
request.reply.news(news) do |article, n, index| # article is return object
article.item title: "#{index} #{n[:title]}", description: n[:content], pic_url: 'http://www.baidu.com/img/bdlogo.gif', url: 'http://www.baidu.com/'
end
end
on :event, with: 'subscribe' do |request|
request.reply.text "#{request[:FromUserName]} subscribe now"
end
# When unsubscribe user scan qrcode qrscene_xxxxxx to subscribe in public account
# notice user will subscribe public account at same time, so wechat won't trigger subscribe event any more
on :scan, with: 'qrscene_xxxxxx' do |request, ticket|
request.reply.text "Unsubscribe user #{request[:FromUserName]} Ticket #{ticket}"
end
# When subscribe user scan scene_id in public account
on :scan, with: 'scene_id' do |request, ticket|
request.reply.text "Subscribe user #{request[:FromUserName]} Ticket #{ticket}"
end
# When no any on :scan responder can match subscribe user scaned scene_id
on :event, with: 'scan' do |request|
if request[:EventKey].present?
request.reply.text "event scan got EventKey #{request[:EventKey]} Ticket #{request[:Ticket]}"
end
end
# When enterprise user press menu BINDING_QR_CODE and success to scan bar code
on :scan, with: 'BINDING_QR_CODE' do |request, scan_result, scan_type|
request.reply.text "User #{request[:FromUserName]} ScanResult #{scan_result} ScanType #{scan_type}"
end
# Except QR code, wechat can also scan CODE_39 bar code in enterprise account
on :scan, with: 'BINDING_BARCODE' do |message, scan_result|
if scan_result.start_with? 'CODE_39,'
message.reply.text "User: #{message[:FromUserName]} scan barcode, result is #{scan_result.split(',')[1]}"
end
end
# When user click the menu button
on :click, with: 'BOOK_LUNCH' do |request, key|
request.reply.text "User: #{request[:FromUserName]} click #{key}"
end
# When user view URL in the menu button
on :view, with: 'http://wechat.somewhere.com/view_url' do |request, view|
request.reply.text "#{request[:FromUserName]} view #{view}"
end
# When user sent the imsage
on :image do |request|
request.reply.image(request[:MediaId]) # Echo the sent image to user
end
# When user sent the voice
on :voice do |request|
request.reply.voice(request[:MediaId]) # Echo the sent voice to user
end
# When user sent the video
on :video do |request|
nickname = wechat.user(request[:FromUserName])['nickname'] # Call wechat api to get sender nickname
request.reply.video(request[:MediaId], title: 'Echo', description: "Got #{nickname} sent video") # Echo the sent video to user
end
# When user sent location
on :location do |request|
request.reply.text("Latitude: #{message[:Latitude]} Longitude: #{message[:Longitude]} Precision: #{message[:Precision]}")
end
on :event, with: 'unsubscribe' do |request|
request.reply.success # user can not receive this message
end
# When user enter the app / agent app
on :event, with: 'enter_agent' do |request|
request.reply.text "#{request[:FromUserName]} enter agent app now"
end
# When batch job create/update user (incremental) finished.
on :batch_job, with: 'sync_user' do |request, batch_job|
request.reply.text "sync_user job #{batch_job[:JobId]} finished, return code #{batch_job[:ErrCode]}, return message #{batch_job[:ErrMsg]}"
end
# When batch job replace user (full sync) finished.
on :batch_job, with: 'replace_user' do |request, batch_job|
request.reply.text "replace_user job #{batch_job[:JobId]} finished, return code #{batch_job[:ErrCode]}, return message #{batch_job[:ErrMsg]}"
end
# When batch job invent user finished.
on :batch_job, with: 'invite_user' do |request, batch_job|
request.reply.text "invite_user job #{batch_job[:JobId]} finished, return code #{batch_job[:ErrCode]}, return message #{batch_job[:ErrMsg]}"
end
# When batch job replace department (full sync) finished.
on :batch_job, with: 'replace_party' do |request, batch_job|
request.reply.text "replace_party job #{batch_job[:JobId]} finished, return code #{batch_job[:ErrCode]}, return message #{batch_job[:ErrMsg]}"
end
# Any not match above will fail to below
on :fallback, respond: 'fallback message'
end

View File

@ -0,0 +1,33 @@
default: &default
corpid: "corpid"
corpsecret: "corpsecret"
agentid: 1
# Or if using public account, only need above two line
# appid: "my_appid"
# secret: "my_secret"
token: "my_token"
access_token: "C:/Users/[username]/wechat_access_token"
encrypt_mode: false # if true must fill encoding_aes_key
encoding_aes_key: "my_encoding_aes_key"
jsapi_ticket: "C:/Users/[user_name]/wechat_jsapi_ticket"
production:
corpid: <%%= ENV['WECHAT_CORPID'] %>
corpsecret: <%%= ENV['WECHAT_CORPSECRET'] %>
agentid: <%%= ENV['WECHAT_AGENTID'] %>
# Or if using public account, only need above two line
# appid: <%= ENV['WECHAT_APPID'] %>
# secret: <%= ENV['WECHAT_APP_SECRET'] %>
token: <%%= ENV['WECHAT_TOKEN'] %>
timeout: 30,
skip_verify_ssl: true
access_token: <%%= ENV['WECHAT_ACCESS_TOKEN'] %>
encrypt_mode: false # if true must fill encoding_aes_key
encoding_aes_key: <%%= ENV['WECHAT_ENCODING_AES_KEY'] %>
jsapi_ticket: <%= ENV['WECHAT_JSAPI_TICKET'] %>
development:
<<: *default
test:
<<: *default

View File

@ -0,0 +1,11 @@
class CreateWechatLogs < ActiveRecord::Migration
def change
create_table :wechat_logs do |t|
t.string :openid, null: false, index: true
t.text :request_raw
t.text :response_raw
t.text :session_raw
t.datetime :created_at, null: false
end
end
end

28
lib/wechat/lib/wechat.rb Normal file
View File

@ -0,0 +1,28 @@
require 'wechat/api_loader'
require 'wechat/api'
require 'wechat/corp_api'
require 'action_controller/wechat_responder'
module Wechat
autoload :Message, 'wechat/message'
autoload :Responder, 'wechat/responder'
autoload :Cipher, 'wechat/cipher'
autoload :WechatLog, 'wechat/wechat_log'
class AccessTokenExpiredError < StandardError; end
class ResponseError < StandardError
attr_reader :error_code
def initialize(errcode, errmsg)
@error_code = errcode
super "#{errmsg}(#{error_code})"
end
end
def self.config
ApiLoader.config
end
def self.api
@wechat_api ||= ApiLoader.with({})
end
end

View File

@ -0,0 +1,124 @@
require 'wechat/api_base'
require 'wechat/client'
require 'wechat/token/public_access_token'
require 'wechat/ticket/public_jsapi_ticket'
module Wechat
class Api < ApiBase
API_BASE = 'https://api.weixin.qq.com/cgi-bin/'
OAUTH2_BASE = 'https://api.weixin.qq.com/sns/oauth2/'
def initialize(appid, secret, token_file, timeout, skip_verify_ssl, jsapi_ticket_file)
@client = Client.new(API_BASE, timeout, skip_verify_ssl)
@access_token = Token::PublicAccessToken.new(@client, appid, secret, token_file)
@jsapi_ticket = Ticket::PublicJsapiTicket.new(@client, @access_token, jsapi_ticket_file)
end
def groups
get 'groups/get'
end
def group_create(group_name)
post 'groups/create', JSON.generate(group: { name: group_name })
end
def group_update(groupid, new_group_name)
post 'groups/update', JSON.generate(group: { id: groupid, name: new_group_name })
end
def group_delete(groupid)
post 'groups/delete', JSON.generate(group: { id: groupid })
end
def users(nextid = nil)
params = { params: { next_openid: nextid } } if nextid.present?
get('user/get', params || {})
end
def user(openid)
get 'user/info', params: { openid: openid }
end
def user_group(openid)
post 'groups/getid', JSON.generate(openid: openid)
end
def user_change_group(openid, to_groupid)
post 'groups/members/update', JSON.generate(openid: openid, to_groupid: to_groupid)
end
def user_update_remark(openid, remark)
post 'user/info/updateremark', JSON.generate(openid: openid, remark: remark)
end
def qrcode_create_scene(scene_id, expire_seconds = 604800)
post 'qrcode/create', JSON.generate(expire_seconds: expire_seconds,
action_name: 'QR_SCENE',
action_info: { scene: { scene_id: scene_id } })
end
def qrcode_create_limit_scene(scene_id_or_str)
case scene_id_or_str
when Fixnum
post 'qrcode/create', JSON.generate(action_name: 'QR_LIMIT_SCENE',
action_info: { scene: { scene_id: scene_id_or_str } })
else
post 'qrcode/create', JSON.generate(action_name: 'QR_LIMIT_STR_SCENE',
action_info: { scene: { scene_str: scene_id_or_str } })
end
end
def menu
get 'menu/get'
end
def menu_delete
get 'menu/delete'
end
def menu_create(menu)
# 微信不接受7bit escaped json(eg \uxxxx), 中文必须UTF-8编码, 这可能是个安全漏洞
post 'menu/create', JSON.generate(menu)
end
def material(media_id)
get 'material/get', params: { media_id: media_id }, as: :file
end
def material_count
get 'material/get_materialcount'
end
def material_list(type, offset, count)
post 'material/batchget_material', JSON.generate(type: type, offset: offset, count: count)
end
def material_add(type, file)
post_file 'material/add_material', file, params: { type: type }
end
def material_delete(media_id)
post 'material/del_material', media_id: media_id
end
def custom_message_send(message)
post 'message/custom/send', message.to_json, content_type: :json
end
def template_message_send(message)
post 'message/template/send', message.to_json, content_type: :json
end
# http://mp.weixin.qq.com/wiki/17/c0f37d5704f0b64713d5d2c37b468d75.html
# 第二步通过code换取网页授权access_token
def web_access_token(code)
params = {
appid: access_token.appid,
secret: access_token.secret,
code: code,
grant_type: 'authorization_code'
}
get 'access_token', params: params, base: OAUTH2_BASE
end
end
end

View File

@ -0,0 +1,51 @@
module Wechat
class ApiBase
attr_reader :access_token, :client, :jsapi_ticket
MP_BASE = 'https://mp.weixin.qq.com/cgi-bin/'
def callbackip
get 'getcallbackip'
end
def qrcode(ticket)
client.get 'showqrcode', params: { ticket: ticket }, base: MP_BASE, as: :file
end
def media(media_id)
get 'media/get', params: { media_id: media_id }, as: :file
end
def media_create(type, file)
post_file 'media/upload', file, params: { type: type }
end
protected
def get(path, headers = {})
with_access_token(headers[:params]) do |params|
client.get path, headers.merge(params: params)
end
end
def post(path, payload, headers = {})
with_access_token(headers[:params]) do |params|
client.post path, payload, headers.merge(params: params)
end
end
def post_file(path, file, headers = {})
with_access_token(headers[:params]) do |params|
client.post_file path, file, headers.merge(params: params)
end
end
def with_access_token(params = {}, tries = 2)
params ||= {}
yield(params.merge(access_token: access_token.token))
rescue AccessTokenExpiredError
access_token.refresh
retry unless (tries -= 1).zero?
end
end
end

View File

@ -0,0 +1,79 @@
module Wechat
module ApiLoader
def self.with(options)
c = ApiLoader.config
token_file = options[:token_file] || c.access_token || '/var/tmp/wechat_access_token'
js_token_file = options[:js_token_file] || c.jsapi_ticket || '/var/tmp/wechat_jsapi_ticket'
if c.appid && c.secret && token_file.present?
Wechat::Api.new(c.appid, c.secret, token_file, c.timeout, c.skip_verify_ssl, js_token_file)
elsif c.corpid && c.corpsecret && token_file.present?
Wechat::CorpApi.new(c.corpid, c.corpsecret, token_file, c.agentid, c.timeout, c.skip_verify_ssl, js_token_file)
else
puts <<-HELP
Need create ~/.wechat.yml with wechat appid and secret
or running at rails root folder so wechat can read config/wechat.yml
HELP
exit 1
end
end
@config = nil
def self.config
return @config unless @config.nil?
@config ||= loading_config!
end
private
def self.loading_config!
config ||= config_from_file || config_from_environment
if defined?(::Rails)
config[:access_token] ||= Rails.root.join('tmp/access_token').to_s
config[:jsapi_ticket] ||= Rails.root.join('tmp/jsapi_ticket').to_s
end
config[:timeout] ||= 20
config.symbolize_keys!
@config = OpenStruct.new(config)
end
def self.config_from_file
if defined?(::Rails)
config_file = Rails.root.join('config/wechat.yml')
return YAML.load(ERB.new(File.read(config_file)).result)[Rails.env] if File.exist?(config_file)
else
rails_config_file = File.join(Dir.getwd, 'config/wechat.yml')
home_config_file = File.join(Dir.home, '.wechat.yml')
if File.exist?(rails_config_file)
rails_env = ENV['RAILS_ENV'] || 'default'
config = YAML.load(ERB.new(File.read(rails_config_file)).result)[rails_env]
if config.present? && (config['appid'] || config['corpid'])
puts "Using rails project config/wechat.yml #{rails_env} setting..."
return config
end
end
if File.exist?(home_config_file)
return YAML.load ERB.new(File.read(home_config_file)).result
end
end
end
def self.config_from_environment
{ appid: ENV['WECHAT_APPID'],
secret: ENV['WECHAT_SECRET'],
corpid: ENV['WECHAT_CORPID'],
corpsecret: ENV['WECHAT_CORPSECRET'],
agentid: ENV['WECHAT_AGENTID'],
token: ENV['WECHAT_TOKEN'],
access_token: ENV['WECHAT_ACCESS_TOKEN'],
encrypt_mode: ENV['WECHAT_ENCRYPT_MODE'],
timeout: ENV['WECHAT_TIMEOUT'],
skip_verify_ssl: ENV['WECHAT_SKIP_VERIFY_SSL'],
encoding_aes_key: ENV['WECHAT_ENCODING_AES_KEY'],
jsapi_ticket: ENV['WECHAT_JSAPI_TICKET'] }
end
end
end

View File

@ -0,0 +1,72 @@
require 'openssl/cipher'
require 'securerandom'
require 'base64'
module Wechat
module Cipher
extend ActiveSupport::Concern
BLOCK_SIZE = 32
CIPHER = 'AES-256-CBC'
def encrypt(plain, encoding_aes_key)
cipher = OpenSSL::Cipher.new(CIPHER)
cipher.encrypt
cipher.padding = 0
key_data = Base64.decode64(encoding_aes_key + '=')
cipher.key = key_data
cipher.iv = key_data[0..16]
cipher.update(plain) + cipher.final
end
def decrypt(msg, encoding_aes_key)
cipher = OpenSSL::Cipher.new(CIPHER)
cipher.decrypt
cipher.padding = 0
key_data = Base64.decode64(encoding_aes_key + '=')
cipher.key = key_data
cipher.iv = key_data[0..16]
plain = cipher.update(msg) + cipher.final
decode_padding(plain)
end
# app_id or corp_id
def pack(content, app_id)
random = SecureRandom.hex(8)
text = content.force_encoding('ASCII-8BIT')
msg_len = [text.length].pack('N')
encode_padding("#{random}#{msg_len}#{text}#{app_id}")
end
def unpack(msg)
msg = decode_padding(msg)
msg_len = msg[16, 4].reverse.unpack('V')[0]
content = msg[20, msg_len]
app_id = msg[(20 + msg_len)..-1]
[content, app_id]
end
private
def encode_padding(data)
length = data.bytes.length
amount_to_pad = BLOCK_SIZE - (length % BLOCK_SIZE)
amount_to_pad = BLOCK_SIZE if amount_to_pad == 0
padding = ([amount_to_pad].pack('c') * amount_to_pad)
data + padding
end
def decode_padding(plain)
pad = plain.bytes[-1]
# no padding
pad = 0 if pad < 1 || pad > BLOCK_SIZE
plain[0...(plain.length - pad)]
end
end
end

View File

@ -0,0 +1,90 @@
require 'http'
module Wechat
class Client
attr_reader :base, :ssl_context
def initialize(base, timeout, skip_verify_ssl)
@base = base
HTTP.timeout(:global, write: timeout, connect: timeout, read: timeout)
@ssl_context = OpenSSL::SSL::SSLContext.new
@ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE if skip_verify_ssl
end
def get(path, get_header = {})
request(path, get_header) do |url, header|
params = header.delete(:params)
HTTP.headers(header).get(url, params: params, ssl_context: ssl_context)
end
end
def post(path, payload, post_header = {})
request(path, post_header) do |url, header|
params = header.delete(:params)
HTTP.headers(header).post(url, params: params, body: payload, ssl_context: ssl_context)
end
end
def post_file(path, file, post_header = {})
request(path, post_header) do |url, header|
params = header.delete(:params)
HTTP.headers(header)
.post(url, params: params,
form: { media: HTTP::FormData::File.new(file),
hack: 'X' }, # Existing here for http-form_data 1.0.1 handle single param improperly
ssl_context: ssl_context)
end
end
private
def request(path, header = {}, &_block)
url = "#{header.delete(:base) || base}#{path}"
as = header.delete(:as)
header.merge!('Accept' => 'application/json')
response = yield(url, header)
fail "Request not OK, response status #{response.status}" if response.status != 200
parse_response(response, as || :json) do |parse_as, data|
break data unless parse_as == :json && data['errcode'].present?
case data['errcode']
when 0 # for request didn't expect results
data
# 42001: access_token timeout
# 40014: invalid access_token
# 40001, invalid credential, access_token is invalid or not latest hint
# 48001, api unauthorized hint, for qrcode creation # 71
when 42001, 40014, 40001, 48001
fail AccessTokenExpiredError
else
fail ResponseError.new(data['errcode'], data['errmsg'])
end
end
end
def parse_response(response, as)
content_type = response.headers[:content_type]
parse_as = {
%r{^application\/json} => :json,
%r{^image\/.*} => :file
}.each_with_object([]) { |match, memo| memo << match[1] if content_type =~ match[0] }.first || as || :text
case parse_as
when :file
file = Tempfile.new('tmp')
file.binmode
file.write(response.body)
file.close
data = file
when :json
data = JSON.parse response.body.to_s.gsub(/[\u0000-\u001f]+/, '')
else
data = response.body
end
yield(parse_as, data)
end
end
end

View File

@ -0,0 +1,166 @@
require 'wechat/api_base'
require 'wechat/client'
require 'wechat/token/corp_access_token'
require 'wechat/ticket/corp_jsapi_ticket'
require 'cgi'
module Wechat
class CorpApi < ApiBase
attr_reader :agentid
API_BASE = 'https://qyapi.weixin.qq.com/cgi-bin/'
def initialize(appid, secret, token_file, agentid, timeout, skip_verify_ssl, jsapi_ticket_file)
@client = Client.new(API_BASE, timeout, skip_verify_ssl)
@access_token = Token::CorpAccessToken.new(@client, appid, secret, token_file)
@agentid = agentid
@jsapi_ticket = Ticket::CorpJsapiTicket.new(@client, @access_token, jsapi_ticket_file)
end
def agent_list
get 'agent/list'
end
def agent(agentid)
get 'agent/get', params: { agentid: agentid }
end
def user(userid)
get 'user/get', params: { userid: userid }
end
def getuserinfo(code)
get 'user/getuserinfo', params: { code: code }
end
def oauth2_url(redirect_uri, appid)
redirect_uri = CGI.escape(redirect_uri)
"https://open.weixin.qq.com/connect/oauth2/authorize?appid=#{appid}&redirect_uri=#{redirect_uri}&response_type=code&scope=snsapi_base#wechat_redirect"
end
def convert_to_openid(userid)
post 'user/convert_to_openid', JSON.generate(userid: userid, agentid: agentid)
end
def invite_user(userid)
post 'invite/send', JSON.generate(userid: userid)
end
def user_auth_success(userid)
get 'user/authsucc', params: { userid: userid }
end
def user_delete(userid)
get 'user/delete', params: { userid: userid }
end
def user_batchdelete(useridlist)
post 'user/batchdelete', JSON.generate(useridlist: useridlist)
end
def batch_job_result(jobid)
get 'batch/getresult', params: { jobid: jobid }
end
def batch_replaceparty(media_id)
post 'batch/replaceparty', JSON.generate(media_id: media_id)
end
def batch_syncuser(media_id)
post 'batch/syncuser', JSON.generate(media_id: media_id)
end
def batch_replaceuser(media_id)
post 'batch/replaceuser', JSON.generate(media_id: media_id)
end
def department_create(name, parentid)
post 'department/create', JSON.generate(name: name, parentid: parentid)
end
def department_delete(departmentid)
get 'department/delete', params: { id: departmentid }
end
def department_update(departmentid, name = nil, parentid = nil, order = nil)
post 'department/update', JSON.generate({ id: departmentid, name: name, parentid: parentid, order: order }.reject { |_k, v| v.nil? })
end
def department(departmentid = 1)
get 'department/list', params: { id: departmentid }
end
def user_simplelist(department_id, fetch_child = 0, status = 0)
get 'user/simplelist', params: { department_id: department_id, fetch_child: fetch_child, status: status }
end
def user_list(department_id, fetch_child = 0, status = 0)
get 'user/list', params: { department_id: department_id, fetch_child: fetch_child, status: status }
end
def tag_create(tagname, tagid = nil)
post 'tag/create', JSON.generate(tagname: tagname, tagid: tagid)
end
def tag_update(tagid, tagname)
post 'tag/update', JSON.generate(tagid: tagid, tagname: tagname)
end
def tag_delete(tagid)
get 'tag/delete', params: { tagid: tagid }
end
def tags
get 'tag/list'
end
def tag(tagid)
get 'tag/get', params: { tagid: tagid }
end
def tag_add_user(tagid, userids = nil, departmentids = nil)
post 'tag/addtagusers', JSON.generate(tagid: tagid, userlist: userids, partylist: departmentids)
end
def tag_del_user(tagid, userids = nil, departmentids = nil)
post 'tag/deltagusers', JSON.generate(tagid: tagid, userlist: userids, partylist: departmentids)
end
def menu
get 'menu/get', params: { agentid: agentid }
end
def menu_delete
get 'menu/delete', params: { agentid: agentid }
end
def menu_create(menu)
# 微信不接受7bit escaped json(eg \uxxxx), 中文必须UTF-8编码, 这可能是个安全漏洞
post 'menu/create', JSON.generate(menu), params: { agentid: agentid }
end
def material_count
get 'material/get_count', params: { agentid: agentid }
end
def material_list(type, offset, count)
post 'material/batchget', JSON.generate(type: type, agentid: agentid, offset: offset, count: count)
end
def material(media_id)
get 'material/get', params: { media_id: media_id, agentid: agentid }, as: :file
end
def material_add(type, file)
post_file 'material/add_material', file, params: { type: type, agentid: agentid }
end
def material_delete(media_id)
get 'material/del', params: { media_id: media_id, agentid: agentid }
end
def message_send(openid, message)
post 'message/send', Message.to(openid).text(message).agent_id(agentid).to_json, content_type: :json
end
end
end

View File

@ -0,0 +1,195 @@
module Wechat
class Message
class << self
def from_hash(message_hash)
new(message_hash)
end
def to(to_user)
new(ToUserName: to_user, CreateTime: Time.now.to_i)
end
end
class ArticleBuilder
attr_reader :items
delegate :count, to: :items
def initialize
@items = []
end
def item(options={})
title = options[:title]
description = options[:description]
pic_url = options[:pic_url]
url = options[:url]
items << { Title: title, Description: description, PicUrl: pic_url, Url: url }.reject { |_k, v| v.nil? }
end
end
attr_reader :message_hash
def initialize(message_hash)
@message_hash = message_hash || {}
session
end
def [](key)
message_hash[key]
end
def reply
Message.new(
ToUserName: message_hash[:FromUserName],
FromUserName: message_hash[:ToUserName],
CreateTime: Time.now.to_i,
session: session
)
end
def session
@message_hash[:session] ||= Wechat::WechatLog.find_session(message_hash[:FromUserName]) || {}
end
def as(type)
case type
when :text
message_hash[:Content]
when :image, :voice, :video
Wechat.api.media(message_hash[:MediaId])
when :location
message_hash.slice(:Location_X, :Location_Y, :Scale, :Label).each_with_object({}) do |value, results|
results[value[0].to_s.underscore.to_sym] = value[1]
end
else
fail "Don't know how to parse message as #{type}"
end
end
def to(openid)
update(ToUserName: openid)
end
def agent_id(agentid)
update(AgentId: agentid)
end
def text(content)
update(MsgType: 'text', Content: content)
end
def transfer_customer_service
update(MsgType: 'transfer_customer_service')
end
def success
update(MsgType: 'success')
end
def image(media_id)
update(MsgType: 'image', Image: { MediaId: media_id })
end
def voice(media_id)
update(MsgType: 'voice', Voice: { MediaId: media_id })
end
def video(media_id, opts = {})
video_fields = camelize_hash_keys({ media_id: media_id }.merge(opts.slice(:title, :description)))
update(MsgType: 'video', Video: video_fields)
end
def music(thumb_media_id, music_url, opts = {})
music_fields = camelize_hash_keys(opts.slice(:title, :description, :HQ_music_url).merge(music_url: music_url, thumb_media_id: thumb_media_id))
update(MsgType: 'music', Music: music_fields)
end
def news(collection, &block)
if block_given?
article = ArticleBuilder.new
collection.take(10).each_with_index { |item, index| yield(article, item, index) }
items = article.items
else
items = collection.collect do |item|
camelize_hash_keys(item.symbolize_keys.slice(:title, :description, :pic_url, :url).reject { |_k, v| v.nil? })
end
end
update(MsgType: 'news', ArticleCount: items.count,
Articles: items.collect { |item| camelize_hash_keys(item) })
end
def template(opts = {})
template_fields = camelize_hash_keys(opts.symbolize_keys.slice(:template_id, :topcolor, :url, :data))
update(MsgType: 'template', Template: template_fields)
end
def to_xml
message_hash.to_xml(root: 'xml', children: 'item', skip_instruct: true, skip_types: true)
end
TO_JSON_KEY_MAP = {
'ToUserName' => 'touser',
'MediaId' => 'media_id',
'ThumbMediaId' => 'thumb_media_id',
'TemplateId' => 'template_id'
}
TO_JSON_ALLOWED = %w(touser msgtype content image voice video music news articles template agentid)
def to_json
json_hash = deep_recursive(message_hash) do |key, value|
key = key.to_s
[(TO_JSON_KEY_MAP[key] || key.downcase), value]
end
json_hash = json_hash.select { |k, _v| TO_JSON_ALLOWED.include? k }
case json_hash['msgtype']
when 'text'
json_hash['text'] = { 'content' => json_hash.delete('content') }
when 'news'
json_hash['news'] = { 'articles' => json_hash.delete('articles') }
when 'template'
json_hash.merge! json_hash['template']
end
JSON.generate(json_hash)
end
def save_to!(model_class)
model = model_class.new(underscore_hash_keys(message_hash.tap { |hs| hs.delete(:session) }))
model.save!
self
end
private
def camelize_hash_keys(hash)
deep_recursive(hash) { |key, value| [key.to_s.camelize.to_sym, value] }
end
def underscore_hash_keys(hash)
deep_recursive(hash) { |key, value| [key.to_s.underscore.to_sym, value] }
end
def update(fields = {})
message_hash.merge!(fields)
self
end
def deep_recursive(hash, &block)
hash.inject({}) do |memo, val|
key, value = *val
case value.class.name
when 'Hash'
value = deep_recursive(value, &block)
when 'Array'
value = value.collect { |item| item.is_a?(Hash) ? deep_recursive(item, &block) : item }
end
key, value = yield(key, value)
memo.merge!(key => value)
end
end
end
end

View File

@ -0,0 +1,275 @@
require 'English'
require 'wechat/signature'
module Wechat
module Responder
extend ActiveSupport::Concern
include Cipher
included do
# Rails 5 remove before_filter and skip_before_filter
if respond_to?(:skip_before_action)
# Rails 5 API mode won't define verify_authenticity_token
skip_before_action :verify_authenticity_token unless respond_to?(:verify_authenticity_token)
before_action :verify_signature, only: [:show, :create]
else
skip_before_filter :verify_authenticity_token
before_filter :verify_signature, only: [:show, :create]
end
end
module ClassMethods
attr_accessor :wechat, :token, :corpid, :agentid, :encrypt_mode, :timeout, :skip_verify_ssl, :encoding_aes_key
def on(message_type, options={}, &block)
fail 'Unknow message type' unless [:text, :image, :voice, :video, :link, :event, :click, :view, :scan, :batch_job, :location, :fallback].include?(message_type)
respond = options[:respond]
with = options[:with]
config = respond.nil? ? {} : { respond: respond }
config.merge!(proc: block) if block_given?
if with.present?
fail 'Only text, event, click, view, scan and batch_job can having :with parameters' unless [:text, :event, :click, :view, :scan, :batch_job].include?(message_type)
config.merge!(with: with)
if message_type == :scan
if with.is_a?(String)
self.known_scan_key_lists = with
else
fail 'on :scan only support string in parameter with, detail see https://github.com/Eric-Guo/wechat/issues/84'
end
end
else
fail 'Message type click, view, scan and batch_job must specify :with parameters' if [:click, :view, :scan, :batch_job].include?(message_type)
end
case message_type
when :click
user_defined_click_responders(with) << config
when :view
user_defined_view_responders(with) << config
when :batch_job
user_defined_batch_job_responders(with) << config
when :scan
user_defined_scan_responders << config
when :location
user_defined_location_responders << config
else
user_defined_responders(message_type) << config
end
config
end
def user_defined_click_responders(with)
@click_responders ||= {}
@click_responders[with] ||= []
end
def user_defined_view_responders(with)
@view_responders ||= {}
@view_responders[with] ||= []
end
def user_defined_batch_job_responders(with)
@batch_job_responders ||= {}
@batch_job_responders[with] ||= []
end
def user_defined_scan_responders
@scan_responders ||= []
end
def user_defined_location_responders
@location_responders ||= []
end
def user_defined_responders(type)
@responders ||= {}
@responders[type] ||= []
end
def responder_for(message)
message_type = message[:MsgType].to_sym
responders = user_defined_responders(message_type)
case message_type
when :text
yield(* match_responders(responders, message[:Content]))
when :event
if 'click' == message[:Event] && !user_defined_click_responders(message[:EventKey]).empty?
yield(* user_defined_click_responders(message[:EventKey]), message[:EventKey])
elsif 'view' == message[:Event] && !user_defined_view_responders(message[:EventKey]).empty?
yield(* user_defined_view_responders(message[:EventKey]), message[:EventKey])
elsif 'click' == message[:Event]
yield(* match_responders(responders, message[:EventKey]))
elsif known_scan_key_lists.include?(message[:EventKey])
yield(* known_scan_with_match_responders(user_defined_scan_responders, message))
elsif 'batch_job_result' == message[:Event]
yield(* user_defined_batch_job_responders(message[:BatchJob][:JobType]), message[:BatchJob])
elsif 'location' == message[:Event]
yield(* user_defined_location_responders, message)
else
yield(* match_responders(responders, message[:Event]))
end
else
yield(responders.first)
end
end
private
def match_responders(responders, value)
matched = responders.each_with_object({}) do |responder, memo|
condition = responder[:with]
if condition.nil?
memo[:general] ||= [responder, value]
next
end
if condition.is_a? Regexp
memo[:scoped] ||= [responder] + $LAST_MATCH_INFO.captures if value =~ condition
else
memo[:scoped] ||= [responder, value] if value == condition
end
end
matched[:scoped] || matched[:general]
end
def known_scan_with_match_responders(responders, message)
matched = responders.each_with_object({}) do |responder, memo|
if %w(scan subscribe).include?(message[:Event]) && message[:EventKey] == responder[:with]
memo[:scaned] ||= [responder, message[:Ticket]]
elsif %w(scancode_push scancode_waitmsg).include?(message[:Event]) && message[:EventKey] == responder[:with]
memo[:scaned] ||= [responder, message[:ScanCodeInfo][:ScanResult], message[:ScanCodeInfo][:ScanType]]
end
end
matched[:scaned]
end
def known_scan_key_lists
@known_scan_key_lists ||= []
end
def known_scan_key_lists=(qrscene_value)
@known_scan_key_lists ||= []
@known_scan_key_lists << qrscene_value
end
end
def wechat
self.class.wechat # Make sure user can continue access wechat at instance level similar to class level
end
def show
if self.class.corpid.present?
echostr, _corp_id = unpack(decrypt(Base64.decode64(params[:echostr]), self.class.encoding_aes_key))
render text: echostr
else
render text: params[:echostr]
end
end
def create
request = Wechat::Message.from_hash(post_xml)
response = run_responder(request)
if response.respond_to? :to_xml
body = process_response(response)
if Rails.version.start_with?("3.")
render text: body
else
render plain: body
end
else
render nothing: true, status: 200, content_type: 'text/html'
end
Wechat::WechatLog.create_by_responder post_xml, response, response.session if response.is_a?(Wechat::Message)
after_wechat_response(request, response) if respond_to?(:after_wechat_response)
end
private
def verify_signature
if self.class.encrypt_mode
signature = params[:signature] || params[:msg_signature]
msg_encrypt = params[:echostr] || request_encrypt_content
else
signature = params[:signature]
end
msg_encrypt = nil unless self.class.corpid.present?
render text: 'Forbidden', status: 403 if signature != Signature.hexdigest(self.class.token,
params[:timestamp],
params[:nonce],
msg_encrypt)
end
def post_xml
data = request_content
if self.class.encrypt_mode && request_encrypt_content.present?
content, @app_id = unpack(decrypt(Base64.decode64(request_encrypt_content), self.class.encoding_aes_key))
data = Hash.from_xml(content)
end
HashWithIndifferentAccess.new_from_hash_copying_default(data.fetch('xml', {})).tap do |msg|
msg[:Event].downcase! if msg[:Event]
end
end
def run_responder(request)
self.class.responder_for(request) do |responder, *args|
responder ||= self.class.user_defined_responders(:fallback).first
next if responder.nil?
case
when responder[:respond]
request.reply.text responder[:respond]
when responder[:proc]
define_singleton_method :process, responder[:proc]
number_of_block_parameter = responder[:proc].arity
send(:process, *args.unshift(request).take(number_of_block_parameter))
else
next
end
end
end
def process_response(response)
if response[:MsgType] == 'success'
msg = 'success'
else
msg = response.to_xml
end
if self.class.encrypt_mode
encrypt = Base64.strict_encode64(encrypt(pack(msg, @app_id), self.class.encoding_aes_key))
msg = gen_msg(encrypt, params[:timestamp], params[:nonce])
end
msg
end
def gen_msg(encrypt, timestamp, nonce)
msg_sign = Signature.hexdigest(self.class.token, timestamp, nonce, encrypt)
{ Encrypt: encrypt,
MsgSignature: msg_sign,
TimeStamp: timestamp,
Nonce: nonce
}.to_xml(root: 'xml', children: 'item', skip_instruct: true, skip_types: true)
end
def request_encrypt_content
request_content['xml']['Encrypt']
end
def request_content
params[:xml].nil? ? Hash.from_xml(request.raw_post) : { 'xml' => params[:xml] }
end
end
end

View File

@ -0,0 +1,10 @@
module Wechat
module Signature
def self.hexdigest(token, timestamp, nonce, msg_encrypt)
array = [token, timestamp, nonce]
array << msg_encrypt unless msg_encrypt.nil?
dev_msg_signature = array.compact.collect(&:to_s).sort.join
Digest::SHA1.hexdigest(dev_msg_signature)
end
end
end

View File

@ -0,0 +1,13 @@
require 'wechat/ticket/jsapi_base'
module Wechat
module Ticket
class CorpJsapiTicket < JsapiBase
def refresh
data = client.get('get_jsapi_ticket', params: { access_token: access_token.token })
write_ticket_to_file(data)
read_ticket_from_file
end
end
end
end

View File

@ -0,0 +1,65 @@
require 'digest/sha1'
module Wechat
module Ticket
class JsapiBase
attr_reader :client, :access_token, :jsapi_ticket_file, :access_ticket, :ticket_life_in_seconds, :got_ticket_at
def initialize(client, access_token, jsapi_ticket_file)
@client = client
@access_token = access_token
@jsapi_ticket_file = jsapi_ticket_file
@random_generator = Random.new
end
def ticket
# Possible two worker running, one worker refresh ticket, other unaware, so must read every time
read_ticket_from_file
refresh if remain_life_seconds < @random_generator.rand(30..3 * 60)
access_ticket
end
# Obtain the wechat jssdk config signature parameter and return below hash
# params = {
# noncestr: noncestr,
# timestamp: timestamp,
# jsapi_ticket: ticket,
# url: url,
# signature: signature
# }
def signature(url)
params = {
noncestr: SecureRandom.base64(16),
timestamp: Time.now.to_i,
jsapi_ticket: ticket,
url: url
}
pairs = params.keys.sort.map do |key|
"#{key}=#{params[key]}"
end
result = Digest::SHA1.hexdigest pairs.join('&')
params.merge(signature: result)
end
protected
def read_ticket_from_file
td = JSON.parse(File.read(jsapi_ticket_file))
@got_ticket_at = td.fetch('got_ticket_at').to_i
@ticket_life_in_seconds = td.fetch('expires_in').to_i
@access_ticket = td.fetch('ticket')
rescue JSON::ParserError, Errno::ENOENT, KeyError
refresh
end
def write_ticket_to_file(ticket_hash)
ticket_hash.merge!('got_ticket_at'.freeze => Time.now.to_i)
File.write(jsapi_ticket_file, ticket_hash.to_json)
end
def remain_life_seconds
ticket_life_in_seconds - (Time.now.to_i - got_ticket_at)
end
end
end
end

View File

@ -0,0 +1,13 @@
require 'wechat/ticket/jsapi_base'
module Wechat
module Ticket
class PublicJsapiTicket < JsapiBase
def refresh
data = client.get('ticket/getticket', params: { access_token: access_token.token, type: 'jsapi' })
write_ticket_to_file(data)
read_ticket_from_file
end
end
end
end

View File

@ -0,0 +1,42 @@
module Wechat
module Token
class AccessTokenBase
attr_reader :client, :appid, :secret, :token_file, :access_token, :token_life_in_seconds, :got_token_at
def initialize(client, appid, secret, token_file)
@appid = appid
@secret = secret
@client = client
@token_file = token_file
@random_generator = Random.new
end
def token
# Possible two worker running, one worker refresh token, other unaware, so must read every time
read_token_from_file
refresh if remain_life_seconds < @random_generator.rand(30..3 * 60)
access_token
end
protected
def read_token_from_file
td = JSON.parse(File.read(token_file))
@got_token_at = td.fetch('got_token_at').to_i
@token_life_in_seconds = td.fetch('expires_in').to_i
@access_token = td.fetch('access_token')
rescue JSON::ParserError, Errno::ENOENT, KeyError
refresh
end
def write_token_to_file(token_hash)
token_hash.merge!('got_token_at'.freeze => Time.now.to_i)
File.write(token_file, token_hash.to_json)
end
def remain_life_seconds
token_life_in_seconds - (Time.now.to_i - got_token_at)
end
end
end
end

View File

@ -0,0 +1,13 @@
require 'wechat/token/access_token_base'
module Wechat
module Token
class CorpAccessToken < AccessTokenBase
def refresh
data = client.get('gettoken', params: { corpid: appid, corpsecret: secret })
write_token_to_file(data)
read_token_from_file
end
end
end
end

View File

@ -0,0 +1,13 @@
require 'wechat/token/access_token_base'
module Wechat
module Token
class PublicAccessToken < AccessTokenBase
def refresh
data = client.get('token', params: { grant_type: 'client_credential', appid: appid, secret: secret })
write_token_to_file(data)
read_token_from_file
end
end
end
end

View File

@ -0,0 +1,24 @@
require 'active_record'
module Wechat
class WechatLog < ::ActiveRecord::Base
def self.create_by_responder(req, res, session)
create openid: req[:FromUserName], request: req, response: res, session: session
end
def self.find_session(openid)
select(:session_raw).where(openid: openid).last.try :session
end
[:request, :response, :session].each do |name|
define_method name do
raw = send(:"#{name}_raw")
raw.blank? ? {} : JSON.parse(raw).symbolize_keys
end
define_method :"#{name}=" do |obj|
self[:"#{name}_raw"] = obj.try(:to_json)
end
end
end
end

View File

@ -0,0 +1,4 @@
# This file is used by Rack-based servers to start the application.
require ::File.expand_path('../config/environment', __FILE__)
run Rails.application

View File

@ -0,0 +1,35 @@
require File.expand_path('../boot', __FILE__)
require "rails"
# Pick the frameworks you want:
# require "active_model/railtie"
#require "active_job/railtie"
require "active_record/railtie"
require "action_controller/railtie"
#require "action_mailer/railtie"
#require "action_view/railtie"
# require "sprockets/railtie"
#require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module Dummy
class Application < Rails::Application
# Settings in config/environments/* take precedence over those specified here.
# Application configuration should go into files in config/initializers
# -- all .rb files in that directory are automatically loaded.
# Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
# Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
# config.time_zone = 'Central Time (US & Canada)'
# The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
# config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
# config.i18n.default_locale = :de
# Do not swallow errors in after_commit/after_rollback callbacks.
#config.active_record.raise_in_transactional_callbacks = true
end
end

View File

@ -0,0 +1,3 @@
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../../Gemfile', __FILE__)
require 'bundler/setup' # Set up gems listed in the Gemfile.

View File

@ -0,0 +1,5 @@
test:
adapter: sqlite3
pool: 5
timeout: 5000
database: ":memory:"

View File

@ -0,0 +1,5 @@
# Load the Rails application.
require File.expand_path('../application', __FILE__)
# Initialize the Rails application.
Rails.application.initialize!

View File

@ -0,0 +1,42 @@
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# The test environment is used exclusively to run your application's
# test suite. You never need to work with it otherwise. Remember that
# your test database is "scratch space" for the test suite and is wiped
# and recreated between test runs. Don't rely on the data there!
config.cache_classes = true
# Do not eager load code on boot. This avoids loading your whole application
# just for the purpose of running a single test. If you are using a tool that
# preloads Rails for running tests, you may have to set it to true.
config.eager_load = false
# Configure static file server for tests with Cache-Control for performance.
config.serve_static_files = true
config.static_cache_control = 'public, max-age=3600'
# Show full error reports and disable caching.
config.consider_all_requests_local = true
config.action_controller.perform_caching = false
# Raise exceptions instead of rendering exception templates.
config.action_dispatch.show_exceptions = false
# Disable request forgery protection in test environment.
config.action_controller.allow_forgery_protection = false
# Tell Action Mailer not to deliver emails to the real world.
# The :test delivery method accumulates sent emails in the
# ActionMailer::Base.deliveries array.
#config.action_mailer.delivery_method = :test
# Randomize the order test cases are executed.
config.active_support.test_order = :random
# Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr
# Raises error for missing translations
# config.action_view.raise_on_missing_translations = true
end

View File

@ -0,0 +1,7 @@
Rails.application.routes.draw do
get 'wechat', to: 'wechat#show'
post 'wechat', to: 'wechat#create'
get 'wechat_corp', to: 'wechat_corp#show'
post 'wechat_corp', to: 'wechat_corp#create'
end

View File

@ -0,0 +1,22 @@
# Be sure to restart your server when you modify this file.
# Your secret key is used for verifying the integrity of signed cookies.
# If you change this key, all old signed cookies will become invalid!
# Make sure the secret is at least 30 characters and all random,
# no regular words or you'll be exposed to dictionary attacks.
# You can use `rake secret` to generate a secure secret key.
# Make sure the secrets in this file are kept private
# if you're sharing your code publicly.
development:
secret_key_base: f95d476dd39f698ea78be4a3c0fbba94cabb08b38aab9deab2e50f4fb7290786a722cfc4c174e30b5b761a31efd3b40133a3d01586eb2745f659fc7d6b17a552
test:
secret_key_base: ebd51f2bd20540c6005cdc743ce71cc4cac49bc918a1207ac6107945da585aad330b07866f16f5ad643defcd50289aa4069c3aeb7e319389b4eb5adf99c27554
# Do not keep production secrets in the repository,
# instead read values from the environment.
production:
secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>

View File

View File

@ -0,0 +1,299 @@
require 'spec_helper'
RSpec.describe Wechat::Api do
let(:token_file) { Rails.root.join('tmp/access_token') }
let(:jsapi_ticket_file) { Rails.root.join('tmp/jsapi_ticket') }
subject do
Wechat::Api.new('appid', 'secret', token_file, 20, false, jsapi_ticket_file)
end
before :each do
allow(subject.access_token).to receive(:token).and_return('access_token')
allow(subject.jsapi_ticket).to receive(:jsapi_ticket).and_return('jsapi_ticket')
end
describe '#API_BASE' do
specify 'will get correct API_BASE' do
expect(subject.client.base).to eq Wechat::Api::API_BASE
end
end
describe '#callbackip' do
specify 'will get callbackip with access_token' do
server_ip_result = 'server_ip_result'
expect(subject.client).to receive(:get).with('getcallbackip', params: { access_token: 'access_token' }).and_return(server_ip_result)
expect(subject.callbackip).to eq server_ip_result
end
end
describe '#qrcode' do
specify 'will get showqrcode with ticket at file based api endpoint as file' do
ticket_result = 'ticket_result'
expect(subject.client).to receive(:get)
.with('showqrcode', params: { ticket: 'ticket' },
base: Wechat::ApiBase::MP_BASE,
as: :file).and_return(ticket_result)
expect(subject.qrcode('ticket')).to eq(ticket_result)
end
end
describe '#groups' do
specify 'will get groups with access_token' do
groups_result = 'groups_result'
expect(subject.client).to receive(:get).with('groups/get', params: { access_token: 'access_token' }).and_return(groups_result)
expect(subject.groups).to eq groups_result
end
end
describe '#group_create' do
specify 'will post groups/create with access_token and new group json_data' do
new_group = { group: { name: 'new_group_name' } }
expect(subject.client).to receive(:post).with('groups/create', new_group.to_json, params: { access_token: 'access_token' }).and_return(true)
expect(subject.group_create('new_group_name')).to be true
end
end
describe '#group_update' do
specify 'will post groups/update with access_token and json_data' do
update_group = { group: { id: 108, name: 'test2_modify2' } }
expect(subject.client).to receive(:post).with('groups/update', update_group.to_json, params: { access_token: 'access_token' }).and_return(true)
expect(subject.group_update(108, 'test2_modify2')).to be true
end
end
describe '#group_delete' do
specify 'will post groups/delete with access_token' do
delete_group = { group: { id: 108 } }
expect(subject.client).to receive(:post).with('groups/delete', delete_group.to_json, params: { access_token: 'access_token' }).and_return(true)
expect(subject.group_delete(108)).to be true
end
end
describe '#users' do
specify 'will get user/get with access_token' do
users_result = 'users_result'
expect(subject.client).to receive(:get).with('user/get', params: { access_token: 'access_token' }).and_return(users_result)
expect(subject.users).to eq(users_result)
end
specify 'will get user/get with access_token and next_openid' do
users_result = 'users_result'
expect(subject.client).to receive(:get)
.with('user/get', params: { access_token: 'access_token',
next_openid: 'next_openid' }).and_return(users_result)
expect(subject.users('next_openid')).to eq(users_result)
end
end
describe '#user' do
specify 'will get user/info with access_token and openid' do
user_result = 'user_result'
expect(subject.client).to receive(:get).with('user/info', params: { access_token: 'access_token', openid: 'openid' }).and_return(user_result)
expect(subject.user 'openid').to eq(user_result)
end
end
describe '#user_group' do
specify 'will post groups/getid with access_token and openid to get user groups info' do
user_request = { openid: 'openid' }
user_response = { groupid: 102 }
expect(subject.client).to receive(:post)
.with('groups/getid', user_request.to_json, params: { access_token: 'access_token' }).and_return(user_response)
expect(subject.user_group 'openid').to eq(user_response)
end
end
describe '#user_change_group' do
specify 'will post groups/getid with access_token and openid to get user groups info' do
user_request = { openid: 'openid', to_groupid: 108 }
expect(subject.client).to receive(:post)
.with('groups/members/update', user_request.to_json, params: { access_token: 'access_token' }).and_return(true)
expect(subject.user_change_group 'openid', 108).to be true
end
end
describe '#user_update_remark' do
specify 'will post groups/getid with access_token and openid to get user groups info' do
user_update_remark_request = { openid: 'openid', remark: 'remark' }
user_update_remark_result = { errcode: 0, errmsg: 'ok' }
expect(subject.client).to receive(:post)
.with('user/info/updateremark', user_update_remark_request.to_json, params: { access_token: 'access_token' }).and_return(user_update_remark_result)
expect(subject.user_update_remark 'openid', 'remark').to eq user_update_remark_result
end
end
describe '#qrcode_create_scene' do
specify 'will post qrcode/create with access_token, scene_id and expire_seconds' do
scene_id = 101
qrcode_scene_req = { expire_seconds: 60,
action_name: 'QR_SCENE',
action_info: { scene: { scene_id: scene_id } } }
qrcode_scene_result = { ticket: 'qr_code_ticket',
expire_seconds: 60, url: 'qr_code_ticket_pic_url' }
expect(subject.client).to receive(:post)
.with('qrcode/create', qrcode_scene_req.to_json, params: { access_token: 'access_token' }).and_return(qrcode_scene_result)
expect(subject.qrcode_create_scene(scene_id, 60)).to eq qrcode_scene_result
end
end
describe '#qrcode_create_limit_scene' do
qrcode_limit_scene_result = { ticket: 'qr_code_ticket',
url: 'qr_code_ticket_pic_url' }
specify 'will post qrcode/create with access_token and scene_id' do
scene_id = 101
qrcode_limit_scene_req = { action_name: 'QR_LIMIT_SCENE',
action_info: { scene: { scene_id: scene_id } } }
expect(subject.client).to receive(:post)
.with('qrcode/create', qrcode_limit_scene_req.to_json, params: { access_token: 'access_token' }).and_return(qrcode_limit_scene_result)
expect(subject.qrcode_create_limit_scene(scene_id)).to eq qrcode_limit_scene_result
end
specify 'will post qrcode/create with access_token and scene_str' do
scene_str = 'scene_str'
qrcode_limit_str_scene_req = { action_name: 'QR_LIMIT_STR_SCENE',
action_info: { scene: { scene_str: scene_str } } }
expect(subject.client).to receive(:post)
.with('qrcode/create', qrcode_limit_str_scene_req.to_json, params: { access_token: 'access_token' }).and_return(qrcode_limit_scene_result)
expect(subject.qrcode_create_limit_scene(scene_str)).to eq qrcode_limit_scene_result
end
end
describe '#menu' do
specify 'will get menu/get with access_token' do
menu_result = 'menu_result'
expect(subject.client).to receive(:get).with('menu/get', params: { access_token: 'access_token' }).and_return(menu_result)
expect(subject.menu).to eq(menu_result)
end
end
describe '#menu_delete' do
specify 'will get menu/delete with access_token' do
expect(subject.client).to receive(:get).with('menu/delete', params: { access_token: 'access_token' }).and_return(true)
expect(subject.menu_delete).to be true
end
end
describe '#menu_create' do
specify 'will post menu/create with access_token and json_data' do
menu = { buttons: ['a_button'] }
expect(subject.client).to receive(:post).with('menu/create', menu.to_json, params: { access_token: 'access_token' }).and_return(true)
expect(subject.menu_create(menu)).to be true
end
end
describe '#media' do
specify 'will get media/get with access_token and media_id at file based api endpoint as file' do
media_result = 'media_result'
expect(subject.client).to receive(:get)
.with('media/get', params: { access_token: 'access_token', media_id: 'media_id' },
as: :file).and_return(media_result)
expect(subject.media('media_id')).to eq(media_result)
end
end
describe '#media_create' do
specify 'will post media/upload with access_token, type and media payload at file based api endpoint' do
file = 'README.md'
expect(subject.client).to receive(:post_file)
.with('media/upload', file,
params: { type: 'image', access_token: 'access_token' }).and_return(true)
expect(subject.media_create('image', file)).to be true
end
end
describe '#material' do
specify 'will get material/get with access_token and media_id at file based api endpoint as file' do
material_result = 'material_result'
expect(subject.client).to receive(:get)
.with('material/get', params: { access_token: 'access_token', media_id: 'media_id' },
as: :file).and_return(material_result)
expect(subject.material('media_id')).to eq(material_result)
end
end
describe '#material_count' do
specify 'will get material_count with access_token' do
material_count_result = { voice_count: 1,
video_count: 2,
image_count: 3,
news_count: 4 }
expect(subject.client).to receive(:get)
.with('material/get_materialcount', params: { access_token: 'access_token' }).and_return(material_count_result)
expect(subject.material_count).to eq material_count_result
end
end
describe '#material_list' do
specify 'will get material list with access_token' do
material_list_request = { type: 'image', offset: 0, count: 20 }
material_list_result = { total_count: 1, item_count: 1,
item: [{ media_id: 'media_id', name: 'name', update_time: 12345, url: 'url' }] }
expect(subject.client).to receive(:post)
.with('material/batchget_material', material_list_request.to_json, params: { access_token: 'access_token' }).and_return(material_list_result)
expect(subject.material_list('image', 0, 20)).to eq material_list_result
end
end
describe '#material_add' do
specify 'will post material/add_material with access_token, type and media payload at file based api endpoint' do
file = 'README.md'
expect(subject.client).to receive(:post_file)
.with('material/add_material', file,
params: { type: 'image', access_token: 'access_token' }).and_return(true)
expect(subject.material_add('image', file)).to be true
end
end
describe '#material_delete' do
specify 'will post material/del_material with access_token and media_id in payload' do
media_id = 'media_id'
material_delete_result = { errcode: 0, errmsg: 'deleted' }
expect(subject.client).to receive(:post)
.with('material/del_material', { media_id: media_id },
params: { access_token: 'access_token' }).and_return(material_delete_result)
expect(subject.material_delete(media_id)).to eq material_delete_result
end
end
describe '#custom_message_send' do
specify 'will post message/custom/send with access_token, and json payload' do
payload = {
touser: 'openid',
msgtype: 'text',
text: { content: 'message content' }
}
expect(subject.client).to receive(:post)
.with('message/custom/send', payload.to_json,
params: { access_token: 'access_token' }, content_type: :json).and_return(true)
expect(subject.custom_message_send Wechat::Message.to('openid').text('message content')).to be true
end
end
describe '#template_message' do
specify 'will post message/custom/send with access_token, and json payload' do
payload = { touser: 'OPENID',
template_id: 'ngqIpbwh8bUfcSsECmogfXcV14J0tQlEpBO27izEYtY',
url: 'http://weixin.qq.com/download',
topcolor: '#FF0000',
data: { first: { value: '恭喜你购买成功!', color: '#173177' },
keynote1: { value: '巧克力', color: '#173177' },
keynote2: { value: '39.8元', color: '#173177' },
keynote3: { value: '2014年9月16日', color: '#173177' },
remark: { value: '欢迎再次购买!', color: '#173177' } } }
response_result = { errcode: 0, errmsg: 'ok', msgid: 332 }
expect(subject.client).to receive(:post)
.with('message/template/send', payload.to_json,
params: { access_token: 'access_token' }, content_type: :json).and_return(response_result)
expect(subject.template_message_send payload).to eq response_result
end
end
end

View File

@ -0,0 +1,62 @@
require 'spec_helper'
RSpec.describe Wechat::Cipher do
subject { Class.new.send(:include, Wechat::Cipher) }
it '#encode_padding' do
result = subject.new.instance_eval { encode_padding('abcd') }
expect(result.length).to eq Wechat::Cipher::BLOCK_SIZE
expect(result.bytes[-1]).to eq Wechat::Cipher::BLOCK_SIZE - 4
end
it '#decode_padding' do
result = subject.new.instance_eval { decode_padding("abcd\x3\x3\x3") }
expect(result).to eq 'abcd'
end
it '#encrypt & #decrypt short' do
key = SecureRandom.base64(32)
plain_text = 'hello world'
encrypt_text = subject.new.instance_eval { encrypt(encode_padding(plain_text), key) }
decrypt_text = subject.new.instance_eval { decrypt(encrypt_text, key) }
expect(decrypt_text).to eq plain_text
end
it '#encrypt & #decrypt long' do
key = SecureRandom.base64(32)
plain_text = <<SHAKESPEARE
Shall I compare thee to a summer's day?
Thou art more lovely and more temperate:
Rough winds do shake the darling buds of May,
And summer's lease hath all too short a date:
Sometime too hot the eye of heaven shines,
And often is his gold complexion dimm'd;
And every fair from fair sometime declines,
By chance, or nature's changing course, untrimm'd;
But thy eternal summer shall not fade
Nor lose possession of that fair thou ow'st;
Nor shall Death brag thou wander'st in his shade,
When in eternal lines to time thou grow'st;
So long as men can breathe or eyes can see,
So long lives this, and this gives life to thee.
SHAKESPEARE
encrypt_text = subject.new.instance_eval { encrypt(encode_padding(plain_text), key) }
decrypt_text = subject.new.instance_eval { decrypt(encrypt_text, key) }
expect(decrypt_text).to eq plain_text
end
it '#pack & #unpack' do
content = '<xml>text</xml>'
app_id = 'bravo_app'
packed_text = subject.new.instance_eval { pack(content, app_id) }
content2, app_id2 = subject.new.instance_eval { unpack(packed_text) }
expect(content2).to eq content
expect(app_id2).to eq app_id
end
end

View File

@ -0,0 +1,94 @@
require 'spec_helper'
RSpec.describe Wechat::Client do
subject do
Wechat::Client.new('http://host/', 20, false)
end
let(:response_params) do
{
headers: { content_type: 'text/plain' },
status: 200
}
end
let(:response_404) { double '404', response_params.merge(status: 404) }
let(:response_text) { double 'text', response_params.merge(body: 'some text') }
let(:response_json) do
double 'json', response_params.merge(body: { result: 'success' }.to_json,
headers: { content_type: 'application/json' })
end
let(:response_image) { double 'image', response_params.merge(body: 'image data', headers: { content_type: 'image/gif' }) }
describe '#get' do
specify 'Will use http get method to request data' do
allow(HTTP).to receive_message_chain('headers.get') { response_json }
subject.get('token')
end
end
describe '#post' do
specify 'Will use http post method to request data' do
allow(HTTP).to receive_message_chain('headers.post') { response_json }
subject.post('token', 'some_data')
end
end
describe '#request' do
specify 'will add accept=>:json for request' do
block = lambda do |url, headers|
expect(url).to eq('http://host/token')
expect(headers).to eq(params: { access_token: '1234' }, 'Accept' => 'application/json')
response_json
end
subject.send(:request, 'token', params: { access_token: '1234' }, &block)
end
specify 'will use base option to construct url' do
block = lambda do |url, _headers|
expect(url).to eq('http://override/token')
response_json
end
subject.send(:request, 'token', base: 'http://override/', &block)
end
specify 'will not pass as option for request' do
block = lambda do |_url, headers|
expect(headers[:as]).to be_nil
response_json
end
subject.send(:request, 'token', as: :text, &block)
end
specify 'will raise error if response code is not 200' do
expect { subject.send(:request, 'token') { response_404 } }.to raise_error
end
context 'parse response body' do
specify 'will return response body for text response' do
expect(subject.send(:request, 'text', as: :text) { response_text }).to eq(response_text.body)
end
specify 'will return response body as file for image' do
expect(subject.send(:request, 'image') { response_image }).to be_a(Tempfile)
end
specify 'will return response body as file for unknown content_type' do
response_stream = double 'image', response_params.merge(body: 'stream', headers: { content_type: 'stream' })
expect(subject.send(:request, 'image', as: :file) { response_stream }).to be_a(Tempfile)
end
end
context 'json error' do
specify 'raise ResponseError given response has error json' do
allow(response_json).to receive(:body).and_return({ errcode: 1106, errmsg: 'error message' }.to_json)
expect { subject.send(:request, 'image', as: :file) { response_json } }.to raise_error(Wechat::ResponseError)
end
specify 'raise AccessTokenExpiredError given response has error json with errorcode 40014' do
allow(response_json).to receive(:body).and_return({ errcode: 40014, errmsg: 'error message' }.to_json)
expect { subject.send(:request, 'image', as: :file) { response_json } }.to raise_error(Wechat::AccessTokenExpiredError)
end
end
end
end

View File

@ -0,0 +1,42 @@
require 'spec_helper'
RSpec.describe Wechat::Token::CorpAccessToken do
let(:token_file) { Rails.root.join('access_token') }
let(:token) { '12345' }
let(:client) { double(:client) }
subject do
Wechat::Token::CorpAccessToken.new(client, 'corpid', 'corpsecret', token_file)
end
before :each do
allow(client).to receive(:get)
.with('gettoken', params: { corpid: 'corpid',
corpsecret: 'corpsecret' }).and_return('access_token' => '12345', 'expires_in' => 7200)
end
after :each do
File.delete(token_file) if File.exist?(token_file)
end
describe '#refresh' do
specify 'will set access_token' do
expect(subject.refresh).to eq(token)
expect(subject.access_token).to eq('12345')
end
specify "won't set access_token if request failed" do
allow(client).to receive(:get).and_raise('error')
expect { subject.refresh }.to raise_error('error')
expect(subject.access_token).to be_nil
end
specify "won't set access_token if response value invalid" do
allow(client).to receive(:get).and_return('rubbish')
expect { subject.refresh }.to raise_error
expect(subject.access_token).to be_nil
end
end
end

View File

@ -0,0 +1,463 @@
require 'spec_helper'
RSpec.describe Wechat::CorpApi do
let(:token_file) { Rails.root.join('tmp/access_token') }
let(:jsapi_ticket_file) { Rails.root.join('tmp/jsapi_ticket') }
subject do
Wechat::CorpApi.new('corpid', 'corpsecret', token_file, '1', 20, false, jsapi_ticket_file)
end
before :each do
allow(subject.access_token).to receive(:token).and_return('access_token')
end
describe '#API_BASE' do
specify 'will get correct API_BASE' do
expect(subject.client.base).to eq Wechat::CorpApi::API_BASE
end
end
describe '#agent_list' do
specify 'will get user/get with access_token and userid' do
agent_list_result = { errcode: 0, errmsg: 'ok',
agentlist: [{ agentid: '5', name: '企业小助手',
square_logo_url: 'url', round_logo_url: 'url' },
{ agentid: '8', name: 'HR小助手',
square_logo_url: 'url', round_logo_url: 'url' }] }
expect(subject.client).to receive(:get)
.with('agent/list', params: { access_token: 'access_token' }).and_return(agent_list_result)
expect(subject.agent_list).to eq agent_list_result
end
end
describe '#agent' do
specify 'will get user/get with access_token and userid' do
agentid = '1'
agent_result = { errcode: 0, errmsg: 'ok', agentid: '1',
name: 'NAME', square_logo_url: 'xxxxxxxx', round_logo_url: 'yyyyyyyy', description: 'desc',
allow_userinfos: { user: [{ userid: 'id1', status: 1 },
{ userid: 'id2', status: 1 }] },
allow_partys: { partyid: [1] },
allow_tags: { tagid: [1, 2, 3] },
close: 0, redirect_domain: 'www.qq.com', report_location_flag: 0,
isreportuser: 0, isreportenter: 0 }
expect(subject.client).to receive(:get)
.with('agent/get', params: { agentid: agentid, access_token: 'access_token' }).and_return(agent_result)
expect(subject.agent(agentid)).to eq agent_result
end
end
describe '#user' do
specify 'will get user/get with access_token and userid' do
userid = 'userid'
user_result = { errcode: 0, errmsg: 'ok',
userid: 'zhangsan',
name: '李四',
department: [1, 2],
position: '后台工程师',
mobile: '15913215421',
gender: '1',
email: 'zhangsan@gzdev.com',
weixinid: 'lisifordev',
avatar: 'http://wx.qlogo.cn/mmopen/ajNVdqHZLLA3WJ6DSZUfiakYe37PKnQhBIeOQBO4czqrnZDS79FH5Wm5m4X69TBicnHFlhiafvDwklOpZeXYQQ2icg/0',
status: 1,
extattr: { attrs: [{ name: '爱好', value: '旅游' }, { name: '卡号', value: '1234567234' }] } }
expect(subject.client).to receive(:get)
.with('user/get', params: { userid: userid, access_token: 'access_token' }).and_return(user_result)
expect(subject.user(userid)).to eq user_result
end
end
describe '#getuserinfo' do
specify 'will get user/getuserinfo with access_token and code' do
code = 'code'
getuserinfo_result = { UserId: 'USERID', DeviceId: 'DEVICEID' }
expect(subject.client).to receive(:get)
.with('user/getuserinfo', params: { code: code, access_token: 'access_token' }).and_return(getuserinfo_result)
expect(subject.getuserinfo(code)).to eq getuserinfo_result
end
end
describe '#convert_to_openid' do
specify 'will get invite/send with access_token and json userid' do
userid = 'userid'
agentid = '1'
convert_to_openid_request = { userid: userid, agentid: agentid }
convert_to_openid_result = { errcode: 0, errmsg: 'ok', openid: 'oDOGms-6yCnGrRovBj2yHij5JL6E', appid: 'wxf874e15f78cc84a7' }
expect(subject.client).to receive(:post)
.with('user/convert_to_openid', convert_to_openid_request.to_json, params: { access_token: 'access_token' }).and_return(convert_to_openid_result)
expect(subject.convert_to_openid(userid)).to eq convert_to_openid_result
end
end
describe '#invite_user' do
specify 'will get invite/send with access_token and json userid' do
userid = 'userid'
invite_request = { userid: userid }
invite_result = { errcode: 0, errmsg: 'ok', type: 1 }
expect(subject.client).to receive(:post)
.with('invite/send', invite_request.to_json, params: { access_token: 'access_token' }).and_return(invite_result)
expect(subject.invite_user(userid)).to eq invite_result
end
end
describe '#user_auth_success' do
specify 'will get user/authsucc with access_token and userid' do
userid = 'userid'
user_auth_result = { errcode: 0, errmsg: 'ok' }
expect(subject.client).to receive(:get)
.with('user/authsucc', params: { userid: userid, access_token: 'access_token' }).and_return(user_auth_result)
expect(subject.user_auth_success(userid)).to eq user_auth_result
end
end
describe '#user_delete' do
specify 'will get user/delete with access_token and userid' do
userid = 'userid'
user_delete_result = { errcode: 0, errmsg: 'deleted' }
expect(subject.client).to receive(:get)
.with('user/delete', params: { userid: userid, access_token: 'access_token' }).and_return(user_delete_result)
expect(subject.user_delete(userid)).to eq user_delete_result
end
end
describe '#user_batchdelete' do
specify 'will get user/delete with access_token and userid' do
batchdelete_request = { useridlist: %w(6749 6110) }
user_delete_result = { errcode: 0, errmsg: 'deleted' }
expect(subject.client).to receive(:post)
.with('user/batchdelete', batchdelete_request.to_json, params: { access_token: 'access_token' }).and_return(user_delete_result)
expect(subject.user_batchdelete(%w(6749 6110))).to eq user_delete_result
end
end
describe '#batch_job_result' do
specify 'will get batch/getresult with access_token and userid' do
batch_result = { errcode: 0, errmsg: 'ok', status: 1,
type: 'replace_user', total: 3, percentage: 33, remaintime: 1,
result: [{}, {}] }
expect(subject.client).to receive(:get)
.with('batch/getresult', params: { jobid: 'jobid', access_token: 'access_token' }).and_return(batch_result)
expect(subject.batch_job_result('jobid')).to eq batch_result
end
end
describe '#batch_replaceparty' do
specify 'will post batch/replaceparty with access_token and new department payload' do
batch_replaceparty_request = { media_id: 'media_id' }
batch_replaceparty_result = { errcode: 0, errmsg: 'ok', jobid: 'jobid' }
expect(subject.client).to receive(:post)
.with('batch/replaceparty', batch_replaceparty_request.to_json, params: { access_token: 'access_token' }).and_return(batch_replaceparty_result)
expect(subject.batch_replaceparty('media_id')).to eq batch_replaceparty_result
end
end
describe '#batch_replaceuser' do
specify 'will post batch/replaceuser with access_token and new department payload' do
batch_replaceuser_request = { media_id: 'media_id' }
batch_replaceuser_result = { errcode: 0, errmsg: 'ok', jobid: 'jobid' }
expect(subject.client).to receive(:post)
.with('batch/replaceuser', batch_replaceuser_request.to_json, params: { access_token: 'access_token' }).and_return(batch_replaceuser_result)
expect(subject.batch_replaceuser('media_id')).to eq batch_replaceuser_result
end
end
describe '#batch_syncuser' do
specify 'will post batch/syncuser with access_token and new department payload' do
batch_syncuser_request = { media_id: 'media_id' }
batch_syncuser_result = { errcode: 0, errmsg: 'ok', jobid: 'jobid' }
expect(subject.client).to receive(:post)
.with('batch/syncuser', batch_syncuser_request.to_json, params: { access_token: 'access_token' }).and_return(batch_syncuser_result)
expect(subject.batch_syncuser('media_id')).to eq batch_syncuser_result
end
end
describe '#department_create' do
specify 'will post department/create with access_token and new department payload' do
department_create_request = { name: '广州研发中心', parentid: '1' }
department_create_result = { errcode: 0, errmsg: 'created', id: 2 }
expect(subject.client).to receive(:post)
.with('department/create', department_create_request.to_json, params: { access_token: 'access_token' }).and_return(department_create_result)
expect(subject.department_create('广州研发中心', '1')).to eq department_create_result
end
end
describe '#department_delete' do
specify 'will get department/delete with access_token and id' do
departmentid = 'departmentid'
department_delete_result = { errcode: 0, errmsg: 'deleted' }
expect(subject.client).to receive(:get)
.with('department/delete', params: { access_token: 'access_token', id: departmentid }).and_return(department_delete_result)
expect(subject.department_delete('departmentid')).to eq department_delete_result
end
end
describe '#department_update' do
specify 'will post department/update with access_token and id' do
departmentid = 'departmentid'
department_update_request = { id: departmentid, name: '广研' }
department_update_result = { errcode: 0, errmsg: 'updated' }
expect(subject.client).to receive(:post)
.with('department/update', department_update_request.to_json, params: { access_token: 'access_token' }).and_return(department_update_result)
expect(subject.department_update('departmentid', '广研')).to eq department_update_result
end
end
describe '#department' do
specify 'will get user/get with access_token and userid' do
departmentid = 'departmentid'
department_result = { errcode: 0, errmsg: 'ok',
department: [
{ id: 2,
name: '广州研发中心',
parentid: 1,
order: 10 },
{ id: 3,
name: '邮箱产品部',
parentid: 2,
order: 40 }] }
expect(subject.client).to receive(:get)
.with('department/list', params: { id: departmentid, access_token: 'access_token' }).and_return(department_result)
expect(subject.department(departmentid)).to eq department_result
end
end
describe '#user_simplelist' do
specify 'will get user/simplelist with access_token and departmentid' do
department_id = 'department_id'
simplelist_result = { errcode: 0, errmsg: 'ok',
userlist: [{ userid: 'zhangsan', name: '李四', department: [1, 2] }] }
expect(subject.client).to receive(:get)
.with('user/simplelist', params: { department_id: department_id, fetch_child: 0, status: 0, access_token: 'access_token' })
.and_return(simplelist_result)
expect(subject.user_simplelist(department_id)).to eq simplelist_result
end
end
describe '#user_list' do
specify 'will get user/list with access_token and departmentid' do
user_list_result = { errcode: 0, errmsg: 'ok',
userlist: [{ userid: 'zhangsan',
name: '李四',
department: [1, 2],
position: '后台工程师',
mobile: '15913215421',
gender: '1',
email: 'zhangsan@gzdev.com',
weixinid: 'lisifordev',
avatar: 'http://wx.qlogo.cn/mmopen/ajNVdqHZLLA3WJ6DSZUfiakYe37PKnQhBIeOQBO4czqrnZDS79FH5Wm5m4X69TBicnHFlhiafvDwklOpZeXYQQ2icg/0',
status: 1,
extattr: { attrs: [{ name: '爱好', value: '旅游' }, { name: '卡号', value: '1234567234' }] } }] }
expect(subject.client).to receive(:get)
.with('user/list', params: { department_id: 1, fetch_child: 0, status: 0, access_token: 'access_token' }).and_return(user_list_result)
expect(subject.user_list(1)).to eq user_list_result
end
end
describe '#tag_create' do
specify 'will post tag/create with access_token and new department payload' do
tag_create_request = { tagname: 'UI', tagid: 1 }
tag_create_result = { errcode: 0, errmsg: 'created', tagid: 1 }
expect(subject.client).to receive(:post)
.with('tag/create', tag_create_request.to_json, params: { access_token: 'access_token' }).and_return(tag_create_result)
expect(subject.tag_create('UI', 1)).to eq tag_create_result
end
end
describe '#tag_update' do
specify 'will post tag/update with access_token and new department payload' do
tag_update_request = { tagid: 1, tagname: 'UI Design' }
tag_update_result = { errcode: 0, errmsg: 'updated' }
expect(subject.client).to receive(:post)
.with('tag/update', tag_update_request.to_json, params: { access_token: 'access_token' }).and_return(tag_update_result)
expect(subject.tag_update(1, 'UI Design')).to eq tag_update_result
end
end
describe '#tag_delete' do
specify 'will get tag/delete with access_token and tagid' do
tag_delete_result = { errcode: 0, errmsg: 'deleted' }
expect(subject.client).to receive(:get)
.with('tag/delete', params: { tagid: 1, access_token: 'access_token' }).and_return(tag_delete_result)
expect(subject.tag_delete(1)).to eq tag_delete_result
end
end
describe '#tags' do
specify 'will get tag/list with access_token' do
tags_result = { errcode: 0, errmsg: 'ok',
taglist: [{ tagid: 1, tagname: 'a' },
{ tagid: 2, tagname: 'b' }] }
expect(subject.client).to receive(:get)
.with('tag/list', params: { access_token: 'access_token' }).and_return(tags_result)
expect(subject.tags).to eq tags_result
end
end
describe '#tag' do
specify 'will get user/get with access_token and tagid' do
tag_result = { errcode: 0, errmsg: 'ok',
userlist: [{ userid: 'zhangsan', name: '李四' }],
partylist: [2] }
expect(subject.client).to receive(:get)
.with('tag/get', params: { tagid: 1, access_token: 'access_token' }).and_return(tag_result)
expect(subject.tag(1)).to eq tag_result
end
end
describe '#tag_add_user' do
specify 'will post tag/addtagusers with tagid, userlist(userids) and access_token' do
tag_add_user_request = { tagid: 1, userlist: %w(6749 6110), partylist: nil }
tag_add_user_result = { errcode: 0, errmsg: 'ok' }
expect(subject.client).to receive(:post)
.with('tag/addtagusers', tag_add_user_request.to_json, params: { access_token: 'access_token' }).and_return(tag_add_user_result)
expect(subject.tag_add_user(1, %w(6749 6110))).to eq tag_add_user_result
end
specify 'will post tag/addtagusers with tagid, partylist(departmentids) and access_token' do
tag_add_party_request = { tagid: 1, userlist: nil, partylist: [1, 2] }
tag_add_party_result = { errcode: 0, errmsg: 'ok' }
expect(subject.client).to receive(:post)
.with('tag/addtagusers', tag_add_party_request.to_json, params: { access_token: 'access_token' }).and_return(tag_add_party_result)
expect(subject.tag_add_user(1, nil, [1, 2])).to eq tag_add_party_result
end
end
describe '#tag_del_user' do
specify 'will post tag/deltagusers with tagid, userlist(userids) and access_token' do
tag_del_user_request = { tagid: 1, userlist: %w(6749 6110), partylist: nil }
tag_del_user_result = { errcode: 0, errmsg: 'deleted' }
expect(subject.client).to receive(:post)
.with('tag/deltagusers', tag_del_user_request.to_json, params: { access_token: 'access_token' }).and_return(tag_del_user_result)
expect(subject.tag_del_user(1, %w(6749 6110))).to eq tag_del_user_result
end
specify 'will post tag/deltagusers with tagid, partylist(departmentids) and access_token' do
tag_del_party_request = { tagid: 1, userlist: nil, partylist: [1, 2] }
tag_del_party_result = { errcode: 0, errmsg: 'deleted' }
expect(subject.client).to receive(:post)
.with('tag/deltagusers', tag_del_party_request.to_json, params: { access_token: 'access_token' }).and_return(tag_del_party_result)
expect(subject.tag_del_user(1, nil, [1, 2])).to eq tag_del_party_result
end
end
describe '#menu' do
specify 'will get menu/get with access_token and agentid' do
menu_result = 'menu_result'
expect(subject.client).to receive(:get).with('menu/get', params: { access_token: 'access_token', agentid: '1' }).and_return(menu_result)
expect(subject.menu).to eq(menu_result)
end
end
describe '#menu_delete' do
specify 'will get menu/delete with access_token and agentid' do
expect(subject.client).to receive(:get).with('menu/delete', params: { access_token: 'access_token', agentid: '1' }).and_return(true)
expect(subject.menu_delete).to be true
end
end
describe '#menu_create' do
specify 'will post menu/create with access_token, agentid and json_data' do
menu = { buttons: ['a_button'] }
expect(subject.client).to receive(:post)
.with('menu/create', menu.to_json, params: { access_token: 'access_token', agentid: '1' }).and_return(true)
expect(subject.menu_create(menu)).to be true
end
end
describe '#media' do
specify 'will get media/get with access_token and media_id at file based api endpoint as file' do
media_result = 'media_result'
expect(subject.client).to receive(:get)
.with('media/get', params: { access_token: 'access_token', media_id: 'media_id' },
as: :file).and_return(media_result)
expect(subject.media('media_id')).to eq(media_result)
end
end
describe '#media_create' do
specify 'will post media/upload with access_token, type and media payload at file based api endpoint' do
file = 'README.md'
expect(subject.client).to receive(:post_file)
.with('media/upload', file,
params: { type: 'image', access_token: 'access_token' }).and_return(true)
expect(subject.media_create('image', file)).to be true
end
end
describe '#material' do
specify 'will get material/get with access_token, media_id and agentid at file based api endpoint as file' do
material_result = 'material_result'
expect(subject.client).to receive(:get)
.with('material/get', params: { access_token: 'access_token', media_id: 'media_id', agentid: '1' },
as: :file).and_return(material_result)
expect(subject.material('media_id')).to eq(material_result)
end
end
describe '#material_count' do
specify 'will get material_count with access_token' do
material_count_result = { errcode: 0, errmsg: 'ok',
total_count: 37,
image_count: 12,
voice_count: 10,
video_count: 3,
file_count: 3,
mpnews_count: 6 }
expect(subject.client).to receive(:get)
.with('material/get_count', params: { access_token: 'access_token', agentid: '1' }).and_return(material_count_result)
expect(subject.material_count).to eq material_count_result
end
end
describe '#material_list' do
specify 'will get material list with access_token' do
material_list_request = { type: 'image', agentid: '1', offset: 0, count: 50 }
material_list_result = { total_count: 1, item_count: 1,
item: [{ media_id: 'media_id', name: 'name', update_time: 12345, url: 'url' }] }
expect(subject.client).to receive(:post)
.with('material/batchget', material_list_request.to_json, params: { access_token: 'access_token' }).and_return(material_list_result)
expect(subject.material_list('image', 0, 50)).to eq material_list_result
end
end
describe '#material_add' do
specify 'will post material/add_material with access_token, type and media payload at file based api endpoint' do
file = 'README.md'
expect(subject.client).to receive(:post_file)
.with('material/add_material', file,
params: { type: 'image', access_token: 'access_token', agentid: '1' }).and_return(true)
expect(subject.material_add('image', file)).to be true
end
end
describe '#material_delete' do
specify 'will post material/del_material with access_token and media_id' do
media_id = 'media_id'
material_delete_result = { errcode: 0, errmsg: 'deleted' }
expect(subject.client).to receive(:get)
.with('material/del', params: { media_id: media_id, access_token: 'access_token', agentid: '1' }).and_return(material_delete_result)
expect(subject.material_delete(media_id)).to eq material_delete_result
end
end
describe '#message_send' do
specify 'will post message with access_token, and json payload' do
payload = {
touser: 'openid',
msgtype: 'text',
agentid: '1',
text: { content: 'message content' }
}
expect(subject.client).to receive(:post)
.with('message/send', payload.to_json,
content_type: :json, params: { access_token: 'access_token' }).and_return(true)
expect(subject.message_send 'openid', 'message content').to be true
end
end
end

View File

@ -0,0 +1,47 @@
require 'spec_helper'
RSpec.describe Wechat::Ticket::CorpJsapiTicket do
let(:jsapi_ticket_file) { Rails.root.join('tmp/jsapi_ticket_file') }
let(:ticket) { 'bxLdikRXVbTPdHSM05e5u5sUoXNKd9' }
let(:client) { double(:client) }
let(:access_token) { double(:access_token) }
let(:token_content) { { access_token: '12345', expires_in: 7200 } }
subject do
Wechat::Ticket::CorpJsapiTicket.new(client, access_token, jsapi_ticket_file)
end
before :each do
allow(client).to receive(:get)
.with('get_jsapi_ticket',
params: { access_token: token_content[:access_token] })
.and_return(errcode: 0, errmsg: 'ok', ticket: ticket, expires_in: 7200)
allow(access_token).to receive(:token).and_return(token_content[:access_token])
end
after :each do
File.delete(jsapi_ticket_file) if File.exist?(jsapi_ticket_file)
end
describe '#ticket' do
specify 'read from file if jsapi_ticket_file is not initialized' do
File.open(jsapi_ticket_file, 'w') { |f| f.write({ errcode: 0, errmsg: 'ok', ticket: ticket, expires_in: 7200 }.to_json) }
expect(subject.ticket).to eq ticket
end
end
describe '#refresh' do
specify 'will set ticket' do
expect(subject.refresh).to eq ticket
expect(subject.ticket).to eq ticket
end
end
describe '#signature' do
specify 'will get signature' do
url = 'http://www.baidu.com?q=ming'
expect(subject.signature(url)[:url]).to eq url
end
end
end

View File

@ -0,0 +1,305 @@
require 'spec_helper'
RSpec.describe Wechat::Message do
let(:text_request) { request_base.merge(MsgType: 'text', Content: 'text message') }
let(:request_base) do
{
ToUserName: 'toUser',
FromUserName: 'fromUser',
CreateTime: '1348831860',
MsgId: '1234567890123456'
}
end
let(:response_base) do
{
ToUserName: 'sender',
FromUserName: 'receiver',
CreateTime: 1_348_831_860,
MsgId: '1234567890123456'
}
end
describe 'fromHash' do
specify 'will create message' do
message = Wechat::Message.from_hash(text_request)
expect(message).to be_a(Wechat::Message)
expect(message.message_hash.size).to eq(7)
end
end
describe 'to' do
let(:message) { Wechat::Message.from_hash(text_request) }
specify 'will create base message' do
reply = Wechat::Message.to('toUser')
expect(reply).to be_a(Wechat::Message)
expect(reply.message_hash).to include(ToUserName: 'toUser')
expect(reply.message_hash[:CreateTime]).to be_a(Integer)
end
end
describe '#reply' do
let(:message) { Wechat::Message.from_hash(text_request) }
specify 'will create base response message' do
reply = message.reply
expect(reply).to be_a(Wechat::Message)
expect(reply.message_hash).to include(FromUserName: 'toUser', ToUserName: 'fromUser')
expect(reply.message_hash[:CreateTime]).to be_a(Integer)
end
end
describe 'parse message using as' do
let(:image_request) { request_base.merge(MsgType: 'image', MediaId: 'media_id', PicUrl: 'pic_url') }
let(:voice_request) { request_base.merge(MsgType: 'voice', MediaId: 'media_id', Format: 'format') }
let(:video_request) { request_base.merge(MsgType: 'video', MediaId: 'media_id', ThumbMediaId: 'thumb_media_id') }
let(:location_request) do
request_base.merge(MsgType: 'location', Location_X: 'location_x', Location_Y: 'location_y',
Scale: 'scale', Label: 'label')
end
specify 'will raise error when parse message as an unkonwn type' do
message = Wechat::Message.from_hash(text_request)
expect { message.as(:unkown) }.to raise_error
end
specify 'will get text content' do
message = Wechat::Message.from_hash(text_request)
expect(message.as(:text)).to eq 'text message'
end
specify 'will get image file' do
message = Wechat::Message.from_hash(image_request)
expect(Wechat.api).to receive(:media).with('media_id')
message.as(:image)
end
specify 'will get voice file' do
message = Wechat::Message.from_hash(voice_request)
expect(Wechat.api).to receive(:media).with('media_id')
message.as(:voice)
end
specify 'will get video file' do
message = Wechat::Message.from_hash(video_request)
expect(Wechat.api).to receive(:media).with('media_id')
message.as(:video)
end
specify 'will get location information' do
message = Wechat::Message.from_hash(location_request)
expect(message.as :location).to eq(location_x: 'location_x', location_y: 'location_y', scale: 'scale', label: 'label')
end
end
context 'altering message fields' do
let(:message) { Wechat::Message.from_hash(response_base) }
describe '#to' do
specify 'will update ToUserName field and return self' do
expect(message.to('a user')).to eq(message)
expect(message[:ToUserName]).to eq 'a user'
end
end
describe '#text' do
specify 'will update MsgType and Content field and return self' do
expect(message.text('content')).to eq(message)
expect(message[:MsgType]).to eq 'text'
expect(message[:Content]).to eq 'content'
end
end
describe '#transfer_customer_service' do
specify 'will update MsgType and return self' do
expect(message.transfer_customer_service).to eq(message)
expect(message[:MsgType]).to eq 'transfer_customer_service'
end
end
describe '#image' do
specify 'will update MsgType and MediaId field and return self' do
expect(message.image('media_id')).to eq(message)
expect(message[:MsgType]).to eq 'image'
expect(message[:Image][:MediaId]).to eq 'media_id'
end
end
describe '#voice' do
specify 'will update MsgType and MediaId field and return self' do
expect(message.voice('media_id')).to eq(message)
expect(message[:MsgType]).to eq 'voice'
expect(message[:Voice][:MediaId]).to eq 'media_id'
end
end
describe '#video' do
specify 'will update MsgType and MediaId, Title, Description field and return self' do
expect(message.video('media_id', title: 'title', description: 'description')).to eq(message)
expect(message[:MsgType]).to eq 'video'
expect(message[:Video][:MediaId]).to eq 'media_id'
expect(message[:Video][:Title]).to eq 'title'
expect(message[:Video][:Description]).to eq 'description'
end
end
describe '#music' do
specify 'will update MsgType and ThumbMediaId, Title, Description field and return self' do
expect(message.music('thumb_media_id', 'music_url', title: 'title', description: 'description', HQ_music_url: 'hq_music_url')).to eq(message)
expect(message[:MsgType]).to eq 'music'
expect(message[:Music][:Title]).to eq 'title'
expect(message[:Music][:Description]).to eq 'description'
expect(message[:Music][:MusicUrl]).to eq 'music_url'
expect(message[:Music][:HQMusicUrl]).to eq 'hq_music_url'
expect(message[:Music][:ThumbMediaId]).to eq 'thumb_media_id'
end
end
describe '#news' do
let(:items) do
[
{ title: 'title', description: 'description', url: 'url', pic_url: 'pic_url' },
{ title: 'title', description: 'description', url: nil, pic_url: 'pic_url' }
]
end
after :each do
expect(message[:MsgType]).to eq('news')
expect(message[:ArticleCount]).to eq(2)
expect(message[:Articles][0][:Title]).to eq 'title'
expect(message[:Articles][0][:Description]).to eq 'description'
expect(message[:Articles][0][:Url]).to eq 'url'
expect(message[:Articles][0][:PicUrl]).to eq 'pic_url'
expect(message[:Articles][1].key?(:Url)).to eq false
end
specify 'when no block is given, whill take the items argument as an array articals hash' do
message.news(items)
end
specify 'will update MesageType, ArticleCount, Articles field and return self' do
message.news(items) { |articals, item| articals.item item }
end
end
describe '#to_xml' do
let(:response) { Wechat::Message.from_hash(response_base) }
specify 'root is xml tag' do
hash = Hash.from_xml(response.text('text content').to_xml)
expect(hash.keys).to eq(['xml'])
end
specify 'collection key is item' do
xml = response.news([
{ title: 'title1', description: 'description', url: 'url', pic_url: 'pic_url' },
{ title: 'title2', description: 'description', url: 'url', pic_url: 'pic_url' }
]).to_xml
hash = Hash.from_xml(xml)
expect(hash['xml']['Articles']['item']).to be_a(Array)
expect(hash['xml']['Articles']['item'].size).to eq 2
end
end
describe '#to_json' do
specify 'can convert text message' do
request = Wechat::Message.to('toUser').text('text content')
expect(request.to_json).to eq({
touser: 'toUser',
msgtype: 'text',
text: { content: 'text content' }
}.to_json)
end
specify 'can convert image message' do
request = Wechat::Message.to('toUser').image('media_id')
expect(request.to_json).to eq({
touser: 'toUser',
msgtype: 'image',
image: { media_id: 'media_id' }
}.to_json)
end
specify 'can convert voice message' do
request = Wechat::Message.to('toUser').voice('media_id')
expect(request.to_json).to eq({
touser: 'toUser',
msgtype: 'voice',
voice: { media_id: 'media_id' }
}.to_json)
end
specify 'can convert video message' do
request = Wechat::Message.to('toUser').video('media_id', title: 'title', description: 'description')
expect(request.to_json).to eq({
touser: 'toUser',
msgtype: 'video',
video: {
media_id: 'media_id',
title: 'title',
description: 'description'
}
}.to_json)
end
specify 'can convert music message' do
request = Wechat::Message.to('toUser')
.music('thumb_media_id', 'music_url', title: 'title', description: 'description', HQ_music_url: 'hq_music_url')
expect(request.to_json).to eq({
touser: 'toUser',
msgtype: 'music',
music: {
title: 'title',
description: 'description',
hqmusicurl: 'hq_music_url',
musicurl: 'music_url',
thumb_media_id: 'thumb_media_id'
}
}.to_json)
end
specify 'can convert news message' do
request = Wechat::Message.to('toUser')
.news([{ title: 'title', description: 'description', url: 'url', pic_url: 'pic_url' }])
expect(request.to_json).to eq({
touser: 'toUser',
msgtype: 'news',
news: {
articles: [
{
title: 'title',
description: 'description',
picurl: 'pic_url',
url: 'url'
}
]
}
}.to_json)
end
end
describe '#save_to!' do
specify 'when given a model class, it will create a new model instance with json_hash and save it.' do
model_class = double('Model Class')
model = double('Model Instance')
message = Wechat::Message.to('toUser')
expect(model_class).to receive(:new)
.with(to_user_name: 'toUser',
msg_type: 'text',
content: 'text message',
create_time: message[:CreateTime]).and_return(model)
expect(model).to receive(:save!).and_return(true)
expect(message.text('text message').save_to!(model_class)).to eq(message)
end
end
end
end

View File

@ -0,0 +1,66 @@
require 'spec_helper'
RSpec.describe Wechat::Token::PublicAccessToken do
let(:token_file) { Rails.root.join('access_token') }
let(:token) { '12345' }
let(:client) { double(:client) }
subject do
Wechat::Token::PublicAccessToken.new(client, 'appid', 'secret', token_file)
end
before :each do
allow(client).to receive(:get)
.with('token', params: { grant_type: 'client_credential',
appid: 'appid',
secret: 'secret' }).and_return('access_token' => '12345', 'expires_in' => 7200)
end
after :each do
File.delete(token_file) if File.exist?(token_file)
end
describe '#token' do
specify 'read from file if access_token is not initialized' do
File.open(token_file, 'w') { |f| f.write({ 'access_token' => '12345', 'expires_in' => 7200 }.to_json) }
expect(subject.token).to eq('12345')
end
specify "refresh access_token if token file didn't exist" do
expect(File.exist? token_file).to be false
expect(subject.token).to eq('12345')
expect(File.exist? token_file).to be true
end
specify 'refresh access_token if token file is invalid' do
File.open(token_file, 'w') { |f| f.write('rubbish') }
expect(subject.token).to eq('12345')
end
specify 'raise exception if refresh failed' do
allow(client).to receive(:get).and_raise('error')
expect { subject.token }.to raise_error('error')
end
end
describe '#refresh' do
specify 'will set access_token' do
expect(subject.refresh).to eq(token)
expect(subject.access_token).to eq('12345')
end
specify "won't set access_token if request failed" do
allow(client).to receive(:get).and_raise('error')
expect { subject.refresh }.to raise_error('error')
expect(subject.access_token).to be_nil
end
specify "won't set access_token if response value invalid" do
allow(client).to receive(:get).and_return('rubbish')
expect { subject.refresh }.to raise_error
expect(subject.access_token).to be_nil
end
end
end

View File

@ -0,0 +1,47 @@
require 'spec_helper'
RSpec.describe Wechat::Ticket::PublicJsapiTicket do
let(:jsapi_ticket_file) { Rails.root.join('tmp/jsapi_ticket_file') }
let(:ticket) { 'bxLdikRXVbTPdHSM05e5u5sUoXNKd8' }
let(:client) { double(:client) }
let(:access_token) { double(:access_token) }
let(:token_content) { { access_token: '12345', expires_in: 7200 } }
subject do
Wechat::Ticket::PublicJsapiTicket.new(client, access_token, jsapi_ticket_file)
end
before :each do
allow(client).to receive(:get)
.with('ticket/getticket',
params: { type: 'jsapi', access_token: token_content[:access_token] })
.and_return(errcode: 0, errmsg: 'ok', ticket: ticket, expires_in: 7200)
allow(access_token).to receive(:token).and_return(token_content[:access_token])
end
after :each do
File.delete(jsapi_ticket_file) if File.exist?(jsapi_ticket_file)
end
describe '#ticket' do
specify 'read from file if jsapi_ticket_file is not initialized' do
File.open(jsapi_ticket_file, 'w') { |f| f.write({ ticket: ticket, expires_in: 7200 }.to_json) }
expect(subject.ticket).to eq ticket
end
end
describe '#refresh' do
specify 'will set ticket' do
expect(subject.refresh).to eq ticket
expect(subject.ticket).to eq ticket
end
end
describe '#signature' do
specify 'will get signature' do
url = 'http://www.baidu.com?q=ming'
expect(subject.signature(url)[:url]).to eq url
end
end
end

View File

@ -0,0 +1,279 @@
require 'spec_helper'
include Wechat::Cipher
ENCODING_AES_KEY = Base64.encode64 SecureRandom.hex(16)
class WechatCorpController < ActionController::Base
wechat_responder corpid: 'corpid', corpsecret: 'corpsecret', token: 'token', access_token: 'controller_access_token',
agentid: 1, encoding_aes_key: ENCODING_AES_KEY
end
RSpec.describe WechatCorpController, type: :controller do
render_views
let(:message_base) do
{
ToUserName: 'toUser',
FromUserName: 'fromUser',
CreateTime: '1348831860',
MsgId: '1234567890123456'
}
end
def signature_params(msg = {})
xml = message_base.merge(msg).to_xml(root: :xml, skip_instruct: true)
encrypt = Base64.strict_encode64 encrypt(pack(xml, 'appid'), ENCODING_AES_KEY)
xml = { Encrypt: encrypt }
timestamp = '1234567'
nonce = 'nonce'
msg_signature = Digest::SHA1.hexdigest(['token', timestamp, nonce, xml[:Encrypt]].sort.join)
{ timestamp: timestamp, nonce: nonce, xml: xml, msg_signature: msg_signature }
end
def signature_echostr(echostr)
encrypt_echostr = Base64.strict_encode64 encrypt(pack(echostr, 'appid'), ENCODING_AES_KEY)
timestamp = '1234567'
nonce = 'nonce'
msg_signature = Digest::SHA1.hexdigest(['token', timestamp, nonce, encrypt_echostr].sort.join)
{ timestamp: timestamp, nonce: nonce, echostr: encrypt_echostr, msg_signature: msg_signature }
end
def xml_to_hash(xml_message)
Hash.from_xml(xml_message)['xml'].symbolize_keys
end
describe 'Verify signature' do
it 'on create action faild' do
post :create, signature_params.merge(msg_signature: 'invalid')
expect(response.code).to eq '403'
end
it 'on create action success' do
post :create, signature_params(MsgType: 'voice', Voice: { MediaId: 'mediaID' })
expect(response.code).to eq '200'
expect(response.body.length).to eq 0
end
end
specify "echo 'echostr' param when show" do
get :show, signature_echostr('hello')
expect(response.body).to eq('hello')
end
describe 'corp' do
controller do
wechat_responder corpid: 'corpid', corpsecret: 'corpsecret', token: 'token', access_token: 'controller_access_token',
agentid: 1, encoding_aes_key: ENCODING_AES_KEY
on :text do |request, content|
request.reply.text "echo: #{content}"
end
on :text, with: 'mpnews' do |request|
request.reply.news(0...1) do |article|
article.item title: 'title', description: 'desc', pic_url: 'http://www.baidu.com/img/bdlogo.gif', url: 'http://www.baidu.com/'
end
end
on :event, with: 'subscribe' do |request|
request.reply.text 'welcome!'
end
on :event, with: 'enter_agent' do |request|
request.reply.text 'echo: enter_agent'
end
on :click, with: 'BOOK_LUNCH' do |request, key|
request.reply.text "#{request[:FromUserName]} click #{key}"
end
on :view, with: 'http://xxx.proxy.qqbrowser.cc/wechat/view_url' do |request, view|
request.reply.text "#{request[:FromUserName]} view #{view}"
end
on :scan, with: 'BINDING_QR_CODE' do |request, scan_result, scan_type|
request.reply.text "User #{request[:FromUserName]} ScanResult #{scan_result} ScanType #{scan_type}"
end
on :scan, with: 'BINDING_BARCODE' do |message, scan_result|
if scan_result.start_with? 'CODE_39,'
message.reply.text "User: #{message[:FromUserName]} scan barcode, result is #{scan_result.split(',')[1]}"
end
end
on :batch_job, with: 'replace_user' do |request, batch_job|
request.reply.text "Replace user job #{batch_job[:JobId]} finished, return code #{batch_job[:ErrCode]}, return message #{batch_job[:ErrMsg]}"
end
end
specify 'will set controller wechat api and token' do
access_token = controller.class.wechat.access_token
expect(access_token.token_file).to eq 'controller_access_token'
expect(controller.class.token).to eq 'token'
expect(controller.class.agentid).to eq 1
expect(controller.class.encrypt_mode).to eq true
expect(controller.class.encoding_aes_key).to eq ENCODING_AES_KEY
end
describe 'response' do
it 'Verify response signature' do
post :create, signature_params(MsgType: 'text', Content: 'hello')
expect(response.code).to eq '200'
expect(response.body.empty?).to eq false
data = Hash.from_xml(response.body)['xml']
msg_signature = Digest::SHA1.hexdigest [data['TimeStamp'], data['Nonce'], 'token', data['Encrypt']].sort.join
expect(data['MsgSignature']).to eq msg_signature
end
it 'on text' do
post :create, signature_params(MsgType: 'text', Content: 'hello')
expect(response.code).to eq '200'
expect(response.body.empty?).to eq false
data = Hash.from_xml(response.body)['xml']
xml_message, app_id = unpack(decrypt(Base64.decode64(data['Encrypt']), ENCODING_AES_KEY))
expect(app_id).to eq 'appid'
expect(xml_message.empty?).to eq false
message = Hash.from_xml(xml_message)['xml']
expect(message['MsgType']).to eq 'text'
expect(message['Content']).to eq 'echo: hello'
end
it 'on mpnews' do
post :create, signature_params(MsgType: 'text', Content: 'mpnews')
expect(response.code).to eq '200'
expect(response.body.empty?).to eq false
data = Hash.from_xml(response.body)['xml']
xml_message, app_id = unpack(decrypt(Base64.decode64(data['Encrypt']), ENCODING_AES_KEY))
expect(app_id).to eq 'appid'
expect(xml_message.empty?).to eq false
message = Hash.from_xml(xml_message)['xml']
articles = { 'item' => { 'Title' => 'title',
'Description' => 'desc',
'PicUrl' => 'http://www.baidu.com/img/bdlogo.gif',
'Url' => 'http://www.baidu.com/' } }
expect(message['MsgType']).to eq 'news'
expect(message['ArticleCount']).to eq '1'
expect(message['Articles']).to eq articles
end
it 'on subscribe' do
post :create, signature_params(MsgType: 'event', Event: 'subscribe')
expect(response.code).to eq '200'
data = Hash.from_xml(response.body)['xml']
xml_message, app_id = unpack(decrypt(Base64.decode64(data['Encrypt']), ENCODING_AES_KEY))
expect(app_id).to eq 'appid'
expect(xml_message.empty?).to eq false
message = Hash.from_xml(xml_message)['xml']
expect(message['MsgType']).to eq 'text'
expect(message['Content']).to eq 'welcome!'
end
it 'on enter_agent' do
post :create, signature_params(MsgType: 'event', Event: 'click', EventKey: 'enter_agent')
expect(response.code).to eq '200'
data = Hash.from_xml(response.body)['xml']
xml_message, app_id = unpack(decrypt(Base64.decode64(data['Encrypt']), ENCODING_AES_KEY))
expect(app_id).to eq 'appid'
expect(xml_message.empty?).to eq false
message = Hash.from_xml(xml_message)['xml']
expect(message['MsgType']).to eq 'text'
expect(message['Content']).to eq 'echo: enter_agent'
end
it 'on click BOOK_LUNCH' do
post :create, signature_params(MsgType: 'event', Event: 'click', EventKey: 'BOOK_LUNCH')
expect(response.code).to eq '200'
data = Hash.from_xml(response.body)['xml']
xml_message, app_id = unpack(decrypt(Base64.decode64(data['Encrypt']), ENCODING_AES_KEY))
expect(app_id).to eq 'appid'
expect(xml_message.empty?).to eq false
message = Hash.from_xml(xml_message)['xml']
expect(message['MsgType']).to eq 'text'
expect(message['Content']).to eq 'fromUser click BOOK_LUNCH'
end
it 'on view http://xxx.proxy.qqbrowser.cc/wechat/view_url' do
post :create, signature_params(MsgType: 'event', Event: 'view', EventKey: 'http://xxx.proxy.qqbrowser.cc/wechat/view_url')
expect(response.code).to eq '200'
data = Hash.from_xml(response.body)['xml']
xml_message, app_id = unpack(decrypt(Base64.decode64(data['Encrypt']), ENCODING_AES_KEY))
expect(app_id).to eq 'appid'
expect(xml_message.empty?).to eq false
message = Hash.from_xml(xml_message)['xml']
expect(message['MsgType']).to eq 'text'
expect(message['Content']).to eq 'fromUser view http://xxx.proxy.qqbrowser.cc/wechat/view_url'
end
it 'on BINDING_QR_CODE' do
post :create, signature_params(FromUserName: 'userid', MsgType: 'event', Event: 'scancode_push', EventKey: 'BINDING_QR_CODE',
ScanCodeInfo: { ScanType: 'qrcode', ScanResult: 'scan_result' })
expect(response.code).to eq '200'
data = Hash.from_xml(response.body)['xml']
xml_message, app_id = unpack(decrypt(Base64.decode64(data['Encrypt']), ENCODING_AES_KEY))
expect(app_id).to eq 'appid'
expect(xml_message.empty?).to eq false
message = Hash.from_xml(xml_message)['xml']
expect(message['MsgType']).to eq 'text'
expect(message['Content']).to eq 'User userid ScanResult scan_result ScanType qrcode'
end
it 'response scancode event with matched event' do
post :create, signature_params(FromUserName: 'userid', MsgType: 'event', Event: 'scancode_waitmsg', EventKey: 'BINDING_BARCODE',
ScanCodeInfo: { ScanType: 'qrcode', ScanResult: 'CODE_39,SAP0D00' })
expect(response.code).to eq '200'
data = Hash.from_xml(response.body)['xml']
xml_message, app_id = unpack(decrypt(Base64.decode64(data['Encrypt']), ENCODING_AES_KEY))
expect(app_id).to eq 'appid'
expect(xml_message.empty?).to eq false
message = Hash.from_xml(xml_message)['xml']
expect(message['MsgType']).to eq 'text'
expect(message['Content']).to eq 'User: userid scan barcode, result is SAP0D00'
end
it 'on replace_user' do
post :create, signature_params(FromUserName: 'sys', MsgType: 'event', Event: 'batch_job_result',
BatchJob: { JobId: 'job_id', JobType: 'replace_user', ErrCode: 0, ErrMsg: 'ok' })
expect(response.code).to eq '200'
data = Hash.from_xml(response.body)['xml']
xml_message, app_id = unpack(decrypt(Base64.decode64(data['Encrypt']), ENCODING_AES_KEY))
expect(app_id).to eq 'appid'
expect(xml_message.empty?).to eq false
message = Hash.from_xml(xml_message)['xml']
expect(message['MsgType']).to eq 'text'
expect(message['Content']).to eq 'Replace user job job_id finished, return code 0, return message ok'
end
end
end
end

View File

@ -0,0 +1,362 @@
require 'spec_helper'
class WechatController < ActionController::Base
wechat_responder
end
RSpec.describe WechatController, type: :controller do
def xml_to_hash(response)
Hash.from_xml(response.body)['xml'].symbolize_keys
end
render_views
let(:signature_params) do
timestamp = '1234567'
nonce = 'nonce'
signature = Digest::SHA1.hexdigest([ENV['WECHAT_TOKEN'], timestamp, nonce].sort.join)
{ timestamp: timestamp, nonce: nonce, signature: signature }
end
let(:message_base) do
{
ToUserName: 'toUser',
FromUserName: 'fromUser',
CreateTime: '1348831860',
MsgId: '1234567890123456'
}
end
let(:text_message) { message_base.merge(MsgType: 'text', Content: 'text message') }
specify 'config responder using global config' do
expect(controller.class.wechat).to eq(Wechat.api)
expect(controller.class.token).to eq(Wechat.config.token)
end
describe 'config responder using per controller configuration' do
controller do
wechat_responder appid: 'controller_appid', secret: 'controller_secret', token: 'controller_token',
access_token: 'controller_access_token',
agentid: 1, encoding_aes_key: 'encoding_aes_key'
end
specify 'will set controller wechat api and token' do
access_token = controller.class.wechat.access_token
expect(access_token.appid).to eq('controller_appid')
expect(access_token.secret).to eq('controller_secret')
expect(access_token.token_file).to eq('controller_access_token')
expect(controller.class.token).to eq('controller_token')
expect(controller.class.agentid).to eq(1)
expect(controller.class.encrypt_mode).to eq(false)
expect(controller.class.encoding_aes_key).to eq('encoding_aes_key')
end
end
describe 'Verify signature' do
specify 'on show action' do
get :show, signature_params.merge(signature: 'invalid_signature')
expect(response.code).to eq('403')
end
specify 'on create action' do
post :create, signature_params.merge(signature: 'invalid_signature')
expect(response.code).to eq('403')
end
end
specify "echo 'echostr' param when show" do
get :show, signature_params.merge(echostr: 'hello')
expect(response.body).to eq('hello')
end
describe 'responders' do
specify 'responders only accept :text, :image, :voice, :video, :location, :link, :event, :fallback message type' do
[:text, :image, :voice, :video, :location, :link, :event, :fallback].each do |message_type|
controller.class.on message_type, respond: 'response'
end
end
specify 'will raise error if message type is unkonwn' do
expect { controller.class.on :unkonwn, respond: 'response' }.to raise_error
end
specify 'responder take :with argument only for :text and :event message_type' do
expect(controller.class.on :text, with: 'command', respond: 'response').to eq(with: 'command', respond: 'response')
expect(controller.class.on :event, with: 'subscribe').to eq(with: 'subscribe')
expect { controller.class.on :image, with: 'with' }.to raise_error
end
end
describe 'responder_for' do
controller do
wechat_responder
on :text, with: 'command', respond: 'string matched'
on :text, with: /^cmd:(.*)$/, respond: 'regex matched'
on :text, respond: 'text content'
on :event, with: 'subscribe', respond: 'subscribe event'
on :click, with: 'EVENTKEY', respond: 'EVENTKEY clicked'
on :image, respond: 'image content'
end
specify 'find first responder for matched type' do
expect do |b|
controller.class.responder_for(MsgType: 'image', &b)
end.to yield_with_args(respond: 'image content')
end
specify "find 'general text' responder if none of the text responders matches request content" do
expect do |b|
controller.class.responder_for(MsgType: 'text', Content: 'some text', &b)
end.to yield_with_args({ respond: 'text content' }, 'some text')
end
specify "find 'string matched' responder if request content matches string" do
expect do |b|
controller.class.responder_for(MsgType: 'text', Content: 'command', &b)
end.to yield_with_args({ respond: 'string matched', with: 'command' }, 'command')
end
specify "find 'regex mached' responder if request content matches regex" do
expect do |b|
controller.class.responder_for(MsgType: 'text', Content: 'cmd:my_command', &b)
end.to yield_with_args({ respond: 'regex matched', with: /^cmd:(.*)$/ }, 'my_command')
end
specify "find 'subscribe event' responder if event request matches event" do
expect do |b|
controller.class.responder_for(MsgType: 'event', Event: 'subscribe', &b)
end.to yield_with_args({ respond: 'subscribe event', with: 'subscribe' }, 'subscribe')
end
specify "find 'click' responder if event request matches click" do
expect do |b|
controller.class.responder_for(MsgType: 'event', Event: 'click', EventKey: 'EVENTKEY', &b)
end.to yield_with_args({ respond: 'EVENTKEY clicked', with: 'EVENTKEY' }, 'EVENTKEY')
end
end
specify 'will respond empty if no responder for the message type' do
post :create, signature_params.merge(xml: text_message)
expect(response.code).to eq('200')
expect(response.body.strip).to be_empty
end
describe 'respond_to wechat helper' do
controller do
wechat_responder
on :text do |_message, _content|
wechat
end
end
specify 'will return normal' do
expect do
post :create, signature_params.merge(xml: text_message)
end.not_to raise_error
end
end
describe 'respond_to wechat_url helper' do
controller do
wechat_responder
on :text do |_message, _content|
wechat_url
end
end
specify 'will return normal' do
expect do
post :create, signature_params.merge(xml: text_message)
end.not_to raise_error
end
end
describe 'fallback responder' do
controller do
wechat_responder
on :fallback, respond: 'fallback responder'
end
specify 'will respond to any message' do
post :create, signature_params.merge(xml: text_message)
expect(xml_to_hash(response)[:Content]).to eq('fallback responder')
end
end
describe 'fallback responder transfer to customer service' do
controller do
wechat_responder
on :fallback do |message|
message.reply.transfer_customer_service
end
end
specify 'will change MsgType to transfer_customer_service' do
post :create, signature_params.merge(xml: text_message)
expect(xml_to_hash(response)[:MsgType]).to eq 'transfer_customer_service'
end
end
describe 'default text transfer to customer service' do
controller do
wechat_responder
on :text do |request, _content|
request.reply.transfer_customer_service
end
end
specify 'will change MsgType to transfer_customer_service' do
post :create, signature_params.merge(xml: text_message)
expect(xml_to_hash(response)[:MsgType]).to eq 'transfer_customer_service'
end
end
describe '#create use cases' do
controller do
wechat_responder
on :text, respond: 'text message' do |message, _content|
message.replay.text('should not be here')
end
on :text, with: 'command' do |message, content|
message.reply.text("text: #{content}")
end
on :text, with: /^cmd:(.*)$/ do |message, cmd|
message.reply.text("cmd: #{cmd}")
end
on :text, with: 'session count' do |message|
message.session[:count] ||= 0
message.session[:count] += 1
message.reply.text message.session[:count]
end
on :event, with: 'subscribe' do |message, event|
message.reply.text("event: #{event}")
end
on :event, with: 'unsubscribe' do |message|
message.reply.success
end
on :scan, with: 'qrscene_xxxxxx' do |request, ticket|
request.reply.text "Unsubscribe user #{request[:FromUserName]} Ticket #{ticket}"
end
on :scan, with: 'scene_id' do |request, ticket|
request.reply.text "Subscribe user #{request[:FromUserName]} Ticket #{ticket}"
end
on :event, with: 'scan' do |request|
if request[:EventKey].present?
request.reply.text "event scan got EventKey #{request[:EventKey]} Ticket #{request[:Ticket]}"
end
end
on :location do |message|
message.reply.text("Latitude: #{message[:Latitude]} Longitude: #{message[:Longitude]}")
end
on :image do |message|
message.reply.text("image: #{message[:PicUrl]}")
end
on :voice do |message|
message.reply.text("voice: #{message[:MediaId]}")
end
on :video do |message|
message.reply.text("video: #{message[:MediaId]}")
end
on :link do |message|
message.reply.text("link: #{message[:Url]}")
end
end
specify 'response with respond field' do
post :create, signature_params.merge(xml: text_message.merge(Content: 'message'))
result = xml_to_hash(response)
expect(result[:ToUserName]).to eq('fromUser')
expect(result[:FromUserName]).to eq('toUser')
expect(result[:Content]).to eq('text message')
end
specify 'response text with text match' do
post :create, signature_params.merge(xml: text_message.merge(Content: 'command'))
expect(xml_to_hash(response)[:Content]).to eq('text: command')
end
specify 'response text with regex matched' do
post :create, signature_params.merge(xml: text_message.merge(Content: 'cmd:reload'))
expect(xml_to_hash(response)[:Content]).to eq('cmd: reload')
end
specify 'response text with session count' do
post :create, signature_params.merge(xml: text_message.merge(Content: 'session count'))
expect(xml_to_hash(response)[:Content]).to eq('1')
end
specify 'response subscribe event with matched event' do
event_message = message_base.merge(MsgType: 'event', Event: 'subscribe', EventKey: 'qrscene_not_exist')
post :create, signature_params.merge(xml: event_message)
expect(xml_to_hash(response)[:Content]).to eq('event: subscribe')
end
specify 'response unsubscribe event with matched event' do
event_message = message_base.merge(MsgType: 'event', Event: 'unsubscribe')
post :create, signature_params.merge(xml: event_message)
expect(response.code).to eq('200')
expect(response.body).to eq('success')
end
specify 'response subscribe scan event with matched event' do
event_message = message_base.merge(MsgType: 'event', Event: 'subscribe', EventKey: 'qrscene_xxxxxx')
post :create, signature_params.merge(xml: event_message.merge(Ticket: 'TICKET'))
expect(xml_to_hash(response)[:Content]).to eq 'Unsubscribe user fromUser Ticket TICKET'
end
specify 'response scan event with matched event' do
event_message = message_base.merge(MsgType: 'event', Event: 'SCAN', EventKey: 'scene_id')
post :create, signature_params.merge(xml: event_message.merge(Ticket: 'TICKET'))
expect(xml_to_hash(response)[:Content]).to eq 'Subscribe user fromUser Ticket TICKET'
end
specify 'response scan event with by_passed scene_id' do
event_message = message_base.merge(MsgType: 'event', Event: 'SCAN', EventKey: 'scene_id_by_pass_scan_process')
post :create, signature_params.merge(xml: event_message.merge(Ticket: 'TICKET'))
expect(xml_to_hash(response)[:Content]).to eq 'event scan got EventKey scene_id_by_pass_scan_process Ticket TICKET'
end
specify 'response location' do
message = message_base.merge(MsgType: 'event', Event: 'LOCATION', Latitude: 23.137466, Longitude: 113.352425, Precision: 119.385040)
post :create, signature_params.merge(xml: message)
expect(xml_to_hash(response)[:Content]).to eq('Latitude: 23.137466 Longitude: 113.352425')
end
specify 'response image' do
image_message = message_base.merge(MsgType: 'image', MediaId: 'image_media_id', PicUrl: 'pic_url')
post :create, signature_params.merge(xml: image_message)
expect(xml_to_hash(response)[:Content]).to eq('image: pic_url')
end
specify 'response voice' do
message = message_base.merge(MsgType: 'voice', MediaId: 'voice_media_id', Format: 'format')
post :create, signature_params.merge(xml: message)
expect(xml_to_hash(response)[:Content]).to eq('voice: voice_media_id')
end
specify 'response video' do
message = message_base.merge(MsgType: 'video', MediaId: 'video_media_id', ThumbMediaId: 'thumb_media_id')
post :create, signature_params.merge(xml: message)
expect(xml_to_hash(response)[:Content]).to eq('video: video_media_id')
end
specify 'response link' do
message = message_base.merge(MsgType: 'link', Url: 'link_url', Title: 'title', Description: 'description')
post :create, signature_params.merge(xml: message)
expect(xml_to_hash(response)[:Content]).to eq('link: link_url')
end
end
end

View File

@ -0,0 +1,49 @@
require 'codeclimate-test-reporter'
CodeClimate::TestReporter.start
ENV['RAILS_ENV'] ||= 'test'
ENV['WECHAT_APPID'] = 'appid'
ENV['WECHAT_SECRET'] = 'secret'
ENV['WECHAT_TOKEN'] = 'token'
require File.expand_path('../dummy/config/environment', __FILE__)
require 'rspec/rails'
Dir[File.join(File.dirname(__FILE__), '../spec/support/**/*.rb')].sort.each { |f| require f }
ActiveRecord::Base.establish_connection adapter: 'sqlite3', database: ':memory:'
ActiveRecord::Migration.verbose = false
ActiveRecord::Schema.define do
create_table :wechat_logs do |t|
t.string :openid, null: false, index: true
t.text :request_raw
t.text :response_raw
t.text :session_raw
t.datetime :created_at, null: false
end
end
RSpec.configure do |config|
config.mock_with :rspec
config.infer_base_class_for_anonymous_controllers = true
config.order = 'random'
# Limits the available syntax to the non-monkey patched syntax that is
# recommended. For more details, see:
# - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax
# - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
# - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching
config.disable_monkey_patching!
# This setting enables warnings. It's recommended, but in some cases may
# be too noisy due to issues in dependencies.
config.warnings = true
# Allows RSpec to persist some state between runs in order to support
# the `--only-failures` and `--next-failure` CLI options. We recommend
# you configure your source control system to ignore this file.
config.example_status_persistence_file_path = 'spec/examples.txt'
end
RSpec::Expectations.configuration.warn_about_potential_false_positives = false

25
lib/wechat/wechat.gemspec Normal file
View File

@ -0,0 +1,25 @@
version = File.read(File.expand_path('../VERSION', __FILE__)).strip
# Describe your gem and declare its dependencies:
Gem::Specification.new do |s|
s.authors = ['Skinnyworm', 'Eric Guo']
s.email = 'eric.guocz@gmail.com'
s.homepage = 'https://github.com/Eric-Guo/wechat'
s.name = 'wechat'
s.version = version
s.licenses = ['MIT']
s.summary = 'DSL for wechat message handling and API'
s.description = 'API and message handling for WeChat in Rails'
s.files = Dir['{bin,lib}/**/*'] + %w(LICENSE Rakefile README.md README-CN.md CHANGELOG.md)
s.executables << 'wechat'
s.add_runtime_dependency 'activerecord', '>= 3.2', '< 5.1.x'
s.add_runtime_dependency 'nokogiri', '>=1.6.0'
s.add_runtime_dependency 'thor'
s.add_runtime_dependency 'http', '~> 1.0', '>= 1.0.1'
s.add_development_dependency 'rspec-rails', '~> 3.4'
s.add_development_dependency 'rails', '>= 3.2'
s.add_development_dependency 'sqlite3'
end

View File

@ -0,0 +1,16 @@
{
"folders":
[
{
"path": ".",
"folder_exclude_patterns": [".bundle",".idea","tmp","log","pkg"],
"file_exclude_patterns": ["*.sublime-workspace","*.sqlite3","spec/examples.txt"]
}
],
"settings":
{
"translate_tabs_to_spaces": true,
"trim_trailing_white_space_on_save": true,
"tab_size": 2
}
}

View File

@ -1,4 +1,4 @@
source 'https://ruby.taobao.org'
source 'https://rubygems.org/'
gem 'rich', '1.4.6'
gem 'kaminari'