cclawd 1.0.6 → 1.0.7

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.
Files changed (113) hide show
  1. package/dist/{active-listener-Dkhmfuwx.js → active-listener-DYmI7imH.js} +2 -2
  2. package/dist/{api-key-rotation-BOfI3cG3.js → api-key-rotation-DLU4jvSu.js} +1 -1
  3. package/dist/{audio-preflight-CtkZ5SAs.js → audio-preflight-C9TMbRb4.js} +15 -15
  4. package/dist/{audio-transcription-runner-CbPqoiHX.js → audio-transcription-runner-Q5zG_hYd.js} +10 -10
  5. package/dist/{audit-membership-runtime-hXUuer4x.js → audit-membership-runtime-DhxSwFnF.js} +6 -6
  6. package/dist/build-info.json +3 -3
  7. package/dist/bundled/boot-md/handler.js +35 -35
  8. package/dist/bundled/bootstrap-extra-files/handler.js +5 -5
  9. package/dist/bundled/command-logger/handler.js +2 -2
  10. package/dist/bundled/session-memory/handler.js +35 -35
  11. package/dist/{channel-activity-DWAER4wd.js → channel-activity-Bx08UTAg.js} +2 -2
  12. package/dist/{commands-registry-BUyiA7nE.js → commands-registry-llLVCTH9.js} +2 -2
  13. package/dist/compact.runtime-BEn3giMt.js +39 -0
  14. package/dist/{deliver-aHOaRbkt.js → deliver-DJf2ZBpe.js} +19 -19
  15. package/dist/deliver-runtime-DkQ3XzGv.js +19 -0
  16. package/dist/deps-send-discord.runtime-BLpqSj6s.js +19 -0
  17. package/dist/deps-send-imessage.runtime-BFzyYqvR.js +18 -0
  18. package/dist/deps-send-signal.runtime-DT0TYCy1.js +17 -0
  19. package/dist/deps-send-slack.runtime-BhaGFfMX.js +17 -0
  20. package/dist/deps-send-telegram.runtime-B6Cic9NX.js +20 -0
  21. package/dist/deps-send-whatsapp.runtime-WtEhIq2S.js +43 -0
  22. package/dist/{diagnostic-BdcXX9iJ.js → diagnostic-BCCMF3O_.js} +2 -2
  23. package/dist/{env-lw2hsIUY.js → env-aYXLHjfZ.js} +1 -1
  24. package/dist/{fetch-D9NUULbj.js → fetch-DCTUdr1U.js} +5 -5
  25. package/dist/{fetch-C0iyt-Iz.js → fetch-bvgIiupu.js} +3 -3
  26. package/dist/{fetch-guard-B5ZMnGaN.js → fetch-guard-CqpEmMQ2.js} +2 -2
  27. package/dist/{frontmatter-C_obXuTp.js → frontmatter-DjZuS525.js} +3 -3
  28. package/dist/{github-copilot-token-8N63GdbE.js → github-copilot-token-CQmATy5E.js} +7 -7
  29. package/dist/{image-4x07m4Jl.js → image-Q8E1-lZn.js} +4 -4
  30. package/dist/image-runtime-B1LFYfQ2.js +12 -0
  31. package/dist/{ir-CsgNUpOU.js → ir-CzM3SxId.js} +6 -6
  32. package/dist/llm-slug-generator.js +35 -35
  33. package/dist/{logger-CbUVl62f.js → logger-ChbX1G7s.js} +7 -7
  34. package/dist/{login-p_O59TVQ.js → login-B0mtU11X.js} +4 -4
  35. package/dist/{login-qr-BCJpDsAy.js → login-qr-DY_i60f5.js} +10 -10
  36. package/dist/{manager-CwYv8O3T.js → manager-FAQPC0uO.js} +12 -12
  37. package/dist/manager-runtime-Da7ME9vS.js +15 -0
  38. package/dist/{model-selection-Cv2Puf5z.js → model-selection-wf3OY5DX.js} +122 -122
  39. package/dist/{outbound-Chpiwybe.js → outbound-Bw0dOVS7.js} +6 -6
  40. package/dist/{outbound-attachment-BnAVJDLe.js → outbound-attachment-1R6r9Pg_.js} +2 -2
  41. package/dist/{paths-CehYKFsO.js → paths-C0HLtPu0.js} +7 -7
  42. package/dist/{paths-DkxwiA8g.js → paths-hfkBoC7i.js} +5 -5
  43. package/dist/{pi-embedded-CJVNBk0y.js → pi-embedded-BAHaY-Oh.js} +150 -150
  44. package/dist/{pi-model-discovery-7IzK0Uc3.js → pi-model-discovery-ItS07aJB.js} +7 -7
  45. package/dist/pi-model-discovery-runtime-DjM7Z1fx.js +12 -0
  46. package/dist/{pi-tools.before-tool-call.runtime-BP2UvGJb.js → pi-tools.before-tool-call.runtime-D_mthvtC.js} +10 -10
  47. package/dist/plugin-sdk/signal.js +2 -2
  48. package/dist/{proxy-fetch-BOh1PLOW.js → proxy-fetch-c1ZUFFcO.js} +1 -1
  49. package/dist/{pw-ai-DwH5GpEO.js → pw-ai-Ok6KGelf.js} +9 -9
  50. package/dist/{qmd-manager-DEscZz5_.js → qmd-manager-DhfEz4Ar.js} +6 -6
  51. package/dist/{query-expansion-BErUY8P2.js → query-expansion-GqNV2iIE.js} +4 -4
  52. package/dist/runtime-whatsapp-login.runtime-D4BRhQkK.js +13 -0
  53. package/dist/runtime-whatsapp-outbound.runtime-DJPpS6g-.js +17 -0
  54. package/dist/{send-DUibfNQD.js → send-6R8b9zsj.js} +5 -5
  55. package/dist/{send-ORtn50qg.js → send-CEg4P96c.js} +5 -5
  56. package/dist/{send-C-Q_WPMf.js → send-CS0ocZHl.js} +3 -3
  57. package/dist/{send-DtBvCnPQ.js → send-DPflcjM5.js} +6 -6
  58. package/dist/{send-BDnOgWIp.js → send-DwAoiT2p.js} +25 -25
  59. package/dist/{session-B7imi6T5.js → session-BoIID5UR.js} +7 -7
  60. package/dist/{skill-commands-B9brPuiL.js → skill-commands-DhdiziMs.js} +9 -9
  61. package/dist/slash-commands.runtime-Cu1lTjV9.js +12 -0
  62. package/dist/slash-dispatch.runtime-DRVJEF4l.js +39 -0
  63. package/dist/slash-skill-commands.runtime-C373PJjv.js +13 -0
  64. package/dist/subagent-registry-runtime-D7hWBo1G.js +39 -0
  65. package/dist/{subsystem-DfXy5gUB.js → subsystem-C8z6w6xC.js} +14 -14
  66. package/dist/{tables-CjQqTOdD.js → tables-DQusRhkD.js} +1 -1
  67. package/dist/{target-errors-BZE1mc-W.js → target-errors-CfavnC9U.js} +1 -1
  68. package/dist/{tokens-6ul2IrzG.js → tokens-BWDIKewp.js} +1 -1
  69. package/dist/{web-Cd8yK1Zq.js → web-CrcrTQ2c.js} +39 -39
  70. package/dist/{whatsapp-actions-CYEzUMBI.js → whatsapp-actions-B0u0ZAme.js} +15 -15
  71. package/dist/{workspace-DGIcKCCW.js → workspace-CWDYHR27.js} +20 -20
  72. package/extensions/mfa-auth/index.ts +1028 -675
  73. package/extensions/mfa-auth/node_modules/.package-lock.json +21 -0
  74. package/extensions/mfa-auth/node_modules/ws/LICENSE +20 -0
  75. package/extensions/mfa-auth/node_modules/ws/README.md +548 -0
  76. package/extensions/mfa-auth/node_modules/ws/browser.js +8 -0
  77. package/extensions/mfa-auth/node_modules/ws/index.js +13 -0
  78. package/extensions/mfa-auth/node_modules/ws/lib/buffer-util.js +131 -0
  79. package/extensions/mfa-auth/node_modules/ws/lib/constants.js +19 -0
  80. package/extensions/mfa-auth/node_modules/ws/lib/event-target.js +292 -0
  81. package/extensions/mfa-auth/node_modules/ws/lib/extension.js +203 -0
  82. package/extensions/mfa-auth/node_modules/ws/lib/limiter.js +55 -0
  83. package/extensions/mfa-auth/node_modules/ws/lib/permessage-deflate.js +528 -0
  84. package/extensions/mfa-auth/node_modules/ws/lib/receiver.js +706 -0
  85. package/extensions/mfa-auth/node_modules/ws/lib/sender.js +602 -0
  86. package/extensions/mfa-auth/node_modules/ws/lib/stream.js +161 -0
  87. package/extensions/mfa-auth/node_modules/ws/lib/subprotocol.js +62 -0
  88. package/extensions/mfa-auth/node_modules/ws/lib/validation.js +152 -0
  89. package/extensions/mfa-auth/node_modules/ws/lib/websocket-server.js +554 -0
  90. package/extensions/mfa-auth/node_modules/ws/lib/websocket.js +1393 -0
  91. package/extensions/mfa-auth/node_modules/ws/package.json +69 -0
  92. package/extensions/mfa-auth/node_modules/ws/wrapper.mjs +8 -0
  93. package/extensions/mfa-auth/openclaw.plugin.json +1 -1
  94. package/extensions/mfa-auth/package-lock.json +23 -1
  95. package/extensions/mfa-auth/src/notification-service.ts +8 -2
  96. package/package.json +1 -1
  97. package/dist/compact.runtime-DpcZpcTl.js +0 -39
  98. package/dist/deliver-runtime-D4bCsr6d.js +0 -19
  99. package/dist/deps-send-discord.runtime-BziKU-pE.js +0 -19
  100. package/dist/deps-send-imessage.runtime-CFRnDTqp.js +0 -18
  101. package/dist/deps-send-signal.runtime-BuOtABJm.js +0 -17
  102. package/dist/deps-send-slack.runtime-BOLqvMxW.js +0 -17
  103. package/dist/deps-send-telegram.runtime-DeEoFLv5.js +0 -20
  104. package/dist/deps-send-whatsapp.runtime-CG1uXYLY.js +0 -43
  105. package/dist/image-runtime-smkMrIol.js +0 -12
  106. package/dist/manager-runtime-D_jEoBr9.js +0 -15
  107. package/dist/pi-model-discovery-runtime-DABef3qy.js +0 -12
  108. package/dist/runtime-whatsapp-login.runtime-BI3U306v.js +0 -13
  109. package/dist/runtime-whatsapp-outbound.runtime-Bsc2uD09.js +0 -17
  110. package/dist/slash-commands.runtime-Cf6ygfBp.js +0 -12
  111. package/dist/slash-dispatch.runtime-CsmvhO5K.js +0 -39
  112. package/dist/slash-skill-commands.runtime-CX7stIEP.js +0 -13
  113. package/dist/subagent-registry-runtime-B_S1nf7y.js +0 -39
@@ -1,712 +1,1065 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { authManager } from "./src/auth-manager.js";
3
- import { config } from "./src/config.js";
4
- import { dabbyClient } from "./src/dabby-client.js";
5
- import { NotificationService } from "./src/notification-service.js";
6
- import { qrCodeAuthProvider } from "./src/providers/qr-code.js";
7
- import { setNotifyCallback } from "./src/server.js";
8
- import type { AuthSession } from "./src/types.js";
9
-
10
- const notificationService = NotificationService.getInstance();
11
- const pendingAuthUsers = new Set<string>();
12
-
13
- async function sendAuthMessage(
14
- channel: string | undefined,
15
- accountId: string | undefined,
16
- to: string,
17
- message: string,
18
- userId: string,
19
- overrideSessionKey?: string,
20
- ): Promise<void> {
21
- const session: AuthSession = {
22
- userId,
23
- sessionId: "notification",
24
- authMethod: "qr-code",
25
- timestamp: Date.now(),
26
- originalContext: {
27
- sessionKey: overrideSessionKey || `${channel || "web"}:${accountId || ""}:${userId}`,
28
- senderId: userId,
29
- commandBody: "",
30
- channel: channel || "web",
31
- accountId: accountId || "",
32
- to,
33
- toolName: "notification",
34
- toolParams: {},
35
- timestamp: Date.now(),
36
- triggerType: "sensitive_operation",
37
- },
38
- };
39
-
40
- await notificationService.sendAuthNotification(session, message);
41
- }
42
-
43
- export default function register(api: OpenClawPluginApi) {
44
- console.log("[mfa-auth] Plugin registration started");
45
- authManager.registerProvider(qrCodeAuthProvider);
46
-
47
- notificationService.setConfig(api.config);
48
-
49
- setNotifyCallback(async (session: AuthSession) => {
50
- api.logger.info(`[mfa-auth] User ${session.userId} verified`);
51
-
52
- if (!config.enableAuthNotification) {
53
- api.logger.info(`[mfa-auth] Auth notification disabled, skipping message send.`);
54
- return;
55
- }
56
-
57
- try {
58
- const commandBody = session.originalContext.commandBody;
59
- const triggerType = session.originalContext.triggerType || "sensitive_operation";
60
-
61
- const isFirstMessageAuth = triggerType === "first_message";
62
- const isReauth = commandBody.trim() === "/reauth";
63
-
64
- let messageText = "";
65
- if (isFirstMessageAuth) {
66
- messageText = isReauth
67
- ? `🎉 重新认证成功!请重新发送消息以继续对话。`
68
- : `🎉 首次认证成功!请重新发送消息以继续对话。`;
69
- } else {
70
- messageText = `✅ 二次认证成功!\n\n请回到聊天窗口,重新发送之前的命令(或回复'确认')即可执行。`;
71
- }
72
-
73
- const channel = session.originalContext.channel;
74
- const sessionKey =
75
- session.originalContext.sessionKey ||
76
- `${channel}:${session.originalContext.accountId || ""}:${session.userId}`;
77
-
78
- api.logger.info(`[mfa-auth] Sending notification to session: ${sessionKey}`);
79
-
80
- await sendAuthMessage(
81
- channel,
82
- session.originalContext.accountId,
83
- session.originalContext.to || session.userId,
84
- messageText,
85
- session.userId,
86
- sessionKey,
87
- );
88
- api.logger.info(`[mfa-auth] Notification sent to user ${session.userId}`);
89
- } catch (error) {
90
- api.logger.error(`[mfa-auth] Failed in notify callback: ${String(error)}`);
91
- }
92
- });
93
-
94
- api.on("message_sending", async (event, ctx) => {
95
- const userId = event.to || ctx.conversationId || "unknown";
96
-
97
- if (pendingAuthUsers.has(userId)) {
98
- const session = authManager.getLatestSessionByUserId(userId);
99
- const metadata = session?.metadata as Record<string, unknown> | undefined;
100
-
101
- if (metadata?.qrCodeUrl) {
102
- pendingAuthUsers.delete(userId);
103
-
104
- let messageText = "";
105
- if (metadata.triggerType === "first_message") {
106
- const isReauth = metadata.isReauth === true;
107
- messageText = isReauth
108
- ? `🔐 重新认证\n\n📱 请点击以下链接完成扫码认证:\n${metadata.qrCodeUrl}\n\n验证有效期: ${Math.floor(config.timeout / 60000)} 分钟`
109
- : `🔐 首次对话需要进行认证\n\n为了您的账户安全,首次对话前需要完成身份验证。\n\n📱 请点击以下链接完成扫码认证:\n${metadata.qrCodeUrl}\n\n验证有效期: ${Math.floor(config.timeout / 60000)} 分钟`;
110
- } else if (metadata.triggerType === "sensitive_operation") {
111
- messageText = `🔐 该操作需要二次认证\n\n检测到敏感操作: ${metadata.commandPreview}\n\n📱 请点击以下链接完成扫码认证:\n${metadata.qrCodeUrl}\n\n验证有效期: ${Math.floor(config.timeout / 60000)} 分钟\n\n验证成功后,请回复"确认"或者重新发送之前的命令以继续执行。`;
112
- }
113
-
114
- return { content: messageText };
115
- }
116
- }
117
- });
118
-
119
- api.on("before_tool_call", async (event, ctx) => {
120
- await authManager.ensureInitialized();
121
- if (!config.requireAuthOnSensitiveOperation) {
122
- return undefined;
123
- }
124
-
125
- const { toolName, params } = event;
126
-
127
- api.logger.info(`[mfa-auth] Tool call detected: ${toolName}`);
128
-
129
- const sensitiveTools = ["bash", "exec", "runCommand", "command", "process"];
130
- if (!sensitiveTools.includes(toolName)) {
131
- api.logger.info(`[mfa-auth] Tool ${toolName} is not in sensitive list, allowing`);
132
- return undefined;
133
- }
134
-
135
- const command =
136
- typeof params?.command === "string"
137
- ? params.command
138
- : typeof params?.cmd === "string"
139
- ? params.cmd
140
- : typeof params?.input === "string"
141
- ? params.input
142
- : typeof params?.args === "string"
143
- ? params.args
144
- : "";
145
-
146
- api.logger.info(`[mfa-auth] Extracted command from ${toolName}: ${command}`);
147
-
148
- if (!command) {
149
- api.logger.info(`[mfa-auth] No command found in params, allowing`);
150
- return undefined;
151
- }
152
-
153
- const { isSensitive, preview } = checkSensitiveOperation(command);
154
- if (!isSensitive) {
155
- api.logger.info(`[mfa-auth] Command is not sensitive, allowing`);
156
- return undefined;
157
- }
158
-
159
- const userId = ctx.sessionKey || "unknown";
160
-
161
- if (authManager.isUserVerifiedForSensitiveOps(userId)) {
162
- api.logger.info(`[mfa-auth] User ${userId} is verified for sensitive ops, allowing`);
163
-
164
- const notificationInfo = authManager.checkAndConsumeNotification(userId);
165
- if (notificationInfo) {
166
- const sessionKey = ctx.sessionKey || "";
167
- const sessionKeyParts = sessionKey.split(":").filter(Boolean);
168
- const parsedChannel = sessionKeyParts[2] || undefined;
169
- let parsedAccountId = sessionKeyParts[3] || undefined;
170
- const parsedTo = sessionKeyParts[sessionKeyParts.length - 1] || undefined;
171
-
172
- if (parsedAccountId === "direct" || parsedAccountId === "group") {
173
- parsedAccountId = undefined;
174
- }
175
-
176
- const targetSessionKey =
177
- parsedChannel === "webchat" || parsedChannel === "web" ? userId : sessionKey;
178
-
179
- sendAuthMessage(
180
- parsedChannel,
181
- parsedAccountId,
182
- parsedTo || userId,
183
- " 二次认证成功,请重新发送之前的命令(或回复'确认')即可执行。",
184
- userId,
185
- targetSessionKey,
186
- ).catch((err) =>
187
- api.logger.error(`[mfa-auth] Failed to send success notification: ${err}`),
188
- );
189
- }
190
-
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { authManager } from "./src/auth-manager.js";
3
+ import { config } from "./src/config.js";
4
+ import { dabbyClient } from "./src/dabby-client.js";
5
+ import { NotificationService } from "./src/notification-service.js";
6
+ import { qrCodeAuthProvider } from "./src/providers/qr-code.js";
7
+ import { setNotifyCallback } from "./src/server.js";
8
+ import type { AuthSession } from "./src/types.js";
9
+
10
+ const notificationService = NotificationService.getInstance();
11
+ const pendingAuthUsers = new Set<string>();
12
+
13
+ function isWebchatChannel(channel: string | undefined): boolean {
14
+ if (!channel) {
15
+ return false;
16
+ }
17
+ const normalized = channel.trim().toLowerCase();
18
+ return normalized === "webchat" || normalized === "web";
19
+ }
20
+
21
+ function isPeerKindToken(value: string | undefined): boolean {
22
+ if (!value) {
23
+ return false;
24
+ }
25
+ const normalized = value.trim().toLowerCase();
26
+ return normalized === "direct" || normalized === "group" || normalized === "dm";
27
+ }
28
+
29
+ function parseSessionContextFromKey(sessionKey: string | undefined): {
30
+ channel?: string;
31
+ accountId?: string;
32
+ to?: string;
33
+ } {
34
+ const parts = String(sessionKey || "")
35
+ .split(":")
36
+ .map((part) => part.trim())
37
+ .filter(Boolean);
38
+ if (parts.length === 0) {
39
+ return {};
40
+ }
41
+
42
+ const channel = parts[2];
43
+ let accountId = parts[3];
44
+ const to = parts[parts.length - 1];
45
+
46
+ // In common session keys, index 3 is peer kind (direct/group/dm), not account id.
47
+ if (isPeerKindToken(accountId)) {
48
+ accountId = undefined;
49
+ }
50
+
51
+ return {
52
+ channel: channel || undefined,
53
+ accountId: accountId || undefined,
54
+ to: to || undefined,
55
+ };
56
+ }
57
+
58
+ function addPendingAuthCandidates(...ids: Array<string | undefined>): void {
59
+ for (const id of expandAuthIdCandidates(ids)) {
60
+ pendingAuthUsers.add(id);
61
+ }
62
+ }
63
+
64
+ function findPendingAuthCandidate(eventTo: string, conversationId?: string): string | undefined {
65
+ for (const candidate of expandAuthIdCandidates([eventTo, conversationId])) {
66
+ if (pendingAuthUsers.has(candidate)) {
67
+ return candidate;
68
+ }
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ function normalizeAuthId(value: string | undefined): string {
74
+ const raw = String(value ?? "").trim();
75
+ if (!raw) {
76
+ return "";
77
+ }
78
+ const lower = raw.toLowerCase();
79
+ if (lower.startsWith("agent:main:")) {
80
+ return raw.slice("agent:main:".length);
81
+ }
82
+ if (lower.startsWith("webchat:")) {
83
+ return raw.slice("webchat:".length);
84
+ }
85
+ if (lower.startsWith("web:")) {
86
+ return raw.slice("web:".length);
87
+ }
88
+ return raw;
89
+ }
90
+
91
+ function expandAuthIdCandidates(values: Array<string | undefined>): string[] {
92
+ const result = new Set<string>();
93
+ for (const value of values) {
94
+ const raw = String(value ?? "").trim();
95
+ if (!raw) {
96
+ continue;
97
+ }
98
+ result.add(raw);
99
+ const normalized = normalizeAuthId(raw);
100
+ if (normalized) {
101
+ result.add(normalized);
102
+ result.add(`agent:main:${normalized}`);
103
+ result.add(`webchat:${normalized}`);
104
+ result.add(`web:${normalized}`);
105
+ }
106
+ }
107
+ return Array.from(result);
108
+ }
109
+
110
+ function authIdEquals(a: string | undefined, b: string | undefined): boolean {
111
+ const aa = String(a ?? "").trim();
112
+ const bb = String(b ?? "").trim();
113
+ if (!aa || !bb) {
114
+ return false;
115
+ }
116
+ return aa === bb || normalizeAuthId(aa) === normalizeAuthId(bb);
117
+ }
118
+
119
+ function resolvePendingAuthSession(candidateIds: string[]): {
120
+ matchedId: string;
121
+ session: AuthSession;
122
+ } | null {
123
+ const uniqueCandidates = expandAuthIdCandidates(candidateIds);
124
+
125
+ for (const id of uniqueCandidates) {
126
+ const hasPendingCandidate = Array.from(pendingAuthUsers).some((pending) => authIdEquals(pending, id));
127
+ if (!hasPendingCandidate) {
128
+ continue;
129
+ }
130
+ const session =
131
+ authManager.getLatestSessionByUserId(id) ||
132
+ authManager.getLatestSessionByUserId(normalizeAuthId(id));
133
+ if (session?.metadata && typeof (session.metadata as Record<string, unknown>).qrCodeUrl === "string") {
134
+ return { matchedId: id, session };
135
+ }
136
+ }
137
+
138
+ let latest: { matchedId: string; session: AuthSession } | null = null;
139
+ for (const sessionId of authManager.getSessionIds()) {
140
+ const session = authManager.getSession(sessionId);
141
+ if (!session?.metadata) {
142
+ continue;
143
+ }
144
+ const metadata = session.metadata as Record<string, unknown>;
145
+ if (typeof metadata.qrCodeUrl !== "string") {
146
+ continue;
147
+ }
148
+ const matches = uniqueCandidates.some((candidate) => {
149
+ return (
150
+ authIdEquals(candidate, session.userId) ||
151
+ authIdEquals(candidate, String(session.originalContext.sessionKey ?? "")) ||
152
+ authIdEquals(candidate, String(session.originalContext.to ?? ""))
153
+ );
154
+ });
155
+ if (!matches) {
156
+ continue;
157
+ }
158
+ if (!latest || session.timestamp > latest.session.timestamp) {
159
+ latest = {
160
+ matchedId:
161
+ uniqueCandidates.find((candidate) => candidate === session.userId) || session.userId,
162
+ session,
163
+ };
164
+ }
165
+ }
166
+
167
+ return latest;
168
+ }
169
+
170
+ function resolveGlobalPendingFirstMessageSession(): AuthSession | null {
171
+ let latestPending: AuthSession | null = null;
172
+
173
+ for (const sessionId of authManager.getSessionIds()) {
174
+ const session = authManager.getSession(sessionId);
175
+ if (!session?.metadata) {
176
+ continue;
177
+ }
178
+
179
+ const metadata = session.metadata as Record<string, unknown>;
180
+ if (metadata.triggerType !== "first_message") {
181
+ continue;
182
+ }
183
+ if (typeof metadata.qrCodeUrl !== "string" || !metadata.qrCodeUrl.trim()) {
184
+ continue;
185
+ }
186
+ if (authManager.isUserVerifiedForFirstMessage(session.userId)) {
187
+ continue;
188
+ }
189
+
190
+ if (!latestPending || session.timestamp > latestPending.timestamp) {
191
+ latestPending = session;
192
+ }
193
+ }
194
+
195
+ return latestPending;
196
+ }
197
+
198
+ function buildFirstMessageChallengeText(metadata: Record<string, unknown>): string {
199
+ const qrCodeUrl = typeof metadata.qrCodeUrl === "string" ? metadata.qrCodeUrl : "";
200
+ const isReauth = metadata.isReauth === true;
201
+ if (isReauth) {
202
+ return `\u9700\u8981\u91cd\u65b0\u8ba4\u8bc1\n\n\u8bf7\u70b9\u51fb\u4e0b\u65b9\u94fe\u63a5\u5b8c\u6210\u626b\u7801\u8ba4\u8bc1\uff1a\n${qrCodeUrl}\n\n\u8ba4\u8bc1\u6709\u6548\u671f\uff1a${Math.floor(config.timeout / 60000)} \u5206\u949f\u3002`;
203
+ }
204
+ return `\u9996\u6b21\u5bf9\u8bdd\u9700\u8981\u8ba4\u8bc1\n\n\u8bf7\u70b9\u51fb\u4e0b\u65b9\u94fe\u63a5\u5b8c\u6210\u626b\u7801\u8ba4\u8bc1\uff1a\n${qrCodeUrl}\n\n\u8ba4\u8bc1\u6709\u6548\u671f\uff1a${Math.floor(config.timeout / 60000)} \u5206\u949f\u3002`;
205
+ }
206
+
207
+ function uniqueNonEmpty(values: Array<string | undefined>): string[] {
208
+ return Array.from(
209
+ new Set(
210
+ values
211
+ .map((value) => String(value ?? "").trim())
212
+ .filter(Boolean),
213
+ ),
214
+ );
215
+ }
216
+
217
+ function isAnyFirstMessageAliasVerified(userIds: string[]): string | undefined {
218
+ for (const userId of userIds) {
219
+ if (authManager.isUserVerifiedForFirstMessage(userId)) {
220
+ return userId;
221
+ }
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ function consumeNotificationByAliases(userIds: string[]): {
227
+ triggerType: "first_message" | "sensitive_operation";
228
+ isReauth: boolean;
229
+ } | null {
230
+ for (const userId of userIds) {
231
+ const notification = authManager.checkAndConsumeNotification(userId);
232
+ if (notification) {
233
+ return notification;
234
+ }
235
+ }
236
+ return null;
237
+ }
238
+
239
+ function resolveInboundAuthUserId(params: {
240
+ channelId?: string;
241
+ from?: string;
242
+ senderId?: string;
243
+ conversationId?: string;
244
+ metadataTo?: string;
245
+ metadataOriginatingTo?: string;
246
+ }): string {
247
+ const candidates = [
248
+ params.from,
249
+ params.senderId,
250
+ params.conversationId,
251
+ params.metadataOriginatingTo,
252
+ params.metadataTo,
253
+ ];
254
+ const firstNonEmpty = candidates
255
+ .map((value) => String(value ?? "").trim())
256
+ .find((value) => value.length > 0);
257
+
258
+ if (isWebchatChannel(params.channelId)) {
259
+ return firstNonEmpty || "unknown";
260
+ }
261
+ return firstNonEmpty || "unknown";
262
+ }
263
+
264
+ async function sendAuthMessage(
265
+ channel: string | undefined,
266
+ accountId: string | undefined,
267
+ to: string,
268
+ message: string,
269
+ userId: string,
270
+ overrideSessionKey?: string,
271
+ ): Promise<void> {
272
+ const session: AuthSession = {
273
+ userId,
274
+ sessionId: "notification",
275
+ authMethod: "qr-code",
276
+ timestamp: Date.now(),
277
+ originalContext: {
278
+ sessionKey: overrideSessionKey || `${channel || "web"}:${accountId || ""}:${userId}`,
279
+ senderId: userId,
280
+ commandBody: "",
281
+ channel: channel || "web",
282
+ accountId: accountId || "",
283
+ to,
284
+ toolName: "notification",
285
+ toolParams: {},
286
+ timestamp: Date.now(),
287
+ triggerType: "sensitive_operation",
288
+ },
289
+ };
290
+
291
+ await notificationService.sendAuthNotification(session, message);
292
+ }
293
+
294
+ export default function register(api: OpenClawPluginApi) {
295
+ console.log("[mfa-auth] Plugin registration started");
296
+ authManager.registerProvider(qrCodeAuthProvider);
297
+
298
+ notificationService.setConfig(api.config);
299
+
300
+ setNotifyCallback(async (session: AuthSession) => {
301
+ api.logger.info(`[mfa-auth] User ${session.userId} verified`);
302
+
303
+ if (!config.enableAuthNotification) {
304
+ api.logger.info(`[mfa-auth] Auth notification disabled, skipping message send.`);
305
+ return;
306
+ }
307
+
308
+ try {
309
+ const commandBody = session.originalContext.commandBody;
310
+ const triggerType = session.originalContext.triggerType || "sensitive_operation";
311
+
312
+ const isFirstMessageAuth = triggerType === "first_message";
313
+ const isReauth = commandBody.trim() === "/reauth";
314
+
315
+ let messageText = "";
316
+ if (isFirstMessageAuth) {
317
+ messageText = isReauth
318
+ ? "\u2705 \u91cd\u65b0\u8ba4\u8bc1\u6210\u529f\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u6d88\u606f\u7ee7\u7eed\u5bf9\u8bdd\u3002"
319
+ : "\u2705 \u9996\u6b21\u8ba4\u8bc1\u6210\u529f\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u6d88\u606f\u7ee7\u7eed\u5bf9\u8bdd\u3002";
320
+ } else {
321
+ messageText = "\u2705 \u4e8c\u6b21\u8ba4\u8bc1\u6210\u529f\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u4e4b\u524d\u7684\u547d\u4ee4\u6216\u56de\u590d\u201c\u786e\u8ba4\u201d\u3002";
322
+ }
323
+
324
+ const channel = session.originalContext.channel;
325
+ const sessionKey =
326
+ session.originalContext.sessionKey ||
327
+ `${channel}:${session.originalContext.accountId || ""}:${session.userId}`;
328
+
329
+ api.logger.info(`[mfa-auth] Sending notification to session: ${sessionKey}`);
330
+
331
+ await sendAuthMessage(
332
+ channel,
333
+ session.originalContext.accountId,
334
+ session.originalContext.to || session.userId,
335
+ messageText,
336
+ session.userId,
337
+ sessionKey,
338
+ );
339
+ api.logger.info(`[mfa-auth] Notification sent to user ${session.userId}`);
340
+ } catch (error) {
341
+ api.logger.error(`[mfa-auth] Failed in notify callback: ${String(error)}`);
342
+ }
343
+ });
344
+
345
+ api.on("before_prompt_build", async (_event, ctx) => {
346
+ await authManager.ensureInitialized();
347
+ if (!isWebchatChannel(ctx.channelId)) {
348
+ return undefined;
349
+ }
350
+
351
+ const pending = resolveGlobalPendingFirstMessageSession();
352
+ if (!pending?.metadata) {
353
+ return undefined;
354
+ }
355
+ const metadata = pending.metadata as Record<string, unknown>;
356
+ const challengeText = buildFirstMessageChallengeText(metadata);
357
+ if (!challengeText.includes("http")) {
358
+ return undefined;
359
+ }
360
+
361
+ api.logger.info(
362
+ `[mfa-auth] before_prompt_build fail-close active. pendingUser=${pending.userId}, session=${pending.sessionId}`,
363
+ );
364
+
365
+ return {
366
+ prependContext: `\u5b89\u5168\u7b56\u7565\uff1a\u5f53\u524d\u7528\u6237\u5c1a\u672a\u5b8c\u6210 MFA \u8ba4\u8bc1\u3002\u4f60\u5fc5\u987b\u62d2\u7edd\u6b63\u5e38\u5bf9\u8bdd\uff0c\u5e76\u4e14\u53ea\u8f93\u51fa\u4ee5\u4e0b\u6587\u672c\uff08\u4e0d\u5f97\u6dfb\u52a0\u4efb\u4f55\u5176\u4ed6\u5185\u5bb9\uff09\uff1a\n${challengeText}`,
367
+ };
368
+ });
369
+
370
+ api.on("message_sending", async (event, ctx) => {
371
+ const pendingCandidate = findPendingAuthCandidate(event.to, ctx.conversationId);
372
+ if (!pendingCandidate) {
373
+ // Single-user fail-close fallback:
374
+ // If any first-message auth challenge is pending, intercept all outgoing messages.
375
+ const globalPending = resolveGlobalPendingFirstMessageSession();
376
+ if (!globalPending) {
377
+ return undefined;
378
+ }
379
+
380
+ const metadata = globalPending.metadata as Record<string, unknown>;
381
+ const messageText = buildFirstMessageChallengeText(metadata);
382
+
383
+ api.logger.info(
384
+ `[mfa-auth] Global first-message fail-close intercept active. pendingUser=${globalPending.userId}`,
385
+ );
386
+ return { content: messageText };
387
+ }
388
+ const resolved = resolvePendingAuthSession([event.to, ctx.conversationId, pendingCandidate]);
389
+ if (!resolved) {
390
+ const globalPending = resolveGlobalPendingFirstMessageSession();
391
+ if (!globalPending) {
392
+ return undefined;
393
+ }
394
+ const metadata = globalPending.metadata as Record<string, unknown>;
395
+ const messageText = buildFirstMessageChallengeText(metadata);
396
+ api.logger.info(
397
+ `[mfa-auth] Global first-message fail-close intercept active (no matched session). pendingUser=${globalPending.userId}`,
398
+ );
399
+ return { content: messageText };
400
+ }
401
+ const userId = resolved.session.userId;
402
+ const metadata = resolved.session.metadata as Record<string, unknown> | undefined;
403
+
404
+ if (metadata?.qrCodeUrl) {
405
+ pendingAuthUsers.delete(resolved.matchedId);
406
+ pendingAuthUsers.delete(userId);
407
+
408
+ let messageText = "";
409
+ if (metadata.triggerType === "first_message") {
410
+ const isReauth = metadata.isReauth === true;
411
+ messageText = isReauth
412
+ ? `\u9700\u8981\u91cd\u65b0\u8ba4\u8bc1\n\n\u8bf7\u70b9\u51fb\u4e0b\u65b9\u94fe\u63a5\u5b8c\u6210\u626b\u7801\u8ba4\u8bc1\uff1a\n${metadata.qrCodeUrl}\n\n\u8ba4\u8bc1\u6709\u6548\u671f\uff1a${Math.floor(config.timeout / 60000)} \u5206\u949f\u3002`
413
+ : `\u9996\u6b21\u5bf9\u8bdd\u9700\u8981\u8ba4\u8bc1\n\n\u8bf7\u70b9\u51fb\u4e0b\u65b9\u94fe\u63a5\u5b8c\u6210\u626b\u7801\u8ba4\u8bc1\uff1a\n${metadata.qrCodeUrl}\n\n\u8ba4\u8bc1\u6709\u6548\u671f\uff1a${Math.floor(config.timeout / 60000)} \u5206\u949f\u3002`;
414
+ } else if (metadata.triggerType === "sensitive_operation") {
415
+ messageText = `\u68c0\u6d4b\u5230\u654f\u611f\u64cd\u4f5c\uff0c\u9700\u4e8c\u6b21\u8ba4\u8bc1\u3002\n\n\u64cd\u4f5c\u5185\u5bb9\uff1a${metadata.commandPreview}\n\n\u8bf7\u70b9\u51fb\u4e0b\u65b9\u94fe\u63a5\u5b8c\u6210\u626b\u7801\u8ba4\u8bc1\uff1a\n${metadata.qrCodeUrl}\n\n\u8ba4\u8bc1\u6709\u6548\u671f\uff1a${Math.floor(config.timeout / 60000)} \u5206\u949f\u3002\n\n\u8ba4\u8bc1\u5b8c\u6210\u540e\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u4e0a\u4e00\u6761\u547d\u4ee4\u6216\u56de\u590d\u201c\u786e\u8ba4\u201d\u3002`;
416
+ }
417
+
418
+ return { content: messageText };
419
+ }
420
+ });
421
+
422
+ api.on("before_tool_call", async (event, ctx) => {
423
+ await authManager.ensureInitialized();
424
+ if (!config.requireAuthOnSensitiveOperation) {
425
+ return undefined;
426
+ }
427
+
428
+ const { toolName, params } = event;
429
+
430
+ api.logger.info(`[mfa-auth] Tool call detected: ${toolName}`);
431
+
432
+ const sensitiveTools = ["bash", "exec", "runCommand", "command", "process"];
433
+ if (!sensitiveTools.includes(toolName)) {
434
+ api.logger.info(`[mfa-auth] Tool ${toolName} is not in sensitive list, allowing`);
435
+ return undefined;
436
+ }
437
+
438
+ const command =
439
+ typeof params?.command === "string"
440
+ ? params.command
441
+ : typeof params?.cmd === "string"
442
+ ? params.cmd
443
+ : typeof params?.input === "string"
444
+ ? params.input
445
+ : typeof params?.args === "string"
446
+ ? params.args
447
+ : "";
448
+
449
+ api.logger.info(`[mfa-auth] Extracted command from ${toolName}: ${command}`);
450
+
451
+ if (!command) {
452
+ api.logger.info(`[mfa-auth] No command found in params, allowing`);
453
+ return undefined;
454
+ }
455
+
456
+ const { isSensitive, preview } = checkSensitiveOperation(command);
457
+ if (!isSensitive) {
458
+ api.logger.info(`[mfa-auth] Command is not sensitive, allowing`);
459
+ return undefined;
460
+ }
461
+
462
+ const sessionKey = ctx.sessionKey || "";
463
+ const parsed = parseSessionContextFromKey(sessionKey);
464
+ const userId = isWebchatChannel(parsed.channel)
465
+ ? parsed.to || sessionKey || "unknown"
466
+ : sessionKey || "unknown";
467
+
468
+ if (authManager.isUserVerifiedForSensitiveOps(userId)) {
469
+ api.logger.info(`[mfa-auth] User ${userId} is verified for sensitive ops, allowing`);
470
+
471
+ const notificationInfo = authManager.checkAndConsumeNotification(userId);
472
+ if (notificationInfo) {
473
+ const parsedChannel = parsed.channel;
474
+ const parsedAccountId = parsed.accountId;
475
+ const parsedTo = parsed.to;
476
+
477
+ const targetSessionKey =
478
+ isWebchatChannel(parsedChannel) ? sessionKey || userId : sessionKey;
479
+
480
+ sendAuthMessage(
481
+ parsedChannel,
482
+ parsedAccountId,
483
+ parsedTo || userId,
484
+ "\u2705 \u4e8c\u6b21\u8ba4\u8bc1\u6210\u529f\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u4e4b\u524d\u7684\u547d\u4ee4\u6216\u56de\u590d\u201c\u786e\u8ba4\u201d\u3002",
485
+ userId,
486
+ targetSessionKey,
487
+ ).catch((err) =>
488
+ api.logger.error(`[mfa-auth] Failed to send success notification: ${err}`),
489
+ );
490
+ }
491
+
492
+ return undefined;
493
+ }
494
+
495
+ api.logger.info(`[mfa-auth] User ${userId} is NOT verified for sensitive ops.`);
496
+
497
+ const parsedChannel = parsed.channel;
498
+ const parsedAccountId = parsed.accountId;
499
+ const parsedTo = parsed.to;
500
+
501
+ api.logger.info(
502
+ `[mfa-auth] Parsed from sessionKey: channel=${parsedChannel}, accountId=${parsedAccountId}, to=${parsedTo}`,
503
+ );
504
+
505
+ const session = await authManager.generateSession(userId, {
506
+ sessionKey,
507
+ senderId: userId,
508
+ commandBody: command,
509
+ channel: parsedChannel,
510
+ to: parsedTo,
511
+ accountId: parsedAccountId,
512
+ toolName,
513
+ toolParams: params,
514
+ timestamp: Date.now(),
515
+ triggerType: "sensitive_operation",
516
+ });
517
+
518
+ if (!session) {
519
+ api.logger.error(`[mfa-auth] Failed to generate session for user ${userId}`);
191
520
  return undefined;
192
521
  }
193
522
 
194
- api.logger.info(`[mfa-auth] User ${userId} is NOT verified for sensitive ops.`);
195
-
196
- const sessionKey = ctx.sessionKey || "";
197
- const sessionKeyParts = sessionKey.split(":").filter(Boolean);
198
-
199
- const parsedChannel = sessionKeyParts[2] || undefined;
200
- let parsedAccountId = sessionKeyParts[3] || undefined;
201
- const parsedTo = sessionKeyParts[sessionKeyParts.length - 1] || undefined;
202
-
203
- // Fix: If accountId is "direct" or "group", it's actually the peerKind, not an accountId.
204
- // This happens when the sessionKey omits the accountId (using default account).
205
- if (parsedAccountId === "direct" || parsedAccountId === "group") {
206
- parsedAccountId = undefined;
207
- }
208
-
209
523
  api.logger.info(
210
- `[mfa-auth] Parsed from sessionKey: channel=${parsedChannel}, accountId=${parsedAccountId}, to=${parsedTo}`,
524
+ `[mfa-auth] Sensitive auth session created: sessionId=${session.sessionId}, hasQrCode=${Boolean(session.qrCodeUrl)}, certToken=${session.certToken ?? "n/a"}`,
211
525
  );
212
526
 
213
- const session = await authManager.generateSession(userId, {
214
- sessionKey,
215
- senderId: userId,
216
- commandBody: command,
217
- channel: parsedChannel,
218
- to: parsedTo,
219
- accountId: parsedAccountId,
220
- toolName,
221
- toolParams: params,
222
- timestamp: Date.now(),
223
- triggerType: "sensitive_operation",
224
- });
225
-
226
- if (!session) {
227
- api.logger.error(`[mfa-auth] Failed to generate session for user ${userId}`);
228
- return undefined;
229
- }
230
-
231
527
  api.logger.info(`[mfa-auth] Blocking sensitive tool call: ${toolName} from ${userId}`);
232
-
233
- // For webchat, use userId as sessionKey instead of agent:main:<userId>
234
- if (parsedChannel === "webchat" || parsedChannel === "web") {
235
- const sessionKeyForWebchat = userId;
528
+
529
+ // For webchat, use userId as sessionKey instead of agent:main:<userId>
530
+ if (isWebchatChannel(parsedChannel)) {
531
+ const sessionKeyForWebchat = sessionKey || userId;
532
+ const authChallengeText = `检测到敏感操作,需二次认证。\n\n操作内容:${preview}\n\n请点击下方链接完成扫码认证:\n${session.qrCodeUrl}\n\n认证有效期:${Math.floor(config.timeout / 60000)} 分钟。\n\n认证完成后,请重新发送上一条命令或回复“确认”。`;
236
533
 
237
534
  try {
238
535
  await sendAuthMessage(
239
536
  parsedChannel,
240
537
  parsedAccountId,
241
538
  parsedTo || userId,
242
- `🔐 该操作需要二次认证\n\n检测到敏感操作: ${preview}\n\n📱 请点击以下链接完成扫码认证:\n${session.qrCodeUrl}\n\n验证有效期: ${Math.floor(config.timeout / 60000)} 分钟\n\n验证成功后,请回复"确认"或者重新发送之前的命令以继续执行。`,
539
+ authChallengeText,
243
540
  userId,
244
541
  sessionKeyForWebchat,
245
542
  );
246
- api.logger.info(
247
- `[mfa-auth] Sent sensitive operation auth notification to webchat: sessionKey=${sessionKeyForWebchat}`,
248
- );
249
- } catch (error) {
250
- api.logger.error(
251
- `[mfa-auth] Failed to send webchat sensitive auth notification: ${String(error)}`,
252
- );
253
- }
254
- // Also add to pending users as fallback
255
- pendingAuthUsers.add(userId);
256
- authManager.setSessionMetadata(session.sessionId, {
257
- qrCodeUrl: session.qrCodeUrl,
258
- triggerType: "sensitive_operation",
259
- commandPreview: preview,
260
- });
261
-
262
- startPollingForAuth(api, userId, session.sessionId, {
263
- triggerType: "sensitive_operation",
264
- isReauth: false,
265
- channel: parsedChannel,
266
- accountId: parsedAccountId,
267
- to: parsedTo,
268
- sessionKey: sessionKeyForWebchat,
269
- });
270
-
543
+ api.logger.info(
544
+ `[mfa-auth] Sent sensitive operation auth notification to webchat: sessionKey=${sessionKeyForWebchat}`,
545
+ );
546
+ } catch (error) {
547
+ api.logger.error(
548
+ `[mfa-auth] Failed to send webchat sensitive auth notification: ${String(error)}`,
549
+ );
550
+ }
551
+ // Also add to pending users as fallback
552
+ addPendingAuthCandidates(userId, sessionKeyForWebchat, parsedTo);
553
+ authManager.setSessionMetadata(session.sessionId, {
554
+ qrCodeUrl: session.qrCodeUrl,
555
+ triggerType: "sensitive_operation",
556
+ commandPreview: preview,
557
+ });
558
+
559
+ startPollingForAuth(api, userId, session.sessionId, {
560
+ triggerType: "sensitive_operation",
561
+ isReauth: false,
562
+ channel: parsedChannel,
563
+ accountId: parsedAccountId,
564
+ to: parsedTo,
565
+ sessionKey: sessionKeyForWebchat,
566
+ });
567
+
271
568
  return {
272
569
  block: true,
273
- blockReason: `🔐 该操作需要二次认证`,
570
+ blockReason: authChallengeText,
274
571
  };
275
572
  }
276
573
 
574
+ const authChallengeText = `检测到敏感操作,需二次认证。\n\n操作内容:${preview}\n\n请点击下方链接完成扫码认证:\n${session.qrCodeUrl}\n\n认证有效期:${Math.floor(config.timeout / 60000)} 分钟。\n\n认证完成后,请重新发送上一条命令或回复“确认”。`;
575
+
277
576
  if (parsedChannel && parsedChannel !== "web") {
278
577
  if (parsedChannel !== "feishu") {
279
578
  api.logger.warn(
280
579
  `[mfa-auth] Channel ${parsedChannel} not supported, skipping auth notification`,
281
580
  );
282
581
  } else {
283
- const messageText = `🔐 该操作需要二次认证\n\n检测到敏感操作: ${preview}\n\n📱 请点击以下链接完成扫码认证:\n${session.qrCodeUrl}\n\n验证有效期: ${Math.floor(config.timeout / 60000)} 分钟\n\n验证成功后,请回复"确认"或者重新发送之前的命令以继续执行。`;
284
-
285
582
  await sendAuthMessage(
286
583
  parsedChannel,
287
584
  parsedAccountId,
288
585
  parsedTo || userId,
289
- messageText,
586
+ authChallengeText,
290
587
  userId,
291
588
  );
292
-
293
- startPollingForAuth(api, userId, session.sessionId, {
294
- triggerType: "sensitive_operation",
295
- isReauth: false,
296
- channel: parsedChannel,
297
- accountId: parsedAccountId,
298
- to: parsedTo,
299
- sessionKey: ctx.sessionKey || "",
300
- });
301
- }
302
- }
303
-
304
- authManager.registerPendingExecution(userId, session.sessionId);
305
-
589
+
590
+ startPollingForAuth(api, userId, session.sessionId, {
591
+ triggerType: "sensitive_operation",
592
+ isReauth: false,
593
+ channel: parsedChannel,
594
+ accountId: parsedAccountId,
595
+ to: parsedTo,
596
+ sessionKey: ctx.sessionKey || "",
597
+ });
598
+ }
599
+ }
600
+
601
+ authManager.registerPendingExecution(userId, session.sessionId);
602
+
306
603
  return {
307
604
  block: true,
308
- blockReason: `🔐 该操作需要二次认证`,
605
+ blockReason: authChallengeText,
309
606
  };
310
607
  });
311
-
312
- api.on("message_received", async (event, ctx) => {
313
- await authManager.ensureInitialized();
314
-
315
- api.logger.info(
316
- `[mfa-auth] First message auth check: config.requireAuthOnFirstMessage=${config.requireAuthOnFirstMessage}`,
317
- );
318
-
319
- if (!config.requireAuthOnFirstMessage) {
320
- api.logger.warn(`[mfa-auth] First message auth is disabled in config, skipping.`);
321
- return;
322
- }
323
-
324
- const content = event.content || "";
325
- const isReauthCommand = content.trim() === "/reauth";
326
-
327
- if (isReauthCommand) {
328
- api.logger.info(`[mfa-auth] /reauth command detected, skipping first message auth check`);
329
- return;
330
- }
331
-
332
- const userId = event.from || ctx.conversationId || "unknown";
333
-
334
- if (authManager.isUserVerifiedForFirstMessage(userId)) {
335
- api.logger.info(`[mfa-auth] User ${userId} already verified for first message`);
336
-
337
- const notificationInfo = authManager.checkAndConsumeNotification(userId);
338
- if (notificationInfo) {
339
- const parsedChannel = ctx.channelId;
340
- const parsedAccountId = ctx.accountId || "";
341
- const parsedTo = event.from;
342
-
343
- let sessionKey = ctx.conversationId;
344
- if (!sessionKey) {
345
- if (parsedChannel === "webchat" || parsedChannel === "web") {
346
- sessionKey = userId;
347
- } else {
348
- sessionKey = `${parsedChannel}:${parsedAccountId}:${event.from}`;
349
- }
350
- }
351
-
352
- const messageText = notificationInfo.isReauth
353
- ? "✅ 重新认证成功,请继续对话。"
354
- : "✅ 首次认证成功,请继续对话。";
355
-
356
- sendAuthMessage(
357
- parsedChannel,
358
- parsedAccountId,
359
- parsedTo || userId,
360
- messageText,
361
- userId,
362
- sessionKey,
363
- ).catch((err) =>
364
- api.logger.error(`[mfa-auth] Failed to send success notification: ${err}`),
365
- );
366
- }
367
-
368
- return;
369
- }
370
-
371
- api.logger.info(`[mfa-auth] First message from unauthenticated user ${userId}, requiring auth`);
372
- api.logger.info(
373
- `[mfa-auth] Debug Context: channelId=${ctx.channelId}, conversationId=${ctx.conversationId}, accountId=${ctx.accountId}, from=${event.from}`,
374
- );
375
-
376
- const parsedChannel = ctx.channelId;
377
- const parsedAccountId = ctx.accountId || "";
378
- const parsedTo = event.from;
379
-
380
- // Use conversationId as sessionKey if available
381
- // For webchat, try to use userId directly as sessionKey (common pattern)
382
- let sessionKey = ctx.conversationId;
383
- if (!sessionKey) {
384
- if (parsedChannel === "webchat" || parsedChannel === "web") {
385
- // For webchat, use userId as sessionKey (this is the most common pattern)
386
- sessionKey = userId;
387
- api.logger.info(`[mfa-auth] Using webchat sessionKey (userId): ${sessionKey}`);
388
- } else {
389
- // Fallback to channel:accountId:from format
390
- sessionKey = `${parsedChannel}:${parsedAccountId}:${event.from}`;
391
- }
392
- }
393
-
394
- const session = await authManager.generateSession(userId, {
395
- sessionKey,
396
- senderId: userId,
397
- commandBody: event.content || "",
398
- channel: parsedChannel,
399
- to: parsedTo,
400
- accountId: parsedAccountId,
401
- toolName: "",
402
- toolParams: {},
403
- timestamp: Date.now(),
404
- triggerType: "first_message",
405
- });
406
-
407
- if (!session) {
408
- api.logger.error(
409
- `[mfa-auth] Failed to generate first message auth session for user ${userId}`,
410
- );
411
- return;
412
- }
413
-
414
- api.logger.info(`[mfa-auth] Blocking first message from ${userId}`);
415
-
416
- if (parsedChannel === "webchat" || parsedChannel === "web") {
417
- pendingAuthUsers.add(userId);
418
- authManager.setSessionMetadata(session.sessionId, {
419
- qrCodeUrl: session.qrCodeUrl,
420
- triggerType: "first_message",
421
- });
422
-
423
- startPollingForAuth(api, userId, session.sessionId, {
424
- triggerType: "first_message",
425
- isReauth: false,
426
- channel: parsedChannel,
427
- accountId: parsedAccountId,
428
- to: parsedTo,
429
- sessionKey: userId,
430
- });
431
-
432
- return;
433
- }
434
-
435
- if (parsedChannel && parsedChannel !== "web") {
436
- if (parsedChannel !== "feishu") {
437
- api.logger.warn(
438
- `[mfa-auth] Channel ${parsedChannel} not supported, skipping auth notification`,
439
- );
440
- } else {
441
- const messageText = `🔐 首次对话需要进行认证\n\n为了您的账户安全,首次对话前需要完成身份验证。\n\n📱 请点击以下链接完成扫码认证:\n${session.qrCodeUrl}\n\n验证有效期: ${Math.floor(config.timeout / 60000)} 分钟`;
442
-
443
- await sendAuthMessage(
444
- parsedChannel,
445
- parsedAccountId,
446
- parsedTo || userId,
447
- messageText,
448
- userId,
449
- sessionKey,
450
- );
451
-
452
- startPollingForAuth(api, userId, session.sessionId, {
453
- triggerType: "first_message",
454
- isReauth: false,
455
- channel: parsedChannel,
456
- accountId: parsedAccountId,
457
- to: parsedTo,
458
- sessionKey: sessionKey,
459
- });
460
- }
461
- }
462
- });
463
-
464
- api.registerCommand({
465
- name: "reauth",
466
- description: "重新进行首次对话认证",
467
- acceptsArgs: false,
468
- requireAuth: false,
469
- handler: async (ctx) => {
470
- const userId = ctx.from || ctx.senderId || "unknown";
471
- api.logger.info(
472
- `[mfa-auth] /reauth command received. userId=${userId}, ctx.channel=${ctx.channel}, ctx.accountId=${ctx.accountId}, ctx.to=${ctx.to}`,
473
- );
474
-
475
- authManager.clearFirstMessageAuth(userId);
476
-
477
- const parsedChannel = ctx.channel;
478
- const parsedAccountId = ctx.accountId || "";
479
- const parsedTo = ctx.to;
480
-
481
- api.logger.info(
482
- `[mfa-auth] Parsed: channel=${parsedChannel}, accountId=${parsedAccountId}, to=${parsedTo}`,
483
- );
484
-
485
- // For webchat, use userId as sessionKey
486
- const sessionKey =
487
- parsedChannel === "webchat" || parsedChannel === "web"
488
- ? userId
489
- : `${parsedChannel}:${parsedAccountId}:${userId}`;
490
-
491
- api.logger.info(`[mfa-auth] Using sessionKey for reauth: ${sessionKey}`);
492
-
493
- const session = await authManager.generateSession(userId, {
494
- sessionKey,
495
- senderId: userId,
496
- commandBody: "/reauth",
497
- channel: parsedChannel,
498
- to: parsedTo,
499
- accountId: parsedAccountId,
500
- toolName: "",
501
- toolParams: {},
502
- timestamp: Date.now(),
503
- triggerType: "first_message",
504
- });
505
-
506
- if (!session) {
507
- api.logger.error(`[mfa-auth] Failed to generate reauth session for user ${userId}`);
508
- return { text: "❌ 认证会话创建失败,请稍后重试。" };
509
- }
510
-
511
- api.logger.info(
512
- `[mfa-auth] Reauth requested by user ${userId}, session=${session.sessionId}`,
513
- );
514
-
515
- const messageText = `🔐 重新认证\n\n📱 请点击以下链接完成扫码认证:\n${session.qrCodeUrl}\n\n验证有效期: ${Math.floor(config.timeout / 60000)} 分钟`;
516
-
517
- // Use sendAuthMessage to ensure consistent delivery via WebSocket for WebChat
518
- // This will use the new robust session resolution logic
519
- if (parsedChannel === "webchat" || parsedChannel === "web") {
520
- try {
521
- pendingAuthUsers.add(userId);
522
- authManager.setSessionMetadata(session.sessionId, {
523
- qrCodeUrl: session.qrCodeUrl,
524
- triggerType: "first_message",
525
- isReauth: true,
526
- });
527
-
528
- await sendAuthMessage(
529
- parsedChannel,
530
- parsedAccountId,
531
- parsedTo || userId,
532
- messageText,
533
- userId,
534
- sessionKey,
535
- );
536
-
537
- startPollingForAuth(api, userId, session.sessionId, {
538
- triggerType: "first_message",
539
- isReauth: true,
540
- channel: parsedChannel,
541
- accountId: parsedAccountId,
542
- to: parsedTo,
543
- sessionKey: sessionKey,
544
- });
545
-
546
- return { text: "� 认证链接已发送,请查看最新消息。" };
547
- } catch (error) {
548
- api.logger.error(`[mfa-auth] Failed to send reauth link to webchat: ${String(error)}`);
549
- // Fallback to returning text directly if push fails, though this might be less reliable if session context is lost
550
- return { text: messageText };
551
- }
552
- }
553
-
554
- if (!parsedChannel || parsedChannel !== "feishu") {
555
- api.logger.warn(`[mfa-auth] Channel ${parsedChannel} not supported`);
556
- return { text: "❌ 当前渠道不支持认证。" };
557
- }
558
-
559
- try {
560
- api.logger.info(
561
- `[mfa-auth] Sending reauth notification: channel=${parsedChannel}, to=${parsedTo}, accountId=${parsedAccountId}`,
562
- );
563
-
564
- await sendAuthMessage(
565
- parsedChannel,
566
- parsedAccountId,
567
- parsedTo || userId,
568
- `🔐 重新认证\n\n📱 请点击以下链接完成扫码认证:\n${session.qrCodeUrl}\n\n验证有效期: ${Math.floor(config.timeout / 60000)} 分钟`,
569
- userId,
570
- );
571
-
572
- startPollingForAuth(api, userId, session.sessionId, {
573
- triggerType: "first_message",
574
- isReauth: true,
575
- channel: parsedChannel,
576
- accountId: parsedAccountId,
577
- to: parsedTo,
578
- sessionKey: sessionKey,
579
- });
580
-
581
- api.logger.info(`[mfa-auth] Reauth notification sent successfully`);
582
- return { text: "📱 认证链接已发送,请查收。" };
583
- } catch (error) {
584
- api.logger.error(`[mfa-auth] Failed to send reauth notification: ${String(error)}`);
585
- return { text: "❌ 认证链接发送失败,请稍后重试。" };
586
- }
587
- },
588
- });
589
-
590
- api.logger.info("mfa-auth plugin loaded");
591
- }
592
-
593
- function startPollingForAuth(
594
- api: OpenClawPluginApi,
595
- userId: string,
596
- sessionId: string,
597
- context: {
598
- triggerType: string;
599
- isReauth: boolean;
600
- channel?: string;
601
- accountId?: string;
602
- to?: string;
603
- sessionKey?: string;
604
- },
605
- ) {
606
- api.logger.info(
607
- `[mfa-auth] Starting polling for auth status: userId=${userId}, sessionId=${sessionId}`,
608
- );
609
-
610
- const pollInterval = setInterval(async () => {
611
- let isVerified = false;
612
- if (context.triggerType === "first_message") {
613
- isVerified = authManager.isUserVerifiedForFirstMessage(userId);
614
- } else {
615
- isVerified = authManager.isUserVerifiedForSensitiveOps(userId);
616
- }
617
-
618
- if (!isVerified) {
619
- const session = authManager.getSession(sessionId);
620
- if (session && session.certToken) {
621
- try {
622
- const authResult = await dabbyClient.getAuthResult(session.certToken);
623
- if (authResult.status === "verified") {
624
- api.logger.info(`[mfa-auth] Auth verification successful for session ${sessionId}`);
625
- authManager.markUserVerified(
626
- userId,
627
- context.triggerType === "first_message" ? "first_message" : "sensitive_operation",
628
- context.isReauth,
629
- );
630
- isVerified = true;
631
- } else if (authResult.status === "failed" || authResult.status === "expired") {
632
- api.logger.warn(
633
- `[mfa-auth] Auth failed or expired for session ${sessionId}: ${authResult.error}`,
634
- );
635
- clearInterval(pollInterval);
636
- return;
637
- }
638
- } catch (error) {
639
- api.logger.error(
640
- `[mfa-auth] Failed to check auth status for session ${sessionId}: ${String(error)}`,
641
- );
642
- }
643
- }
644
- }
645
-
646
- if (isVerified) {
647
- clearInterval(pollInterval);
648
-
649
- const notificationInfo = authManager.checkAndConsumeNotification(userId);
650
- if (notificationInfo) {
651
- api.logger.info(`[mfa-auth] Polling detected verification for user ${userId}`);
652
-
653
- const messageText = notificationInfo.isReauth
654
- ? "✅ 重新认证成功,请继续对话。"
655
- : notificationInfo.triggerType === "first_message"
656
- ? "✅ 首次认证成功,请继续对话。"
657
- : "✅ 二次认证成功,请重新发送之前的命令(或回复'确认')即可执行。";
658
-
659
- sendAuthMessage(
660
- context.channel,
661
- context.accountId,
662
- context.to || userId,
663
- messageText,
664
- userId,
665
- context.sessionKey,
666
- ).catch((err) =>
667
- api.logger.error(`[mfa-auth] Failed to send success notification from polling: ${err}`),
668
- );
669
- }
670
- return;
671
- }
672
-
673
- const session = authManager.getSession(sessionId);
674
- if (!session) {
675
- clearInterval(pollInterval);
676
- api.logger.info(
677
- `[mfa-auth] Polling stopped: session ${sessionId} not found and user not verified`,
678
- );
679
- }
680
- }, 2000);
681
-
682
- setTimeout(() => {
683
- clearInterval(pollInterval);
684
- }, config.timeout + 10000);
685
- }
686
-
687
- function checkSensitiveOperation(text: string): { isSensitive: boolean; preview: string } {
688
- const lowerText = text.toLowerCase();
689
-
690
- if (config.debug) {
691
- console.log(`[mfa-auth] Checking sensitive keywords for: ${text}`);
692
- console.log(
693
- `[mfa-auth] Sensitive keywords configured: ${JSON.stringify(config.sensitiveKeywords)}`,
694
- );
695
- }
696
-
697
- for (const keyword of config.sensitiveKeywords) {
698
- if (lowerText.includes(keyword.toLowerCase())) {
699
- const preview = text;
700
- if (config.debug) {
701
- console.log(`[mfa-auth] Sensitive keyword matched: ${keyword}`);
702
- }
703
- return { isSensitive: true, preview };
704
- }
705
- }
706
-
707
- if (config.debug) {
708
- console.log(`[mfa-auth] No sensitive keyword matched`);
709
- }
710
-
711
- return { isSensitive: false, preview: "" };
712
- }
608
+
609
+ api.on("message_received", async (event, ctx) => {
610
+ await authManager.ensureInitialized();
611
+ const metadata =
612
+ event.metadata && typeof event.metadata === "object"
613
+ ? (event.metadata as Record<string, unknown>)
614
+ : undefined;
615
+ const metadataSenderId =
616
+ typeof metadata?.senderId === "string" ? metadata.senderId : undefined;
617
+ const metadataTo = typeof metadata?.to === "string" ? metadata.to : undefined;
618
+ const metadataOriginatingTo =
619
+ typeof metadata?.originatingTo === "string" ? metadata.originatingTo : undefined;
620
+ const userId = resolveInboundAuthUserId({
621
+ channelId: ctx.channelId,
622
+ from: event.from,
623
+ senderId: metadataSenderId,
624
+ conversationId: ctx.conversationId,
625
+ metadataTo,
626
+ metadataOriginatingTo,
627
+ });
628
+ const authAliases = expandAuthIdCandidates([
629
+ event.from,
630
+ metadataSenderId,
631
+ ctx.conversationId,
632
+ metadataOriginatingTo,
633
+ metadataTo,
634
+ userId,
635
+ ]);
636
+
637
+ api.logger.info(
638
+ `[mfa-auth] First message auth check: config.requireAuthOnFirstMessage=${config.requireAuthOnFirstMessage}`,
639
+ );
640
+ if (config.debug) {
641
+ api.logger.info(
642
+ `[mfa-auth] Inbound auth aliases: userId=${userId}, from=${event.from}, senderId=${metadataSenderId}, conversationId=${ctx.conversationId}, metadata.to=${metadataTo}, metadata.originatingTo=${metadataOriginatingTo}, aliases=${authAliases.join(",")}`,
643
+ );
644
+ }
645
+
646
+ if (!config.requireAuthOnFirstMessage) {
647
+ api.logger.warn(`[mfa-auth] First message auth is disabled in config, skipping.`);
648
+ return;
649
+ }
650
+
651
+ const content = event.content || "";
652
+ const isReauthCommand = content.trim() === "/reauth";
653
+
654
+ if (isReauthCommand) {
655
+ api.logger.info(`[mfa-auth] /reauth command detected, skipping first message auth check`);
656
+ return;
657
+ }
658
+
659
+ const verifiedAlias = isAnyFirstMessageAliasVerified(authAliases);
660
+ if (verifiedAlias) {
661
+ api.logger.info(
662
+ `[mfa-auth] User ${userId} already verified for first message (matchedAlias=${verifiedAlias})`,
663
+ );
664
+
665
+ const notificationInfo = consumeNotificationByAliases(authAliases);
666
+ if (notificationInfo) {
667
+ const parsedChannel = ctx.channelId;
668
+ const parsedAccountId = ctx.accountId || "";
669
+ const parsedTo = isWebchatChannel(parsedChannel) ? userId : event.from;
670
+
671
+ let sessionKey = ctx.conversationId;
672
+ if (!sessionKey) {
673
+ if (parsedChannel === "webchat" || parsedChannel === "web") {
674
+ sessionKey = userId;
675
+ } else {
676
+ sessionKey = `${parsedChannel}:${parsedAccountId}:${userId}`;
677
+ }
678
+ }
679
+
680
+ const messageText = notificationInfo.isReauth
681
+ ? "\u2705 \u91cd\u65b0\u8ba4\u8bc1\u6210\u529f\uff0c\u8bf7\u7ee7\u7eed\u5bf9\u8bdd\u3002"
682
+ : "\u2705 \u9996\u6b21\u8ba4\u8bc1\u6210\u529f\uff0c\u8bf7\u7ee7\u7eed\u5bf9\u8bdd\u3002";
683
+
684
+ sendAuthMessage(
685
+ parsedChannel,
686
+ parsedAccountId,
687
+ parsedTo || userId,
688
+ messageText,
689
+ userId,
690
+ sessionKey,
691
+ ).catch((err) =>
692
+ api.logger.error(`[mfa-auth] Failed to send success notification: ${err}`),
693
+ );
694
+ }
695
+
696
+ return;
697
+ }
698
+
699
+ api.logger.info(`[mfa-auth] First message from unauthenticated user ${userId}, requiring auth`);
700
+ api.logger.info(
701
+ `[mfa-auth] Debug Context: channelId=${ctx.channelId}, conversationId=${ctx.conversationId}, accountId=${ctx.accountId}, from=${event.from}`,
702
+ );
703
+
704
+ const parsedChannel = ctx.channelId;
705
+ const parsedAccountId = ctx.accountId || "";
706
+ const parsedTo = isWebchatChannel(parsedChannel) ? userId : event.from;
707
+
708
+ // Use conversationId as sessionKey if available
709
+ // For webchat, try to use userId directly as sessionKey (common pattern)
710
+ let sessionKey = ctx.conversationId;
711
+ if (!sessionKey) {
712
+ if (isWebchatChannel(parsedChannel)) {
713
+ // For webchat, use userId as sessionKey (this is the most common pattern)
714
+ sessionKey = userId;
715
+ api.logger.info(`[mfa-auth] Using webchat sessionKey (userId): ${sessionKey}`);
716
+ } else {
717
+ // Fallback to channel:accountId:from format
718
+ sessionKey = `${parsedChannel}:${parsedAccountId}:${userId}`;
719
+ }
720
+ }
721
+
722
+ const session = await authManager.generateSession(userId, {
723
+ sessionKey,
724
+ senderId: userId,
725
+ commandBody: event.content || "",
726
+ channel: parsedChannel,
727
+ to: parsedTo,
728
+ accountId: parsedAccountId,
729
+ toolName: "",
730
+ toolParams: {},
731
+ timestamp: Date.now(),
732
+ triggerType: "first_message",
733
+ });
734
+
735
+ if (!session) {
736
+ api.logger.error(
737
+ `[mfa-auth] Failed to generate first message auth session for user ${userId}`,
738
+ );
739
+ return;
740
+ }
741
+
742
+ api.logger.info(`[mfa-auth] Blocking first message from ${userId}`);
743
+
744
+ if (isWebchatChannel(parsedChannel)) {
745
+ addPendingAuthCandidates(userId, sessionKey, parsedTo);
746
+ authManager.setSessionMetadata(session.sessionId, {
747
+ qrCodeUrl: session.qrCodeUrl,
748
+ triggerType: "first_message",
749
+ });
750
+
751
+ try {
752
+ await sendAuthMessage(
753
+ parsedChannel,
754
+ parsedAccountId,
755
+ parsedTo || userId,
756
+ `\u9996\u6b21\u5bf9\u8bdd\u9700\u8981\u8ba4\u8bc1\n\n\u4e3a\u4e86\u8d26\u53f7\u5b89\u5168\uff0c\u9996\u6b21\u5bf9\u8bdd\u524d\u9700\u8981\u5b8c\u6210\u8eab\u4efd\u9a8c\u8bc1\u3002\n\n\u8bf7\u70b9\u51fb\u4e0b\u65b9\u94fe\u63a5\u5b8c\u6210\u626b\u7801\u8ba4\u8bc1\uff1a\n${session.qrCodeUrl}\n\n\u8ba4\u8bc1\u6709\u6548\u671f\uff1a${Math.floor(config.timeout / 60000)} \u5206\u949f\u3002`,
757
+ userId,
758
+ sessionKey,
759
+ );
760
+ } catch (error) {
761
+ api.logger.error(
762
+ `[mfa-auth] Failed to send first-message auth link to webchat: ${String(error)}`,
763
+ );
764
+ }
765
+
766
+ startPollingForAuth(api, userId, session.sessionId, {
767
+ triggerType: "first_message",
768
+ isReauth: false,
769
+ channel: parsedChannel,
770
+ accountId: parsedAccountId,
771
+ to: parsedTo,
772
+ sessionKey: userId,
773
+ });
774
+
775
+ return;
776
+ }
777
+
778
+ if (parsedChannel && parsedChannel !== "web") {
779
+ if (parsedChannel !== "feishu") {
780
+ api.logger.warn(
781
+ `[mfa-auth] Channel ${parsedChannel} not supported, skipping auth notification`,
782
+ );
783
+ } else {
784
+ const messageText = `\u9996\u6b21\u5bf9\u8bdd\u9700\u8981\u8ba4\u8bc1\n\n\u4e3a\u4e86\u8d26\u53f7\u5b89\u5168\uff0c\u9996\u6b21\u5bf9\u8bdd\u524d\u9700\u8981\u5b8c\u6210\u8eab\u4efd\u9a8c\u8bc1\u3002\n\n\u8bf7\u70b9\u51fb\u4e0b\u65b9\u94fe\u63a5\u5b8c\u6210\u626b\u7801\u8ba4\u8bc1\uff1a\n${session.qrCodeUrl}\n\n\u8ba4\u8bc1\u6709\u6548\u671f\uff1a${Math.floor(config.timeout / 60000)} \u5206\u949f\u3002`;
785
+
786
+ await sendAuthMessage(
787
+ parsedChannel,
788
+ parsedAccountId,
789
+ parsedTo || userId,
790
+ messageText,
791
+ userId,
792
+ sessionKey,
793
+ );
794
+
795
+ startPollingForAuth(api, userId, session.sessionId, {
796
+ triggerType: "first_message",
797
+ isReauth: false,
798
+ channel: parsedChannel,
799
+ accountId: parsedAccountId,
800
+ to: parsedTo,
801
+ sessionKey: sessionKey,
802
+ });
803
+ }
804
+ }
805
+ });
806
+
807
+ api.registerCommand({
808
+ name: "reauth",
809
+ description: "\u91cd\u65b0\u8fdb\u884c\u9996\u6b21\u5bf9\u8bdd\u8ba4\u8bc1",
810
+ acceptsArgs: false,
811
+ requireAuth: false,
812
+ handler: async (ctx) => {
813
+ const rawCtx = ctx as unknown as Record<string, unknown>;
814
+ const ctxSessionKey = typeof rawCtx.sessionKey === "string" ? rawCtx.sessionKey : undefined;
815
+ const userId =
816
+ normalizeAuthId(ctx.from || ctx.senderId || ctx.to || ctxSessionKey || "unknown") ||
817
+ "unknown";
818
+ const authUserIds = expandAuthIdCandidates(
819
+ uniqueNonEmpty([ctx.from, ctx.senderId, ctx.to, ctxSessionKey, userId]),
820
+ );
821
+ api.logger.info(
822
+ `[mfa-auth] /reauth command received. userId=${userId}, aliases=${authUserIds.join(",")}, ctx.channel=${ctx.channel}, ctx.accountId=${ctx.accountId}, ctx.to=${ctx.to}`,
823
+ );
824
+
825
+ for (const authUserId of authUserIds) {
826
+ authManager.clearFirstMessageAuth(authUserId);
827
+ }
828
+
829
+ const parsedChannel = ctx.channel;
830
+ const parsedAccountId = ctx.accountId || "";
831
+ const parsedTo = ctx.to;
832
+
833
+ api.logger.info(
834
+ `[mfa-auth] Parsed: channel=${parsedChannel}, accountId=${parsedAccountId}, to=${parsedTo}`,
835
+ );
836
+
837
+ // For webchat, use userId as sessionKey
838
+ const sessionKey =
839
+ isWebchatChannel(parsedChannel)
840
+ ? ctxSessionKey || userId
841
+ : `${parsedChannel}:${parsedAccountId}:${userId}`;
842
+
843
+ api.logger.info(`[mfa-auth] Using sessionKey for reauth: ${sessionKey}`);
844
+
845
+ const session = await authManager.generateSession(userId, {
846
+ sessionKey,
847
+ senderId: userId,
848
+ commandBody: "/reauth",
849
+ channel: parsedChannel,
850
+ to: parsedTo,
851
+ accountId: parsedAccountId,
852
+ toolName: "",
853
+ toolParams: {},
854
+ timestamp: Date.now(),
855
+ triggerType: "first_message",
856
+ });
857
+
858
+ if (!session) {
859
+ api.logger.error(`[mfa-auth] Failed to generate reauth session for user ${userId}`);
860
+ return { text: "\u8ba4\u8bc1\u4f1a\u8bdd\u521b\u5efa\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002" };
861
+ }
862
+
863
+ api.logger.info(
864
+ `[mfa-auth] Reauth requested by user ${userId}, session=${session.sessionId}`,
865
+ );
866
+
867
+ const messageText = `\u9700\u8981\u91cd\u65b0\u8ba4\u8bc1\n\n\u8bf7\u70b9\u51fb\u4e0b\u65b9\u94fe\u63a5\u5b8c\u6210\u626b\u7801\u8ba4\u8bc1\uff1a\n${session.qrCodeUrl}\n\n\u8ba4\u8bc1\u6709\u6548\u671f\uff1a${Math.floor(config.timeout / 60000)} \u5206\u949f\u3002`;
868
+
869
+ // Use sendAuthMessage to ensure consistent delivery via WebSocket for WebChat
870
+ // This will use the new robust session resolution logic
871
+ if (isWebchatChannel(parsedChannel)) {
872
+ try {
873
+ addPendingAuthCandidates(userId, sessionKey, parsedTo);
874
+ authManager.setSessionMetadata(session.sessionId, {
875
+ qrCodeUrl: session.qrCodeUrl,
876
+ triggerType: "first_message",
877
+ isReauth: true,
878
+ });
879
+
880
+ await sendAuthMessage(
881
+ parsedChannel,
882
+ parsedAccountId,
883
+ parsedTo || userId,
884
+ messageText,
885
+ userId,
886
+ sessionKey,
887
+ );
888
+
889
+ startPollingForAuth(api, userId, session.sessionId, {
890
+ triggerType: "first_message",
891
+ isReauth: true,
892
+ channel: parsedChannel,
893
+ accountId: parsedAccountId,
894
+ to: parsedTo,
895
+ sessionKey: sessionKey,
896
+ });
897
+
898
+ return { text: messageText };
899
+ } catch (error) {
900
+ api.logger.error(`[mfa-auth] Failed to send reauth link to webchat: ${String(error)}`);
901
+ // Fallback to returning text directly if push fails, though this might be less reliable if session context is lost
902
+ return { text: messageText };
903
+ }
904
+ }
905
+
906
+ if (!parsedChannel || parsedChannel !== "feishu") {
907
+ api.logger.warn(`[mfa-auth] Channel ${parsedChannel} not supported`);
908
+ return { text: "\u5f53\u524d\u6e20\u9053\u6682\u4e0d\u652f\u6301\u6b64\u8ba4\u8bc1\u6d41\u7a0b\u3002" };
909
+ }
910
+
911
+ try {
912
+ api.logger.info(
913
+ `[mfa-auth] Sending reauth notification: channel=${parsedChannel}, to=${parsedTo}, accountId=${parsedAccountId}`,
914
+ );
915
+
916
+ await sendAuthMessage(
917
+ parsedChannel,
918
+ parsedAccountId,
919
+ parsedTo || userId,
920
+ `\u9700\u8981\u91cd\u65b0\u8ba4\u8bc1\n\n\u8bf7\u70b9\u51fb\u4e0b\u65b9\u94fe\u63a5\u5b8c\u6210\u626b\u7801\u8ba4\u8bc1\uff1a\n${session.qrCodeUrl}\n\n\u8ba4\u8bc1\u6709\u6548\u671f\uff1a${Math.floor(config.timeout / 60000)} \u5206\u949f\u3002`,
921
+ userId,
922
+ );
923
+
924
+ startPollingForAuth(api, userId, session.sessionId, {
925
+ triggerType: "first_message",
926
+ isReauth: true,
927
+ channel: parsedChannel,
928
+ accountId: parsedAccountId,
929
+ to: parsedTo,
930
+ sessionKey: sessionKey,
931
+ });
932
+
933
+ api.logger.info(`[mfa-auth] Reauth notification sent successfully`);
934
+ return { text: "\u8ba4\u8bc1\u94fe\u63a5\u5df2\u53d1\u9001\uff0c\u8bf7\u67e5\u770b\u6700\u65b0\u6d88\u606f\u3002" };
935
+ } catch (error) {
936
+ api.logger.error(`[mfa-auth] Failed to send reauth notification: ${String(error)}`);
937
+ return { text: "\u8ba4\u8bc1\u94fe\u63a5\u53d1\u9001\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002" };
938
+ }
939
+ },
940
+ });
941
+
942
+ api.logger.info("mfa-auth plugin loaded");
943
+ }
944
+
945
+ function startPollingForAuth(
946
+ api: OpenClawPluginApi,
947
+ userId: string,
948
+ sessionId: string,
949
+ context: {
950
+ triggerType: string;
951
+ isReauth: boolean;
952
+ channel?: string;
953
+ accountId?: string;
954
+ to?: string;
955
+ sessionKey?: string;
956
+ },
957
+ ) {
958
+ api.logger.info(
959
+ `[mfa-auth] Starting polling for auth status: userId=${userId}, sessionId=${sessionId}`,
960
+ );
961
+
962
+ const pollInterval = setInterval(async () => {
963
+ let isVerified = false;
964
+ if (context.triggerType === "first_message") {
965
+ isVerified = authManager.isUserVerifiedForFirstMessage(userId);
966
+ } else {
967
+ isVerified = authManager.isUserVerifiedForSensitiveOps(userId);
968
+ }
969
+
970
+ if (!isVerified) {
971
+ const session = authManager.getSession(sessionId);
972
+ if (session && session.certToken) {
973
+ try {
974
+ const authResult = await dabbyClient.getAuthResult(session.certToken);
975
+ if (authResult.status === "verified") {
976
+ api.logger.info(`[mfa-auth] Auth verification successful for session ${sessionId}`);
977
+ authManager.markUserVerified(
978
+ userId,
979
+ context.triggerType === "first_message" ? "first_message" : "sensitive_operation",
980
+ context.isReauth,
981
+ );
982
+ isVerified = true;
983
+ } else if (authResult.status === "failed" || authResult.status === "expired") {
984
+ api.logger.warn(
985
+ `[mfa-auth] Auth failed or expired for session ${sessionId}: ${authResult.error}`,
986
+ );
987
+ clearInterval(pollInterval);
988
+ return;
989
+ }
990
+ } catch (error) {
991
+ api.logger.error(
992
+ `[mfa-auth] Failed to check auth status for session ${sessionId}: ${String(error)}`,
993
+ );
994
+ }
995
+ }
996
+ }
997
+
998
+ if (isVerified) {
999
+ clearInterval(pollInterval);
1000
+
1001
+ const notificationInfo = authManager.checkAndConsumeNotification(userId);
1002
+ if (notificationInfo) {
1003
+ api.logger.info(`[mfa-auth] Polling detected verification for user ${userId}`);
1004
+
1005
+ const messageText = notificationInfo.isReauth
1006
+ ? "\u2705 \u91cd\u65b0\u8ba4\u8bc1\u6210\u529f\uff0c\u8bf7\u7ee7\u7eed\u5bf9\u8bdd\u3002"
1007
+ : notificationInfo.triggerType === "first_message"
1008
+ ? "\u2705 \u9996\u6b21\u8ba4\u8bc1\u6210\u529f\uff0c\u8bf7\u7ee7\u7eed\u5bf9\u8bdd\u3002"
1009
+ : "\u2705 \u4e8c\u6b21\u8ba4\u8bc1\u6210\u529f\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u4e4b\u524d\u7684\u547d\u4ee4\u6216\u56de\u590d\u201c\u786e\u8ba4\u201d\u3002";
1010
+
1011
+ sendAuthMessage(
1012
+ context.channel,
1013
+ context.accountId,
1014
+ context.to || userId,
1015
+ messageText,
1016
+ userId,
1017
+ context.sessionKey,
1018
+ ).catch((err) =>
1019
+ api.logger.error(`[mfa-auth] Failed to send success notification from polling: ${err}`),
1020
+ );
1021
+ }
1022
+ return;
1023
+ }
1024
+
1025
+ const session = authManager.getSession(sessionId);
1026
+ if (!session) {
1027
+ clearInterval(pollInterval);
1028
+ api.logger.info(
1029
+ `[mfa-auth] Polling stopped: session ${sessionId} not found and user not verified`,
1030
+ );
1031
+ }
1032
+ }, 2000);
1033
+
1034
+ setTimeout(() => {
1035
+ clearInterval(pollInterval);
1036
+ }, config.timeout + 10000);
1037
+ }
1038
+
1039
+ function checkSensitiveOperation(text: string): { isSensitive: boolean; preview: string } {
1040
+ const lowerText = text.toLowerCase();
1041
+
1042
+ if (config.debug) {
1043
+ console.log(`[mfa-auth] Checking sensitive keywords for: ${text}`);
1044
+ console.log(
1045
+ `[mfa-auth] Sensitive keywords configured: ${JSON.stringify(config.sensitiveKeywords)}`,
1046
+ );
1047
+ }
1048
+
1049
+ for (const keyword of config.sensitiveKeywords) {
1050
+ if (lowerText.includes(keyword.toLowerCase())) {
1051
+ const preview = text;
1052
+ if (config.debug) {
1053
+ console.log(`[mfa-auth] Sensitive keyword matched: ${keyword}`);
1054
+ }
1055
+ return { isSensitive: true, preview };
1056
+ }
1057
+ }
1058
+
1059
+ if (config.debug) {
1060
+ console.log(`[mfa-auth] No sensitive keyword matched`);
1061
+ }
1062
+
1063
+ return { isSensitive: false, preview: "" };
1064
+ }
1065
+