feat(dingtalk): OAuth one-tap binding + voice tool + public Kong route
- DingTalk binding UX replaced with OAuth one-tap flow:
- GET /api/v1/agent/channels/dingtalk/oauth/init returns OAuth URL
- GET /api/v1/agent/channels/dingtalk/oauth/callback (public, no JWT)
exchanges code+state for openId, saves binding, returns HTML page
- oauthStates Map with 10-min TTL; state validated before exchange
- msg.senderId (openId) aligned with OAuth openId for consistent routing
- CODE_TTL_MS extended from 5→15 min (fallback code method preserved)
- Kong: dingtalk-oauth-public service declared before agent-service
so callback path matches without JWT plugin
- Voice sessions: use stored session.systemPrompt + voice rules;
allowedTools includes Bash so Claude can call internal APIs
- Flutter _DingTalkBindSheet: OAuth-first UX with code-based fallback
phases: idle→loadingOAuth→waitingOAuth→success + polling every 2s
- docker-compose: IT0_BASE_URL env var for agent-service (redirect URI)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2d0bdbd27f
commit
3d626aebb5
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void> _fetchCode() async {
|
||||
setState(() { _phase = _BindPhase.loading; _errorMsg = null; });
|
||||
// ── OAuth flow ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _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<String, dynamic>)['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<void> _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<String, dynamic>;
|
||||
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<void> _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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<string, BindingEntry>(); // code → entry
|
||||
private readonly oauthStates = new Map<string, { instanceId: string; expiresAt: number }>(); // state → entry
|
||||
private readonly dedup = new Map<string, number>(); // msgId → ts
|
||||
private readonly rateWindows = new Map<string, number[]>(); // userId → timestamps
|
||||
private readonly queueTails = new Map<string, Promise<void>>(); // 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<string>('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<string> {
|
||||
|
|
@ -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<T>(hostname: string, path: string, headers: Record<string, string> = {}): Promise<T> {
|
||||
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<T>(
|
||||
hostname: string,
|
||||
|
|
|
|||
|
|
@ -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 `<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${title}</title>
|
||||
<style>body{font-family:-apple-system,sans-serif;background:#0F1117;color:#E2E8F0;
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
min-height:100vh;margin:0;padding:24px;text-align:center;}
|
||||
h1{font-size:28px;color:${color};margin-bottom:12px;}
|
||||
p{font-size:15px;line-height:1.7;color:#94A3B8;max-width:320px;white-space:pre-line;}
|
||||
</style></head><body>
|
||||
<div style="font-size:64px;margin-bottom:16px">${icon}</div>
|
||||
<h1>${title}</h1>
|
||||
<p>${message}</p>
|
||||
</body></html>`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue