diff --git a/Gemfile b/Gemfile index ac91f1303..e898453bd 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/lib/rails_kindeditor/Gemfile b/lib/rails_kindeditor/Gemfile index 0af516a49..9a36070cb 100644 --- a/lib/rails_kindeditor/Gemfile +++ b/lib/rails_kindeditor/Gemfile @@ -1,4 +1,4 @@ -source "http://rubygems.org" +source "https://rubygems.org" gemspec diff --git a/lib/wechat/.codeclimate.yml b/lib/wechat/.codeclimate.yml new file mode 100644 index 000000000..eddce1616 --- /dev/null +++ b/lib/wechat/.codeclimate.yml @@ -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" diff --git a/lib/wechat/.rubocop.yml b/lib/wechat/.rubocop.yml new file mode 100644 index 000000000..f458355b7 --- /dev/null +++ b/lib/wechat/.rubocop.yml @@ -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 \ No newline at end of file diff --git a/lib/wechat/.travis.yml b/lib/wechat/.travis.yml new file mode 100644 index 000000000..879159651 --- /dev/null +++ b/lib/wechat/.travis.yml @@ -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 diff --git a/lib/wechat/CHANGELOG.md b/lib/wechat/CHANGELOG.md new file mode 100644 index 000000000..4d70b49ef --- /dev/null +++ b/lib/wechat/CHANGELOG.md @@ -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). diff --git a/lib/wechat/Gemfile b/lib/wechat/Gemfile new file mode 100644 index 000000000..ee4ef6e9e --- /dev/null +++ b/lib/wechat/Gemfile @@ -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] diff --git a/lib/wechat/LICENSE b/lib/wechat/LICENSE new file mode 100644 index 000000000..df683048b --- /dev/null +++ b/lib/wechat/LICENSE @@ -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. \ No newline at end of file diff --git a/lib/wechat/README-CN.md b/lib/wechat/README-CN.md new file mode 100644 index 000000000..a34735b3b --- /dev/null +++ b/lib/wechat/README-CN.md @@ -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 + + # 当请求的文字信息内容为'条新闻'时, 使用这个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 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 diff --git a/lib/wechat/README.md b/lib/wechat/README.md new file mode 100644 index 000000000..b1947a959 --- /dev/null +++ b/lib/wechat/README.md @@ -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) API(Can 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 'news', will match and will got count as 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 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 message,but gems will mapping to menu click event. +- :view virtual view message, wechat still sent event message,but 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 diff --git a/lib/wechat/Rakefile b/lib/wechat/Rakefile new file mode 100644 index 000000000..cd372f9a4 --- /dev/null +++ b/lib/wechat/Rakefile @@ -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 diff --git a/lib/wechat/VERSION b/lib/wechat/VERSION new file mode 100644 index 000000000..7deb86fee --- /dev/null +++ b/lib/wechat/VERSION @@ -0,0 +1 @@ +0.7.1 \ No newline at end of file diff --git a/lib/wechat/bin/wechat b/lib/wechat/bin/wechat new file mode 100644 index 000000000..8931a2dcb --- /dev/null +++ b/lib/wechat/bin/wechat @@ -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 diff --git a/lib/wechat/lib/action_controller/wechat_responder.rb b/lib/wechat/lib/action_controller/wechat_responder.rb new file mode 100644 index 000000000..c19646498 --- /dev/null +++ b/lib/wechat/lib/action_controller/wechat_responder.rb @@ -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 diff --git a/lib/wechat/lib/generators/wechat/install_generator.rb b/lib/wechat/lib/generators/wechat/install_generator.rb new file mode 100644 index 000000000..dac5fba80 --- /dev/null +++ b/lib/wechat/lib/generators/wechat/install_generator.rb @@ -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 diff --git a/lib/wechat/lib/generators/wechat/templates/app/controllers/wechats_controller.rb b/lib/wechat/lib/generators/wechat/templates/app/controllers/wechats_controller.rb new file mode 100644 index 000000000..b4663d6c5 --- /dev/null +++ b/lib/wechat/lib/generators/wechat/templates/app/controllers/wechats_controller.rb @@ -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 'news', will match and will got count as 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 diff --git a/lib/wechat/lib/generators/wechat/templates/config/wechat.yml b/lib/wechat/lib/generators/wechat/templates/config/wechat.yml new file mode 100644 index 000000000..5147b59fb --- /dev/null +++ b/lib/wechat/lib/generators/wechat/templates/config/wechat.yml @@ -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 \ No newline at end of file diff --git a/lib/wechat/lib/generators/wechat/templates/db/migration.rb b/lib/wechat/lib/generators/wechat/templates/db/migration.rb new file mode 100644 index 000000000..a60c68f9f --- /dev/null +++ b/lib/wechat/lib/generators/wechat/templates/db/migration.rb @@ -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 diff --git a/lib/wechat/lib/wechat.rb b/lib/wechat/lib/wechat.rb new file mode 100644 index 000000000..266e1aa06 --- /dev/null +++ b/lib/wechat/lib/wechat.rb @@ -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 diff --git a/lib/wechat/lib/wechat/api.rb b/lib/wechat/lib/wechat/api.rb new file mode 100644 index 000000000..5cb457b9f --- /dev/null +++ b/lib/wechat/lib/wechat/api.rb @@ -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 diff --git a/lib/wechat/lib/wechat/api_base.rb b/lib/wechat/lib/wechat/api_base.rb new file mode 100644 index 000000000..b873251f7 --- /dev/null +++ b/lib/wechat/lib/wechat/api_base.rb @@ -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 diff --git a/lib/wechat/lib/wechat/api_loader.rb b/lib/wechat/lib/wechat/api_loader.rb new file mode 100644 index 000000000..8d7c666a1 --- /dev/null +++ b/lib/wechat/lib/wechat/api_loader.rb @@ -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 diff --git a/lib/wechat/lib/wechat/cipher.rb b/lib/wechat/lib/wechat/cipher.rb new file mode 100644 index 000000000..acb190992 --- /dev/null +++ b/lib/wechat/lib/wechat/cipher.rb @@ -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 diff --git a/lib/wechat/lib/wechat/client.rb b/lib/wechat/lib/wechat/client.rb new file mode 100644 index 000000000..8a2bf6e59 --- /dev/null +++ b/lib/wechat/lib/wechat/client.rb @@ -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 diff --git a/lib/wechat/lib/wechat/corp_api.rb b/lib/wechat/lib/wechat/corp_api.rb new file mode 100644 index 000000000..0811cea14 --- /dev/null +++ b/lib/wechat/lib/wechat/corp_api.rb @@ -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 diff --git a/lib/wechat/lib/wechat/message.rb b/lib/wechat/lib/wechat/message.rb new file mode 100644 index 000000000..904590473 --- /dev/null +++ b/lib/wechat/lib/wechat/message.rb @@ -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 diff --git a/lib/wechat/lib/wechat/responder.rb b/lib/wechat/lib/wechat/responder.rb new file mode 100644 index 000000000..fd9ed4036 --- /dev/null +++ b/lib/wechat/lib/wechat/responder.rb @@ -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 diff --git a/lib/wechat/lib/wechat/signature.rb b/lib/wechat/lib/wechat/signature.rb new file mode 100644 index 000000000..385e890e8 --- /dev/null +++ b/lib/wechat/lib/wechat/signature.rb @@ -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 diff --git a/lib/wechat/lib/wechat/ticket/corp_jsapi_ticket.rb b/lib/wechat/lib/wechat/ticket/corp_jsapi_ticket.rb new file mode 100644 index 000000000..3d65b8b5c --- /dev/null +++ b/lib/wechat/lib/wechat/ticket/corp_jsapi_ticket.rb @@ -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 diff --git a/lib/wechat/lib/wechat/ticket/jsapi_base.rb b/lib/wechat/lib/wechat/ticket/jsapi_base.rb new file mode 100644 index 000000000..b6ab510a0 --- /dev/null +++ b/lib/wechat/lib/wechat/ticket/jsapi_base.rb @@ -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 diff --git a/lib/wechat/lib/wechat/ticket/public_jsapi_ticket.rb b/lib/wechat/lib/wechat/ticket/public_jsapi_ticket.rb new file mode 100644 index 000000000..116cd125a --- /dev/null +++ b/lib/wechat/lib/wechat/ticket/public_jsapi_ticket.rb @@ -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 diff --git a/lib/wechat/lib/wechat/token/access_token_base.rb b/lib/wechat/lib/wechat/token/access_token_base.rb new file mode 100644 index 000000000..7b8cecfac --- /dev/null +++ b/lib/wechat/lib/wechat/token/access_token_base.rb @@ -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 diff --git a/lib/wechat/lib/wechat/token/corp_access_token.rb b/lib/wechat/lib/wechat/token/corp_access_token.rb new file mode 100644 index 000000000..e0ccb485b --- /dev/null +++ b/lib/wechat/lib/wechat/token/corp_access_token.rb @@ -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 diff --git a/lib/wechat/lib/wechat/token/public_access_token.rb b/lib/wechat/lib/wechat/token/public_access_token.rb new file mode 100644 index 000000000..e67f8f0f3 --- /dev/null +++ b/lib/wechat/lib/wechat/token/public_access_token.rb @@ -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 diff --git a/lib/wechat/lib/wechat/wechat_log.rb b/lib/wechat/lib/wechat/wechat_log.rb new file mode 100644 index 000000000..460ff7c15 --- /dev/null +++ b/lib/wechat/lib/wechat/wechat_log.rb @@ -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 diff --git a/lib/wechat/spec/dummy/config.ru b/lib/wechat/spec/dummy/config.ru new file mode 100644 index 000000000..bd83b2541 --- /dev/null +++ b/lib/wechat/spec/dummy/config.ru @@ -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 diff --git a/lib/wechat/spec/dummy/config/application.rb b/lib/wechat/spec/dummy/config/application.rb new file mode 100644 index 000000000..6b132b4d3 --- /dev/null +++ b/lib/wechat/spec/dummy/config/application.rb @@ -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 diff --git a/lib/wechat/spec/dummy/config/boot.rb b/lib/wechat/spec/dummy/config/boot.rb new file mode 100644 index 000000000..c2a354fb3 --- /dev/null +++ b/lib/wechat/spec/dummy/config/boot.rb @@ -0,0 +1,3 @@ +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../../Gemfile', __FILE__) + +require 'bundler/setup' # Set up gems listed in the Gemfile. diff --git a/lib/wechat/spec/dummy/config/database.yml b/lib/wechat/spec/dummy/config/database.yml new file mode 100644 index 000000000..97fb2b5f9 --- /dev/null +++ b/lib/wechat/spec/dummy/config/database.yml @@ -0,0 +1,5 @@ +test: + adapter: sqlite3 + pool: 5 + timeout: 5000 + database: ":memory:" diff --git a/lib/wechat/spec/dummy/config/environment.rb b/lib/wechat/spec/dummy/config/environment.rb new file mode 100644 index 000000000..ee8d90dc6 --- /dev/null +++ b/lib/wechat/spec/dummy/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require File.expand_path('../application', __FILE__) + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/lib/wechat/spec/dummy/config/environments/test.rb b/lib/wechat/spec/dummy/config/environments/test.rb new file mode 100644 index 000000000..20b22693e --- /dev/null +++ b/lib/wechat/spec/dummy/config/environments/test.rb @@ -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 diff --git a/lib/wechat/spec/dummy/config/routes.rb b/lib/wechat/spec/dummy/config/routes.rb new file mode 100644 index 000000000..f4e3cffd7 --- /dev/null +++ b/lib/wechat/spec/dummy/config/routes.rb @@ -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 diff --git a/lib/wechat/spec/dummy/config/secrets.yml b/lib/wechat/spec/dummy/config/secrets.yml new file mode 100644 index 000000000..3d0bad1b3 --- /dev/null +++ b/lib/wechat/spec/dummy/config/secrets.yml @@ -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"] %> diff --git a/lib/wechat/spec/dummy/log/.gitkeep b/lib/wechat/spec/dummy/log/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/lib/wechat/spec/lib/wechat/api_spec.rb b/lib/wechat/spec/lib/wechat/api_spec.rb new file mode 100644 index 000000000..06a649b54 --- /dev/null +++ b/lib/wechat/spec/lib/wechat/api_spec.rb @@ -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 diff --git a/lib/wechat/spec/lib/wechat/cipher_spec.rb b/lib/wechat/spec/lib/wechat/cipher_spec.rb new file mode 100644 index 000000000..641884429 --- /dev/null +++ b/lib/wechat/spec/lib/wechat/cipher_spec.rb @@ -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 = <: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 diff --git a/lib/wechat/spec/lib/wechat/corp_access_token_spec.rb b/lib/wechat/spec/lib/wechat/corp_access_token_spec.rb new file mode 100644 index 000000000..294e24446 --- /dev/null +++ b/lib/wechat/spec/lib/wechat/corp_access_token_spec.rb @@ -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 diff --git a/lib/wechat/spec/lib/wechat/corp_api_spec.rb b/lib/wechat/spec/lib/wechat/corp_api_spec.rb new file mode 100644 index 000000000..55867f3e0 --- /dev/null +++ b/lib/wechat/spec/lib/wechat/corp_api_spec.rb @@ -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 diff --git a/lib/wechat/spec/lib/wechat/corp_jsapi_ticket_spec.rb b/lib/wechat/spec/lib/wechat/corp_jsapi_ticket_spec.rb new file mode 100644 index 000000000..bb3b11ba3 --- /dev/null +++ b/lib/wechat/spec/lib/wechat/corp_jsapi_ticket_spec.rb @@ -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 diff --git a/lib/wechat/spec/lib/wechat/message_spec.rb b/lib/wechat/spec/lib/wechat/message_spec.rb new file mode 100644 index 000000000..ba7a64c52 --- /dev/null +++ b/lib/wechat/spec/lib/wechat/message_spec.rb @@ -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 diff --git a/lib/wechat/spec/lib/wechat/public_access_token_spec.rb b/lib/wechat/spec/lib/wechat/public_access_token_spec.rb new file mode 100644 index 000000000..dc6554373 --- /dev/null +++ b/lib/wechat/spec/lib/wechat/public_access_token_spec.rb @@ -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 diff --git a/lib/wechat/spec/lib/wechat/public_jsapi_ticket_spec.rb b/lib/wechat/spec/lib/wechat/public_jsapi_ticket_spec.rb new file mode 100644 index 000000000..86c3a969d --- /dev/null +++ b/lib/wechat/spec/lib/wechat/public_jsapi_ticket_spec.rb @@ -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 diff --git a/lib/wechat/spec/lib/wechat/responder_corp_spec.rb b/lib/wechat/spec/lib/wechat/responder_corp_spec.rb new file mode 100644 index 000000000..c843cb345 --- /dev/null +++ b/lib/wechat/spec/lib/wechat/responder_corp_spec.rb @@ -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 diff --git a/lib/wechat/spec/lib/wechat/responder_spec.rb b/lib/wechat/spec/lib/wechat/responder_spec.rb new file mode 100644 index 000000000..e4e8fc30c --- /dev/null +++ b/lib/wechat/spec/lib/wechat/responder_spec.rb @@ -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 diff --git a/lib/wechat/spec/spec_helper.rb b/lib/wechat/spec/spec_helper.rb new file mode 100644 index 000000000..77d4d1f63 --- /dev/null +++ b/lib/wechat/spec/spec_helper.rb @@ -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 diff --git a/lib/wechat/wechat.gemspec b/lib/wechat/wechat.gemspec new file mode 100644 index 000000000..c30384410 --- /dev/null +++ b/lib/wechat/wechat.gemspec @@ -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 diff --git a/lib/wechat/wechat.sublime-project b/lib/wechat/wechat.sublime-project new file mode 100644 index 000000000..4394ca3cc --- /dev/null +++ b/lib/wechat/wechat.sublime-project @@ -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 + } +} diff --git a/plugins/redmine_ckeditor/Gemfile b/plugins/redmine_ckeditor/Gemfile index 07a2bac50..115107d18 100644 --- a/plugins/redmine_ckeditor/Gemfile +++ b/plugins/redmine_ckeditor/Gemfile @@ -1,4 +1,4 @@ -source 'https://ruby.taobao.org' +source 'https://rubygems.org/' gem 'rich', '1.4.6' gem 'kaminari'