├── .gitignore ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── RELEASE.md ├── Rakefile ├── bin ├── console ├── refresh_token_and_ticket └── setup ├── config └── wechat.yml ├── lib ├── tasks │ └── wechat_gate.rake ├── wechat-gate.rb └── wechat_gate │ ├── config.rb │ ├── controller.rb │ ├── exception.rb │ ├── media.rb │ ├── menu.rb │ ├── message.rb │ ├── oauth.rb │ ├── railtie.rb │ ├── request.rb │ ├── send_message.rb │ ├── tokens │ ├── access_token.rb │ ├── base.rb │ ├── ext.rb │ └── jsapi_ticket.rb │ ├── user.rb │ └── version.rb └── wechat-gate.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /data/APP-* 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | wechat-gate (0.1.6) 5 | activesupport (>= 5.0.1) 6 | rest-client (>= 1.8) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | activesupport (5.1.4) 12 | concurrent-ruby (~> 1.0, >= 1.0.2) 13 | i18n (~> 0.7) 14 | minitest (~> 5.1) 15 | tzinfo (~> 1.1) 16 | concurrent-ruby (1.0.5) 17 | domain_name (0.5.20170404) 18 | unf (>= 0.0.5, < 1.0.0) 19 | http-cookie (1.0.3) 20 | domain_name (~> 0.5) 21 | i18n (0.9.1) 22 | concurrent-ruby (~> 1.0) 23 | mime-types (3.1) 24 | mime-types-data (~> 3.2015) 25 | mime-types-data (3.2016.0521) 26 | minitest (5.10.3) 27 | netrc (0.11.0) 28 | rake (10.5.0) 29 | rest-client (2.0.2) 30 | http-cookie (>= 1.0.2, < 2.0) 31 | mime-types (>= 1.16, < 4.0) 32 | netrc (~> 0.8) 33 | thread_safe (0.3.6) 34 | tzinfo (1.2.4) 35 | thread_safe (~> 0.1) 36 | unf (0.1.4) 37 | unf_ext 38 | unf_ext (0.0.7.4) 39 | 40 | PLATFORMS 41 | ruby 42 | 43 | DEPENDENCIES 44 | bundler (>= 1.10) 45 | rake (>= 10.0) 46 | wechat-gate! 47 | 48 | BUNDLED WITH 49 | 1.15.3 50 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Lei Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WechatGate 2 | 3 | **微信公众平台开发库** 4 | 5 | 支持的接口: 6 | 7 | - access_token(后端API使用) 8 | - 用户授权信息获取(OAuth2) 9 | - JS-SDK 10 | - 回复消息封装 11 | - 菜单接口 12 | - 素材接口 13 | 14 | 功能特点: 15 | 16 | - 自动管理access_token和JS-SDK的ticket刷新和过期 17 | - 多微信公众号支持 18 | - 多环境支持(development, production),方便本地测试 19 | - Controler和helper方法(微信session管理等等) 20 | - 接口简单,方便定制 21 | 22 | 使用视频教程: [微信公众号开发](https://eggman.tv/c/s-wechat-development-using-ruby-on-rails) 23 | 24 | ## Installation 25 | 26 | Add this line to your application's Gemfile: 27 | 28 | ```ruby 29 | gem 'wechat-gate' 30 | ``` 31 | 32 | And then execute: 33 | 34 | $ bundle 35 | 36 | Or install it yourself as: 37 | 38 | $ gem install wechat-gate 39 | 40 | ## 公众号 41 | 42 | 在开工之前你需要在微信公众账号平台做以下配置: 43 | 44 | 1. 开通你的公众号(服务号),并开通微信认证(300元认证服务费) 45 | 2. 在公众号后台“公众号设置” - “功能设置”中设置你的JS接口安全域名,就是你的公众号调用的网站的域名 46 | 3. 在“接口权限” - “网页授权获取用户基本信息”中设置你的授权回调页面域名,这个用于OAuth2的回调域名认证 47 | 4. 在“基本配置”中查看并配置你的AppID和AppSecret 48 | 49 | ## 配置 50 | 51 | 在Rails项目config目录下建立文件wechat.yml,并配置你的公众号信息. 52 | 53 | ``` 54 | # 区分不同的环境 55 | eggman: 56 | development: 57 | host: http://wechat-test1.eggman.tv 58 | 59 | wechat_id: xxxxxxxxxx 60 | app_id: xxxxxxxxxx 61 | app_secret: xxxxxxxxxx 62 | 63 | oauth2_redirect_uri: "http://wechat-test1.eggman.tv/wechat/users/callback" 64 | 65 | push_url: "http://wechat-test1.eggman.tv/wechat/push" 66 | push_token: xxxxxxxxxxxxxxxxxxxx 67 | production: 68 | host: https://eggman.tv 69 | 70 | wechat_id: xxxxxxxxxx 71 | app_id: xxxxxxxxxxxxxxxxxxxx 72 | app_secret: xxxxxxxxxxxxxxxxxxxxxxxxxx 73 | 74 | # 如果不需要多环境支持,也可以这样 75 | app_name: 76 | app_id: <%= ENV['WECHAT_APP_NAME_APP_ID'] %> 77 | app_secret: <%= ENV['WECHAT_APP_NAME_APP_SECRET'] %> 78 | oauth2_redirect_uri: <%= ENV['WECHAT_APP_NAME_OAUTH2_REDIRECT_URI'] %> 79 | ``` 80 | 81 | 然后在ApplicationController中指定当前要读取的公众号名称: 82 | 83 | ``` 84 | self.wechat_gate_app_name = 'eggman' 85 | ``` 86 | 87 | ## 后端调用 88 | 89 | 后台API操作(比如微信用户信息获取等等操作)。 90 | 91 | 默认情况下在controller中已经初始化了配置,方法为**wechat_gate_config**,直接使用就行。 92 | 93 | 94 | ```ruby 95 | wechat_gate_config.users # 获取用户列表 96 | wechat_gate_config.user('ONE_OPEN_ID') # 获取一个用户的详细信息 97 | wechat_gate_config.access_token # 获取当前access_token 98 | 99 | # OAuth 2 100 | wechat_gate_config.oauth2_entrance_url(scope: "snsapi_userinfo", state: "CURENT_STATE") # 获取当前OAuth2授权入口URL 101 | wechat_gate_config.oauth2_access_token("TOKEN") # 根据OAuth2返回的TOKEN获取access_token 102 | wechat_gate_config.oauth2_user("ACCESS_TOKEN", "ONE_OPEN_ID") # 获取一个用户的信息 103 | 104 | wechat_gate_config.medias # 获取素材列表, 参数type: image | video | voice | news (图文) 105 | 106 | wechat_gate_config.menu_get # 获取菜单 107 | wechat_gate_config.menu_create(MENU_HASH) # 创建菜单 108 | 109 | wechat_gate_config.generate_js_request_params(REFERER_URL) # 返回JS-SDK的验证参数,供前端JS-SDK使用 110 | ``` 111 | 112 | 当然你也可以手工来初始化配置,甚至指定配置文件的路径: 113 | 114 | ``` 115 | config = WechatGate::Config.new('eggman', '/path/to/what/ever/you/want.yml') 116 | ``` 117 | 118 | access_token和JS_SDK中ticket都有过期时间和刷新次数限制,这里已经考虑了,你可以不用管,如果你想手工刷新,可以这样: 119 | 120 | ``` 121 | config.refresh_access_token 122 | config.refresh_jsapi_ticket 123 | ``` 124 | 125 | **配置文件支持erb** 126 | 127 | > 更多接口和文档请直接看源码,写的很详细 128 | 129 | ## JS-SDK 130 | 131 | ```ruby 132 | def ticket 133 | url = CGI.unescape(params[:url]) # 微信中用户访问的页面 134 | @data = wechat_gate_config.generate_js_request_params(url) # 生成微信JS-SDK所需的jsapi_ticket,signature等等参数供前段js使用 135 | render content_type: "application/javascript" 136 | end 137 | ``` 138 | 139 | ticket.js.erb: 140 | 141 | ``` 142 | var wxServerConfig = <%= @data.to_json.html_safe %>; 143 | <%= params[:callback] %>(); 144 | ``` 145 | 146 | 然后在微信端页面引入以下代码: 147 | 148 | ```js 149 | (function() { 150 | var ticket = document.createElement("script"); 151 | ticket.src = "http://localhost/api/wechat_ticket/ticket.js?url=" + encodeURIComponent(window.location.href.split('#')[0]) + "&callback=wxCallback"; 152 | var s = document.getElementsByTagName("script")[0]; 153 | s.parentNode.insertBefore(ticket, s); 154 | })(); 155 | ``` 156 | 157 | ## 其他功能 158 | 159 | ### 自定义菜单 160 | 161 | 首先设置菜单配置文件,config/wechat_menu.yml,支持erb,格式请参考[微信自定义菜单文档](https://mp.weixin.qq.com/wiki): 162 | 163 | ``` 164 | button: 165 | - type: view 166 | name: 我的2 167 | url: <%= @config.oauth2_entrance_url(scope: 'snsapi_userinfo', state: 'profile') %> 168 | - type: view 169 | name: 课程 170 | sub_button: 171 | - type: view 172 | name: 免费课程 173 | url: <%= @config.oauth2_entrance_url(scope: 'snsapi_userinfo', state: 'free') %> 174 | - type: view 175 | name: 付费课程 176 | url: <%= @config.oauth2_entrance_url(scope: 'snsapi_userinfo', state: 'paid') %> 177 | ``` 178 | 179 | > 其中的**@config**变量为当前微信公众号实例,请不要修改,直接使用 180 | 181 | 然后执行rake任务: 182 | 183 | ```shell 184 | $rails wechat_gate:create_menu APP_NAME=eggman CONFIG=/path/to/wechat.yml MENU=/path/to/wechat_menu.yml 185 | ``` 186 | 187 | 其中,CONFIG默认为config/wechat.yml,MENU默认为config/wechat_menu.yml,APP_NAME必须指定 188 | 189 | ## TODO 190 | 191 | 添加测试 192 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # v0.1.5 2 | 3 | - add app_id to js-sdk API 4 | 5 | # v0.1.4 6 | 7 | - add create_menu rake task 8 | 9 | # v0.1.3 10 | 11 | # v0.1.2 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "wechat-gate" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/refresh_token_and_ticket: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "wechat-gate" 5 | 6 | # usage: 7 | # output_type: "/file/to/write/js/variable" | ruby | js 8 | # refresh_token_and_ticket app_name page_url output_type 9 | # 10 | 11 | WechatGate::Config.new(ARGV[0]) do |config| 12 | config.write_token_to_file ARGV[1], ARGV[2] 13 | end 14 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /config/wechat.yml: -------------------------------------------------------------------------------- 1 | # 请参考这个配置文件 2 | # 3 | app_name: 4 | development: 5 | app_id: <%= ENV['WECHAT_APP_NAME_APP_ID'] %> 6 | app_secret: <%= ENV['WECHAT_APP_NAME_APP_SECRET'] %> 7 | oauth2_redirect_uri: <%= ENV['WECHAT_APP_NAME_OAUTH2_REDIRECT_URI'] %> 8 | 9 | production: 10 | app_id: <%= ENV['WECHAT_APP_NAME_APP_ID'] %> 11 | app_secret: <%= ENV['WECHAT_APP_NAME_APP_SECRET'] %> 12 | oauth2_redirect_uri: <%= ENV['WECHAT_APP_NAME_OAUTH2_REDIRECT_URI'] %> 13 | 14 | # 如果不需要多环境支持,也可以这样 15 | app_name: 16 | app_id: <%= ENV['WECHAT_APP_NAME_APP_ID'] %> 17 | app_secret: <%= ENV['WECHAT_APP_NAME_APP_SECRET'] %> 18 | oauth2_redirect_uri: <%= ENV['WECHAT_APP_NAME_OAUTH2_REDIRECT_URI'] %> 19 | -------------------------------------------------------------------------------- /lib/tasks/wechat_gate.rake: -------------------------------------------------------------------------------- 1 | require 'wechat_gate/exception' 2 | 3 | namespace :wechat_gate do 4 | 5 | def validate_envs 6 | raise WechatGate::Exception::ConfigException, 'need specify APP_NAME!' unless ENV['APP_NAME'] 7 | end 8 | 9 | desc "create menu, APP_NAME=app_name, CONFIG=/path/to/config/file.yml, MENU=/path/to/menu/config/file.yml" 10 | task :create_menu => :environment do 11 | validate_envs 12 | 13 | @config = WechatGate::Config.new(ENV['APP_NAME'], ENV['CONFIG']) 14 | 15 | menu_file = ENV['MENU'] 16 | menu_file = "#{Dir.pwd}/config/wechat_menu.yml" unless menu_file 17 | raise WechatGate::Exception::ConfigException, "MENU #{menu_file} not found!" unless File.exists?(menu_file) 18 | 19 | menu = YAML.load(ERB.new(File.read(menu_file)).result(binding)) 20 | @config.menu_create(JSON.generate(menu)) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/wechat-gate.rb: -------------------------------------------------------------------------------- 1 | require "wechat_gate/version" 2 | require "wechat_gate/config" 3 | require "wechat_gate/railtie" if defined?(Rails) 4 | 5 | if defined?(ActionController) 6 | ActionController::Base.send(:include, WechatGate::Controller) 7 | end 8 | -------------------------------------------------------------------------------- /lib/wechat_gate/config.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'erb' 3 | require 'wechat_gate/tokens/access_token' 4 | require 'wechat_gate/tokens/jsapi_ticket' 5 | require 'wechat_gate/tokens/ext' 6 | require 'wechat_gate/oauth' 7 | require 'wechat_gate/user' 8 | require 'wechat_gate/menu' 9 | require 'wechat_gate/media' 10 | require 'wechat_gate/message' 11 | require 'wechat_gate/send_message' 12 | require 'wechat_gate/exception' 13 | require 'wechat_gate/controller' 14 | 15 | module WechatGate 16 | class Config 17 | 18 | attr_reader :app_name 19 | attr_reader :config 20 | attr_reader :output_type 21 | 22 | include WechatGate::Tokens::AccessToken 23 | include WechatGate::Tokens::JsapiTicket 24 | include WechatGate::Tokens::Ext 25 | include WechatGate::Oauth 26 | include WechatGate::User 27 | include WechatGate::Menu 28 | include WechatGate::Media 29 | include WechatGate::Message 30 | include WechatGate::SendMessage 31 | 32 | def initialize app_name, config_file = nil 33 | unless config_file 34 | if defined?(Rails) 35 | config_file = "#{Rails.root}/config/wechat.yml" 36 | end 37 | end 38 | 39 | raise Exception::ConfigException, "no wechat configuration file found!" unless config_file 40 | unless File.exists?(config_file) 41 | raise Exception::ConfigException, "configuration file does not exist!" 42 | end 43 | 44 | config_text = ERB.new(File.read(config_file)).result 45 | configs = YAML.load(config_text) 46 | unless configs[app_name] 47 | raise Exception::ConfigException, "no configuration found for app: #{app_name}!" 48 | end 49 | 50 | @config = if defined?(Rails) 51 | configs[app_name][Rails.env] || configs[app_name] 52 | else 53 | configs[app_name] 54 | end 55 | 56 | @app_name = app_name 57 | 58 | yield(self) if block_given? 59 | end 60 | 61 | end 62 | 63 | 64 | end 65 | -------------------------------------------------------------------------------- /lib/wechat_gate/controller.rb: -------------------------------------------------------------------------------- 1 | require 'wechat_gate/exception' 2 | 3 | module WechatGate 4 | module Controller 5 | 6 | def self.included base 7 | base.send :include, InstanceMethods 8 | 9 | base.class_eval do 10 | helper_method :is_wechat_logged_in? 11 | helper_method :current_open_id 12 | 13 | class_attribute :wechat_gate_app_name 14 | end 15 | end 16 | 17 | module InstanceMethods 18 | protected 19 | def wechat_gate_setup 20 | unless self.class.wechat_gate_app_name 21 | raise Exception::ConfigException, "please specify wechat_gate_app_name!" 22 | end 23 | 24 | @wechat_gate_config = WechatGate::Config.new(self.class.wechat_gate_app_name) 25 | end 26 | 27 | def wechat_gate_config 28 | @wechat_gate_config ||= wechat_gate_setup 29 | end 30 | 31 | def is_wechat_logged_in? 32 | !!session[:user_open_id] 33 | end 34 | 35 | def current_open_id 36 | session[:user_open_id] 37 | end 38 | 39 | def wechat_user_signin(user_open_id) 40 | session[:user_open_id] = user_open_id 41 | end 42 | 43 | def wechat_user_auth 44 | unless is_wechat_logged_in? 45 | redirect_to wechat_gate_config.oauth2_entrance_url(scope: "snsapi_base") 46 | end 47 | end 48 | 49 | def bind_user_with_open_id user_model 50 | if is_wechat_logged_in? and user_model.open_id.blank? 51 | user_model.update_attribute :open_id, current_open_id 52 | end 53 | end 54 | 55 | def is_legal_from_wechat_server? 56 | data = [ 57 | wechat_gate_config.config["push_token"], 58 | params[:timestamp], 59 | params[:nonce] 60 | ] 61 | 62 | Digest::SHA1.hexdigest(data.sort.join('')) == params[:signature] 63 | end 64 | 65 | def check_wechat_server 66 | unless is_legal_from_wechat_server? 67 | render text: "illegal signature!" 68 | end 69 | end 70 | end 71 | 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/wechat_gate/exception.rb: -------------------------------------------------------------------------------- 1 | module WechatGate 2 | module Exception 3 | 4 | class ConfigException < StandardError; end 5 | 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/wechat_gate/media.rb: -------------------------------------------------------------------------------- 1 | require 'wechat_gate/request' 2 | 3 | module WechatGate 4 | module Media 5 | # 6 | # http://mp.weixin.qq.com/wiki/15/8386c11b7bc4cdd1499c572bfe2e95b3.html 7 | # 8 | 9 | # type: image | video | voice | news (图文) 10 | def medias(type = 'news', offset = 0, count = 20) 11 | WechatGate::Request.send( 12 | "https://api.weixin.qq.com/cgi-bin/material/batchget_material?access_token=#{self.access_token}", 13 | :post, 14 | { 15 | "type": type, 16 | "offset": offset, 17 | "count": count 18 | }.to_json 19 | ) 20 | end 21 | end 22 | 23 | 24 | end 25 | -------------------------------------------------------------------------------- /lib/wechat_gate/menu.rb: -------------------------------------------------------------------------------- 1 | require 'wechat_gate/request' 2 | 3 | module WechatGate 4 | module Menu 5 | def menu_get 6 | WechatGate::Request.send( 7 | "https://api.weixin.qq.com/cgi-bin/menu/get?access_token=#{self.access_token}" 8 | ) 9 | end 10 | 11 | def menu_create(menu_hash) 12 | WechatGate::Request.send( 13 | "https://api.weixin.qq.com/cgi-bin/menu/create?access_token=#{self.access_token}", 14 | :post, 15 | menu_hash 16 | ) 17 | end 18 | end 19 | 20 | 21 | end 22 | -------------------------------------------------------------------------------- /lib/wechat_gate/message.rb: -------------------------------------------------------------------------------- 1 | module WechatGate 2 | module Message 3 | # 4 | # http://mp.weixin.qq.com/wiki/17/f298879f8fb29ab98b2f2971d42552fd.html 5 | # 6 | # 消息发送只能是被动的,就是微信会把用户的聊天数据推送到服务器端,然后服务器利用返回值作出相应 7 | # 8 | 9 | def message_body(type, to, body) 10 | content = case type.to_sym 11 | when :text 12 | %Q{ 13 | 14 | } 15 | when :image 16 | # body: media_id 17 | %Q{ 18 | 19 | 20 | 21 | } 22 | when :voice 23 | %Q{ 24 | 25 | 26 | 27 | } 28 | when :video 29 | # body: { media_id: MEDIA_ID, title: TITLE, description: DESCRIPTION } 30 | %Q{ 31 | 36 | } 37 | end 38 | 39 | %Q{ 40 | 41 | 42 | 43 | #{Time.now.to_i} 44 | 45 | #{content} 46 | 47 | } 48 | end 49 | 50 | end 51 | 52 | 53 | end 54 | -------------------------------------------------------------------------------- /lib/wechat_gate/oauth.rb: -------------------------------------------------------------------------------- 1 | require 'rest-client' 2 | require 'wechat_gate/request' 3 | 4 | module WechatGate 5 | module Oauth 6 | # 7 | # 8 | # 此module中的access_token是微信网页授权OAuth2.0的专用access_token,和其他modules中所需需要的access_token 9 | # 不是一个概念,这里的access_token主要就是用户非公众号的关注用户来进行网页授权访问的,其中: 10 | # scope: 11 | # snsapi_base 用于公众号已关注用户,已关注的用户默认已经得到了授权,这里只是为了取得当前用户的openid, 12 | # 此时对用户的所有操作 (ex: WechatGate::User模块) 可以直接利用系统基本的access_token (WechatGate::Tokens::AccessToken模块) 来进行。 13 | # snsapi_userinfo 用户非关注用户取得授权,这里遵循标准的OAuth2.0授权流程,取得用户信息需要利用本模块中的方法来进行。 14 | # 这里的access_token是和用户绑定的。 15 | # 16 | # 17 | 18 | # 19 | # 用户点击授权入口页面 20 | # 21 | def oauth2_entrance_url(ops = {}) 22 | ops = { 23 | state: 'empty', # 自定义参数值 24 | redirect_uri: self.config["oauth2_redirect_uri"], 25 | scope: 'snsapi_base' # snsapi_base | snsapi_userinfo 26 | }.merge(ops) 27 | 28 | "https://open.weixin.qq.com/connect/oauth2/authorize?appid=#{self.config['app_id']}&redirect_uri=#{CGI.escape(ops[:redirect_uri])}&response_type=code&scope=#{ops[:scope]}&state=#{ops[:state]}#wechat_redirect" 29 | end 30 | 31 | # TODO 32 | # 这里目前需要调用该gem的应用自身来保存用户的access_token, refresh_token, openid等参数以及判断有效期 33 | # 不过这个地方也可以不做缓存,微信在这个地方没有限制API的调用次数。 34 | # 35 | # code: 36 | # code来源于网页端redirect_uri页面得到的微信端返回的参数,专门用户取得下一步的access_token 37 | # 该接口会返回: 38 | # { 39 | # "access_token" => "access_token", 40 | # "expires_in"=>7200, 41 | # "refresh_token"=>"refresh_token", 42 | # "openid"=>"MZkwG5sAx-d4PMQ6Lq1xisE", 43 | # "scope"=>"snsapi_base" 44 | # } 45 | # 此时已经获得了用户的openid,如果用户为公众号的订阅用户,就可以直接利用Tokens::AccessToken的token来对改用户调用业务接口了, 46 | # 此时这里的access_token意义就不大了,这里的access_token和Tokens::AccessToken的token是完全不一样的。 47 | # 48 | def oauth2_access_token(code) 49 | WechatGate::Request.send("https://api.weixin.qq.com/sns/oauth2/access_token?appid=#{self.config['app_id']}&secret=#{self.config['app_secret']}&code=#{code}&grant_type=authorization_code") 50 | end 51 | 52 | # access_token拥有较短的有效期,当access_token超时后,可以使用refresh_token进行刷新, 53 | # refresh_token拥有较长的有效期(7天、30天、60天、90天),当refresh_token失效的后,需要用户重新授权。 54 | # 55 | def oauth2_access_token_valid?(access_token, openid) 56 | WechatGate::Request.send("https://api.weixin.qq.com/sns/auth?access_token=#{access_token}&openid=#{openid}") 57 | end 58 | 59 | # 利用refresh_token刷新access_token 60 | # 61 | # response: 62 | # { 63 | # "access_token":"ACCESS_TOKEN", 64 | # "expires_in":7200, 65 | # "refresh_token":"REFRESH_TOKEN", 66 | # "openid":"OPENID", 67 | # "scope":"SCOPE" 68 | # } 69 | def oauth2_refresh_access_token(refresh_token) 70 | WechatGate::Request.send("https://api.weixin.qq.com/sns/oauth2/refresh_token?appid=#{self.config['app_id']}&grant_type=refresh_token&refresh_token=#{refresh_token}") 71 | end 72 | 73 | # 获取用户信息 74 | # 75 | def oauth2_user(access_token, openid) 76 | WechatGate::Request.send("https://api.weixin.qq.com/sns/userinfo?access_token=#{access_token}&openid=#{openid}&lang=zh_CN") 77 | end 78 | end 79 | 80 | end 81 | -------------------------------------------------------------------------------- /lib/wechat_gate/railtie.rb: -------------------------------------------------------------------------------- 1 | module WechatGate 2 | require 'rails' 3 | 4 | class Railtie < Rails::Railtie 5 | rake_tasks { load "tasks/wechat_gate.rake" } 6 | end 7 | 8 | end 9 | -------------------------------------------------------------------------------- /lib/wechat_gate/request.rb: -------------------------------------------------------------------------------- 1 | require 'rest-client' 2 | 3 | module WechatGate 4 | module Request 5 | def self.send(url, method = :get, payload = nil, headers = nil, &block) 6 | method = method.to_sym 7 | 8 | opts = { 9 | method: method, 10 | url: url, 11 | verify_ssl: false 12 | } 13 | if method == :post and payload 14 | opts.merge! payload: payload 15 | end 16 | 17 | if headers 18 | opts.merge! headers: headers 19 | end 20 | 21 | response = RestClient::Request.execute(opts) 22 | response = JSON.parse(response) 23 | raise response.to_s if response['errmsg'] and response['errmsg'] != 'ok' 24 | 25 | if block_given? 26 | yield(response) 27 | else 28 | response 29 | end 30 | end 31 | end 32 | 33 | 34 | end 35 | -------------------------------------------------------------------------------- /lib/wechat_gate/send_message.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/module/attribute_accessors' 2 | require 'wechat_gate/request' 3 | 4 | module WechatGate 5 | module SendMessage 6 | 7 | # 8 | # https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421140549&token=&lang=zh_CN 9 | # 这个接口有发送限制,服务号每月只能发送4次,订阅号每天一次 10 | # 11 | def mass_send open_ids, msg_options = {} 12 | payload = { 13 | "touser": [open_ids].flatten, 14 | "msgtype": "text", 15 | "text": { "content": "hello from boxer."} 16 | }.merge(msg_options) 17 | 18 | WechatGate::Request.send( 19 | "https://api.weixin.qq.com/cgi-bin/message/mass/send?access_token=#{self.access_token}", 20 | :post, 21 | payload.to_json 22 | ) 23 | end 24 | 25 | # 26 | # 这个是微信的私有接口,在微信网页端才能使用,给单个用户发消息,而且没有发送数量限制, 27 | # 这里是模拟公众号登录,然后给用户发送消息(在“用户管理”页面点击单个用户) 28 | # 29 | # **这个接口要求48小时内只能发送20条** 30 | # **用户在48小时内与公众账号只要发生互动(点击公众号菜单或者发过消息),阀值就会刷新** 31 | # 32 | # Request URL:https://mp.weixin.qq.com/cgi-bin/singlesend?t=ajax-response&f=json&token=1927434739&lang=zh_CN 33 | # Request Method:POST 34 | # Query String: 35 | # t:ajax-response 36 | # f:json 37 | # token:1927434739 38 | # lang:zh_CN 39 | # Form Data 40 | # token:1927434739 41 | # lang:zh_CN 42 | # f:json 43 | # ajax:1 44 | # random:0.9569772763087352 45 | # type:1 46 | # content:ll 47 | # tofakeid:oMZZkwziWkGiXq3-RjGHcgXn6v70 48 | # imgcode: 49 | # 50 | mattr_accessor :single_send_cookies 51 | mattr_accessor :single_send_token 52 | mattr_accessor :single_send_cookies_refreshed_at 53 | mattr_accessor :single_send_cookies_expired_in 54 | 55 | mattr_accessor :single_send_ua 56 | 57 | # 58 | # 在公众号端没有cookie过期的概念,这里把登录后的cookie和token缓存起来,为了不频繁的登录公众号 59 | # 60 | # **这个功能只能在生产环境的真实公众账号中测试,不支持在沙盒中测试** 61 | # 62 | self.single_send_cookies_refreshed_at = Time.now.to_i 63 | self.single_send_cookies_expired_in = 3600 64 | 65 | self.single_send_ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.110 Safari/537.36" 66 | 67 | def single_send open_id, content 68 | single_send_if_need_refresh_cookie 69 | 70 | opts = { 71 | method: :post, 72 | url: "https://mp.weixin.qq.com/cgi-bin/singlesend?t=ajax-response&f=json&token=#{self.single_send_token}&lang=zh_CN", 73 | verify_ssl: false, 74 | payload: { 75 | token: self.single_send_token, 76 | lang: "zh_CN", 77 | f: "json", 78 | ajax: 1, 79 | random: Random.rand, 80 | type: 1, # 文本消息 81 | content: content, 82 | tofakeid: open_id, 83 | imgcode: "" 84 | }.to_query, 85 | headers: { 86 | 'User-Agent': self.single_send_ua, 87 | 'Cookie': self.single_send_cookies, 88 | 'Referer': "https://mp.weixin.qq.com/cgi-bin/singlesendpage?t=message/send&action=index&tofakeid=#{open_id}&token=#{self.single_send_token}&lang=zh_CN" 89 | } 90 | } 91 | 92 | response = RestClient::Request.execute(opts) 93 | data = JSON.parse(response) 94 | raise response.to_s if data['errmsg'] and data['errmsg'] != 'ok' 95 | data 96 | end 97 | 98 | private 99 | def single_send_if_need_refresh_cookie 100 | if self.single_send_cookies.nil? || 101 | (Time.now.to_i - self.single_send_cookies_refreshed_at > self.single_send_cookies_expired_in) 102 | single_send_login_and_refresh_cookies_w_token 103 | end 104 | end 105 | 106 | # 107 | # 登录公众号,缓存登录后的cookie和token 108 | # 109 | def single_send_login_and_refresh_cookies_w_token 110 | opts = { 111 | method: :post, 112 | url: "https://mp.weixin.qq.com/cgi-bin/login?lang=zh_CN", 113 | verify_ssl: false, 114 | payload: { 115 | username: self.config['wechat_login_username'], 116 | pwd: Digest::MD5.hexdigest(self.config['wechat_login_password']), 117 | imgcode: "", 118 | f: "json" 119 | }.to_query, 120 | headers: { 121 | 'User-Agent': self.single_send_ua, 122 | 'Referer': 'https://mp.weixin.qq.com/' 123 | } 124 | } 125 | 126 | response = RestClient::Request.execute(opts) 127 | data = JSON.parse(response) 128 | raise response.to_s if data['errmsg'] and data['errmsg'] != 'ok' 129 | 130 | self.single_send_cookies = response.cookies.map {|k, v| k + "=" + v}.join("; ") 131 | data["redirect_url"].gsub(/token=(\d+)/) { |x| self.single_send_token = $1 } 132 | self.single_send_cookies_refreshed_at = Time.now.to_i 133 | end 134 | 135 | end 136 | 137 | 138 | end 139 | -------------------------------------------------------------------------------- /lib/wechat_gate/tokens/access_token.rb: -------------------------------------------------------------------------------- 1 | require 'wechat_gate/tokens/base' 2 | 3 | module WechatGate 4 | module Tokens 5 | module AccessToken 6 | def self.included base 7 | base.send :include, InstanceMethods 8 | end 9 | 10 | module InstanceMethods 11 | def access_token 12 | token = WechatGate::Tokens::AccessToken::Get.refresh(self) 13 | end 14 | end 15 | 16 | class Get < WechatGate::Tokens::Base 17 | def url 18 | "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=#{@config.config['app_id']}&secret=#{@config.config['app_secret']}" 19 | end 20 | 21 | def save response 22 | File.open(saved_file, 'w') do |f| 23 | f.puts "#{Time.now.to_i} #{response['access_token']}" 24 | end 25 | end 26 | end 27 | 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/wechat_gate/tokens/base.rb: -------------------------------------------------------------------------------- 1 | require 'rest-client' 2 | require 'wechat_gate/request' 3 | 4 | module WechatGate 5 | module Tokens 6 | class Base 7 | 8 | def self.refresh config 9 | handler = new(config) 10 | handler.run 11 | end 12 | 13 | def initialize config 14 | @config = config 15 | end 16 | 17 | def run 18 | fetch if is_expired? 19 | File.readlines(saved_file).first.chomp.split(' ').last 20 | end 21 | 22 | def expired_in 23 | 7100 24 | end 25 | 26 | protected 27 | def url 28 | raise "need to implement #ur method in sub-class" 29 | end 30 | 31 | def save response 32 | raise "need to implement #save method in sub-class" 33 | end 34 | 35 | private 36 | def fetch 37 | WechatGate::Request.send(url) do |response| 38 | save response 39 | response 40 | end 41 | end 42 | 43 | def saved_file 44 | # File.expand_path("../../../../data/APP-#{@config.app_name}-#{self.class.name}", __FILE__) 45 | "/tmp/APP-#{@config.app_name}-#{self.class.name}-#{@config.config['app_id']}" 46 | end 47 | 48 | def is_expired? 49 | if File.exists?(saved_file) 50 | line = File.readlines(saved_file).first 51 | unless line 52 | return true 53 | else 54 | line = line.chomp 55 | return Time.now.to_i - line.split(' ').first.to_i >= expired_in 56 | end 57 | else 58 | FileUtils.touch(saved_file) 59 | return true 60 | end 61 | end 62 | 63 | end 64 | 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/wechat_gate/tokens/ext.rb: -------------------------------------------------------------------------------- 1 | module WechatGate 2 | module Tokens 3 | module Ext 4 | # please refer 5 | # http://mp.weixin.qq.com/wiki/7/aaa137b55fb2e0456bf8dd9148dd613f.html 6 | # 7 | def generate_js_request_params current_page_url 8 | current_page_url = current_page_url.gsub(/#.*/, '') 9 | 10 | letters = ('a'..'z').to_a + (0..9).to_a 11 | word_creator = proc { letters.sample } 12 | noncestr = [] 13 | 16.times { noncestr << word_creator.call } 14 | 15 | params = { 16 | "jsapi_ticket" => self.jsapi_ticket, 17 | "noncestr" => noncestr.join, 18 | "timestamp" => Time.now.to_i, 19 | "url" => current_page_url 20 | } 21 | 22 | sign_string = params.keys.sort.inject([]) { |m, n| m << "#{n}=#{params[n]}" }.join('&') 23 | sign = Digest::SHA1.hexdigest(sign_string) 24 | params["signature"] = sign 25 | params["app_id"] = self.config['app_id'] 26 | 27 | params 28 | end 29 | 30 | def write_token_to_file current_page_url, output_type 31 | params = generate_js_request_params(current_page_url) 32 | case output_type 33 | when /\// # write to file 34 | f = File.open(output_type, 'w') 35 | f.write %Q{} 36 | f.close 37 | when 'ruby' 38 | params 39 | when 'js' 40 | params.to_json 41 | else 42 | params 43 | end 44 | end 45 | 46 | 47 | end 48 | 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/wechat_gate/tokens/jsapi_ticket.rb: -------------------------------------------------------------------------------- 1 | require 'wechat_gate/tokens/base' 2 | 3 | module WechatGate 4 | module Tokens 5 | module JsapiTicket 6 | def self.included base 7 | base.send :include, InstanceMethods 8 | end 9 | 10 | module InstanceMethods 11 | def jsapi_ticket 12 | token = WechatGate::Tokens::JsapiTicket::Get.refresh(self) 13 | end 14 | end 15 | 16 | class Get < WechatGate::Tokens::Base 17 | def url 18 | token = WechatGate::Tokens::AccessToken::Get.refresh(@config) 19 | "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=#{token}&type=jsapi" 20 | end 21 | 22 | def save response 23 | File.open(saved_file, 'w') do |f| 24 | f.puts "#{Time.now.to_i} #{response['ticket']}" 25 | end 26 | end 27 | end 28 | end 29 | 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/wechat_gate/user.rb: -------------------------------------------------------------------------------- 1 | require 'wechat_gate/request' 2 | 3 | module WechatGate 4 | module User 5 | def users(next_openid = nil) 6 | WechatGate::Request.send("https://api.weixin.qq.com/cgi-bin/user/get?access_token=#{self.access_token}&next_openid=#{next_openid}") 7 | end 8 | 9 | def user(openid) 10 | WechatGate::Request.send("https://api.weixin.qq.com/cgi-bin/user/info?access_token=#{self.access_token}&openid=#{openid}&lang=zh_CN") 11 | end 12 | 13 | end 14 | 15 | 16 | end 17 | -------------------------------------------------------------------------------- /lib/wechat_gate/version.rb: -------------------------------------------------------------------------------- 1 | module WechatGate 2 | VERSION = "0.1.6" 3 | end 4 | -------------------------------------------------------------------------------- /wechat-gate.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'wechat_gate/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "wechat-gate" 8 | spec.version = WechatGate::VERSION 9 | spec.authors = ["Lei Lee"] 10 | spec.email = ["mytake6@gmail.com"] 11 | 12 | spec.summary = %q{隔壁家老李的微信开发Ruby Gem} 13 | spec.description = %q{接口简单易用,实在是微信开发必备之好Gem} 14 | spec.homepage = "https://github.com/eggmantv/wechat_gate" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_dependency "rest-client", '>= 1.8' 23 | spec.add_dependency "activesupport", '>= 5.0.1' 24 | 25 | spec.add_development_dependency "bundler", ">= 1.10" 26 | spec.add_development_dependency "rake", ">= 10.0" 27 | 28 | end 29 | --------------------------------------------------------------------------------