wechat 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +405 -0
- data/Rakefile +29 -0
- data/bin/wechat +172 -0
- data/lib/wechat.rb +104 -0
- data/lib/wechat/access_token.rb +41 -0
- data/lib/wechat/api.rb +85 -0
- data/lib/wechat/cipher.rb +72 -0
- data/lib/wechat/client.rb +79 -0
- data/lib/wechat/corp_api.rb +66 -0
- data/lib/wechat/jsapi_ticket.rb +86 -0
- data/lib/wechat/message.rb +176 -0
- data/lib/wechat/responder.rb +177 -0
- metadata +115 -0
data/bin/wechat
ADDED
@@ -0,0 +1,172 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
lib = File.expand_path(File.dirname(__FILE__) + '/../lib')
|
4
|
+
$LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
|
5
|
+
|
6
|
+
require 'thor'
|
7
|
+
require 'wechat'
|
8
|
+
require 'json'
|
9
|
+
require 'active_support/dependencies/autoload'
|
10
|
+
require 'active_support/core_ext'
|
11
|
+
require 'active_support/json'
|
12
|
+
require 'fileutils'
|
13
|
+
require 'yaml'
|
14
|
+
|
15
|
+
class App < Thor
|
16
|
+
class Helper
|
17
|
+
def self.with(options)
|
18
|
+
config = loading_config
|
19
|
+
|
20
|
+
appid = config['appid']
|
21
|
+
secret = config['secret']
|
22
|
+
corpid = config['corpid']
|
23
|
+
corpsecret = config['corpsecret']
|
24
|
+
token_file = options[:toke_file] || config['access_token'] || '/var/tmp/wechat_access_token'
|
25
|
+
agentid = config['agentid']
|
26
|
+
|
27
|
+
if appid.present? && secret.present? && token_file.present?
|
28
|
+
Wechat::Api.new(appid, secret, token_file)
|
29
|
+
elsif corpid.present? && corpsecret.present? && token_file.present?
|
30
|
+
Wechat::CorpApi.new(corpid, corpsecret, token_file, agentid)
|
31
|
+
else
|
32
|
+
puts <<-HELP
|
33
|
+
Need create ~/.wechat.yml with wechat appid and secret
|
34
|
+
or running at rails root folder so wechat can read config/wechat.yml
|
35
|
+
HELP
|
36
|
+
exit 1
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def self.loading_config
|
43
|
+
config = {}
|
44
|
+
|
45
|
+
rails_config_file = File.join(Dir.getwd, 'config/wechat.yml')
|
46
|
+
home_config_file = File.join(Dir.home, '.wechat.yml')
|
47
|
+
|
48
|
+
if File.exist?(rails_config_file)
|
49
|
+
config = YAML.load(ERB.new(File.new(rails_config_file).read).result)['default']
|
50
|
+
if config.present? && (config['appid'] || config['corpid'])
|
51
|
+
puts 'Using rails project config/wechat.yml default setting...'
|
52
|
+
else
|
53
|
+
config = {}
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
if config.blank? && File.exist?(home_config_file)
|
58
|
+
config = YAML.load ERB.new(File.read(home_config_file)).result
|
59
|
+
end
|
60
|
+
config
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
package_name 'Wechat'
|
65
|
+
option :toke_file, aliases: '-t', desc: 'File to store access token'
|
66
|
+
|
67
|
+
desc 'users', '关注者列表'
|
68
|
+
def users
|
69
|
+
puts Helper.with(options).users
|
70
|
+
end
|
71
|
+
|
72
|
+
desc 'user [OPEN_ID]', '查找关注者'
|
73
|
+
def user(open_id)
|
74
|
+
puts Helper.with(options).user(open_id)
|
75
|
+
end
|
76
|
+
|
77
|
+
desc 'menu', '当前菜单'
|
78
|
+
def menu
|
79
|
+
puts Helper.with(options).menu
|
80
|
+
end
|
81
|
+
|
82
|
+
desc 'menu_delete', '删除菜单'
|
83
|
+
def menu_delete
|
84
|
+
puts 'Menu deleted' if Helper.with(options).menu_delete
|
85
|
+
end
|
86
|
+
|
87
|
+
desc 'menu_create [MENU_YAML_PATH]', '创建菜单'
|
88
|
+
def menu_create(menu_yaml_path)
|
89
|
+
menu = YAML.load(File.new(menu_yaml_path).read)
|
90
|
+
puts 'Menu created' if Helper.with(options).menu_create(menu)
|
91
|
+
end
|
92
|
+
|
93
|
+
desc 'media [MEDIA_ID, PATH]', '媒体下载'
|
94
|
+
def media(media_id, path)
|
95
|
+
tmp_file = Helper.with(options).media(media_id)
|
96
|
+
FileUtils.mv(tmp_file.path, path)
|
97
|
+
puts 'File downloaded'
|
98
|
+
end
|
99
|
+
|
100
|
+
desc 'media_create [MEDIA_TYPE, PATH]', '媒体上传'
|
101
|
+
def media_create(type, path)
|
102
|
+
file = File.new(path)
|
103
|
+
puts Helper.with(options).media_create(type, file)
|
104
|
+
end
|
105
|
+
|
106
|
+
desc 'message_send [OPENID, TEXT_MESSAGE]', '发送文字消息(仅企业号)'
|
107
|
+
def message_send(openid, text_message)
|
108
|
+
puts Helper.with(options).message_send Wechat::Message.to(openid).text(text_message)
|
109
|
+
end
|
110
|
+
|
111
|
+
desc 'custom_text [OPENID, TEXT_MESSAGE]', '发送文字客服消息'
|
112
|
+
def custom_text(openid, text_message)
|
113
|
+
puts Helper.with(options).custom_message_send Wechat::Message.to(openid).text(text_message)
|
114
|
+
end
|
115
|
+
|
116
|
+
desc 'custom_image [OPENID, IMAGE_PATH]', '发送图片客服消息'
|
117
|
+
def custom_image(openid, image_path)
|
118
|
+
file = File.new(image_path)
|
119
|
+
api = Helper.with(options)
|
120
|
+
|
121
|
+
media_id = api.media_create('image', file)['media_id']
|
122
|
+
puts api.custom_message_send Wechat::Message.to(openid).image(media_id)
|
123
|
+
end
|
124
|
+
|
125
|
+
desc 'custom_voice [OPENID, VOICE_PATH]', '发送语音客服消息'
|
126
|
+
def custom_voice(openid, voice_path)
|
127
|
+
file = File.new(voice_path)
|
128
|
+
api = Helper.with(options)
|
129
|
+
|
130
|
+
media_id = api.media_create('voice', file)['media_id']
|
131
|
+
puts api.custom_message_send Wechat::Message.to(openid).voice(media_id)
|
132
|
+
end
|
133
|
+
|
134
|
+
desc 'custom_video [OPENID, VIDEO_PATH]', '发送视频客服消息'
|
135
|
+
method_option :title, aliases: '-h', desc: '视频标题'
|
136
|
+
method_option :description, aliases: '-d', desc: '视频描述'
|
137
|
+
def custom_video(openid, video_path)
|
138
|
+
file = File.new(video_path)
|
139
|
+
api = Helper.with(options)
|
140
|
+
|
141
|
+
api_opts = options.slice(:title, :description)
|
142
|
+
media_id = api.media_create('video', file)['media_id']
|
143
|
+
puts api.custom_message_send Wechat::Message.to(openid).video(media_id, api_opts)
|
144
|
+
end
|
145
|
+
|
146
|
+
desc 'custom_music [OPENID, THUMBNAIL_PATH, MUSIC_URL]', '发送音乐客服消息'
|
147
|
+
method_option :title, aliases: '-h', desc: '音乐标题'
|
148
|
+
method_option :description, aliases: '-d', desc: '音乐描述'
|
149
|
+
method_option :HQ_music_url, aliases: '-u', desc: '高质量音乐URL链接'
|
150
|
+
def custom_music(openid, thumbnail_path, music_url)
|
151
|
+
file = File.new(thumbnail_path)
|
152
|
+
api = Helper.with(options)
|
153
|
+
|
154
|
+
api_opts = options.slice(:title, :description, :HQ_music_url)
|
155
|
+
thumb_media_id = api.media_create('thumb', file)['thumb_media_id']
|
156
|
+
puts api.custom_message_send Wechat::Message.to(openid).music(thumb_media_id, music_url, api_opts)
|
157
|
+
end
|
158
|
+
|
159
|
+
desc 'custom_news [OPENID, NEWS_YAML_PATH]', '发送图文客服消息'
|
160
|
+
def custom_news(openid, news_yaml_path)
|
161
|
+
articles = YAML.load(File.new(news_yaml_path).read)
|
162
|
+
puts Helper.with(options).custom_message_send Wechat::Message.to(openid).news(articles['articles'])
|
163
|
+
end
|
164
|
+
|
165
|
+
desc 'template_message [OPENID, TEMPLATE_YAML_PATH]', '模板消息接口'
|
166
|
+
def template_message(openid, template_yaml_path)
|
167
|
+
template = YAML.load(File.new(template_yaml_path).read)
|
168
|
+
puts Helper.with(options).template_message_send Wechat::Message.to(openid).template(template['template'])
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
App.start
|
data/lib/wechat.rb
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
require 'wechat/api'
|
2
|
+
require 'wechat/corp_api'
|
3
|
+
|
4
|
+
module Wechat
|
5
|
+
autoload :Message, 'wechat/message'
|
6
|
+
autoload :Responder, 'wechat/responder'
|
7
|
+
autoload :Cipher, 'wechat/cipher'
|
8
|
+
|
9
|
+
class AccessTokenExpiredError < StandardError; end
|
10
|
+
class ResponseError < StandardError
|
11
|
+
attr_reader :error_code
|
12
|
+
def initialize(errcode, errmsg)
|
13
|
+
@error_code = errcode
|
14
|
+
super "#{errmsg}(#{error_code})"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
attr_reader :config
|
19
|
+
|
20
|
+
def self.config
|
21
|
+
@config ||= begin
|
22
|
+
if defined? Rails
|
23
|
+
config_file = Rails.root.join('config/wechat.yml')
|
24
|
+
config = YAML.load(ERB.new(File.new(config_file).read).result)[Rails.env] if File.exist?(config_file)
|
25
|
+
end
|
26
|
+
|
27
|
+
config ||= { appid: ENV['WECHAT_APPID'],
|
28
|
+
secret: ENV['WECHAT_SECRET'],
|
29
|
+
corpid: ENV['WECHAT_CORPID'],
|
30
|
+
corpsecret: ENV['WECHAT_CORPSECRET'],
|
31
|
+
agentid: ENV['WECHAT_AGENTID'],
|
32
|
+
token: ENV['WECHAT_TOKEN'],
|
33
|
+
access_token: ENV['WECHAT_ACCESS_TOKEN'],
|
34
|
+
encrypt_mode: ENV['WECHAT_ENCRYPT_MODE'],
|
35
|
+
encoding_aes_key: ENV['WECHAT_ENCODING_AES_KEY'] }
|
36
|
+
config.symbolize_keys!
|
37
|
+
config[:access_token] ||= Rails.root.join('tmp/access_token').to_s
|
38
|
+
config[:jsapi_ticket] ||= Rails.root.join('tmp/jsapi_ticket').to_s
|
39
|
+
OpenStruct.new(config)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.api
|
44
|
+
if config.corpid.present?
|
45
|
+
@api ||= Wechat::CorpApi.new(config.corpid, config.corpsecret, config.access_token, config.agentid)
|
46
|
+
else
|
47
|
+
@api ||= Wechat::Api.new(config.appid, config.secret, config.access_token, config.jsapi_ticket)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
if defined? ActionController::Base
|
53
|
+
class ActionController::Base
|
54
|
+
def self.wechat_responder(opts = {})
|
55
|
+
send(:include, Wechat::Responder)
|
56
|
+
if opts.empty?
|
57
|
+
self.corpid = Wechat.config.corpid
|
58
|
+
self.wechat = Wechat.api
|
59
|
+
self.agentid = Wechat.config.agentid
|
60
|
+
self.token = Wechat.config.token
|
61
|
+
self.encrypt_mode = Wechat.config.encrypt_mode
|
62
|
+
self.encoding_aes_key = Wechat.config.encoding_aes_key
|
63
|
+
else
|
64
|
+
self.corpid = opts[:corpid]
|
65
|
+
if corpid.present?
|
66
|
+
self.wechat = Wechat::CorpApi.new(opts[:corpid], opts[:corpsecret], opts[:access_token], opts[:agentid])
|
67
|
+
else
|
68
|
+
self.wechat = Wechat::Api.new(opts[:appid], opts[:secret], opts[:access_token], opts[:jsapi_ticket])
|
69
|
+
end
|
70
|
+
self.agentid = opts[:agentid]
|
71
|
+
self.token = opts[:token]
|
72
|
+
self.encrypt_mode = opts[:encrypt_mode]
|
73
|
+
self.encoding_aes_key = opts[:encoding_aes_key]
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
if defined? ActionController::API
|
80
|
+
class ActionController::API
|
81
|
+
def self.wechat_responder(opts = {})
|
82
|
+
send(:include, Wechat::Responder)
|
83
|
+
if opts.empty?
|
84
|
+
self.corpid = Wechat.config.corpid
|
85
|
+
self.wechat = Wechat.api
|
86
|
+
self.agentid = Wechat.config.agentid
|
87
|
+
self.token = Wechat.config.token
|
88
|
+
self.encrypt_mode = Wechat.config.encrypt_mode
|
89
|
+
self.encoding_aes_key = Wechat.config.encoding_aes_key
|
90
|
+
else
|
91
|
+
self.corpid = opts[:corpid]
|
92
|
+
if corpid.present?
|
93
|
+
self.wechat = Wechat::CorpApi.new(opts[:corpid], opts[:corpsecret], opts[:access_token], opts[:agentid])
|
94
|
+
else
|
95
|
+
self.wechat = Wechat::Api.new(opts[:appid], opts[:secret], opts[:access_token], opts[:jsapi_ticket])
|
96
|
+
end
|
97
|
+
self.agentid = opts[:agentid]
|
98
|
+
self.token = opts[:token]
|
99
|
+
self.encrypt_mode = opts[:encrypt_mode]
|
100
|
+
self.encoding_aes_key = opts[:encoding_aes_key]
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Wechat
|
2
|
+
class AccessToken
|
3
|
+
attr_reader :client, :appid, :secret, :token_file, :token_data
|
4
|
+
|
5
|
+
def initialize(client, appid, secret, token_file)
|
6
|
+
@appid = appid
|
7
|
+
@secret = secret
|
8
|
+
@client = client
|
9
|
+
@token_file = token_file
|
10
|
+
end
|
11
|
+
|
12
|
+
def token
|
13
|
+
begin
|
14
|
+
@token_data ||= JSON.parse(File.read(token_file))
|
15
|
+
created_at = token_data['created_at'].to_i
|
16
|
+
expires_in = token_data['expires_in'].to_i
|
17
|
+
if Time.now.to_i - created_at >= expires_in - 3 * 60
|
18
|
+
raise 'token_data may be expired'
|
19
|
+
end
|
20
|
+
rescue
|
21
|
+
refresh
|
22
|
+
end
|
23
|
+
valid_token(@token_data)
|
24
|
+
end
|
25
|
+
|
26
|
+
def refresh
|
27
|
+
data = client.get('token', params: { grant_type: 'client_credential', appid: appid, secret: secret })
|
28
|
+
data.merge!(created_at: Time.now.to_i)
|
29
|
+
File.open(token_file, 'w') { |f| f.write(data.to_json) } if valid_token(data)
|
30
|
+
@token_data = data
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def valid_token(token_data)
|
36
|
+
access_token = token_data['access_token']
|
37
|
+
raise "Response didn't have access_token" if access_token.blank?
|
38
|
+
access_token
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
data/lib/wechat/api.rb
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
require 'wechat/client'
|
2
|
+
require 'wechat/access_token'
|
3
|
+
require 'wechat/jsapi_ticket'
|
4
|
+
|
5
|
+
class Wechat::Api
|
6
|
+
attr_reader :access_token, :client, :jsapi_ticket
|
7
|
+
|
8
|
+
API_BASE = 'https://api.weixin.qq.com/cgi-bin/'
|
9
|
+
FILE_BASE = 'http://file.api.weixin.qq.com/cgi-bin/'
|
10
|
+
OAUTH2_BASE = 'https://api.weixin.qq.com/sns/oauth2/'
|
11
|
+
|
12
|
+
def initialize(appid, secret, token_file, jsapi_ticket_file = '/var/tmp/wechat_jsapi_ticket')
|
13
|
+
@client = Wechat::Client.new(API_BASE)
|
14
|
+
@access_token = Wechat::AccessToken.new(@client, appid, secret, token_file)
|
15
|
+
@jsapi_ticket = Wechat::JsapiTicket.new(@client, @access_token, jsapi_ticket_file)
|
16
|
+
end
|
17
|
+
|
18
|
+
def users(nextid = nil)
|
19
|
+
params = { params: { next_openid: nextid } } if nextid.present?
|
20
|
+
get('user/get', params || {})
|
21
|
+
end
|
22
|
+
|
23
|
+
def user(openid)
|
24
|
+
get('user/info', params: { openid: openid })
|
25
|
+
end
|
26
|
+
|
27
|
+
def menu
|
28
|
+
get('menu/get')
|
29
|
+
end
|
30
|
+
|
31
|
+
def menu_delete
|
32
|
+
get('menu/delete')
|
33
|
+
end
|
34
|
+
|
35
|
+
def menu_create(menu)
|
36
|
+
# 微信不接受7bit escaped json(eg \uxxxx), 中文必须UTF-8编码, 这可能是个安全漏洞
|
37
|
+
post('menu/create', JSON.generate(menu))
|
38
|
+
end
|
39
|
+
|
40
|
+
def media(media_id)
|
41
|
+
get 'media/get', params: { media_id: media_id }, base: FILE_BASE, as: :file
|
42
|
+
end
|
43
|
+
|
44
|
+
def media_create(type, file)
|
45
|
+
post 'media/upload', { upload: { media: file } }, params: { type: type }, base: FILE_BASE
|
46
|
+
end
|
47
|
+
|
48
|
+
def custom_message_send(message)
|
49
|
+
post 'message/custom/send', message.to_json, content_type: :json
|
50
|
+
end
|
51
|
+
|
52
|
+
def template_message_send(message)
|
53
|
+
post 'message/template/send', message.to_json, content_type: :json
|
54
|
+
end
|
55
|
+
|
56
|
+
# http://mp.weixin.qq.com/wiki/17/c0f37d5704f0b64713d5d2c37b468d75.html
|
57
|
+
# 第二步:通过code换取网页授权access_token
|
58
|
+
def web_access_token(code)
|
59
|
+
params = {
|
60
|
+
appid: access_token.appid,
|
61
|
+
secret: access_token.secret,
|
62
|
+
code: code,
|
63
|
+
grant_type: 'authorization_code'
|
64
|
+
}
|
65
|
+
get 'access_token', params: params, base: OAUTH2_BASE
|
66
|
+
end
|
67
|
+
|
68
|
+
protected
|
69
|
+
|
70
|
+
def get(path, headers = {})
|
71
|
+
with_access_token(headers[:params]) { |params| client.get path, headers.merge(params: params) }
|
72
|
+
end
|
73
|
+
|
74
|
+
def post(path, payload, headers = {})
|
75
|
+
with_access_token(headers[:params]) { |params| client.post path, payload, headers.merge(params: params) }
|
76
|
+
end
|
77
|
+
|
78
|
+
def with_access_token(params = {}, tries = 2)
|
79
|
+
params ||= {}
|
80
|
+
yield(params.merge(access_token: access_token.token))
|
81
|
+
rescue Wechat::AccessTokenExpiredError
|
82
|
+
access_token.refresh
|
83
|
+
retry unless (tries -= 1).zero?
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'openssl/cipher'
|
2
|
+
require 'securerandom'
|
3
|
+
require 'base64'
|
4
|
+
|
5
|
+
module Wechat
|
6
|
+
module Cipher
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
BLOCK_SIZE = 32
|
10
|
+
CIPHER = 'AES-256-CBC'
|
11
|
+
|
12
|
+
def encrypt(plain, encoding_aes_key)
|
13
|
+
cipher = OpenSSL::Cipher.new(CIPHER)
|
14
|
+
cipher.encrypt
|
15
|
+
|
16
|
+
cipher.padding = 0
|
17
|
+
key_data = Base64.decode64(encoding_aes_key)
|
18
|
+
cipher.key = key_data
|
19
|
+
cipher.iv = key_data[0..16]
|
20
|
+
|
21
|
+
cipher.update(encode_padding(plain)) + cipher.final
|
22
|
+
end
|
23
|
+
|
24
|
+
def decrypt(msg, encoding_aes_key)
|
25
|
+
cipher = OpenSSL::Cipher.new(CIPHER)
|
26
|
+
cipher.decrypt
|
27
|
+
|
28
|
+
cipher.padding = 0
|
29
|
+
key_data = Base64.decode64(encoding_aes_key)
|
30
|
+
cipher.key = key_data
|
31
|
+
cipher.iv = key_data[0..16]
|
32
|
+
|
33
|
+
plain = cipher.update(msg) + cipher.final
|
34
|
+
decode_padding(plain)
|
35
|
+
end
|
36
|
+
|
37
|
+
# app_id or corp_id
|
38
|
+
def pack(content, app_id)
|
39
|
+
random = SecureRandom.hex(8)
|
40
|
+
text = content.force_encoding('ASCII-8BIT')
|
41
|
+
msg_len = [text.length].pack('N')
|
42
|
+
|
43
|
+
encode_padding("#{random}#{msg_len}#{text}#{app_id}")
|
44
|
+
end
|
45
|
+
|
46
|
+
def unpack(msg)
|
47
|
+
msg = decode_padding(msg)
|
48
|
+
msg_len = msg[16, 4].reverse.unpack('V')[0]
|
49
|
+
content = msg[20, msg_len]
|
50
|
+
app_id = msg[(20 + msg_len)..-1]
|
51
|
+
|
52
|
+
[content, app_id]
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def encode_padding(data)
|
58
|
+
length = data.bytes.length
|
59
|
+
amount_to_pad = BLOCK_SIZE - (length % BLOCK_SIZE)
|
60
|
+
amount_to_pad = BLOCK_SIZE if amount_to_pad == 0
|
61
|
+
padding = ([amount_to_pad].pack('c') * amount_to_pad)
|
62
|
+
data + padding
|
63
|
+
end
|
64
|
+
|
65
|
+
def decode_padding(plain)
|
66
|
+
pad = plain.bytes[-1]
|
67
|
+
# no padding
|
68
|
+
pad = 0 if pad < 1 || pad > BLOCK_SIZE
|
69
|
+
plain[0...(plain.length - pad)]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|