easyai 1.0.2 → 1.0.4

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.
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'webrick'
4
+ require 'json'
5
+ require 'net/http'
6
+ require 'uri'
7
+ require 'securerandom'
8
+ require 'digest'
9
+ require 'base64'
10
+ require 'fileutils'
11
+
12
+ module EasyAI
13
+ module Auth
14
+ class JPSLogin
15
+ attr_reader :access_token, :username, :expires_at
16
+
17
+ def initialize
18
+ @client_id = "cli_a7bc7fe9b3d1d00b"
19
+ @server_port = 8898
20
+ @state = "client_login"
21
+ @api_endpoint = "https://jps-api.devtestapp.com/api/lark_login"
22
+ @redirect_uri = "https://jps-new.devtestapp.com/auth/jwt/login"
23
+ @feishu_auth_url = 'https://passport.feishu.cn/suite/passport/oauth/authorize?'
24
+
25
+ @scope_list = [
26
+ 'task:task:write',
27
+ 'task:section:write',
28
+ 'task:custom_field:write',
29
+ 'task:tasklist:write'
30
+ ]
31
+
32
+ @access_token = nil
33
+ @username = nil
34
+ @expires_at = nil
35
+
36
+ # token 存储路径
37
+ @token_dir = File.expand_path('~/.easyai')
38
+ @token_file = File.join(@token_dir, '.jpstoken')
39
+ end
40
+
41
+ # 主登录入口
42
+ def login
43
+ # 检查已存储的 token
44
+ if load_stored_token && validate_token
45
+ return true
46
+ end
47
+
48
+ puts "🔐 需要登录 JPS..."
49
+ return authorize_and_login
50
+ end
51
+
52
+ # 获取用户名(登录后可用)
53
+ def get_username
54
+ return @username if @username
55
+
56
+ # 如果没有用户名但有 token,尝试从存储中加载
57
+ if load_stored_token
58
+ return @username
59
+ end
60
+
61
+ nil
62
+ end
63
+
64
+ # 检查 token 是否有效
65
+ def validate_token(token = nil)
66
+ token_to_check = token || @access_token
67
+ return false unless token_to_check
68
+
69
+ # 检查过期时间
70
+ if @expires_at && Time.now.to_i > @expires_at
71
+ puts "Token 已过期,需要重新登录"
72
+ return false
73
+ end
74
+
75
+ # 简单验证(实际项目中可以调用 API 验证)
76
+ return true
77
+ end
78
+
79
+ private
80
+
81
+ # 完整的授权和登录流程
82
+ def authorize_and_login
83
+ # 构建授权 URL
84
+ authorization_uri = build_authorization_uri
85
+ puts "正在打开浏览器进行飞书 OAuth 授权..."
86
+ puts "授权 URL: #{authorization_uri}"
87
+
88
+ # 在浏览器中打开授权 URL
89
+ open_browser(authorization_uri)
90
+
91
+ # 启动本地服务器处理回调
92
+ code = start_callback_server
93
+
94
+ # 如果自动获取失败,提示用户手动输入
95
+ if code.nil?
96
+ puts "自动获取授权码失败,您可以手动输入:"
97
+ puts "1. 授权码 (直接复制 'code=' 后面的内容)"
98
+ puts "2. 完整回调 URL"
99
+ print "> "
100
+ input = STDIN.gets.chomp
101
+
102
+ if input.start_with?("http")
103
+ # 尝试从 URL 中提取 code
104
+ begin
105
+ uri = URI(input)
106
+ query_params = URI.decode_www_form(uri.query || '').to_h
107
+ code = query_params['code']
108
+ puts "从 URL 中成功提取授权码" if code
109
+ rescue => e
110
+ puts "无法从 URL 中提取授权码: #{e.message}"
111
+ end
112
+ else
113
+ # 将输入直接作为 code
114
+ code = input unless input.empty?
115
+ end
116
+ end
117
+
118
+ if code
119
+ puts "成功获取授权码!正在使用飞书身份登录 JPS..."
120
+ if exchange_code_for_token(code)
121
+ puts "✓ JPS 登录成功!"
122
+ puts " 用户名: #{@username}"
123
+ store_token
124
+ return true
125
+ end
126
+ return false
127
+ else
128
+ puts "✗ 授权失败"
129
+ return false
130
+ end
131
+ end
132
+
133
+ # 构建授权 URI
134
+ def build_authorization_uri
135
+ uri = URI(@feishu_auth_url)
136
+
137
+ params = {
138
+ 'client_id' => @client_id,
139
+ 'redirect_uri' => @redirect_uri,
140
+ 'response_type' => 'code',
141
+ 'state' => @state,
142
+ 'scope' => @scope_list.join(' ')
143
+ }
144
+
145
+ uri.query = URI.encode_www_form(params)
146
+ uri.to_s
147
+ end
148
+
149
+ # 在浏览器中打开 URL
150
+ def open_browser(url)
151
+ case RbConfig::CONFIG['host_os']
152
+ when /mswin|mingw|cygwin/
153
+ system("start", url)
154
+ when /darwin/
155
+ system("open", url)
156
+ when /linux|bsd/
157
+ system("xdg-open", url)
158
+ else
159
+ puts "无法自动打开浏览器,请手动访问: #{url}"
160
+ end
161
+ end
162
+
163
+ # 启动本地服务器处理回调
164
+ def start_callback_server
165
+ code = nil
166
+
167
+ puts "启动本地服务器,监听端口 #{@server_port}..."
168
+
169
+ server = WEBrick::HTTPServer.new(
170
+ Port: @server_port,
171
+ Logger: WEBrick::Log.new("/dev/null"),
172
+ AccessLog: []
173
+ )
174
+
175
+ # 处理根路径的请求
176
+ server.mount_proc '/' do |req, res|
177
+ begin
178
+ # 解析请求参数
179
+ query_params = URI.decode_www_form(req.query_string || '').to_h
180
+ puts "接收到回调请求,参数: #{query_params.inspect}"
181
+
182
+ if query_params['error']
183
+ puts "授权错误: #{query_params['error']}"
184
+ res.content_type = "text/html; charset=UTF-8"
185
+ res.body = build_error_page(query_params['error'])
186
+ elsif query_params['code']
187
+ code = query_params['code']
188
+ puts "成功获取授权码"
189
+ res.content_type = "text/html; charset=UTF-8"
190
+ res.body = build_success_page
191
+ else
192
+ puts "未获取到授权码"
193
+ res.content_type = "text/html; charset=UTF-8"
194
+ res.body = build_error_page("未获取到授权码")
195
+ end
196
+
197
+ # 4秒后关闭服务器
198
+ Thread.new do
199
+ sleep 4
200
+ server.shutdown
201
+ end
202
+ rescue => e
203
+ puts "处理请求时出错: #{e.class} - #{e.message}"
204
+ res.content_type = "text/html; charset=UTF-8"
205
+ res.body = build_error_page("处理请求时出错")
206
+
207
+ Thread.new do
208
+ sleep 4
209
+ server.shutdown
210
+ end
211
+ end
212
+ end
213
+
214
+ # 捕获 Ctrl+C
215
+ trap('INT') { server.shutdown }
216
+
217
+ # 在线程中运行服务器,最多等待 3 分钟
218
+ thread = Thread.new { server.start }
219
+ begin
220
+ thread.join(180) # 最多等待 3 分钟
221
+ rescue => e
222
+ puts "服务器等待超时"
223
+ server.shutdown
224
+ end
225
+
226
+ code
227
+ end
228
+
229
+ # 使用授权码换取 token
230
+ def exchange_code_for_token(code)
231
+ begin
232
+ request_data = {
233
+ 'code' => code,
234
+ 'redirectUri' => @redirect_uri,
235
+ 'scope' => @scope_list.join(' ')
236
+ }
237
+
238
+ uri = URI(@api_endpoint)
239
+ http = Net::HTTP.new(uri.host, uri.port)
240
+ http.use_ssl = (uri.scheme == 'https')
241
+
242
+ request = Net::HTTP::Post.new(uri)
243
+ request['Content-Type'] = 'application/json'
244
+ request.body = request_data.to_json
245
+
246
+ puts "正在请求 JPS API: #{@api_endpoint}"
247
+ response = http.request(request)
248
+
249
+ puts "API 响应状态码: #{response.code}"
250
+
251
+ if response.body
252
+ result = JSON.parse(response.body)
253
+ puts "API 响应: #{result.inspect}"
254
+
255
+ if result['meta'] && result['meta']['code'] == 200 && result['data']
256
+ data = result['data']
257
+ @access_token = data['token'] if data['token']
258
+ @username = data['username'] if data['username']
259
+ # 设置过期时间为 6 天后
260
+ @expires_at = Time.now.to_i + 6 * 24 * 60 * 60
261
+
262
+ return true if @access_token
263
+ else
264
+ error_msg = result['meta'] && result['meta']['message'] ? result['meta']['message'] : '未知错误'
265
+ puts "JPS 登录失败: #{error_msg}"
266
+ end
267
+ else
268
+ puts "API 返回空响应"
269
+ end
270
+
271
+ return false
272
+ rescue => e
273
+ puts "请求 JPS API 失败: #{e.class} - #{e.message}"
274
+ return false
275
+ end
276
+ end
277
+
278
+ # 存储 token 和用户名到 ~/.easyai/.jpstoken 文件
279
+ def store_token
280
+ return unless @access_token
281
+
282
+ # 确保目录存在
283
+ FileUtils.mkdir_p(@token_dir) unless Dir.exist?(@token_dir)
284
+
285
+ token_data = {
286
+ 'token' => @access_token,
287
+ 'username' => @username,
288
+ 'expires_at' => @expires_at,
289
+ 'created_at' => Time.now.to_i
290
+ }
291
+
292
+ File.write(@token_file, token_data.to_json)
293
+ puts "✓ JPS Token 和用户名已存储到 #{@token_file}"
294
+ rescue => e
295
+ puts "⚠ 存储 JPS Token 失败: #{e.message}"
296
+ end
297
+
298
+ # 从 ~/.easyai/.jpstoken 文件加载 token 和用户名
299
+ def load_stored_token
300
+ return false unless File.exist?(@token_file)
301
+
302
+ begin
303
+ token_data = JSON.parse(File.read(@token_file))
304
+ @access_token = token_data['token']
305
+ @username = token_data['username']
306
+ @expires_at = token_data['expires_at']
307
+
308
+ return @access_token ? true : false
309
+ rescue => e
310
+ puts "⚠️ 读取 JPS Token 失败: #{e.message}"
311
+ return false
312
+ end
313
+ end
314
+
315
+ # 构建成功页面
316
+ def build_success_page
317
+ <<~HTML
318
+ <!DOCTYPE html>
319
+ <html>
320
+ <head>
321
+ <meta charset="UTF-8">
322
+ <title>JPS 授权成功</title>
323
+ <style>
324
+ body { font-family: Arial, sans-serif; text-align: center; padding: 40px; background-color: #f5f5f5; }
325
+ .container { max-width: 600px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
326
+ .success { color: #4CAF50; margin-bottom: 20px; }
327
+ .countdown { font-weight: bold; color: #FF5722; }
328
+ .close-btn { background: #4CAF50; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 4px; font-size: 16px; }
329
+ </style>
330
+ <script>
331
+ var secondsLeft = 3;
332
+ function updateCountdown() {
333
+ document.getElementById('countdown').innerText = secondsLeft;
334
+ if (secondsLeft <= 0) {
335
+ window.close();
336
+ } else {
337
+ secondsLeft -= 1;
338
+ setTimeout(updateCountdown, 1000);
339
+ }
340
+ }
341
+ window.onload = function() { updateCountdown(); }
342
+ </script>
343
+ </head>
344
+ <body>
345
+ <div class="container">
346
+ <h1 class="success">✓ JPS 授权成功!</h1>
347
+ <p>已获取授权码,正在返回命令行...</p>
348
+ <p>此窗口将在 <span id="countdown" class="countdown">3</span> 秒后自动关闭</p>
349
+ <button class="close-btn" onclick="window.close()">立即关闭</button>
350
+ </div>
351
+ </body>
352
+ </html>
353
+ HTML
354
+ end
355
+
356
+ # 构建错误页面
357
+ def build_error_page(error_msg)
358
+ <<~HTML
359
+ <!DOCTYPE html>
360
+ <html>
361
+ <head>
362
+ <meta charset="UTF-8">
363
+ <title>JPS 授权失败</title>
364
+ <style>
365
+ body { font-family: Arial, sans-serif; text-align: center; padding: 40px; background-color: #f5f5f5; }
366
+ .container { max-width: 600px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
367
+ .error { color: #f44336; margin-bottom: 20px; }
368
+ .close-btn { background: #f44336; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 4px; font-size: 16px; }
369
+ </style>
370
+ </head>
371
+ <body>
372
+ <div class="container">
373
+ <h1 class="error">✗ JPS 授权失败</h1>
374
+ <p>错误信息: #{error_msg}</p>
375
+ <p>请返回命令行重试</p>
376
+ <button class="close-btn" onclick="window.close()">关闭窗口</button>
377
+ </div>
378
+ </body>
379
+ </html>
380
+ HTML
381
+ end
382
+ end
383
+ end
384
+ end
@@ -0,0 +1,214 @@
1
+ require 'openssl'
2
+ require 'base64'
3
+ require 'digest'
4
+ require 'io/console'
5
+ require 'fileutils'
6
+
7
+ module EasyAI
8
+ module Base
9
+ module FileCrypto
10
+
11
+ # 生成 AES-128 密钥
12
+ def self.generate_key(password)
13
+ Digest::MD5.hexdigest(password)[0, 16]
14
+ end
15
+
16
+ # AES-128-ECB 加密字符串
17
+ def self.aes_128_ecb_encrypt(key, data)
18
+ cipher = OpenSSL::Cipher.new('AES-128-ECB')
19
+ cipher.encrypt
20
+ cipher.key = key
21
+ encrypted = cipher.update(data) + cipher.final
22
+ Base64.strict_encode64(encrypted)
23
+ end
24
+
25
+ # AES-128-ECB 解密字符串
26
+ def self.aes_128_ecb_decrypt(key, encrypted_data)
27
+ cipher = OpenSSL::Cipher.new('AES-128-ECB')
28
+ cipher.decrypt
29
+ cipher.key = key
30
+ decrypted_data = Base64.strict_decode64(encrypted_data)
31
+ cipher.update(decrypted_data) + cipher.final
32
+ end
33
+
34
+ # 加密文件
35
+ def self.encrypt_file(file_path, password, output_path = nil)
36
+ raise "文件不存在: #{file_path}" unless File.exist?(file_path)
37
+ raise "指定路径不是文件: #{file_path}" unless File.file?(file_path)
38
+
39
+ # 如果没有指定输出路径,使用原文件路径加 .encrypted 后缀
40
+ output_path ||= "#{file_path}.encrypted"
41
+
42
+ # 生成密钥
43
+ key = generate_key(password)
44
+
45
+ # 读取文件内容
46
+ file_content = File.read(file_path)
47
+
48
+ # 加密内容
49
+ encrypted_content = aes_128_ecb_encrypt(key, file_content)
50
+
51
+ # 写入加密文件
52
+ File.write(output_path, encrypted_content)
53
+
54
+ output_path
55
+ rescue => e
56
+ raise "加密文件失败: #{e.message}"
57
+ end
58
+
59
+ # 加密目录中的所有文件
60
+ def self.encrypt_directory(dir_path, password, output_dir = nil)
61
+ raise "目录不存在: #{dir_path}" unless File.exist?(dir_path)
62
+ raise "指定路径不是目录: #{dir_path}" unless File.directory?(dir_path)
63
+
64
+ # 如果没有指定输出目录,使用原目录路径加 _encrypted 后缀
65
+ output_dir ||= "#{dir_path}_encrypted"
66
+
67
+ # 创建输出目录
68
+ Dir.mkdir(output_dir) unless File.exist?(output_dir)
69
+
70
+ encrypted_files = []
71
+
72
+ # 遍历目录中的所有文件(递归)
73
+ Dir.glob(File.join(dir_path, "**", "*")).each do |file_path|
74
+ next unless File.file?(file_path)
75
+
76
+ # 计算相对路径
77
+ relative_path = file_path.gsub(/^#{Regexp.escape(dir_path)}\//, '')
78
+ output_file_path = File.join(output_dir, "#{relative_path}.encrypted")
79
+
80
+ # 确保输出文件的目录存在
81
+ output_file_dir = File.dirname(output_file_path)
82
+ FileUtils.mkdir_p(output_file_dir) unless File.exist?(output_file_dir)
83
+
84
+ begin
85
+ encrypt_file(file_path, password, output_file_path)
86
+ encrypted_files << output_file_path
87
+ rescue => e
88
+ puts "⚠️ 跳过文件 #{relative_path}: #{e.message}"
89
+ end
90
+ end
91
+
92
+ { output_dir: output_dir, encrypted_files: encrypted_files }
93
+ rescue => e
94
+ raise "加密目录失败: #{e.message}"
95
+ end
96
+
97
+ # 解密文件
98
+ def self.decrypt_file(file_path, password, output_path = nil)
99
+ raise "文件不存在: #{file_path}" unless File.exist?(file_path)
100
+ raise "指定路径不是文件: #{file_path}" unless File.file?(file_path)
101
+
102
+ # 如果没有指定输出路径,移除 .encrypted 后缀或加 .decrypted 后缀
103
+ if output_path.nil?
104
+ if file_path.end_with?('.encrypted')
105
+ output_path = file_path.gsub(/\.encrypted$/, '')
106
+ else
107
+ output_path = "#{file_path}.decrypted"
108
+ end
109
+ end
110
+
111
+ # 生成密钥
112
+ key = generate_key(password)
113
+
114
+ # 读取加密文件内容
115
+ encrypted_content = File.read(file_path)
116
+
117
+ # 解密内容
118
+ decrypted_content = aes_128_ecb_decrypt(key, encrypted_content)
119
+
120
+ # 写入解密文件
121
+ File.write(output_path, decrypted_content)
122
+
123
+ output_path
124
+ rescue => e
125
+ raise "解密文件失败: #{e.message}"
126
+ end
127
+
128
+ # 解密目录中的所有加密文件
129
+ def self.decrypt_directory(dir_path, password, output_dir = nil)
130
+ raise "目录不存在: #{dir_path}" unless File.exist?(dir_path)
131
+ raise "指定路径不是目录: #{dir_path}" unless File.directory?(dir_path)
132
+
133
+ # 如果没有指定输出目录,智能判断输出路径
134
+ if output_dir.nil?
135
+ if dir_path.end_with?('_encrypted')
136
+ output_dir = dir_path.gsub(/_encrypted$/, '')
137
+ else
138
+ output_dir = "#{dir_path}_decrypted"
139
+ end
140
+ end
141
+
142
+ # 创建输出目录
143
+ Dir.mkdir(output_dir) unless File.exist?(output_dir)
144
+
145
+ decrypted_files = []
146
+
147
+ # 遍历目录中的所有文件(递归)
148
+ Dir.glob(File.join(dir_path, "**", "*")).each do |file_path|
149
+ next unless File.file?(file_path)
150
+
151
+ # 计算相对路径
152
+ relative_path = file_path.gsub(/^#{Regexp.escape(dir_path)}\//, '')
153
+
154
+ # 智能处理输出文件路径
155
+ if relative_path.end_with?('.encrypted')
156
+ # 移除 .encrypted 后缀
157
+ output_relative_path = relative_path.gsub(/\.encrypted$/, '')
158
+ else
159
+ # 如果不是 .encrypted 文件,跳过或添加 .decrypted 后缀
160
+ puts "⚠️ 跳过非加密文件: #{relative_path}"
161
+ next
162
+ end
163
+
164
+ output_file_path = File.join(output_dir, output_relative_path)
165
+
166
+ # 确保输出文件的目录存在
167
+ output_file_dir = File.dirname(output_file_path)
168
+ FileUtils.mkdir_p(output_file_dir) unless File.exist?(output_file_dir)
169
+
170
+ begin
171
+ decrypt_file(file_path, password, output_file_path)
172
+ decrypted_files << output_file_path
173
+ rescue => e
174
+ puts "⚠️ 跳过文件 #{relative_path}: #{e.message}"
175
+ end
176
+ end
177
+
178
+ { output_dir: output_dir, decrypted_files: decrypted_files }
179
+ rescue => e
180
+ raise "解密目录失败: #{e.message}"
181
+ end
182
+
183
+ # 获取用户密码输入
184
+ def self.get_password(prompt = "请输入密码: ")
185
+ # 如果设置了环境变量,直接使用(主要用于测试和脚本自动化)
186
+ if ENV['EASYAI_TEST_PASSWORD']
187
+ password = ENV['EASYAI_TEST_PASSWORD']
188
+ puts "使用环境变量密码"
189
+ return password
190
+ end
191
+
192
+ begin
193
+ print prompt
194
+ password = STDIN.noecho(&:gets)&.chomp
195
+ puts # 换行
196
+
197
+ if password.nil? || password.empty?
198
+ puts "❌ 密码不能为空"
199
+ return nil
200
+ end
201
+
202
+ password
203
+ rescue Interrupt
204
+ puts "\n\n用户取消输入"
205
+ return nil
206
+ rescue => e
207
+ puts "\n❌ 密码输入出错: #{e.message}"
208
+ return nil
209
+ end
210
+ end
211
+
212
+ end
213
+ end
214
+ end