diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index ca271df..588ed39 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -152,6 +152,7 @@ services: - AGENT_SERVICE_PUBLIC_URL=${AGENT_SERVICE_PUBLIC_URL} - IT0_DINGTALK_CLIENT_ID=${IT0_DINGTALK_CLIENT_ID:-} - IT0_DINGTALK_CLIENT_SECRET=${IT0_DINGTALK_CLIENT_SECRET:-} + - IT0_BASE_URL=${IT0_BASE_URL:-https://it0api.szaiai.com} healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3002/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""] interval: 30s diff --git a/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart b/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart index f592fb6..67f428e 100644 --- a/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart +++ b/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart @@ -718,9 +718,11 @@ class _TemplateChip extends StatelessWidget { } // --------------------------------------------------------------------------- -// DingTalk bind bottom sheet +// DingTalk bind bottom sheet — OAuth-first (one-tap Authorize) // --------------------------------------------------------------------------- +enum _BindPhase { idle, loadingOAuth, waitingOAuth, loadingCode, waitingCode, success, expired, error } + class _DingTalkBindSheet extends StatefulWidget { final AgentInstance instance; final VoidCallback? onBound; @@ -732,22 +734,16 @@ class _DingTalkBindSheet extends StatefulWidget { } class _DingTalkBindSheetState extends State<_DingTalkBindSheet> { - static const _dtScheme = 'dingtalk://'; - - _BindPhase _phase = _BindPhase.loading; - String _code = ''; - DateTime? _expiresAt; + _BindPhase _phase = _BindPhase.idle; String? _errorMsg; Timer? _pollTimer; + + // Code fallback state + String _code = ''; + DateTime? _expiresAt; Timer? _countdownTimer; int _secondsLeft = 0; - @override - void initState() { - super.initState(); - _fetchCode(); - } - @override void dispose() { _pollTimer?.cancel(); @@ -755,31 +751,47 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> { super.dispose(); } - Future _fetchCode() async { - setState(() { _phase = _BindPhase.loading; _errorMsg = null; }); + // ── OAuth flow ───────────────────────────────────────────────────────────── + + Future _startOAuth() async { + setState(() { _phase = _BindPhase.loadingOAuth; _errorMsg = null; }); try { - final container = ProviderScope.containerOf(context); - final dio = container.read(dioClientProvider); + final dio = ProviderScope.containerOf(context).read(dioClientProvider); + final res = await dio.get( + '${ApiEndpoints.agent}/channels/dingtalk/oauth/init', + queryParameters: {'instanceId': widget.instance.id}, + ); + final oauthUrl = (res.data as Map)['oauthUrl'] as String; + final uri = Uri.parse(oauthUrl); + await launchUrl(uri, mode: LaunchMode.externalApplication); + setState(() { _phase = _BindPhase.waitingOAuth; }); + _startPolling(); + } catch (e) { + setState(() { _phase = _BindPhase.error; _errorMsg = ErrorHandler.friendlyMessage(e); }); + } + } + + // ── Code fallback ────────────────────────────────────────────────────────── + + Future _fetchCode() async { + setState(() { _phase = _BindPhase.loadingCode; _errorMsg = null; }); + try { + final dio = ProviderScope.containerOf(context).read(dioClientProvider); final res = await dio.post( '${ApiEndpoints.agent}/channels/dingtalk/bind/${widget.instance.id}', ); final data = res.data as Map; - final code = data['code'] as String; - final expiresAtMs = data['expiresAt'] as int; - final expiresAt = DateTime.fromMillisecondsSinceEpoch(expiresAtMs); + final expiresAt = DateTime.fromMillisecondsSinceEpoch(data['expiresAt'] as int); setState(() { - _code = code; + _code = data['code'] as String; _expiresAt = expiresAt; - _secondsLeft = expiresAt.difference(DateTime.now()).inSeconds.clamp(0, 600); + _secondsLeft = expiresAt.difference(DateTime.now()).inSeconds.clamp(0, 900); _phase = _BindPhase.waitingCode; }); _startCountdown(); _startPolling(); } catch (e) { - setState(() { - _phase = _BindPhase.error; - _errorMsg = ErrorHandler.friendlyMessage(e); - }); + setState(() { _phase = _BindPhase.error; _errorMsg = ErrorHandler.friendlyMessage(e); }); } } @@ -790,21 +802,22 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> { final left = _expiresAt!.difference(DateTime.now()).inSeconds; if (left <= 0) { _countdownTimer?.cancel(); - setState(() { _phase = _BindPhase.expired; }); _pollTimer?.cancel(); + setState(() { _phase = _BindPhase.expired; }); } else { setState(() { _secondsLeft = left; }); } }); } + // ── Shared polling ───────────────────────────────────────────────────────── + void _startPolling() { _pollTimer?.cancel(); _pollTimer = Timer.periodic(const Duration(seconds: 2), (_) async { if (!mounted) return; try { - final container = ProviderScope.containerOf(context); - final dio = container.read(dioClientProvider); + final dio = ProviderScope.containerOf(context).read(dioClientProvider); final res = await dio.get( '${ApiEndpoints.agent}/channels/dingtalk/status/${widget.instance.id}', ); @@ -815,24 +828,11 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> { setState(() { _phase = _BindPhase.success; }); widget.onBound?.call(); } - } catch (_) { - // network hiccup — keep polling - } + } catch (_) { /* network hiccup — keep polling */ } }); } - Future _openDingTalk() async { - final uri = Uri.parse(_dtScheme); - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('未检测到钉钉,请先安装钉钉')), - ); - } - } - } + // ── Build ────────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { @@ -845,7 +845,6 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Drag handle Container( width: 36, height: 4, margin: const EdgeInsets.only(bottom: 20), @@ -854,26 +853,16 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> { borderRadius: BorderRadius.circular(2), ), ), - - // Title Row( children: [ const Icon(Icons.chat_bubble_outline, color: Color(0xFF1A73E8), size: 22), const SizedBox(width: 10), - Text( - '绑定钉钉', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary), - ), + const Text('绑定钉钉', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary)), const Spacer(), - Text( - '「${widget.instance.name}」', - style: const TextStyle(fontSize: 12, color: AppColors.textMuted), - ), + Text('「${widget.instance.name}」', style: const TextStyle(fontSize: 12, color: AppColors.textMuted)), ], ), const SizedBox(height: 24), - - // Content by phase _buildPhaseContent(), const SizedBox(height: 8), ], @@ -881,35 +870,129 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> { ); } - Widget _buildPhaseContent() { - return switch (_phase) { - _BindPhase.loading => const Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), - _BindPhase.error => _buildError(), - _BindPhase.expired => _buildExpired(), - _BindPhase.success => _buildSuccess(), - _BindPhase.waitingCode => _buildWaitingCode(), - }; + Widget _buildPhaseContent() => switch (_phase) { + _BindPhase.idle => _buildIdle(), + _BindPhase.loadingOAuth => _buildSpinner('正在获取授权地址…'), + _BindPhase.waitingOAuth => _buildWaitingOAuth(), + _BindPhase.loadingCode => _buildSpinner('正在获取验证码…'), + _BindPhase.waitingCode => _buildWaitingCode(), + _BindPhase.success => _buildSuccess(), + _BindPhase.expired => _buildExpired(), + _BindPhase.error => _buildError(), + }; + + // ── Idle: show primary OAuth button ─────────────────────────────────────── + + Widget _buildIdle() { + return Column( + children: [ + // Hero icon + Container( + width: 72, height: 72, + decoration: BoxDecoration( + color: const Color(0xFF1A73E8).withOpacity(0.12), + shape: BoxShape.circle, + ), + child: const Icon(Icons.chat_bubble_outline, color: Color(0xFF1A73E8), size: 36), + ), + const SizedBox(height: 16), + const Text( + '一键连接钉钉', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary), + ), + const SizedBox(height: 8), + const Text( + '点击下方按钮,钉钉自动打开并请求授权\n点一下"同意"即完成绑定', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6), + ), + const SizedBox(height: 28), + + // Primary OAuth button + SizedBox( + width: double.infinity, + child: FilledButton.icon( + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('用钉钉授权绑定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + onPressed: _startOAuth, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF1A73E8), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + const SizedBox(height: 16), + + // Fallback link + TextButton( + onPressed: _fetchCode, + child: const Text( + '没有自动打开?使用验证码手动绑定', + style: TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + ), + ], + ); } + // ── Waiting OAuth: spinner + "已授权?" retry ───────────────────────────── + + Widget _buildWaitingOAuth() { + return Column( + children: [ + const SizedBox(height: 12), + const SizedBox(width: 48, height: 48, child: CircularProgressIndicator(strokeWidth: 3)), + const SizedBox(height: 20), + const Text( + '钉钉已打开', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + ), + const SizedBox(height: 8), + const Text( + '请在钉钉中点击"同意授权"\n授权完成后此页面会自动跳转', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _startOAuth, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF1A73E8), + side: const BorderSide(color: Color(0xFF1A73E8)), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: const Text('重新打开钉钉'), + ), + ), + const SizedBox(height: 10), + TextButton( + onPressed: _fetchCode, + child: const Text('切换到验证码方式', style: TextStyle(fontSize: 12, color: AppColors.textMuted)), + ), + ], + ); + } + + // ── Code fallback ────────────────────────────────────────────────────────── + Widget _buildWaitingCode() { final mm = (_secondsLeft ~/ 60).toString().padLeft(2, '0'); final ss = (_secondsLeft % 60).toString().padLeft(2, '0'); - return Column( children: [ - // Steps - _buildStep('1', '打开钉钉,找到 IT0 机器人对话'), - const SizedBox(height: 10), - _buildStep('2', '发送以下验证码(区分大小写)'), + const Text('在钉钉中找到 iAgent 机器人,发送以下验证码:', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: AppColors.textSecondary)), const SizedBox(height: 16), - - // Code display GestureDetector( onTap: () { Clipboard.setData(ClipboardData(text: _code)); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('验证码已复制'), duration: Duration(seconds: 1)), - ); + const SnackBar(content: Text('已复制'), duration: Duration(seconds: 1))); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16), @@ -921,16 +1004,8 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - _code, - style: const TextStyle( - fontSize: 36, - fontWeight: FontWeight.bold, - color: Color(0xFF1A73E8), - letterSpacing: 6, - fontFamily: 'monospace', - ), - ), + Text(_code, style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold, + color: Color(0xFF1A73E8), letterSpacing: 6, fontFamily: 'monospace')), const SizedBox(width: 12), const Icon(Icons.copy_outlined, size: 16, color: Color(0xFF1A73E8)), ], @@ -938,31 +1013,10 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> { ), ), const SizedBox(height: 8), - Text( - '有效期 $mm:$ss', - style: TextStyle( - fontSize: 12, - color: _secondsLeft < 60 ? AppColors.error : AppColors.textMuted, - ), - ), - const SizedBox(height: 20), - - // Open DingTalk button - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - icon: const Icon(Icons.open_in_new, size: 16), - label: const Text('打开钉钉'), - onPressed: _openDingTalk, - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF1A73E8), - side: const BorderSide(color: Color(0xFF1A73E8)), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ), - ), - const SizedBox(height: 10), + Text('有效期 $mm:$ss', + style: TextStyle(fontSize: 12, + color: _secondsLeft < 60 ? AppColors.error : AppColors.textMuted)), + const SizedBox(height: 16), const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -975,99 +1029,66 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> { ); } - Widget _buildStep(String num, String text) { - return Row( - children: [ - Container( - width: 22, height: 22, - decoration: BoxDecoration( - color: const Color(0xFF1A73E8).withOpacity(0.15), - shape: BoxShape.circle, - ), - child: Center( - child: Text(num, style: const TextStyle(fontSize: 12, color: Color(0xFF1A73E8), fontWeight: FontWeight.bold)), - ), - ), - const SizedBox(width: 10), - Expanded(child: Text(text, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary))), - ], - ); - } + // ── Common widgets ───────────────────────────────────────────────────────── - Widget _buildSuccess() { - return Column( - children: [ - const Icon(Icons.check_circle_rounded, color: Color(0xFF22C55E), size: 64), - const SizedBox(height: 16), - const Text('绑定成功!', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary)), - const SizedBox(height: 8), - Text( - '「${widget.instance.name}」已与钉钉绑定\n现在可以直接在钉钉中与它对话了', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6), - ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () => Navigator.pop(context), - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF22C55E), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - child: const Text('完成'), - ), - ), - ], - ); - } + Widget _buildSpinner(String label) => Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Column(children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textMuted)), + ]), + ); - Widget _buildExpired() { - return Column( - children: [ - const Icon(Icons.timer_off_outlined, color: AppColors.textMuted, size: 48), - const SizedBox(height: 12), - const Text('验证码已过期', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), - const SizedBox(height: 8), - const Text('请重新获取验证码', style: TextStyle(fontSize: 13, color: AppColors.textMuted)), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: _fetchCode, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - child: const Text('重新获取'), - ), - ), - ], - ); - } + Widget _buildSuccess() => Column(children: [ + const Icon(Icons.check_circle_rounded, color: Color(0xFF22C55E), size: 64), + const SizedBox(height: 16), + const Text('绑定成功!', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary)), + const SizedBox(height: 8), + Text('「${widget.instance.name}」已与钉钉绑定\n现在可以直接在钉钉中与它对话了', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6)), + const SizedBox(height: 24), + SizedBox(width: double.infinity, + child: FilledButton( + onPressed: () => Navigator.pop(context), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF22C55E), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))), + child: const Text('完成'), + )), + ]); - Widget _buildError() { - return Column( - children: [ - const Icon(Icons.error_outline, color: AppColors.error, size: 48), - const SizedBox(height: 12), - Text(_errorMsg ?? '获取验证码失败', style: const TextStyle(fontSize: 13, color: AppColors.error), textAlign: TextAlign.center), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: _fetchCode, - child: const Text('重试'), - ), - ), - ], - ); - } + Widget _buildExpired() => Column(children: [ + const Icon(Icons.timer_off_outlined, color: AppColors.textMuted, size: 48), + const SizedBox(height: 12), + const Text('验证码已过期', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const SizedBox(height: 20), + SizedBox(width: double.infinity, + child: FilledButton( + onPressed: () => setState(() { _phase = _BindPhase.idle; }), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))), + child: const Text('重新开始'), + )), + ]); + + Widget _buildError() => Column(children: [ + const Icon(Icons.error_outline, color: AppColors.error, size: 48), + const SizedBox(height: 12), + Text(_errorMsg ?? '操作失败', + style: const TextStyle(fontSize: 13, color: AppColors.error), textAlign: TextAlign.center), + const SizedBox(height: 20), + SizedBox(width: double.infinity, + child: OutlinedButton( + onPressed: () => setState(() { _phase = _BindPhase.idle; }), + child: const Text('重试'), + )), + ]); } -enum _BindPhase { loading, waitingCode, success, expired, error } - // --------------------------------------------------------------------------- // Dismiss confirm dialog // --------------------------------------------------------------------------- diff --git a/packages/gateway/config/kong.yml b/packages/gateway/config/kong.yml index 2df5073..d8ef4a0 100644 --- a/packages/gateway/config/kong.yml +++ b/packages/gateway/config/kong.yml @@ -20,6 +20,16 @@ services: - /api/v1/admin strip_path: false + # Public DingTalk OAuth callback — no JWT (DingTalk redirects here after user taps Authorize) + # Must be declared BEFORE agent-service so Kong matches this specific path first. + - name: dingtalk-oauth-public + url: http://agent-service:3002 + routes: + - name: dingtalk-oauth-callback + paths: + - /api/v1/agent/channels/dingtalk/oauth/callback + strip_path: false + - name: agent-service url: http://agent-service:3002 routes: diff --git a/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts b/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts index 6ad3137..b5c274b 100644 --- a/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts +++ b/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts @@ -47,6 +47,9 @@ interface DtFrame { } interface BotMsg { + /** openId — per-app unique, consistent with OAuth /contact/users/me response */ + senderId: string; + /** staffId — enterprise employee ID (kept for backward compat) */ senderStaffId: string; sessionWebhook: string; /** @@ -68,7 +71,8 @@ interface BindingEntry { // ── Constants ───────────────────────────────────────────────────────────────── const DINGTALK_MAX_CHARS = 4800; -const CODE_TTL_MS = 5 * 60 * 1000; // 5 min +const CODE_TTL_MS = 15 * 60 * 1000; // 15 min +const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; // 10 min const TOKEN_REFRESH_BUFFER = 300; // seconds before expiry to proactively refresh const WS_RECONNECT_BASE_MS = 2_000; const WS_RECONNECT_MAX_MS = 60_000; @@ -104,6 +108,7 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { // State private readonly bindingCodes = new Map(); // code → entry + private readonly oauthStates = new Map(); // state → entry private readonly dedup = new Map(); // msgId → ts private readonly rateWindows = new Map(); // userId → timestamps private readonly queueTails = new Map>(); // userId → tail @@ -156,6 +161,72 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { return { code, expiresAt }; } + // ── OAuth API ────────────────────────────────────────────────────────────── + + /** + * Generate a DingTalk OAuth authorization URL. + * The state token encodes the instanceId so the callback can complete binding + * without user interaction (one-tap "Authorize" in DingTalk on the same phone). + */ + generateOAuthUrl(instanceId: string): { oauthUrl: string; state: string } { + // Invalidate old states for this instance + for (const [s, entry] of this.oauthStates) { + if (entry.instanceId === instanceId) this.oauthStates.delete(s); + } + const state = crypto.randomBytes(16).toString('hex'); + const expiresAt = Date.now() + OAUTH_STATE_TTL_MS; + this.oauthStates.set(state, { instanceId, expiresAt }); + + const baseUrl = this.configService.get('IT0_BASE_URL', 'https://it0api.szaiai.com'); + const redirectUri = `${baseUrl}/api/v1/agent/channels/dingtalk/oauth/callback`; + const params = new URLSearchParams({ + client_id: this.clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid', + state, + prompt: 'consent', + }); + return { state, oauthUrl: `https://login.dingtalk.com/oauth2/auth?${params.toString()}` }; + } + + /** + * Complete the OAuth flow: exchange auth code → get user openId → save to DB. + * Called by the public callback endpoint (no JWT). + */ + async completeOAuthBinding(code: string, state: string): Promise<{ instanceId: string; instanceName: string }> { + const entry = this.oauthStates.get(state); + if (!entry) throw new Error('无效或已过期的授权状态,请重新绑定'); + if (Date.now() > entry.expiresAt) { + this.oauthStates.delete(state); + throw new Error('授权已超时,请重新绑定'); + } + this.oauthStates.delete(state); + + // Exchange auth code for user access token + const tokenResult = await this.httpsPost<{ accessToken: string; expireIn: number }>( + 'api.dingtalk.com', '/v1.0/oauth2/userAccessToken', + { clientId: this.clientId, clientSecret: this.clientSecret, code, grantType: 'authorization_code' }, + ); + + // Get user's openId — this is the same as senderId in bot messages (per-app ID) + const userInfo = await this.httpsGet<{ openId: string; unionId: string }>( + 'api.dingtalk.com', '/v1.0/contact/users/me', + { 'x-acs-dingtalk-access-token': tokenResult.accessToken }, + ); + + const dingTalkUserId = userInfo.openId; + if (!dingTalkUserId) throw new Error('无法获取钉钉用户身份,请重试'); + + const instance = await this.instanceRepo.findById(entry.instanceId); + if (!instance) throw new Error('智能体实例不存在'); + instance.dingTalkUserId = dingTalkUserId; + await this.instanceRepo.save(instance); + + this.logger.log(`OAuth binding: instance ${entry.instanceId} → DingTalk openId ${dingTalkUserId}`); + return { instanceId: entry.instanceId, instanceName: instance.name }; + } + // ── Token management ─────────────────────────────────────────────────────── private async getToken(): Promise { @@ -333,9 +404,11 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { } private dispatchMessage(msg: BotMsg): void { - const userId = msg.senderStaffId?.trim(); + // senderId = openId (per-app), consistent with OAuth binding identifier. + // Fall back to senderStaffId for legacy messages that lack senderId. + const userId = (msg.senderId ?? msg.senderStaffId)?.trim(); if (!userId) { - this.logger.warn('Received message with empty senderStaffId, ignoring'); + this.logger.warn('Received message with no sender ID, ignoring'); return; } @@ -566,10 +639,38 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { for (const [code, entry] of this.bindingCodes) { if (now > entry.expiresAt) this.bindingCodes.delete(code); } + + for (const [state, entry] of this.oauthStates) { + if (now > entry.expiresAt) this.oauthStates.delete(state); + } } // ── HTTP helpers ─────────────────────────────────────────────────────────── + /** HTTPS GET to DingTalk API. Response body capped at MAX_RESPONSE_BYTES. */ + private httpsGet(hostname: string, path: string, headers: Record = {}): Promise { + return new Promise((resolve, reject) => { + const req = https.request( + { hostname, path, method: 'GET', headers: { ...headers }, timeout: 10_000 }, + (res) => { + let data = ''; let totalBytes = 0; + res.on('data', (chunk: Buffer) => { totalBytes += chunk.length; if (totalBytes <= MAX_RESPONSE_BYTES) data += chunk.toString(); }); + res.on('end', () => { + if (totalBytes > MAX_RESPONSE_BYTES) { reject(new Error(`Response too large (${totalBytes} bytes)`)); return; } + try { + const json = JSON.parse(data); + if (res.statusCode && res.statusCode >= 400) reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`)); + else resolve(json as T); + } catch (e) { reject(e); } + }); + }, + ); + req.on('timeout', () => { req.destroy(); reject(new Error('DingTalk API GET timeout')); }); + req.on('error', reject); + req.end(); + }); + } + /** HTTPS POST to DingTalk API. Response body capped at MAX_RESPONSE_BYTES. */ private httpsPost( hostname: string, diff --git a/packages/services/agent-service/src/interfaces/rest/controllers/agent-channel.controller.ts b/packages/services/agent-service/src/interfaces/rest/controllers/agent-channel.controller.ts index 026ffa4..e03de6e 100644 --- a/packages/services/agent-service/src/interfaces/rest/controllers/agent-channel.controller.ts +++ b/packages/services/agent-service/src/interfaces/rest/controllers/agent-channel.controller.ts @@ -3,9 +3,13 @@ import { Post, Get, Param, + Query, NotFoundException, ServiceUnavailableException, + BadRequestException, + Res, } from '@nestjs/common'; +import { Response } from 'express'; import { DingTalkRouterService } from '../../../infrastructure/dingtalk/dingtalk-router.service'; import { AgentInstanceRepository } from '../../../infrastructure/repositories/agent-instance.repository'; @@ -46,4 +50,66 @@ export class AgentChannelController { await this.instanceRepo.save(inst); return { unbound: true }; } + + /** + * Initiate DingTalk OAuth binding. + * Returns the OAuth authorization URL — Flutter opens it via launchUrl. + * On the same phone, DingTalk intercepts the URL and shows a one-tap auth screen. + */ + @Get('dingtalk/oauth/init') + async oauthInit(@Query('instanceId') instanceId: string) { + if (!instanceId) throw new BadRequestException('instanceId is required'); + if (!this.dingTalkRouter.isEnabled()) { + throw new ServiceUnavailableException('DingTalk integration not configured'); + } + const inst = await this.instanceRepo.findById(instanceId); + if (!inst) throw new NotFoundException(`Instance ${instanceId} not found`); + + const { oauthUrl, state } = this.dingTalkRouter.generateOAuthUrl(instanceId); + return { oauthUrl, state }; + } + + /** + * DingTalk OAuth callback — PUBLIC endpoint, no JWT. + * DingTalk redirects here after user taps "Authorize". + * Exchanges the auth code for openId, saves binding, returns success HTML. + */ + @Get('dingtalk/oauth/callback') + async oauthCallback( + @Query('code') code: string, + @Query('state') state: string, + @Res() res: Response, + ) { + if (!code || !state) { + return res.status(400).send(this.htmlPage('参数错误', '缺少 code 或 state 参数,请重新绑定。', false)); + } + try { + const { instanceName } = await this.dingTalkRouter.completeOAuthBinding(code, state); + return res.send(this.htmlPage( + '绑定成功 ✅', + `「${instanceName}」已成功与您的钉钉账号绑定!\n现在可以关闭此页面,在钉钉中与 iAgent 机器人对话了。`, + true, + )); + } catch (e: any) { + return res.status(400).send(this.htmlPage('绑定失败', e.message ?? '请返回 iAgent App 重新操作。', false)); + } + } + + private htmlPage(title: string, message: string, success: boolean): string { + const color = success ? '#22C55E' : '#EF4444'; + const icon = success ? '✅' : '❌'; + return ` + +${title} + +
${icon}
+

${title}

+

${message}

+`; + } }