evolcore 0.0.3 → 0.0.4
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.
- package/CHANGELOG.md +64 -0
- package/README.md +45 -10
- package/bin/ec-safe-output.js +89 -24
- package/dist/agents/baseagent.js +46 -0
- package/dist/agents/claude-runner.js +1 -31
- package/dist/agents/codex-app-server-client.js +68 -0
- package/dist/agents/codex-runner.js +157 -5
- package/dist/agents/runner-types.js +2 -2
- package/dist/aun/aid/agentmd.js +79 -0
- package/dist/aun/aid/encryption-seed-policy.js +29 -0
- package/dist/aun/aid/index.js +1 -1
- package/dist/aun/aid/store.js +2 -5
- package/dist/channels/aun.js +220 -112
- package/dist/channels/daemon.js +18 -4
- package/dist/channels/dingtalk.js +52 -137
- package/dist/channels/feishu.js +56 -4
- package/dist/channels/qqbot.js +23 -1
- package/dist/channels/wechat.js +303 -155
- package/dist/channels/wecom.js +383 -7
- package/dist/cli/aun-commands.js +11 -11
- package/dist/cli/handoff-command.js +2 -1
- package/dist/cli/init-channel.js +185 -67
- package/dist/cli/init.js +7 -5
- package/dist/cli/trigger-command.js +92 -11
- package/dist/config/access-policy-domain.js +38 -0
- package/dist/config/access-policy.js +134 -0
- package/dist/config/builtin-role-templates.js +27 -14
- package/dist/config/builtin-roles.js +25 -6
- package/dist/config/config-field-policy.js +17 -5
- package/dist/config/config-manager.js +198 -22
- package/dist/config/config-operation-service.js +35 -38
- package/dist/config/contact-bind-code.js +274 -0
- package/dist/config/contact-book-store.js +173 -5
- package/dist/config/contact-book.js +32 -0
- package/dist/config/contact-operation-service.js +9 -3
- package/dist/config/contact-request-service.js +331 -0
- package/dist/config/peer-role-resolver.js +3 -1
- package/dist/config/schema-registry.js +49 -24
- package/dist/config-store.js +14 -2
- package/dist/core/auth/agent-delegation.js +105 -11
- package/dist/core/auth/authorization-audit.js +6 -0
- package/dist/core/auth/operation-authorizer.js +8 -0
- package/dist/core/auth/operation-catalog.js +51 -3
- package/dist/core/auth/trigger-authorization.js +15 -0
- package/dist/core/bootstrap-service.js +2 -9
- package/dist/core/channel-loader.js +6 -2
- package/dist/core/command/command-handler.js +10 -13
- package/dist/core/command/connect-menu.js +249 -24
- package/dist/core/command/evol-menu-version-gate.js +38 -0
- package/dist/core/command/group-menu.js +421 -0
- package/dist/core/command/menu-handler.js +269 -85
- package/dist/core/command/menu-protocol.js +8 -2
- package/dist/core/command/menu-token-store.js +102 -0
- package/dist/core/command/slash-gate.js +1 -1
- package/dist/core/command/slash-handler.js +158 -31
- package/dist/core/daemon-file-cache.js +28 -0
- package/dist/core/event-catalog.js +29 -0
- package/dist/core/evolagent-registry.js +4 -39
- package/dist/core/handoff/runtime.js +162 -37
- package/dist/core/handoff/store.js +46 -2
- package/dist/core/handoff/types.js +1 -0
- package/dist/core/inference/text-inference.js +16 -74
- package/dist/core/message/file-markers.js +7 -0
- package/dist/core/message/im-renderer.js +18 -14
- package/dist/core/message/inbound-admission.js +124 -0
- package/dist/core/message/message-bridge.js +616 -74
- package/dist/core/message/message-log.js +1 -0
- package/dist/core/message/message-queue.js +185 -10
- package/dist/core/message/peer-mode.js +7 -8
- package/dist/core/message/response-engine.js +381 -79
- package/dist/core/permission/approval-gateway.js +17 -10
- package/dist/core/permission/ec-command-parser.js +101 -46
- package/dist/core/permission/tool-policy.js +69 -24
- package/dist/core/protected-paths.js +36 -11
- package/dist/core/session/session-manager.js +25 -3
- package/dist/core/session/session-mapper.js +2 -0
- package/dist/core/session/session-renew.js +125 -69
- package/dist/core/session/session-turn-coordinator.js +5 -1
- package/dist/eck/kit-renderer.js +12 -1
- package/dist/eck/message-renderer.js +79 -1
- package/dist/index.js +224 -89
- package/dist/ipc.js +81 -4
- package/dist/paths.js +3 -0
- package/dist/response-system/config-resolver.js +29 -0
- package/dist/response-system/coordinator.js +8 -32
- package/dist/response-system/engines/v1/proactive-flow.js +1 -1
- package/dist/response-system/index.js +1 -0
- package/dist/response-system/modes/single-session/index.js +7 -4
- package/dist/trigger/parser.js +55 -15
- package/dist/trigger/patch.js +4 -1
- package/dist/trigger/scheduler.js +441 -39
- package/dist/utils/error-dict.json +7 -0
- package/dist/utils/evolcore-version.js +21 -0
- package/dist/utils/logger.js +2 -2
- package/dist/utils/stable-semver.js +21 -0
- package/dist/utils/stats.js +33 -9
- package/kits/docs/channels/aun.md +4 -13
- package/kits/docs/evolcore/INDEX.md +1 -1
- package/kits/docs/evolcore/config.md +47 -2
- package/kits/docs/evolcore/contact.md +7 -3
- package/kits/docs/evolcore/group-rules.md +46 -4
- package/kits/docs/evolcore/group.md +4 -4
- package/kits/docs/evolcore/msg.md +5 -5
- package/kits/docs/evolcore/trigger.md +25 -8
- package/kits/docs/path-registry.md +36 -17
- package/kits/eck_message_manifest.json +12 -1
- package/kits/migrations/migrate-contact-book-v2.mjs +7 -0
- package/kits/rules/01-overview.md +17 -7
- package/kits/rules/02-navigation.md +38 -18
- package/kits/rules/03-identity.md +28 -24
- package/kits/rules/04-relation.md +44 -28
- package/kits/rules/05-venue.md +31 -15
- package/kits/rules/06-channel.md +11 -7
- package/kits/schemas/_meta.json +18 -7
- package/kits/schemas/agent-config.schema.7.json +303 -0
- package/kits/schemas/agent-config.schema.8.json +304 -0
- package/kits/schemas/agent-config.schema.9.json +374 -0
- package/kits/schemas/contact-book.schema.3.json +67 -0
- package/kits/schemas/daemon.schema.1.json +3 -2
- package/kits/schemas/daemon.schema.2.json +101 -0
- package/kits/schemas/daemon.schema.3.json +123 -0
- package/kits/schemas/defaults.schema.2.json +85 -0
- package/kits/schemas/defaults.schema.3.json +73 -0
- package/kits/schemas/relation-config.schema.6.json +46 -0
- package/kits/schemas/relation-config.schema.7.json +59 -0
- package/kits/schemas/single-session.schema.2.json +57 -0
- package/kits/templates/message-fragments/handoff-context-to-target.md +9 -0
- package/kits/templates/message-fragments/handoff-request-to-target.md +14 -8
- package/kits/templates/message-fragments/handoff-response-to-origin.md +5 -6
- package/kits/templates/roles/admin.json +46 -0
- package/kits/templates/roles/member.json +4 -0
- package/kits/templates/roles/visitor.json +4 -0
- package/kits/templates/system-fragments/channel.md +12 -2
- package/kits/templates/system-fragments/identity.md +3 -1
- package/kits/templates/system-fragments/relation.md +1 -1
- package/kits/templates/system-fragments/session.md +6 -2
- package/package.json +4 -2
- package/MIGRATION-0.5.0.md +0 -378
- package/ROLE_ACCESS_CONTROL.md +0 -174
- package/dist/channels/contact-bind-code.js +0 -134
- package/dist/channels/wecom-card.js +0 -101
- package/dist/channels/wecom-onboarding.js +0 -82
- package/dist/channels/wecom-state.js +0 -191
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { randomInt } from 'crypto';
|
|
2
|
+
import { isValidAid } from '../aun/aid/validation.js';
|
|
3
|
+
import { isAcceptedContact, resolveContactView } from './contact-book.js';
|
|
4
|
+
import { formatContactAlias, normalizeContactChannelKey } from './contact-alias.js';
|
|
5
|
+
import { mutateContactBookSync } from './contact-book-store.js';
|
|
6
|
+
export const CONTACT_BIND_CODE_TTL_MS = 10 * 60 * 1000;
|
|
7
|
+
export const CONTACT_BIND_MAX_FAILED_ATTEMPTS = 5;
|
|
8
|
+
export const CONTACT_BIND_MAX_PENDING_PER_CHANNEL = 100;
|
|
9
|
+
export const CONTACT_BIND_MAX_PENDING_TOTAL = 5_000;
|
|
10
|
+
export const CONTACT_BIND_MAX_INVALID_ACTORS = 1_000;
|
|
11
|
+
/** In-memory, one-time channel identity proof used before writing a Contact alias. */
|
|
12
|
+
export class ContactBindCodeRegistry {
|
|
13
|
+
options;
|
|
14
|
+
/** `selfAid + channelName + primaryId` -> pending request. */
|
|
15
|
+
pendingByPrimary = new Map();
|
|
16
|
+
/** `selfAid + channelName + code` -> pending request key. */
|
|
17
|
+
pendingByCode = new Map();
|
|
18
|
+
/** Scope -> pending request keys, used for bounded cleanup and capacity checks. */
|
|
19
|
+
pendingKeysByScope = new Map();
|
|
20
|
+
/** Unknown-code throttling is deliberately separate from any pending request. */
|
|
21
|
+
invalidAttemptsByActor = new Map();
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
}
|
|
25
|
+
register(req) {
|
|
26
|
+
const selfAid = String(req.selfAid || '').trim();
|
|
27
|
+
const primaryId = String(req.primaryId || '').trim();
|
|
28
|
+
const now = req.now ?? Date.now();
|
|
29
|
+
const requestedCode = req.code ? String(req.code).trim() : undefined;
|
|
30
|
+
if (!isValidAid(selfAid))
|
|
31
|
+
return { ok: false, error: `invalid selfAid: ${req.selfAid}` };
|
|
32
|
+
const channelName = normalizeContactChannelKey(selfAid, this.options.channelType, req.channelName);
|
|
33
|
+
if (!channelName)
|
|
34
|
+
return { ok: false, error: `invalid channelName: ${req.channelName}` };
|
|
35
|
+
if (!isValidAid(primaryId))
|
|
36
|
+
return { ok: false, error: `invalid primaryId: ${req.primaryId}` };
|
|
37
|
+
const contact = resolveContactView(selfAid, primaryId);
|
|
38
|
+
if (!contact.isOwner && !isAcceptedContact(selfAid, primaryId)) {
|
|
39
|
+
return { ok: false, error: `only an active contact or Agent owner can bind an identity: ${primaryId}` };
|
|
40
|
+
}
|
|
41
|
+
if (requestedCode && !/^\d{6}$/.test(requestedCode))
|
|
42
|
+
return { ok: false, error: 'binding code must be 6 digits' };
|
|
43
|
+
const scope = pendingScopeKey(selfAid, channelName);
|
|
44
|
+
this.purgeExpiredAll(now);
|
|
45
|
+
const key = pendingBindKey(selfAid, channelName, primaryId);
|
|
46
|
+
const replaced = this.pendingByPrimary.has(key);
|
|
47
|
+
const code = requestedCode ?? this.generateUniqueCode(scope);
|
|
48
|
+
if (!code)
|
|
49
|
+
return { ok: false, error: 'unable to allocate a unique binding code' };
|
|
50
|
+
const codeKey = pendingCodeKey(scope, code);
|
|
51
|
+
const existingCodeOwner = this.pendingByCode.get(codeKey);
|
|
52
|
+
if (existingCodeOwner && existingCodeOwner !== key) {
|
|
53
|
+
return { ok: false, error: 'binding code is already active for this channel instance' };
|
|
54
|
+
}
|
|
55
|
+
const pendingInScope = this.pendingKeysByScope.get(scope)?.size ?? 0;
|
|
56
|
+
if (!replaced && pendingInScope >= CONTACT_BIND_MAX_PENDING_PER_CHANNEL) {
|
|
57
|
+
return { ok: false, error: 'too many pending binding requests for this channel instance' };
|
|
58
|
+
}
|
|
59
|
+
if (!replaced && this.pendingByPrimary.size >= CONTACT_BIND_MAX_PENDING_TOTAL) {
|
|
60
|
+
return { ok: false, error: 'too many pending binding requests in this process' };
|
|
61
|
+
}
|
|
62
|
+
if (replaced)
|
|
63
|
+
this.removePending(key);
|
|
64
|
+
const item = {
|
|
65
|
+
selfAid,
|
|
66
|
+
channelName,
|
|
67
|
+
primaryId,
|
|
68
|
+
code,
|
|
69
|
+
createdAt: now,
|
|
70
|
+
expiresAt: now + CONTACT_BIND_CODE_TTL_MS,
|
|
71
|
+
};
|
|
72
|
+
this.pendingByPrimary.set(key, item);
|
|
73
|
+
this.pendingByCode.set(codeKey, key);
|
|
74
|
+
const scopeKeys = this.pendingKeysByScope.get(scope) ?? new Set();
|
|
75
|
+
scopeKeys.add(key);
|
|
76
|
+
this.pendingKeysByScope.set(scope, scopeKeys);
|
|
77
|
+
return { ok: true, code, expiresAt: item.expiresAt, replaced };
|
|
78
|
+
}
|
|
79
|
+
handle(ctx) {
|
|
80
|
+
if (ctx.channelType !== this.options.channelType)
|
|
81
|
+
return { handled: false };
|
|
82
|
+
const selfAid = String(ctx.selfAid || '').trim();
|
|
83
|
+
const channelName = normalizeContactChannelKey(selfAid, this.options.channelType, ctx.channelName);
|
|
84
|
+
if (!channelName)
|
|
85
|
+
return { handled: false };
|
|
86
|
+
const now = ctx.now ?? Date.now();
|
|
87
|
+
if (ctx.chatType !== 'private')
|
|
88
|
+
return { handled: false };
|
|
89
|
+
const input = String(ctx.content ?? '').trim();
|
|
90
|
+
const scope = pendingScopeKey(selfAid, channelName);
|
|
91
|
+
const codeKey = /^\d{6}$/.test(input) ? pendingCodeKey(scope, input) : undefined;
|
|
92
|
+
const pendingKey = codeKey ? this.pendingByCode.get(codeKey) : undefined;
|
|
93
|
+
const matchingItem = pendingKey ? this.pendingByPrimary.get(pendingKey) : undefined;
|
|
94
|
+
if (matchingItem && now >= matchingItem.expiresAt) {
|
|
95
|
+
this.removePending(pendingKey);
|
|
96
|
+
return {
|
|
97
|
+
handled: true,
|
|
98
|
+
status: 'expired',
|
|
99
|
+
remainingAttempts: 0,
|
|
100
|
+
reply: this.expiredReply(),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
this.purgeExpiredAll(now);
|
|
104
|
+
const hasPending = (this.pendingKeysByScope.get(scope)?.size ?? 0) > 0;
|
|
105
|
+
if (!hasPending)
|
|
106
|
+
return { handled: false };
|
|
107
|
+
if (!/^\d{6}$/.test(input)) {
|
|
108
|
+
return {
|
|
109
|
+
handled: true,
|
|
110
|
+
status: 'format',
|
|
111
|
+
reply: '请发送 /bind <6 位数字绑定码>。',
|
|
112
|
+
remainingAttempts: CONTACT_BIND_MAX_FAILED_ATTEMPTS,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (!matchingItem) {
|
|
116
|
+
const invalidAttempt = this.recordInvalidAttempt(scope, String(ctx.actorId || '').trim(), now);
|
|
117
|
+
if (invalidAttempt.remaining > 0) {
|
|
118
|
+
return {
|
|
119
|
+
handled: true,
|
|
120
|
+
status: 'wrong-code',
|
|
121
|
+
remainingAttempts: invalidAttempt.remaining,
|
|
122
|
+
reply: '绑定码错误,请重新发送 /bind <6 位数字绑定码>。',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
handled: true,
|
|
127
|
+
status: 'rate-limited',
|
|
128
|
+
remainingAttempts: 0,
|
|
129
|
+
reply: '绑定码错误次数过多,请稍后再试。有效绑定码仍可直接提交。',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const actorId = String(ctx.actorId || '').trim();
|
|
133
|
+
if (!actorId) {
|
|
134
|
+
return {
|
|
135
|
+
handled: true,
|
|
136
|
+
status: 'missing-actor',
|
|
137
|
+
remainingAttempts: CONTACT_BIND_MAX_FAILED_ATTEMPTS,
|
|
138
|
+
reply: `无法识别当前${this.options.displayName}发送者身份,未建立绑定。请重新发送绑定码或重新执行绑定流程。`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// Claim first. A matching one-time code is never reusable, including when the
|
|
142
|
+
// final Contact Book validation or write fails.
|
|
143
|
+
this.removePending(pendingKey);
|
|
144
|
+
try {
|
|
145
|
+
const alias = formatContactAlias(matchingItem.channelName, actorId);
|
|
146
|
+
mutateContactBookSync({
|
|
147
|
+
selfAid: matchingItem.selfAid,
|
|
148
|
+
actor: actorId,
|
|
149
|
+
mutation: { type: 'bind-alias-eligible', primaryId: matchingItem.primaryId, alias },
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
handled: true,
|
|
153
|
+
status: 'bound',
|
|
154
|
+
reply: `${this.options.displayName}身份绑定成功:${matchingItem.channelName}:${encodeURIComponent(actorId)} -> ${matchingItem.primaryId}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
159
|
+
return {
|
|
160
|
+
handled: true,
|
|
161
|
+
status: 'write-failed',
|
|
162
|
+
remainingAttempts: CONTACT_BIND_MAX_FAILED_ATTEMPTS,
|
|
163
|
+
reply: `${this.options.displayName}身份绑定写入失败:${message}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
get(selfAid, channelName, primaryId) {
|
|
168
|
+
const normalizedSelfAid = String(selfAid || '').trim();
|
|
169
|
+
const normalizedChannelName = normalizeContactChannelKey(normalizedSelfAid, this.options.channelType, channelName);
|
|
170
|
+
if (!normalizedChannelName)
|
|
171
|
+
return null;
|
|
172
|
+
const scope = pendingScopeKey(normalizedSelfAid, normalizedChannelName);
|
|
173
|
+
const key = primaryId
|
|
174
|
+
? pendingBindKey(normalizedSelfAid, normalizedChannelName, primaryId)
|
|
175
|
+
: this.pendingKeysByScope.get(scope)?.values().next().value;
|
|
176
|
+
const item = key ? this.pendingByPrimary.get(key) : undefined;
|
|
177
|
+
return item ? { ...item } : null;
|
|
178
|
+
}
|
|
179
|
+
clear() {
|
|
180
|
+
this.pendingByPrimary.clear();
|
|
181
|
+
this.pendingByCode.clear();
|
|
182
|
+
this.pendingKeysByScope.clear();
|
|
183
|
+
this.invalidAttemptsByActor.clear();
|
|
184
|
+
}
|
|
185
|
+
noPendingReply() {
|
|
186
|
+
return `当前没有待验证的${this.options.displayName}身份绑定码。请先在目标 Agent 的 AUN 私聊发送 /bind ${this.options.channelType},或执行 ${this.options.initCommand} 生成新的绑定码。`;
|
|
187
|
+
}
|
|
188
|
+
generateUniqueCode(scope) {
|
|
189
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
190
|
+
const code = generateBindCode();
|
|
191
|
+
if (!this.pendingByCode.has(pendingCodeKey(scope, code)))
|
|
192
|
+
return code;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
purgeExpiredAll(now) {
|
|
197
|
+
for (const [key, item] of this.pendingByPrimary) {
|
|
198
|
+
if (!item || now >= item.expiresAt)
|
|
199
|
+
this.removePending(key);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
removePending(key) {
|
|
203
|
+
const item = this.pendingByPrimary.get(key);
|
|
204
|
+
if (!item)
|
|
205
|
+
return;
|
|
206
|
+
const scope = pendingScopeKey(item.selfAid, item.channelName);
|
|
207
|
+
this.pendingByPrimary.delete(key);
|
|
208
|
+
this.pendingByCode.delete(pendingCodeKey(scope, item.code));
|
|
209
|
+
const scopeKeys = this.pendingKeysByScope.get(scope);
|
|
210
|
+
scopeKeys?.delete(key);
|
|
211
|
+
if (scopeKeys?.size === 0)
|
|
212
|
+
this.pendingKeysByScope.delete(scope);
|
|
213
|
+
}
|
|
214
|
+
recordInvalidAttempt(scope, actorId, now) {
|
|
215
|
+
const key = `${scope}\u0000${actorId || '<unknown>'}`;
|
|
216
|
+
this.purgeExpiredInvalidAttempts(now);
|
|
217
|
+
const current = this.invalidAttemptsByActor.get(key);
|
|
218
|
+
const attempts = current && now < current.expiresAt ? current.attempts + 1 : 1;
|
|
219
|
+
this.invalidAttemptsByActor.delete(key);
|
|
220
|
+
if (this.invalidAttemptsByActor.size >= CONTACT_BIND_MAX_INVALID_ACTORS) {
|
|
221
|
+
const oldestKey = this.invalidAttemptsByActor.keys().next().value;
|
|
222
|
+
if (oldestKey)
|
|
223
|
+
this.invalidAttemptsByActor.delete(oldestKey);
|
|
224
|
+
}
|
|
225
|
+
this.invalidAttemptsByActor.set(key, { attempts, expiresAt: now + CONTACT_BIND_CODE_TTL_MS });
|
|
226
|
+
return { remaining: Math.max(0, CONTACT_BIND_MAX_FAILED_ATTEMPTS - attempts) };
|
|
227
|
+
}
|
|
228
|
+
purgeExpiredInvalidAttempts(now) {
|
|
229
|
+
for (const [key, value] of this.invalidAttemptsByActor) {
|
|
230
|
+
if (now >= value.expiresAt)
|
|
231
|
+
this.invalidAttemptsByActor.delete(key);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
expiredReply() {
|
|
235
|
+
return `${this.options.displayName}身份绑定码已超时。请重新在 AUN 私聊目标 Agent 发送 /bind ${this.options.channelType},或执行 ${this.options.initCommand} 并再次绑定。`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Reserve `/bind` for channel identity binding so it is never treated as an
|
|
240
|
+
* ordinary chat message. A pending request determines whether the command can
|
|
241
|
+
* complete a binding; otherwise the user receives setup guidance.
|
|
242
|
+
*/
|
|
243
|
+
export function handleExplicitContactBindCommand(registry, ctx) {
|
|
244
|
+
const match = /^\s*\/bind(?:\s+(.+?))?\s*$/i.exec(String(ctx.content ?? ''));
|
|
245
|
+
if (!match)
|
|
246
|
+
return { handled: false };
|
|
247
|
+
if (ctx.chatType !== 'private') {
|
|
248
|
+
return {
|
|
249
|
+
handled: true,
|
|
250
|
+
status: 'private-only',
|
|
251
|
+
reply: '身份绑定仅支持私聊机器人。请在私聊中发送 /bind <6 位数字绑定码>。',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const result = registry.handle({ ...ctx, content: match[1] ?? '' });
|
|
255
|
+
if (result.handled)
|
|
256
|
+
return result;
|
|
257
|
+
return {
|
|
258
|
+
handled: true,
|
|
259
|
+
status: 'no-pending',
|
|
260
|
+
reply: registry.noPendingReply(),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function generateBindCode() {
|
|
264
|
+
return String(randomInt(0, 1_000_000)).padStart(6, '0');
|
|
265
|
+
}
|
|
266
|
+
function pendingScopeKey(selfAid, channelName) {
|
|
267
|
+
return `${String(selfAid || '').trim()}\u0000${String(channelName || '').trim()}`;
|
|
268
|
+
}
|
|
269
|
+
function pendingBindKey(selfAid, channelName, primaryId) {
|
|
270
|
+
return `${pendingScopeKey(selfAid, channelName)}\u0000${String(primaryId || '').trim()}`;
|
|
271
|
+
}
|
|
272
|
+
function pendingCodeKey(scope, code) {
|
|
273
|
+
return `${scope}\u0000${code}`;
|
|
274
|
+
}
|
|
@@ -36,9 +36,32 @@ export function readContactRevision(selfAid) {
|
|
|
36
36
|
export function mutateContactBook(request) {
|
|
37
37
|
return enqueueContactMutation(request, []);
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Prepare a mutation while holding the same cross-process lock used by every Contact Book write.
|
|
41
|
+
* The callback must stay synchronous so the lock is never retained across an await boundary.
|
|
42
|
+
*/
|
|
43
|
+
export function mutateContactBookPrepared(selfAid, prepare) {
|
|
44
|
+
const normalizedAid = normalizeAid(selfAid, 'self AID');
|
|
45
|
+
const previous = mutationTails.get(normalizedAid) ?? Promise.resolve();
|
|
46
|
+
let release;
|
|
47
|
+
const tail = new Promise(resolve => { release = resolve; });
|
|
48
|
+
mutationTails.set(normalizedAid, tail);
|
|
49
|
+
return previous
|
|
50
|
+
.catch(() => { })
|
|
51
|
+
.then(() => mutateContactBookPreparedSyncInternal(normalizedAid, prepare))
|
|
52
|
+
.finally(() => {
|
|
53
|
+
release();
|
|
54
|
+
if (mutationTails.get(normalizedAid) === tail)
|
|
55
|
+
mutationTails.delete(normalizedAid);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
39
58
|
export function mutateContactBookTransaction(request, changes) {
|
|
40
59
|
return enqueueContactMutation(request, changes);
|
|
41
60
|
}
|
|
61
|
+
/** Synchronous transaction variant for configuration writes that must commit atomically with contacts. */
|
|
62
|
+
export function mutateContactBookTransactionSync(request, changes) {
|
|
63
|
+
return mutateContactBookSyncInternal(request, changes);
|
|
64
|
+
}
|
|
42
65
|
function enqueueContactMutation(request, changes) {
|
|
43
66
|
const selfAid = normalizeAid(request.selfAid, 'self AID');
|
|
44
67
|
const previous = mutationTails.get(selfAid) ?? Promise.resolve();
|
|
@@ -139,8 +162,79 @@ function mutateContactBookSyncInternal(request, transactionChanges) {
|
|
|
139
162
|
release();
|
|
140
163
|
}
|
|
141
164
|
}
|
|
165
|
+
function mutateContactBookPreparedSyncInternal(selfAid, prepare) {
|
|
166
|
+
const release = acquireAgentLock(selfAid);
|
|
167
|
+
try {
|
|
168
|
+
recoverMutation(selfAid);
|
|
169
|
+
const file = agentContactConfig(selfAid);
|
|
170
|
+
const current = readContactBookFromDisk(selfAid);
|
|
171
|
+
const previousRevision = contactRevision(current);
|
|
172
|
+
const next = cloneBook(current);
|
|
173
|
+
const prepared = prepare({ contact: next, contactRevision: previousRevision });
|
|
174
|
+
if (prepared.mutation)
|
|
175
|
+
applyMutation(next, prepared.mutation, selfAid);
|
|
176
|
+
validateContactBook(next, selfAid);
|
|
177
|
+
const nextRevision = contactRevision(next);
|
|
178
|
+
if (nextRevision === previousRevision) {
|
|
179
|
+
return {
|
|
180
|
+
changed: false,
|
|
181
|
+
contactRevision: previousRevision,
|
|
182
|
+
previousContactRevision: previousRevision,
|
|
183
|
+
contact: next,
|
|
184
|
+
value: prepared.value,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
prepared.beforeCommit?.({
|
|
188
|
+
contact: next,
|
|
189
|
+
previousContactRevision: previousRevision,
|
|
190
|
+
contactRevision: nextRevision,
|
|
191
|
+
});
|
|
192
|
+
const before = readTextOrNull(file);
|
|
193
|
+
const after = `${JSON.stringify(next, null, 2)}\n`;
|
|
194
|
+
const changes = [{ file, before, after }];
|
|
195
|
+
writeJournal(selfAid, { schemaVersion: 1, state: 'prepared', selfAid, changes });
|
|
196
|
+
try {
|
|
197
|
+
applyTransactionChange(changes[0]);
|
|
198
|
+
writeJournal(selfAid, { schemaVersion: 1, state: 'committed', selfAid, changes });
|
|
199
|
+
removeJournal(selfAid);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
recoverMutation(selfAid);
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
notifyConfigWrite(ConfigTarget.Contact, { self: selfAid });
|
|
206
|
+
appendAudit(selfAid, {
|
|
207
|
+
selfAid,
|
|
208
|
+
actor: prepared.actor,
|
|
209
|
+
mutation: prepared.mutation,
|
|
210
|
+
}, previousRevision, nextRevision);
|
|
211
|
+
return {
|
|
212
|
+
changed: true,
|
|
213
|
+
contactRevision: nextRevision,
|
|
214
|
+
previousContactRevision: previousRevision,
|
|
215
|
+
contact: next,
|
|
216
|
+
value: prepared.value,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
release();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
142
223
|
function applyMutation(book, mutation, selfAid) {
|
|
143
224
|
const contacts = book.contacts;
|
|
225
|
+
if (mutation.type === 'batch') {
|
|
226
|
+
for (const item of mutation.mutations)
|
|
227
|
+
applyMutation(book, item, selfAid);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (mutation.type === 'ensure-contacts') {
|
|
231
|
+
for (const rawPrimaryId of mutation.primaryIds) {
|
|
232
|
+
const primaryId = normalizeContactAid(rawPrimaryId);
|
|
233
|
+
if (!contacts[primaryId])
|
|
234
|
+
contacts[primaryId] = {};
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
144
238
|
const primaryId = normalizeContactAid(mutation.primaryId);
|
|
145
239
|
const existing = contacts[primaryId];
|
|
146
240
|
if (mutation.type === 'set-status') {
|
|
@@ -150,6 +244,53 @@ function applyMutation(book, mutation, selfAid) {
|
|
|
150
244
|
contacts[primaryId] = compactEntry({
|
|
151
245
|
...existing,
|
|
152
246
|
status: mutation.status === 'active' ? undefined : mutation.status,
|
|
247
|
+
pendingRequest: undefined,
|
|
248
|
+
declinedAt: mutation.status === 'declined' ? existing?.declinedAt ?? new Date().toISOString() : undefined,
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (mutation.type === 'set-request') {
|
|
253
|
+
if (isOwner(selfAid, primaryId)) {
|
|
254
|
+
throw new ContactMutationError('OWNER_PROTECTED', 'An Agent owner cannot submit a contact request');
|
|
255
|
+
}
|
|
256
|
+
contacts[primaryId] = compactEntry({
|
|
257
|
+
...existing,
|
|
258
|
+
status: 'pending',
|
|
259
|
+
pendingRequest: { ...mutation.request },
|
|
260
|
+
declinedAt: undefined,
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (mutation.type === 'review-request') {
|
|
265
|
+
if (!existing || existing.status !== 'pending' || existing.pendingRequest?.id !== mutation.requestId) {
|
|
266
|
+
throw new ContactMutationError('REQUEST_STALE', 'The contact request has changed or was already handled');
|
|
267
|
+
}
|
|
268
|
+
if (Date.parse(existing.pendingRequest.expiresAt) <= Date.parse(mutation.now)) {
|
|
269
|
+
throw new ContactMutationError('REQUEST_EXPIRED', 'The contact request has expired');
|
|
270
|
+
}
|
|
271
|
+
if (mutation.decision === 'reject') {
|
|
272
|
+
delete contacts[primaryId];
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
contacts[primaryId] = compactEntry({
|
|
276
|
+
...existing,
|
|
277
|
+
status: mutation.decision === 'block' ? 'blocked' : undefined,
|
|
278
|
+
pendingRequest: undefined,
|
|
279
|
+
declinedAt: undefined,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (mutation.type === 'expire-request') {
|
|
285
|
+
if (!existing || existing.status !== 'pending' || existing.pendingRequest?.id !== mutation.requestId)
|
|
286
|
+
return;
|
|
287
|
+
if (Date.parse(existing.pendingRequest.expiresAt) > Date.parse(mutation.now))
|
|
288
|
+
return;
|
|
289
|
+
contacts[primaryId] = compactEntry({
|
|
290
|
+
...existing,
|
|
291
|
+
status: 'declined',
|
|
292
|
+
pendingRequest: undefined,
|
|
293
|
+
declinedAt: existing.pendingRequest.expiresAt,
|
|
153
294
|
});
|
|
154
295
|
return;
|
|
155
296
|
}
|
|
@@ -157,7 +298,14 @@ function applyMutation(book, mutation, selfAid) {
|
|
|
157
298
|
contacts[primaryId] = compactEntry({ ...existing, displayName: mutation.displayName ?? undefined });
|
|
158
299
|
return;
|
|
159
300
|
}
|
|
160
|
-
if (mutation.type === 'bind-alias') {
|
|
301
|
+
if (mutation.type === 'bind-alias' || mutation.type === 'bind-alias-eligible') {
|
|
302
|
+
if (mutation.type === 'bind-alias-eligible') {
|
|
303
|
+
const eligible = isOwner(selfAid, primaryId)
|
|
304
|
+
|| (!!existing && (existing.status === undefined || existing.status === 'active'));
|
|
305
|
+
if (!eligible) {
|
|
306
|
+
throw new ContactMutationError('CONTACT_NOT_ACTIVE', 'Only an active contact or Agent owner can bind an alias');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
161
309
|
for (const [owner, entry] of Object.entries(contacts)) {
|
|
162
310
|
if (owner !== primaryId && entry.aliases?.includes(mutation.alias)) {
|
|
163
311
|
throw new ContactMutationError('ALIAS_CONFLICT', `Alias ${mutation.alias} is already bound to ${owner}`);
|
|
@@ -186,8 +334,24 @@ function validateContactBook(book, selfAid) {
|
|
|
186
334
|
if (!isValidAid(primaryId) || isExplicitGroupId(primaryId)) {
|
|
187
335
|
throw new ContactMutationError('INVALID_CONTACT_BOOK', `Invalid contact AID: ${primaryId}`);
|
|
188
336
|
}
|
|
189
|
-
if (entry.status
|
|
190
|
-
throw new ContactMutationError('OWNER_PROTECTED', 'An Agent owner
|
|
337
|
+
if (entry.status && isOwner(selfAid, primaryId)) {
|
|
338
|
+
throw new ContactMutationError('OWNER_PROTECTED', 'An Agent owner must remain active');
|
|
339
|
+
}
|
|
340
|
+
if (entry.status === 'pending') {
|
|
341
|
+
if (!entry.pendingRequest || !Number.isFinite(Date.parse(entry.pendingRequest.submittedAt))
|
|
342
|
+
|| !Number.isFinite(Date.parse(entry.pendingRequest.expiresAt))
|
|
343
|
+
|| Date.parse(entry.pendingRequest.expiresAt) <= Date.parse(entry.pendingRequest.submittedAt)) {
|
|
344
|
+
throw new ContactMutationError('INVALID_CONTACT_BOOK', `Invalid pending request for ${primaryId}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else if (entry.pendingRequest) {
|
|
348
|
+
throw new ContactMutationError('INVALID_CONTACT_BOOK', `pendingRequest requires pending status for ${primaryId}`);
|
|
349
|
+
}
|
|
350
|
+
if (entry.status === 'declined' && !Number.isFinite(Date.parse(entry.declinedAt ?? ''))) {
|
|
351
|
+
throw new ContactMutationError('INVALID_CONTACT_BOOK', `declinedAt is required for ${primaryId}`);
|
|
352
|
+
}
|
|
353
|
+
if (entry.status !== 'declined' && entry.declinedAt) {
|
|
354
|
+
throw new ContactMutationError('INVALID_CONTACT_BOOK', `declinedAt requires declined status for ${primaryId}`);
|
|
191
355
|
}
|
|
192
356
|
for (const alias of entry.aliases ?? []) {
|
|
193
357
|
const parsed = parseContactAlias(alias, selfAid);
|
|
@@ -225,9 +389,13 @@ function readConfiguredContactChannelKeys(selfAid) {
|
|
|
225
389
|
function cloneBook(book) {
|
|
226
390
|
const contacts = {};
|
|
227
391
|
for (const [primaryId, entry] of Object.entries(book?.contacts ?? {})) {
|
|
228
|
-
contacts[primaryId] = {
|
|
392
|
+
contacts[primaryId] = {
|
|
393
|
+
...entry,
|
|
394
|
+
...(entry.aliases ? { aliases: [...entry.aliases] } : {}),
|
|
395
|
+
...(entry.pendingRequest ? { pendingRequest: { ...entry.pendingRequest } } : {}),
|
|
396
|
+
};
|
|
229
397
|
}
|
|
230
|
-
return { $schema_version:
|
|
398
|
+
return { $schema_version: 3, contacts };
|
|
231
399
|
}
|
|
232
400
|
function compactEntry(entry) {
|
|
233
401
|
return Object.fromEntries(Object.entries(entry).filter(([, value]) => value !== undefined));
|
|
@@ -70,6 +70,9 @@ export function bindContactAlias(selfAid, primaryId, channelType, actorId, chann
|
|
|
70
70
|
if (!canonicalChannelKey || !actor) {
|
|
71
71
|
throw new ContactBookError('INVALID_ALIAS', `Invalid contact alias: ${String(channelKey)}:${String(actorId)}`);
|
|
72
72
|
}
|
|
73
|
+
if (!getContactSnapshot(selfAid).contacts.has(primary)) {
|
|
74
|
+
throw new ContactBookError('NOT_FOUND', `Contact was not found: ${primary}`);
|
|
75
|
+
}
|
|
73
76
|
const alias = formatContactAlias(canonicalChannelKey, actor);
|
|
74
77
|
try {
|
|
75
78
|
return mutateContactBookSync({
|
|
@@ -143,6 +146,35 @@ export function resolveContactView(selfAid, primaryId) {
|
|
|
143
146
|
export function isBlockedContact(selfAid, primaryId) {
|
|
144
147
|
return resolveContactView(selfAid, primaryId).blocked;
|
|
145
148
|
}
|
|
149
|
+
export function isAcceptedContact(selfAid, primaryId) {
|
|
150
|
+
const id = String(primaryId || '').trim();
|
|
151
|
+
const snapshot = getContactSnapshot(selfAid);
|
|
152
|
+
const entry = snapshot.contacts.get(id);
|
|
153
|
+
return !!entry && (entry.status === undefined || entry.status === 'active');
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 对端展示名:联系人簿 displayName → principalId。仅用于展示(预览行/日志),
|
|
157
|
+
* 关系键始终用原生 peerId,不受此影响。
|
|
158
|
+
*
|
|
159
|
+
* 非 AUN 渠道的 actorId 是渠道原生 ID(飞书 open_id 等),直接展示不可读;
|
|
160
|
+
* 联系人簿已把它 alias 到主体 AID 上,取其 displayName 即可。
|
|
161
|
+
* 未绑定/联系人簿损坏时返回 undefined,由调用方按自己的兜底链继续。
|
|
162
|
+
*/
|
|
163
|
+
export function resolvePeerDisplayLabel(selfAid, channelType, actorId, channelKey) {
|
|
164
|
+
const actor = String(actorId || '').trim();
|
|
165
|
+
if (!String(selfAid || '').trim() || !actor)
|
|
166
|
+
return undefined;
|
|
167
|
+
try {
|
|
168
|
+
const { principalId } = resolvePrincipal(selfAid, channelType, actor, channelKey);
|
|
169
|
+
if (!principalId)
|
|
170
|
+
return undefined;
|
|
171
|
+
return resolveContactView(selfAid, principalId).displayName || principalId;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// 联系人簿损坏(校验失败/alias 冲突)不应影响消息处理
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
146
178
|
function buildSnapshot(book, selfAid, aliases) {
|
|
147
179
|
const contacts = new Map();
|
|
148
180
|
const blockedIndex = new Set();
|
|
@@ -3,7 +3,8 @@ import { isValidAid } from '../aun/aid/validation.js';
|
|
|
3
3
|
import { isExplicitGroupId } from '../aun/group-identity.js';
|
|
4
4
|
import { agentConfig } from '../paths.js';
|
|
5
5
|
import { getContactSnapshot, resolveContactView } from './contact-book.js';
|
|
6
|
-
import { ContactMutationError
|
|
6
|
+
import { ContactMutationError } from './contact-book-store.js';
|
|
7
|
+
import { mutateContactWithOperation } from './contact-request-service.js';
|
|
7
8
|
export function resolveContactCommand(argv) {
|
|
8
9
|
const args = argv[0] === 'contact' ? argv.slice(1) : [...argv];
|
|
9
10
|
const selfAid = String(argValue(args, '--self') || '').trim();
|
|
@@ -56,9 +57,14 @@ export async function executeResolvedContactCommand(command, actor = 'local-cli'
|
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
if (command.kind === 'set-blocked') {
|
|
59
|
-
|
|
60
|
+
if (!getContactSnapshot(command.selfAid).contacts.has(command.primaryId)) {
|
|
61
|
+
return { ok: false, code: 'NOT_FOUND', error: `Contact was not found: ${command.primaryId}` };
|
|
62
|
+
}
|
|
63
|
+
const result = await mutateContactWithOperation({
|
|
60
64
|
selfAid: command.selfAid,
|
|
61
|
-
actor,
|
|
65
|
+
actorId: actor,
|
|
66
|
+
operation: command.blocked ? 'block' : 'unblock',
|
|
67
|
+
primaryId: command.primaryId,
|
|
62
68
|
mutation: {
|
|
63
69
|
type: 'set-status',
|
|
64
70
|
primaryId: command.primaryId,
|