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:
hailin 2026-03-08 09:09:00 -07:00
parent 2d0bdbd27f
commit 3d626aebb5
5 changed files with 398 additions and 199 deletions

View File

@ -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

View File

@ -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(),
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,98 +1029,65 @@ 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: [
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 _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现在可以直接在钉钉中与它对话了',
Text('${widget.instance.name}」已与钉钉绑定\n现在可以直接在钉钉中与它对话了',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6),
),
style: const TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6)),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
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)),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
child: const Text('完成'),
),
),
],
);
}
)),
]);
Widget _buildExpired() {
return Column(
children: [
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: 8),
const Text('请重新获取验证码', style: TextStyle(fontSize: 13, color: AppColors.textMuted)),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
SizedBox(width: double.infinity,
child: FilledButton(
onPressed: _fetchCode,
onPressed: () => setState(() { _phase = _BindPhase.idle; }),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const Text('重新获取'),
),
),
],
);
}
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))),
child: const Text('重新开始'),
)),
]);
Widget _buildError() {
return Column(
children: [
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),
Text(_errorMsg ?? '操作失败',
style: const TextStyle(fontSize: 13, color: AppColors.error), textAlign: TextAlign.center),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
SizedBox(width: double.infinity,
child: OutlinedButton(
onPressed: _fetchCode,
onPressed: () => setState(() { _phase = _BindPhase.idle; }),
child: const Text('重试'),
),
),
],
);
)),
]);
}
}
enum _BindPhase { loading, waitingCode, success, expired, error }
// ---------------------------------------------------------------------------
// Dismiss confirm dialog

View File

@ -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:

View File

@ -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,

View File

@ -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>`;
}
}