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