easyai 1.2.1 → 1.4.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.
@@ -1,655 +0,0 @@
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(options = {})
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
- # 从选项中获取 verbose 标志
37
- @verbose = options[:verbose] || false
38
-
39
- # token 存储路径
40
- @token_dir = File.expand_path('~/.easyai')
41
- @token_file = File.join(@token_dir, '.jpstoken')
42
- end
43
-
44
- # 主登录入口
45
- def login
46
- # 检查已存储的 token
47
- if load_stored_token && validate_token
48
- return true
49
- end
50
-
51
- puts "🔐 需要登录 JPS..."
52
- result = authorize_and_login
53
-
54
- # 如果用户主动取消,返回特殊标识
55
- return :user_cancelled if result == :user_cancelled
56
-
57
- return result
58
- end
59
-
60
- # 获取用户名(登录后可用)
61
- def get_username
62
- return @username if @username
63
-
64
- # 如果没有用户名但有 token,尝试从存储中加载
65
- if load_stored_token
66
- return @username
67
- end
68
-
69
- nil
70
- end
71
-
72
- # 检查 token 是否有效
73
- def validate_token(token = nil)
74
- token_to_check = token || @access_token
75
- return false unless token_to_check
76
-
77
- # 检查过期时间
78
- if @expires_at && Time.now.to_i > @expires_at
79
- puts "Token 已过期,需要重新登录"
80
- return false
81
- end
82
-
83
- # 简单验证(实际项目中可以调用 API 验证)
84
- return true
85
- end
86
-
87
- private
88
-
89
- # 获取跨平台的 null 设备路径
90
- def get_null_device
91
- case RbConfig::CONFIG['host_os']
92
- when /mswin|mingw|cygwin/
93
- 'NUL'
94
- else
95
- '/dev/null'
96
- end
97
- end
98
-
99
- # 完整的授权和登录流程
100
- def authorize_and_login
101
- # 构建授权 URL
102
- authorization_uri = build_authorization_uri
103
- puts "正在打开浏览器进行飞书 OAuth 授权..."
104
- puts "\n授权 URL(如自动打开失败,请手动复制下面的链接到浏览器):"
105
- puts "=" * 80
106
- puts authorization_uri
107
- puts "=" * 80
108
- puts ""
109
-
110
- # 在浏览器中打开授权 URL
111
- open_browser(authorization_uri)
112
-
113
- # 启动本地服务器处理回调
114
- code = start_callback_server
115
-
116
- # 如果自动获取失败,提示用户手动输入
117
- if code.nil?
118
- loop do
119
- puts "\n自动获取授权码失败,请选择:"
120
- puts "1. 输入授权码 (直接复制 'code=' 后面的内容)"
121
- puts "2. 输入完整回调 URL"
122
- puts "3. 重新打开授权网页"
123
- puts "4. 退出"
124
- print "> "
125
-
126
- begin
127
- choice = STDIN.gets&.chomp
128
-
129
- # 处理 Ctrl+C 中断
130
- if choice.nil?
131
- puts "\n用户中断操作"
132
- return :user_cancelled
133
- end
134
-
135
- case choice
136
- when "1"
137
- puts "请输入授权码:"
138
- print "> "
139
- code_input = STDIN.gets&.chomp
140
- if code_input.nil?
141
- puts "用户中断操作"
142
- return :user_cancelled
143
- elsif !code_input.empty?
144
- code = code_input
145
- break
146
- else
147
- puts "授权码不能为空,请重新选择"
148
- end
149
- when "2"
150
- puts "请输入完整回调 URL:"
151
- print "> "
152
- url_input = STDIN.gets&.chomp
153
- if url_input.nil?
154
- puts "用户中断操作"
155
- return :user_cancelled
156
- elsif url_input.start_with?("http")
157
- # 尝试从 URL 中提取 code
158
- begin
159
- uri = URI(url_input)
160
- query_params = URI.decode_www_form(uri.query || '').to_h
161
- code = query_params['code']
162
- if code
163
- puts "✓ 从 URL 中成功提取授权码"
164
- break
165
- else
166
- puts "✗ URL 中没有找到授权码,请重新选择"
167
- end
168
- rescue => e
169
- puts "✗ 无法从 URL 中提取授权码: #{e.message}"
170
- end
171
- else
172
- puts "✗ 无效的 URL 格式,请重新选择"
173
- end
174
- when "3"
175
- # 重新打开授权网页
176
- puts "正在重新打开授权网页..."
177
- open_browser(authorization_uri)
178
- # 重新启动服务器尝试获取授权码
179
- code = start_callback_server
180
- if code
181
- break
182
- end
183
- # 如果还是失败,继续循环让用户选择
184
- when "4"
185
- puts "已退出授权流程"
186
- return :user_cancelled
187
- else
188
- puts "无效的选择,请输入 1-4 之间的数字"
189
- end
190
- rescue Interrupt
191
- puts "\n\n用户中断操作"
192
- return :user_cancelled
193
- end
194
- end
195
- end
196
-
197
- if code
198
- if exchange_code_for_token(code)
199
- puts "✓ JPS 登录成功!用户名: #{@username}"
200
- store_token
201
- return true
202
- end
203
- return false
204
- else
205
- puts "✗ 授权失败"
206
- return false
207
- end
208
- end
209
-
210
- # 构建授权 URI
211
- def build_authorization_uri
212
- uri = URI(@feishu_auth_url)
213
-
214
- params = {
215
- 'client_id' => @client_id,
216
- 'redirect_uri' => @redirect_uri,
217
- 'response_type' => 'code',
218
- 'state' => @state,
219
- 'scope' => @scope_list.join(' ')
220
- }
221
-
222
- uri.query = URI.encode_www_form(params)
223
- uri.to_s
224
- end
225
-
226
- # 在浏览器中打开 URL
227
- def open_browser(url)
228
- case RbConfig::CONFIG['host_os']
229
- when /mswin|mingw|cygwin/
230
- # Windows: 使用双引号包围URL避免参数解析问题
231
- system("start \"\" \"#{url}\"")
232
- when /darwin/
233
- system("open", url)
234
- when /linux|bsd/
235
- system("xdg-open", url)
236
- else
237
- puts "无法自动打开浏览器,请手动访问: #{url}"
238
- end
239
- end
240
-
241
- # 启动本地服务器处理回调
242
- def start_callback_server
243
- code = nil
244
-
245
- # 检查并处理端口占用
246
- unless ensure_port_available(@server_port)
247
- puts "✗ 无法使用端口 #{@server_port},登录失败"
248
- return nil
249
- end
250
-
251
- puts "启动本地服务器,监听端口 #{@server_port}..." if @verbose
252
- puts "提示:按 Ctrl+C 可以中断并获得更多选择"
253
- puts "🔄 正在使用飞书身份登录 JPS..."
254
-
255
- begin
256
- server = WEBrick::HTTPServer.new(
257
- Port: @server_port,
258
- BindAddress: '127.0.0.1',
259
- Logger: WEBrick::Log.new(get_null_device),
260
- AccessLog: []
261
- )
262
- rescue Errno::EADDRINUSE
263
- puts "✗ 端口 #{@server_port} 仍被占用,无法启动服务器"
264
- return nil
265
- rescue Errno::ENOENT
266
- puts "✗ 启动服务器失败: 系统找不到指定的路径或文件"
267
- return nil
268
- rescue => e
269
- puts "✗ 启动服务器失败: #{e.message}"
270
- if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
271
- puts " 提示: 如果是Windows系统,请确保防火墙允许Ruby访问网络"
272
- end
273
- return nil
274
- end
275
-
276
- # 处理根路径的请求
277
- server.mount_proc '/' do |req, res|
278
- begin
279
- # 解析请求参数
280
- query_params = URI.decode_www_form(req.query_string || '').to_h
281
- puts "接收到回调请求,参数: #{query_params.inspect}" if @verbose
282
-
283
- if query_params['error']
284
- puts "授权错误: #{query_params['error']}" if @verbose
285
- res.content_type = "text/html; charset=UTF-8"
286
- res.body = build_error_page(query_params['error'])
287
- elsif query_params['code']
288
- code = query_params['code']
289
- puts "成功获取授权码" if @verbose
290
- res.content_type = "text/html; charset=UTF-8"
291
- res.body = build_success_page
292
- else
293
- puts "未获取到授权码" if @verbose
294
- res.content_type = "text/html; charset=UTF-8"
295
- res.body = build_error_page("未获取到授权码")
296
- end
297
-
298
- # 4秒后关闭服务器
299
- Thread.new do
300
- sleep 4
301
- server.shutdown
302
- end
303
- rescue => e
304
- puts "处理请求时出错: #{e.class} - #{e.message}"
305
- res.content_type = "text/html; charset=UTF-8"
306
- res.body = build_error_page("处理请求时出错")
307
-
308
- Thread.new do
309
- sleep 4
310
- server.shutdown
311
- end
312
- end
313
- end
314
-
315
- # 捕获 Ctrl+C
316
- trap('INT') { server.shutdown }
317
-
318
- # 在线程中运行服务器,最多等待 3 分钟
319
- thread = Thread.new { server.start }
320
- begin
321
- thread.join(180) # 最多等待 3 分钟
322
- rescue => e
323
- puts "服务器等待超时"
324
- server.shutdown
325
- end
326
-
327
- code
328
- end
329
-
330
- # 使用授权码换取 token
331
- def exchange_code_for_token(code)
332
- begin
333
- request_data = {
334
- 'code' => code,
335
- 'redirectUri' => @redirect_uri,
336
- 'scope' => @scope_list.join(' ')
337
- }
338
-
339
- uri = URI(@api_endpoint)
340
- http = Net::HTTP.new(uri.host, uri.port)
341
- http.use_ssl = (uri.scheme == 'https')
342
-
343
- request = Net::HTTP::Post.new(uri)
344
- request['Content-Type'] = 'application/json'
345
- request.body = request_data.to_json
346
-
347
- puts "正在请求 JPS API: #{@api_endpoint}" if @verbose
348
- response = http.request(request)
349
-
350
- puts "API 响应状态码: #{response.code}" if @verbose
351
-
352
- if response.body
353
- result = JSON.parse(response.body)
354
- puts "API 响应: #{result.inspect}" if @verbose
355
-
356
- if result['meta'] && result['meta']['code'] == 200 && result['data']
357
- data = result['data']
358
- @access_token = data['token'] if data['token']
359
- @username = data['username'] if data['username']
360
- # 设置过期时间为 6 天后
361
- @expires_at = Time.now.to_i + 6 * 24 * 60 * 60
362
-
363
- return true if @access_token
364
- else
365
- error_msg = result['meta'] && result['meta']['message'] ? result['meta']['message'] : '未知错误'
366
- puts "JPS 登录失败: #{error_msg}"
367
- end
368
- else
369
- puts "API 返回空响应"
370
- end
371
-
372
- return false
373
- rescue => e
374
- puts "请求 JPS API 失败: #{e.class} - #{e.message}"
375
- return false
376
- end
377
- end
378
-
379
- # 存储 token 和用户名到 ~/.easyai/.jpstoken 文件
380
- def store_token
381
- return unless @access_token
382
-
383
- # 确保目录存在
384
- FileUtils.mkdir_p(@token_dir) unless Dir.exist?(@token_dir)
385
-
386
- token_data = {
387
- 'token' => @access_token,
388
- 'username' => @username,
389
- 'expires_at' => @expires_at,
390
- 'created_at' => Time.now.to_i
391
- }
392
-
393
- File.write(@token_file, token_data.to_json)
394
- puts "✓ JPS Token 和用户名已存储到 #{@token_file}" if @verbose
395
- rescue => e
396
- puts "⚠ 存储 JPS Token 失败: #{e.message}"
397
- end
398
-
399
- # 从 ~/.easyai/.jpstoken 文件加载 token 和用户名
400
- def load_stored_token
401
- return false unless File.exist?(@token_file)
402
-
403
- begin
404
- token_data = JSON.parse(File.read(@token_file))
405
- @access_token = token_data['token']
406
- @username = token_data['username']
407
- @expires_at = token_data['expires_at']
408
-
409
- return @access_token ? true : false
410
- rescue => e
411
- puts "⚠️ 读取 JPS Token 失败: #{e.message}"
412
- return false
413
- end
414
- end
415
-
416
- # 构建成功页面
417
- def build_success_page
418
- <<~HTML
419
- <!DOCTYPE html>
420
- <html>
421
- <head>
422
- <meta charset="UTF-8">
423
- <title>JPS 授权成功</title>
424
- <style>
425
- body { font-family: Arial, sans-serif; text-align: center; padding: 40px; background-color: #f5f5f5; }
426
- .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); }
427
- .success { color: #4CAF50; margin-bottom: 20px; }
428
- .countdown { font-weight: bold; color: #FF5722; }
429
- .close-btn { background: #4CAF50; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 4px; font-size: 16px; }
430
- </style>
431
- <script>
432
- var secondsLeft = 3;
433
- function updateCountdown() {
434
- document.getElementById('countdown').innerText = secondsLeft;
435
- if (secondsLeft <= 0) {
436
- window.close();
437
- } else {
438
- secondsLeft -= 1;
439
- setTimeout(updateCountdown, 1000);
440
- }
441
- }
442
- window.onload = function() { updateCountdown(); }
443
- </script>
444
- </head>
445
- <body>
446
- <div class="container">
447
- <h1 class="success">✓ JPS 授权成功!</h1>
448
- <p>已获取授权码,正在返回命令行...</p>
449
- <p>此窗口将在 <span id="countdown" class="countdown">3</span> 秒后自动关闭</p>
450
- <button class="close-btn" onclick="window.close()">立即关闭</button>
451
- </div>
452
- </body>
453
- </html>
454
- HTML
455
- end
456
-
457
- # 构建错误页面
458
- def build_error_page(error_msg)
459
- <<~HTML
460
- <!DOCTYPE html>
461
- <html>
462
- <head>
463
- <meta charset="UTF-8">
464
- <title>JPS 授权失败</title>
465
- <style>
466
- body { font-family: Arial, sans-serif; text-align: center; padding: 40px; background-color: #f5f5f5; }
467
- .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); }
468
- .error { color: #f44336; margin-bottom: 20px; }
469
- .close-btn { background: #f44336; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 4px; font-size: 16px; }
470
- </style>
471
- </head>
472
- <body>
473
- <div class="container">
474
- <h1 class="error">✗ JPS 授权失败</h1>
475
- <p>错误信息: #{error_msg}</p>
476
- <p>请返回命令行重试</p>
477
- <button class="close-btn" onclick="window.close()">关闭窗口</button>
478
- </div>
479
- </body>
480
- </html>
481
- HTML
482
- end
483
-
484
- # 确保端口可用,处理端口占用问题
485
- def ensure_port_available(port)
486
- return true unless port_occupied?(port)
487
-
488
- puts "⚠️ 端口 #{port} 被占用,正在检查占用进程..."
489
-
490
- # 获取占用端口的进程信息
491
- process_info = get_port_process_info(port)
492
-
493
- if process_info.nil?
494
- puts "✗ 无法获取端口占用信息"
495
- return false
496
- end
497
-
498
- puts "占用进程信息:"
499
- puts " PID: #{process_info[:pid]}"
500
- puts " 进程名: #{process_info[:name]}"
501
- puts " 命令: #{process_info[:command]}"
502
-
503
- # 检查是否是自己的进程(可能是之前未正常关闭的实例)
504
- if process_info[:name].include?('ruby') && process_info[:command].include?('easyai')
505
- puts "检测到可能是 EasyAI 之前的遗留进程"
506
- if ask_user_kill_process?
507
- return kill_process_by_pid(process_info[:pid])
508
- end
509
- else
510
- puts "端口被其他应用程序占用"
511
- if ask_user_kill_process?
512
- return kill_process_by_pid(process_info[:pid])
513
- end
514
- end
515
-
516
- return false
517
- end
518
-
519
- # 检查端口是否被占用
520
- def port_occupied?(port)
521
- require 'socket'
522
-
523
- begin
524
- server = TCPServer.new('127.0.0.1', port)
525
- server.close
526
- false # 端口未被占用
527
- rescue Errno::EADDRINUSE
528
- true # 端口被占用
529
- rescue => e
530
- puts "⚠️ 检查端口时出错: #{e.message}"
531
- true # 出错时假设端口被占用,避免冲突
532
- end
533
- end
534
-
535
- # 获取占用指定端口的进程信息
536
- def get_port_process_info(port)
537
- begin
538
- # macOS/Linux 使用 lsof 命令
539
- if system('which lsof > /dev/null 2>&1')
540
- output = `lsof -ti :#{port} 2>/dev/null`.strip
541
- return nil if output.empty?
542
-
543
- pid = output.split("\n").first
544
- return nil if pid.nil? || pid.empty?
545
-
546
- # 获取进程详细信息
547
- ps_output = `ps -p #{pid} -o pid,comm,args 2>/dev/null`.lines
548
- return nil if ps_output.length < 2
549
-
550
- process_line = ps_output[1].strip
551
- parts = process_line.split(/\s+/, 3)
552
-
553
- return {
554
- pid: parts[0],
555
- name: parts[1] || 'unknown',
556
- command: parts[2] || 'unknown'
557
- }
558
- end
559
-
560
- # Windows 使用 netstat 命令
561
- if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
562
- output = `netstat -ano | findstr :#{port}`.strip
563
- return nil if output.empty?
564
-
565
- lines = output.split("\n")
566
- listening_line = lines.find { |line| line.include?('LISTENING') }
567
- return nil unless listening_line
568
-
569
- pid = listening_line.split.last
570
- return nil if pid.nil? || pid.empty?
571
-
572
- # 获取进程名称
573
- task_output = `tasklist /FI "PID eq #{pid}" /FO CSV /NH 2>nul`.strip
574
- if !task_output.empty?
575
- process_name = task_output.split(',').first.gsub('"', '')
576
- return {
577
- pid: pid,
578
- name: process_name,
579
- command: process_name
580
- }
581
- end
582
- end
583
-
584
- return nil
585
- rescue => e
586
- puts "获取进程信息时出错: #{e.message}"
587
- return nil
588
- end
589
- end
590
-
591
- # 询问用户是否终止占用端口的进程
592
- def ask_user_kill_process?
593
- puts "\n是否终止占用端口的进程?"
594
- puts " y/yes - 终止进程并继续 (可能影响其他应用)"
595
- puts " n/no - 取消登录 (默认)"
596
- print "> "
597
-
598
- begin
599
- response = STDIN.gets&.chomp&.downcase
600
- return ['y', 'yes'].include?(response)
601
- rescue Interrupt
602
- puts "\n\n用户中断操作"
603
- return false
604
- rescue => e
605
- puts "无法读取用户输入: #{e.message}"
606
- return false
607
- end
608
- end
609
-
610
- # 根据 PID 终止进程
611
- def kill_process_by_pid(pid)
612
- return false if pid.nil? || pid.empty?
613
-
614
- begin
615
- puts "正在终止进程 #{pid}..."
616
-
617
- # 跨平台的进程终止命令
618
- if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
619
- # Windows
620
- success = system("taskkill /PID #{pid} /F >nul 2>&1")
621
- else
622
- # macOS/Linux - 先尝试优雅终止,再强制终止
623
- success = system("kill -TERM #{pid} 2>/dev/null")
624
- sleep(2)
625
- # 检查进程是否还存在
626
- if system("kill -0 #{pid} 2>/dev/null")
627
- puts "优雅终止失败,使用强制终止..."
628
- success = system("kill -KILL #{pid} 2>/dev/null")
629
- end
630
- end
631
-
632
- if success
633
- puts "✓ 进程 #{pid} 已终止"
634
- sleep(1) # 等待端口释放
635
-
636
- # 再次检查端口是否可用
637
- unless port_occupied?(@server_port)
638
- puts "✓ 端口 #{@server_port} 现在可用"
639
- return true
640
- else
641
- puts "⚠️ 端口仍被占用,可能需要等待更长时间"
642
- return false
643
- end
644
- else
645
- puts "✗ 终止进程失败,可能需要管理员权限"
646
- return false
647
- end
648
- rescue => e
649
- puts "终止进程时出错: #{e.message}"
650
- return false
651
- end
652
- end
653
- end
654
- end
655
- end