evolcore 0.0.26 → 0.0.27

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 (169) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/codex-managed-acl.ps1 +43 -0
  3. package/dist/agents/baseagent-availability.js +68 -0
  4. package/dist/agents/claude-runner.js +123 -22
  5. package/dist/agents/codex/app-server-client.js +63 -26
  6. package/dist/agents/codex-runner.js +316 -411
  7. package/dist/agents/ecagent-runner.js +170 -25
  8. package/dist/agents/gemini-runner.js +6 -0
  9. package/dist/agents/runner-types.js +50 -2
  10. package/dist/aun/aid/agentmd.js +73 -31
  11. package/dist/aun/aid/client.js +1 -1
  12. package/dist/aun/aid/control-aid.js +180 -1
  13. package/dist/aun/aid/identity.js +3 -3
  14. package/dist/aun/group/history.js +303 -0
  15. package/dist/aun/group/identity.js +43 -0
  16. package/dist/aun/group/index.js +5 -0
  17. package/dist/aun/{msg/group.js → group/operations.js} +161 -38
  18. package/dist/aun/{menu-client.js → menu/client.js} +12 -7
  19. package/dist/aun/menu/index.js +2 -0
  20. package/dist/aun/menu/transport.js +49 -0
  21. package/dist/aun/msg/index.js +2 -1
  22. package/dist/aun/msg/managed-operation.js +84 -18
  23. package/dist/aun/msg/mention-schema.js +51 -0
  24. package/dist/aun/msg/p2p.js +13 -8
  25. package/dist/aun/msg/upload.js +43 -88
  26. package/dist/aun/outbox.js +29 -7
  27. package/dist/aun/rpc/client.js +1 -0
  28. package/dist/aun/storage/client.js +39 -50
  29. package/dist/channels/aun.js +522 -233
  30. package/dist/channels/daemon.js +10 -6
  31. package/dist/cli/agent-command.js +108 -6
  32. package/dist/cli/agent-create.js +515 -2
  33. package/dist/cli/agent.js +109 -8
  34. package/dist/cli/aun-commands.js +191 -42
  35. package/dist/cli/config.js +8 -5
  36. package/dist/cli/contact.js +2 -2
  37. package/dist/cli/ctl-command.js +2 -2
  38. package/dist/cli/daemon-commands.js +35 -23
  39. package/dist/cli/diag.js +2 -2
  40. package/dist/cli/fs-command.js +3 -3
  41. package/dist/cli/handoff-command.js +6 -6
  42. package/dist/cli/index.js +8 -2
  43. package/dist/cli/init.js +34 -1
  44. package/dist/cli/managed-command-guard.js +2 -1
  45. package/dist/cli/menu-client.js +9 -5
  46. package/dist/cli/model.js +3 -3
  47. package/dist/cli/queue-command.js +2 -2
  48. package/dist/cli/response.js +2 -2
  49. package/dist/cli/restart-monitor.js +16 -2
  50. package/dist/cli/task-command.js +59 -0
  51. package/dist/cli/task-context.js +97 -49
  52. package/dist/cli/trigger-command.js +20 -10
  53. package/dist/config/builtin-roles.js +10 -0
  54. package/dist/config/config-field-policy.js +11 -0
  55. package/dist/config/config-manager.js +32 -4
  56. package/dist/config/config-operation-service.js +20 -5
  57. package/dist/config/contact-book-store.js +1 -1
  58. package/dist/config/contact-book.js +1 -1
  59. package/dist/config/contact-operation-service.js +1 -1
  60. package/dist/config/peer-role-resolver.js +1 -1
  61. package/dist/config/resolved-config-op.js +3 -0
  62. package/dist/config-store.js +1 -1
  63. package/dist/core/agent-application-service.js +5 -0
  64. package/dist/core/audit/event-key.js +17 -2
  65. package/dist/core/audit/log-integrity.js +34 -8
  66. package/dist/core/auth/agent-delegation.js +48 -14
  67. package/dist/core/auth/auth-gateway.js +2 -0
  68. package/dist/core/auth/authorization-audit.js +34 -38
  69. package/dist/core/auth/operation-authorizer.js +67 -5
  70. package/dist/core/auth/operation-catalog.js +110 -0
  71. package/dist/core/auth/remote-access-store.js +115 -9
  72. package/dist/core/bootstrap-messages.js +66 -7
  73. package/dist/core/bootstrap-service.js +110 -19
  74. package/dist/core/command/agent-control.js +88 -7
  75. package/dist/core/command/agent-storage-menu.js +443 -0
  76. package/dist/core/command/cli-intent-parser.js +19 -0
  77. package/dist/core/command/command-handler.js +76 -39
  78. package/dist/core/command/data-root-resolver.js +273 -0
  79. package/dist/core/command/group-menu.js +7 -2
  80. package/dist/core/command/menu-catalog.js +34 -4
  81. package/dist/core/command/menu-handler.js +348 -77
  82. package/dist/core/command/menu-protocol.js +40 -6
  83. package/dist/core/command/role-menu.js +2 -2
  84. package/dist/core/command/slash-handler.js +37 -22
  85. package/dist/core/event-catalog.js +4 -0
  86. package/dist/core/evolagent-registry.js +28 -2
  87. package/dist/core/group/invite-service.js +924 -0
  88. package/dist/core/interaction-router.js +22 -8
  89. package/dist/core/message/im-renderer.js +76 -15
  90. package/dist/core/message/message-bridge.js +94 -11
  91. package/dist/core/message/message-log.js +5 -3
  92. package/dist/core/message/message-queue.js +73 -41
  93. package/dist/core/message/response-engine.js +709 -117
  94. package/dist/core/permission/approval-gateway.js +36 -3
  95. package/dist/core/permission/ec-command-parser.js +61 -29
  96. package/dist/core/permission/protected-paths.js +134 -16
  97. package/dist/core/permission/sandbox-runtime.js +72 -3
  98. package/dist/core/permission/tool-error-code.js +15 -0
  99. package/dist/core/permission/tool-policy.js +7 -7
  100. package/dist/core/relation/peer-identity.js +57 -6
  101. package/dist/core/relation/shared-data.js +106 -0
  102. package/dist/core/role/runtime-policy.js +64 -0
  103. package/dist/core/runtime-lock.js +10 -26
  104. package/dist/core/session/runner-task-history.js +170 -0
  105. package/dist/core/session/session-manager.js +34 -6
  106. package/dist/core/session/session-mapper.js +41 -0
  107. package/dist/core/session/session-turn-coordinator.js +37 -7
  108. package/dist/core/session/session-turns.js +48 -7
  109. package/dist/core/task/task-recovery-service.js +176 -0
  110. package/dist/core/task/task-resume-operation.js +32 -0
  111. package/dist/eck/group-rules-sync.js +5 -3
  112. package/dist/eck/kit-renderer.js +1 -0
  113. package/dist/index.js +308 -54
  114. package/dist/ipc.js +141 -15
  115. package/dist/paths.js +17 -4
  116. package/dist/product.js +2 -0
  117. package/dist/trigger/event-source.js +19 -0
  118. package/dist/trigger/feedback.js +233 -40
  119. package/dist/trigger/parser.js +55 -5
  120. package/dist/trigger/patch.js +9 -0
  121. package/dist/trigger/scheduler.js +30 -4
  122. package/dist/trigger/validation.js +20 -3
  123. package/dist/utils/ecweb-utils.js +1 -0
  124. package/dist/utils/error-utils.js +40 -2
  125. package/dist/utils/logger.js +23 -13
  126. package/dist/utils/tool-summary.js +59 -3
  127. package/dist/utils/welcome.js +174 -1
  128. package/ecagent/dist/harness/agent-harness.js +3 -0
  129. package/ecagent/dist/harness/session/jsonl-storage.js +21 -2
  130. package/kits/docs/INDEX.md +0 -6
  131. package/kits/docs/channels/aun.md +15 -2
  132. package/kits/docs/channels/feishu.md +1 -1
  133. package/kits/docs/context-assembly.md +29 -17
  134. package/kits/docs/evolcore/INDEX.md +8 -3
  135. package/kits/docs/evolcore/agent-create.md +39 -7
  136. package/kits/docs/evolcore/agent.md +33 -1
  137. package/kits/docs/evolcore/aid.md +9 -1
  138. package/kits/docs/evolcore/config.md +40 -1
  139. package/kits/docs/evolcore/group-collaboration.md +1 -1
  140. package/kits/docs/evolcore/group.md +117 -2
  141. package/kits/docs/evolcore/menu-client.md +16 -0
  142. package/kits/docs/evolcore/msg.md +3 -0
  143. package/kits/docs/evolcore/storage.md +3 -0
  144. package/kits/docs/evolcore/task.md +14 -0
  145. package/kits/docs/evolcore/trigger.md +51 -3
  146. package/kits/docs/venues/group.md +5 -0
  147. package/kits/eck_manifest.json +27 -0
  148. package/kits/rules/01-overview.md +11 -7
  149. package/kits/rules/04-relation.md +2 -1
  150. package/kits/rules/05-venue.md +1 -1
  151. package/kits/rules/06-channel.md +6 -4
  152. package/kits/schemas/agent-config.schema.12.json +25 -1
  153. package/kits/schemas/daemon.schema.6.json +2 -2
  154. package/kits/schemas/defaults.schema.5.json +1 -1
  155. package/kits/schemas/relation-config.schema.1.json +1 -1
  156. package/kits/schemas/relation-config.schema.5.json +1 -1
  157. package/kits/schemas/role-config.schema.1.json +14 -0
  158. package/kits/templates/roles/member.json +5 -0
  159. package/kits/templates/roles/visitor.json +5 -0
  160. package/kits/templates/system-fragments/baseagent.md +1 -1
  161. package/kits/templates/system-fragments/bootstrap.md +26 -9
  162. package/kits/templates/system-fragments/channel.md +8 -44
  163. package/kits/templates/system-fragments/commands.md +10 -2
  164. package/kits/templates/system-fragments/group-unread-history.md +7 -0
  165. package/kits/templates/system-fragments/reply.md +56 -0
  166. package/package.json +7 -3
  167. package/dist/aun/group-identity.js +0 -9
  168. /package/dist/aun/{group-fs-download.js → group/fs-download.js} +0 -0
  169. /package/dist/aun/{msg/group-index.js → group/index-rpc.js} +0 -0
@@ -0,0 +1,924 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { atomicReadJson, atomicWriteJson } from '../../utils/atomic-write.js';
3
+ import { agentGroupInviteReviewsPath } from '../../paths.js';
4
+ import { normalizeGroupAid } from '../../aun/group/identity.js';
5
+ import { isValidAid } from '../../aun/aid/validation.js';
6
+ import { listStaticAgentOwners } from '../../config/peer-role-resolver.js';
7
+ import { read, ConfigTarget } from '../../config/config-manager.js';
8
+ import { resolveAgentLifecycle } from '../../config/lifecycle.js';
9
+ import { logger } from '../../utils/logger.js';
10
+ import { buildEnvelope, sendInteractionPayload, withEnvelopeReplyContext } from '../message/message-utils.js';
11
+ import { renderActionAsText } from '../interaction-router.js';
12
+ export const OPEN_INVITE_STATES = ['pending', 'processing', 'reconciling'];
13
+ export function recordValue(value) {
14
+ return !!value && typeof value === 'object' && !Array.isArray(value);
15
+ }
16
+ function strings(value) {
17
+ const items = Array.isArray(value) ? value : value === undefined ? [] : [value];
18
+ if (items.some(item => typeof item !== 'string' || !item.trim() || item.length > 1024)) {
19
+ throw new Error('Invalid group invitation identifiers');
20
+ }
21
+ return [...new Set(items.map(item => item.trim()))].sort();
22
+ }
23
+ /** Pending list entries are facts. Malformed entries must abort the entire snapshot, never look like absence. */
24
+ export function normalizeGroupInvite(value, selfAid) {
25
+ if (!recordValue(value))
26
+ throw new Error('Invalid group invitation');
27
+ const invitee = value.invitee_aid ?? value.inviteeAid ?? selfAid;
28
+ if (typeof invitee !== 'string' || invitee.toLowerCase() !== selfAid.toLowerCase()) {
29
+ throw new Error('Group invitation belongs to another AID');
30
+ }
31
+ if (value.status !== undefined && typeof value.status !== 'string')
32
+ throw new Error('Invalid invitation status');
33
+ if (typeof value.status === 'string' && value.status.toLowerCase() !== 'pending') {
34
+ if (['accepted', 'rejected', 'revoked', 'expired'].includes(value.status.toLowerCase()))
35
+ return null;
36
+ throw new Error('Unknown invitation status');
37
+ }
38
+ const rawGroup = value.group_id ?? value.group_aid ?? value.groupId ?? value.groupAid;
39
+ if (typeof rawGroup !== 'string')
40
+ throw new Error('Group invitation is missing group AID');
41
+ const groupId = normalizeGroupAid(rawGroup);
42
+ const inviteIds = strings(value.invite_ids ?? value.invite_id ?? value.inviteIds);
43
+ if (!inviteIds.length)
44
+ throw new Error('Group invitation is missing invite IDs');
45
+ const inviterAids = strings(value.inviter_aids ?? value.inviter_aid ?? value.inviterAids);
46
+ if (inviterAids.some(aid => !isValidAid(aid)))
47
+ throw new Error('Invalid inviter AID');
48
+ const expiry = value.expires_at ?? value.expiresAt;
49
+ const expiresAt = typeof expiry === 'number'
50
+ ? (expiry < 1e12 ? expiry * 1000 : expiry)
51
+ : typeof expiry === 'string' ? Date.parse(expiry) : undefined;
52
+ if (expiry !== undefined && expiry !== null
53
+ && (expiresAt === undefined || !Number.isFinite(expiresAt) || expiresAt < 0)) {
54
+ throw new Error('Invalid invitation expiry');
55
+ }
56
+ return { selfAid, groupId, inviteIds, inviterAids,
57
+ ...(expiresAt !== undefined && Number.isFinite(expiresAt) && expiresAt > 0 ? { expiresAt } : {}) };
58
+ }
59
+ /** One daemon owns writes; the review service serializes async work per Agent. */
60
+ export class GroupInviteReviewStore {
61
+ filePath;
62
+ constructor(filePath = agentGroupInviteReviewsPath) {
63
+ this.filePath = filePath;
64
+ }
65
+ read(selfAid) {
66
+ if (!isValidAid(selfAid))
67
+ throw new Error('Invalid review owner AID');
68
+ const snapshot = atomicReadJson(this.filePath(selfAid));
69
+ if (snapshot === null)
70
+ return { schemaVersion: 1, records: [] };
71
+ if (!recordValue(snapshot) || snapshot.schemaVersion !== 1 || !Array.isArray(snapshot.records)) {
72
+ throw new Error('Unsupported or corrupt group invitation review store');
73
+ }
74
+ const ids = new Set();
75
+ for (const item of snapshot.records) {
76
+ if (!recordValue(item) || item.selfAid !== selfAid || typeof item.reviewId !== 'string'
77
+ || !/^gir-[a-f0-9-]+$/.test(item.reviewId) || ids.has(item.reviewId)
78
+ || typeof item.groupId !== 'string' || normalizeGroupAid(item.groupId) !== item.groupId
79
+ || !Array.isArray(item.inviteIds) || !item.inviteIds.length || !Array.isArray(item.inviterAids)
80
+ || !['pending', 'processing', 'reconciling', 'accepted', 'rejected', 'stale'].includes(String(item.status))
81
+ || !Array.isArray(item.ownerDeliveries) || !Number.isFinite(item.updatedAt)
82
+ || !Number.isFinite(item.createdAt) || !Number.isSafeInteger(item.attempts)) {
83
+ throw new Error('Corrupt group invitation review record');
84
+ }
85
+ strings(item.inviteIds);
86
+ strings(item.inviterAids);
87
+ if (item.status === 'processing' || item.status === 'reconciling') {
88
+ if (!['accept', 'reject'].includes(String(item.decision)) || typeof item.actorAid !== 'string'
89
+ || !isValidAid(item.actorAid) || typeof item.selectedInviteId !== 'string'
90
+ || !item.inviteIds.includes(item.selectedInviteId) || typeof item.attemptId !== 'string') {
91
+ throw new Error('Corrupt group invitation decision');
92
+ }
93
+ }
94
+ for (const delivery of item.ownerDeliveries) {
95
+ if (!recordValue(delivery) || typeof delivery.ownerAid !== 'string' || !isValidAid(delivery.ownerAid)
96
+ || typeof delivery.interactionId !== 'string' || !delivery.interactionId.startsWith(`${item.reviewId}:`)
97
+ || typeof delivery.operationId !== 'string' || !Number.isSafeInteger(delivery.generation)
98
+ || !Number.isFinite(delivery.expiresAt) || !Array.isArray(delivery.messageIds)
99
+ || !['prepared', 'sent', 'queued', 'failed', 'expired', 'invalidated'].includes(String(delivery.status))) {
100
+ throw new Error('Corrupt group invitation delivery');
101
+ }
102
+ }
103
+ ids.add(item.reviewId);
104
+ }
105
+ return snapshot;
106
+ }
107
+ list(selfAid, states) {
108
+ return this.read(selfAid).records.filter(item => !states || states.includes(item.status));
109
+ }
110
+ create(invite) {
111
+ const normalized = normalizeGroupInvite(invite, invite.selfAid);
112
+ if (!normalized)
113
+ throw new Error('Cannot create a non-pending invitation');
114
+ invite = normalized;
115
+ const snapshot = this.read(invite.selfAid);
116
+ const open = snapshot.records.find(item => item.groupId === invite.groupId && OPEN_INVITE_STATES.includes(item.status));
117
+ if (open) {
118
+ if (invite.inviteIds.some(id => open.inviteIds.includes(id)))
119
+ return open;
120
+ throw new Error('Previous invitation round must be reconciled before creating a new round');
121
+ }
122
+ const record = {
123
+ ...invite, reviewId: `gir-${randomUUID()}`, status: 'pending', attempts: 0,
124
+ createdAt: Date.now(), updatedAt: Date.now(), ownerDeliveries: [],
125
+ };
126
+ snapshot.records.push(record);
127
+ this.write(invite.selfAid, snapshot);
128
+ return record;
129
+ }
130
+ update(selfAid, reviewId, change) {
131
+ const snapshot = this.read(selfAid);
132
+ const record = snapshot.records.find(item => item.reviewId === reviewId);
133
+ if (!record)
134
+ throw new Error('Group invitation review not found');
135
+ change(record);
136
+ record.updatedAt = Date.now();
137
+ if (['accepted', 'rejected', 'stale'].includes(record.status))
138
+ record.resolvedAt ??= Date.now();
139
+ this.write(selfAid, snapshot);
140
+ return record;
141
+ }
142
+ write(selfAid, snapshot) {
143
+ snapshot.records = snapshot.records.filter(item => OPEN_INVITE_STATES.includes(item.status)
144
+ || (item.resolvedAt ?? item.updatedAt) > Date.now() - 30 * 86400_000
145
+ || item.outcomeOwners?.some(owner => !item.outcomeDelivered?.includes(owner)));
146
+ atomicWriteJson(this.filePath(selfAid), snapshot);
147
+ }
148
+ }
149
+ /** Read every page, rejecting ambiguous/truncated/error responses before callers can reconcile absence. */
150
+ export async function readGroupPages(client, method, fields, base = {}) {
151
+ const result = [];
152
+ const seenPages = new Set();
153
+ const seenCursors = new Set();
154
+ let cursor;
155
+ for (let page = 1; page <= 100; page++) {
156
+ const raw = await client.call(method, { ...base, ...(cursor ? { cursor } : { page }), size: 100 });
157
+ if (recordValue(raw) && (raw.ok === false || raw.error))
158
+ throw new Error(`${method} returned an error`);
159
+ const data = recordValue(raw) && recordValue(raw.data) ? raw.data : raw;
160
+ if (recordValue(data) && (data.ok === false || data.error))
161
+ throw new Error(`${method} returned an error`);
162
+ const rows = Array.isArray(data) ? data
163
+ : recordValue(data) ? fields.map(field => data[field]).find(Array.isArray) : undefined;
164
+ if (!Array.isArray(rows))
165
+ throw new Error(`${method} returned an invalid list`);
166
+ const signature = JSON.stringify(rows);
167
+ if (rows.length && seenPages.has(signature))
168
+ throw new Error(`${method} repeated a page`);
169
+ seenPages.add(signature);
170
+ result.push(...rows);
171
+ const meta = recordValue(data) ? { ...(recordValue(raw) ? raw : {}), ...data } : {};
172
+ if (meta.total !== undefined && (!Number.isSafeInteger(meta.total) || Number(meta.total) < 0)) {
173
+ throw new Error(`${method} returned an invalid total`);
174
+ }
175
+ const hasMore = meta.has_more ?? meta.hasMore;
176
+ if (hasMore !== undefined && typeof hasMore !== 'boolean')
177
+ throw new Error('Invalid has_more flag');
178
+ if (meta.size !== undefined && (!Number.isSafeInteger(meta.size) || Number(meta.size) <= 0)) {
179
+ throw new Error('Invalid page size');
180
+ }
181
+ const size = typeof meta.size === 'number' ? meta.size : 100;
182
+ const next = meta.next_cursor ?? meta.nextCursor ?? meta.cursor;
183
+ if (next !== undefined && next !== null && typeof next !== 'string')
184
+ throw new Error('Invalid next cursor');
185
+ if (typeof next === 'string' && next) {
186
+ if (seenCursors.has(next))
187
+ throw new Error(`${method} repeated a cursor`);
188
+ seenCursors.add(next);
189
+ cursor = next;
190
+ continue;
191
+ }
192
+ const more = hasMore === true || (typeof meta.total === 'number' && result.length < meta.total)
193
+ || (hasMore === undefined && meta.total === undefined && next === undefined && rows.length >= size);
194
+ if (!more)
195
+ return result;
196
+ if (!rows.length || cursor)
197
+ throw new Error(`${method} returned incomplete pagination`);
198
+ }
199
+ throw new Error(`${method} exceeded the page limit`);
200
+ }
201
+ export async function listPendingGroupInvites(client, selfAid) {
202
+ const raw = await readGroupPages(client, 'group.list_my_invites', ['items', 'invites']);
203
+ const groups = new Map();
204
+ for (const value of raw) {
205
+ const invite = normalizeGroupInvite(value, selfAid);
206
+ if (!invite)
207
+ continue;
208
+ const existing = groups.get(invite.groupId);
209
+ if (existing) {
210
+ existing.inviteIds = [...new Set([...existing.inviteIds, ...invite.inviteIds])].sort();
211
+ existing.inviterAids = [...new Set([...existing.inviterAids, ...invite.inviterAids])].sort();
212
+ if (invite.expiresAt !== undefined)
213
+ existing.expiresAt = Math.min(existing.expiresAt ?? Infinity, invite.expiresAt);
214
+ }
215
+ else
216
+ groups.set(invite.groupId, invite);
217
+ }
218
+ return [...groups.values()];
219
+ }
220
+ export async function isInvitedGroupMember(client, selfAid, groupId) {
221
+ if (client.aid !== selfAid)
222
+ throw new Error('Membership query identity mismatch');
223
+ const raw = await client.call('group.list_my', { size: 200 });
224
+ if (recordValue(raw) && (raw.ok === false || raw.error))
225
+ throw new Error('group.list_my returned an error');
226
+ const data = recordValue(raw) && recordValue(raw.data) ? raw.data : raw;
227
+ const groups = Array.isArray(data) ? data
228
+ : recordValue(data) ? ['items', 'groups'].map(key => data[key]).find(Array.isArray) : undefined;
229
+ if (!Array.isArray(groups))
230
+ throw new Error('group.list_my returned an invalid list');
231
+ const ids = groups.map(item => {
232
+ const id = recordValue(item) ? item.group_id ?? item.group_aid : undefined;
233
+ if (typeof id !== 'string')
234
+ throw new Error('Invalid group membership snapshot');
235
+ return normalizeGroupAid(id);
236
+ });
237
+ if (ids.includes(groupId))
238
+ return true;
239
+ const meta = recordValue(data) ? { ...(recordValue(raw) ? raw : {}), ...data } : {};
240
+ const total = meta.total;
241
+ const hasMore = meta.has_more ?? meta.hasMore;
242
+ const next = meta.next_cursor ?? meta.nextCursor ?? meta.cursor;
243
+ if (total !== undefined && (!Number.isSafeInteger(total) || Number(total) < groups.length)) {
244
+ throw new Error('group.list_my returned an invalid total');
245
+ }
246
+ if (hasMore !== undefined && typeof hasMore !== 'boolean')
247
+ throw new Error('Invalid membership pagination');
248
+ if (hasMore === true || next || (typeof total === 'number' && total > groups.length)
249
+ || (total === undefined && hasMore !== false && groups.length >= (Number(meta.size) || 200))) {
250
+ throw new Error('group.list_my membership snapshot is incomplete');
251
+ }
252
+ return false;
253
+ }
254
+ export async function decideGroupInvite(client, action, inviteId) {
255
+ const result = action === 'accept'
256
+ ? await client.group.acceptInvite({ invite_id: inviteId })
257
+ : await client.group.rejectInvite({ invite_id: inviteId });
258
+ if (recordValue(result) && (result.error || result.ok === false)) {
259
+ const message = recordValue(result.error) ? result.error.message : result.message;
260
+ throw new Error(typeof message === 'string' ? message : 'Gateway rejected the invitation decision');
261
+ }
262
+ }
263
+ const CARD_TTL_MS = 86400_000;
264
+ const RECONCILE_TICK_MS = 30_000;
265
+ const PAGE_SIZE = 10;
266
+ const CARD_BATCH = 20;
267
+ function cleanText(value, limit) {
268
+ return (value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, limit);
269
+ }
270
+ function latestDelivery(record, owner) {
271
+ return record.ownerDeliveries.filter(item => item.ownerAid === owner).sort((a, b) => b.generation - a.generation)[0];
272
+ }
273
+ function errorText(error) {
274
+ return (error instanceof Error ? error.message : String(error)).slice(0, 512);
275
+ }
276
+ /** The daemon is the sole writer. Snapshot reconciliation and decisions share one serial lane per Agent. */
277
+ export class GroupInviteReviewService {
278
+ router;
279
+ store;
280
+ bindings = new Map();
281
+ lanes = new Map();
282
+ syncs = new Map();
283
+ timer;
284
+ constructor(router, store = new GroupInviteReviewStore()) {
285
+ this.router = router;
286
+ this.store = store;
287
+ this.timer = setInterval(() => {
288
+ for (const runtime of this.bindings.values())
289
+ if (runtime.nextSync <= Date.now())
290
+ this.wake(runtime);
291
+ }, RECONCILE_TICK_MS);
292
+ this.timer.unref?.();
293
+ }
294
+ attach(binding) {
295
+ this.detach(binding.channelKey);
296
+ this.detach(binding.channelName);
297
+ const previous = this.bindings.get(binding.selfAid);
298
+ if (previous)
299
+ this.detach(previous.channelKey);
300
+ const runtime = { ...binding, ready: false, restored: false, nextSync: 0, failures: 0, registered: new Set() };
301
+ this.bindings.set(binding.selfAid, runtime);
302
+ binding.channel.setGroupInviteReviewHandlers({
303
+ onInvite: () => this.wake(runtime),
304
+ onConnected: () => { runtime.ready = false; this.wake(runtime); },
305
+ canDeliver: (id, owner) => this.canDeliver(runtime, id, owner),
306
+ onDelivered: (id, mid) => this.recordDelivery(runtime, id, mid),
307
+ });
308
+ if (binding.channel.isConnected())
309
+ this.wake(runtime);
310
+ }
311
+ detach(channelKey) {
312
+ for (const runtime of this.bindings.values()) {
313
+ if (runtime.channelKey !== channelKey && runtime.channelName !== channelKey)
314
+ continue;
315
+ this.bindings.delete(runtime.selfAid);
316
+ runtime.channel.setGroupInviteReviewHandlers(undefined);
317
+ for (const id of runtime.registered) {
318
+ void this.router.cancel(id, 'channel_stopped').catch(error => this.log(runtime.selfAid, error));
319
+ }
320
+ runtime.registered.clear();
321
+ }
322
+ }
323
+ async dispose() {
324
+ clearInterval(this.timer);
325
+ for (const runtime of [...this.bindings.values()])
326
+ this.detach(runtime.channelKey);
327
+ await Promise.allSettled(this.lanes.values());
328
+ }
329
+ configurationChanged(selfAid) {
330
+ const runtime = this.bindings.get(selfAid);
331
+ if (runtime)
332
+ this.wake(runtime);
333
+ }
334
+ owners(selfAid) {
335
+ return [...new Set(listStaticAgentOwners(selfAid, { fresh: true }).filter(isValidAid))];
336
+ }
337
+ active(runtime) {
338
+ const config = read(ConfigTarget.Agent, { self: runtime.selfAid }, { cache: false });
339
+ return this.bindings.get(runtime.selfAid) === runtime && !!config && config.enabled !== false
340
+ && resolveAgentLifecycle(config) === 'active';
341
+ }
342
+ client(runtime) {
343
+ if (!this.active(runtime) || !runtime.channel.isConnected())
344
+ throw new Error('Agent AUN 渠道尚未就绪');
345
+ const client = runtime.channel.getClient();
346
+ if (!client || client.aid !== runtime.selfAid)
347
+ throw new Error('AUN 客户端身份不匹配');
348
+ return client;
349
+ }
350
+ assertCurrent(runtime, client) {
351
+ if (this.client(runtime) !== client)
352
+ throw new Error('AUN 连接已更换,请重试');
353
+ }
354
+ serial(selfAid, work) {
355
+ const previous = this.lanes.get(selfAid) ?? Promise.resolve();
356
+ const task = previous.catch(() => undefined).then(work);
357
+ this.lanes.set(selfAid, task);
358
+ void task.finally(() => { if (this.lanes.get(selfAid) === task)
359
+ this.lanes.delete(selfAid); }).catch(() => undefined);
360
+ return task;
361
+ }
362
+ wake(runtime) {
363
+ if (this.bindings.get(runtime.selfAid) !== runtime)
364
+ return;
365
+ void this.reconcile(runtime.selfAid).catch(error => this.log(runtime.selfAid, error));
366
+ }
367
+ reconcile(selfAid) {
368
+ const existing = this.syncs.get(selfAid);
369
+ if (existing)
370
+ return existing;
371
+ const runtime = this.bindings.get(selfAid);
372
+ if (!runtime)
373
+ return Promise.reject(new Error('邀请审核服务未就绪'));
374
+ const task = this.serial(selfAid, async () => {
375
+ try {
376
+ await this.sync(runtime);
377
+ runtime.failures = 0;
378
+ const open = this.store.list(selfAid, OPEN_INVITE_STATES);
379
+ runtime.nextSync = Date.now() + (open.some(item => item.status !== 'pending') ? 30_000 : open.length ? 300_000 : 900_000);
380
+ }
381
+ catch (error) {
382
+ runtime.failures++;
383
+ runtime.nextSync = Date.now() + Math.min(900_000, 30_000 * 2 ** Math.min(runtime.failures, 5)) + Math.random() * 5000;
384
+ throw error;
385
+ }
386
+ });
387
+ this.syncs.set(selfAid, task);
388
+ void task.finally(() => { if (this.syncs.get(selfAid) === task)
389
+ this.syncs.delete(selfAid); }).catch(() => undefined);
390
+ return task;
391
+ }
392
+ async restore(runtime) {
393
+ if (runtime.restored)
394
+ return;
395
+ for (const record of this.store.list(runtime.selfAid)) {
396
+ for (const delivery of record.ownerDeliveries)
397
+ await this.invalidate(runtime, record.reviewId, delivery, 'daemon_restarted');
398
+ if (record.status === 'processing')
399
+ this.store.update(runtime.selfAid, record.reviewId, item => { item.status = 'reconciling'; });
400
+ }
401
+ runtime.restored = true;
402
+ }
403
+ async sync(runtime) {
404
+ const client = this.client(runtime);
405
+ await this.restore(runtime);
406
+ const pending = await listPendingGroupInvites(client, runtime.selfAid);
407
+ this.assertCurrent(runtime, client);
408
+ const byGroup = new Map(pending.map(invite => [invite.groupId, invite]));
409
+ for (const snapshot of this.store.list(runtime.selfAid, OPEN_INVITE_STATES)) {
410
+ const record = this.get(runtime.selfAid, snapshot.reviewId);
411
+ const fact = byGroup.get(record.groupId);
412
+ if (record.status !== 'pending') {
413
+ try {
414
+ await this.recoverDecision(runtime, record, fact ?? null);
415
+ }
416
+ catch (error) {
417
+ this.log(runtime.selfAid, error);
418
+ }
419
+ // An unresolved request may have committed. Keep the group locked until
420
+ // we can distinguish its outcome from a later invitation round.
421
+ const latest = this.get(runtime.selfAid, record.reviewId);
422
+ if (fact && (OPEN_INVITE_STATES.includes(latest.status)
423
+ || fact.inviteIds.some(id => record.inviteIds.includes(id))))
424
+ byGroup.delete(record.groupId);
425
+ }
426
+ else if (!fact || !fact.inviteIds.some(id => record.inviteIds.includes(id))) {
427
+ await this.finish(runtime, record, 'stale');
428
+ }
429
+ else {
430
+ const changed = JSON.stringify(record.inviteIds) !== JSON.stringify(fact.inviteIds)
431
+ || JSON.stringify(record.inviterAids) !== JSON.stringify(fact.inviterAids)
432
+ || record.expiresAt !== fact.expiresAt;
433
+ this.store.update(runtime.selfAid, record.reviewId, item => {
434
+ item.inviteIds = fact.inviteIds;
435
+ item.inviterAids = fact.inviterAids;
436
+ item.expiresAt = fact.expiresAt;
437
+ });
438
+ byGroup.delete(record.groupId);
439
+ if (changed) {
440
+ for (const delivery of record.ownerDeliveries) {
441
+ if (['prepared', 'queued', 'sent'].includes(delivery.status)) {
442
+ await this.invalidate(runtime, record.reviewId, delivery, 'invitation_updated');
443
+ }
444
+ }
445
+ }
446
+ }
447
+ }
448
+ this.assertCurrent(runtime, client);
449
+ for (const invite of byGroup.values()) {
450
+ const known = this.store.list(runtime.selfAid).some(record => record.groupId === invite.groupId
451
+ && record.inviteIds.some(id => invite.inviteIds.includes(id)));
452
+ if (!known)
453
+ this.store.create(invite);
454
+ }
455
+ runtime.ready = true;
456
+ let budget = CARD_BATCH;
457
+ for (const record of this.store.list(runtime.selfAid)) {
458
+ this.assertCurrent(runtime, client);
459
+ if (record.status === 'pending')
460
+ budget -= await this.ensureCards(runtime, record, budget);
461
+ else if (!OPEN_INVITE_STATES.includes(record.status))
462
+ await this.notifyOutcome(runtime, record);
463
+ }
464
+ }
465
+ get(selfAid, id) {
466
+ const record = this.store.list(selfAid).find(item => item.reviewId === id);
467
+ if (!record)
468
+ throw new Error('未找到邀请审核记录');
469
+ return record;
470
+ }
471
+ canDeliver(runtime, id, owner) {
472
+ if (this.bindings.get(runtime.selfAid) !== runtime || !this.active(runtime))
473
+ return 'stale';
474
+ if (!runtime.ready)
475
+ return 'defer';
476
+ try {
477
+ const record = this.store.list(runtime.selfAid).find(item => item.ownerDeliveries.some(d => d.interactionId === id));
478
+ const delivery = record?.ownerDeliveries.find(item => item.interactionId === id);
479
+ return record?.status === 'pending' && delivery?.ownerAid === owner && delivery.expiresAt > Date.now()
480
+ && ['prepared', 'queued', 'sent'].includes(delivery.status) && runtime.registered.has(id)
481
+ && this.owners(runtime.selfAid).includes(owner) ? 'allow' : 'stale';
482
+ }
483
+ catch {
484
+ return 'defer';
485
+ }
486
+ }
487
+ recordDelivery(runtime, id, mid) {
488
+ const record = this.store.list(runtime.selfAid).find(item => item.ownerDeliveries.some(d => d.interactionId === id));
489
+ if (!record)
490
+ return;
491
+ this.store.update(runtime.selfAid, record.reviewId, item => {
492
+ const delivery = item.ownerDeliveries.find(d => d.interactionId === id);
493
+ delivery.messageIds = [...new Set([...delivery.messageIds, mid])];
494
+ if (this.canDeliver(runtime, id, delivery.ownerAid) === 'allow')
495
+ delivery.status = 'sent';
496
+ });
497
+ }
498
+ async invalidate(runtime, id, delivery, reason) {
499
+ runtime.registered.delete(delivery.interactionId);
500
+ this.store.update(runtime.selfAid, id, record => {
501
+ const saved = record.ownerDeliveries.find(item => item.interactionId === delivery.interactionId);
502
+ if (saved.status !== 'expired')
503
+ saved.status = 'invalidated';
504
+ });
505
+ try {
506
+ await this.router.cancel(delivery.interactionId, reason);
507
+ if (runtime.channel.invalidateGroupInviteCard) {
508
+ await runtime.channel.invalidateGroupInviteCard(delivery.interactionId, delivery.ownerAid, delivery.messageIds);
509
+ }
510
+ else
511
+ await runtime.adapter.invalidateInteraction?.(delivery.interactionId, reason);
512
+ }
513
+ catch (error) {
514
+ this.log(runtime.selfAid, error);
515
+ }
516
+ }
517
+ async ensureCards(runtime, record, budget, reopenOwner) {
518
+ const owners = this.owners(runtime.selfAid);
519
+ for (const delivery of record.ownerDeliveries) {
520
+ if (!owners.includes(delivery.ownerAid) && !['invalidated', 'expired'].includes(delivery.status)) {
521
+ await this.invalidate(runtime, record.reviewId, delivery, 'owner_removed');
522
+ }
523
+ }
524
+ let sent = 0;
525
+ for (const owner of owners) {
526
+ if (sent >= budget || (reopenOwner && owner !== reopenOwner))
527
+ continue;
528
+ const current = this.get(runtime.selfAid, record.reviewId);
529
+ if (current.status !== 'pending')
530
+ break;
531
+ const latest = latestDelivery(current, owner);
532
+ if (latest) {
533
+ if (latest.expiresAt <= Date.now() && latest.status !== 'expired') {
534
+ await this.invalidate(runtime, record.reviewId, latest, 'expired');
535
+ this.store.update(runtime.selfAid, record.reviewId, item => {
536
+ item.ownerDeliveries.find(d => d.interactionId === latest.interactionId).status = 'expired';
537
+ });
538
+ }
539
+ if (!reopenOwner && (latest.status === 'expired' || latest.expiresAt <= Date.now()))
540
+ continue;
541
+ if (!reopenOwner && latest.status === 'sent' && runtime.registered.has(latest.interactionId))
542
+ continue;
543
+ if (!reopenOwner && latest.status === 'queued') {
544
+ const state = latest.outboxId ? runtime.channel.getGroupInviteDeliveryState?.(latest.outboxId) : undefined;
545
+ if (state?.status === 'sent' && state.messageId) {
546
+ this.recordDelivery(runtime, latest.interactionId, state.messageId);
547
+ continue;
548
+ }
549
+ if (state?.status === 'queued')
550
+ continue;
551
+ if (!state || state.status === 'missing') {
552
+ await this.invalidate(runtime, record.reviewId, latest, 'outbox_missing');
553
+ this.store.update(runtime.selfAid, record.reviewId, item => {
554
+ item.ownerDeliveries.find(d => d.interactionId === latest.interactionId).status = 'failed';
555
+ });
556
+ }
557
+ }
558
+ if (!reopenOwner && latest.nextRetryAt && latest.nextRetryAt > Date.now())
559
+ continue;
560
+ await this.invalidate(runtime, record.reviewId, latest, 'replaced');
561
+ }
562
+ const expiresAt = Math.min(Date.now() + CARD_TTL_MS, record.expiresAt ?? Infinity);
563
+ if (expiresAt <= Date.now())
564
+ continue;
565
+ const generation = (latest?.generation ?? 0) + 1;
566
+ const interactionId = `${record.reviewId}:${owner}:${generation}`;
567
+ const delivery = {
568
+ ownerAid: owner, generation, interactionId, operationId: `group-invite-review:${interactionId}`,
569
+ status: 'prepared', messageIds: [], expiresAt,
570
+ };
571
+ this.store.update(runtime.selfAid, record.reviewId, item => { item.ownerDeliveries.push(delivery); });
572
+ await this.deliverCard(runtime, record, delivery);
573
+ sent++;
574
+ }
575
+ return sent;
576
+ }
577
+ async deliverCard(runtime, record, delivery) {
578
+ const client = this.client(runtime);
579
+ const summary = await runtime.channel.getGroupInviteSummary(record.groupId);
580
+ this.assertCurrent(runtime, client);
581
+ const inviterText = record.inviterAids.slice(0, 5).join('、') || '未知';
582
+ const groupText = summary.name ? `${cleanText(summary.name, 120)}(${record.groupId})` : record.groupId;
583
+ const body = [`受邀 Agent:${cleanText(runtime.agentName, 80)}(${record.selfAid})`, `群聊:${groupText}`,
584
+ `邀请者:${inviterText}${record.inviterAids.length > 5 ? ` 等 ${record.inviterAids.length} 位` : ''}`,
585
+ `待处理邀请:${record.inviteIds.length} 条`,
586
+ ...(summary.description ? [`群简介:${cleanText(summary.description, 240)}`] : []),
587
+ '', '接受后,该 Agent 将作为成员加入此群。'].join('\n');
588
+ const interaction = {
589
+ type: 'interaction', id: delivery.interactionId, channelId: delivery.ownerAid,
590
+ sessionId: `group-invite:${record.reviewId}`, initiatorId: delivery.ownerAid,
591
+ blocking: false, expiresAt: delivery.expiresAt,
592
+ fallback: { command: 'invites', buttonArgMap: { accept: `${record.reviewId} accept`, reject: `${record.reviewId} reject` } },
593
+ kind: { kind: 'action', title: '群聊邀请待确认', bodyFormat: 'plain', body,
594
+ buttons: [{ key: 'accept', label: '接受入群', style: 'primary' }, { key: 'reject', label: '拒绝邀请', style: 'danger' }] },
595
+ };
596
+ runtime.registered.add(delivery.interactionId);
597
+ this.router.register(delivery.interactionId, interaction.sessionId, (action, values, operator, channelKey) => {
598
+ if (operator !== delivery.ownerAid || channelKey !== runtime.channelKey)
599
+ return false;
600
+ if (values?.behavior === 'dismiss') {
601
+ if (this.canDeliver(runtime, delivery.interactionId, operator) !== 'allow')
602
+ return false;
603
+ runtime.registered.delete(delivery.interactionId);
604
+ void this.serial(runtime.selfAid, async () => {
605
+ this.store.update(runtime.selfAid, record.reviewId, item => {
606
+ item.ownerDeliveries.find(d => d.interactionId === delivery.interactionId).status = 'expired';
607
+ });
608
+ await this.invalidate(runtime, record.reviewId, delivery, 'dismissed');
609
+ }).catch(error => this.log(runtime.selfAid, error));
610
+ return true;
611
+ }
612
+ if (action !== 'accept' && action !== 'reject')
613
+ return false;
614
+ const claimed = this.claimDecision(runtime, record.reviewId, operator, action, delivery.interactionId);
615
+ if (!claimed.accepted) {
616
+ void this.notice(runtime, delivery.ownerAid, `retry:${delivery.interactionId}`, claimed.text);
617
+ runtime.registered.delete(delivery.interactionId);
618
+ return false;
619
+ }
620
+ void this.serial(runtime.selfAid, () => this.runClaimedDecision(runtime, claimed.record))
621
+ .catch(error => this.log(runtime.selfAid, error));
622
+ void this.notice(runtime, delivery.ownerAid, `claimed:${claimed.record.attemptId}`, '已记录你的决定,正在向群服务核实结果。');
623
+ return true;
624
+ }, {
625
+ blocking: false, initiatorId: delivery.ownerAid, initiatorChannelKey: runtime.channelKey,
626
+ timeoutMs: Math.max(1, delivery.expiresAt - Date.now()),
627
+ onTimeout: () => {
628
+ runtime.registered.delete(delivery.interactionId);
629
+ this.store.update(runtime.selfAid, record.reviewId, item => {
630
+ item.ownerDeliveries.find(d => d.interactionId === delivery.interactionId).status = 'expired';
631
+ });
632
+ void Promise.resolve(runtime.adapter.invalidateInteraction?.(delivery.interactionId, 'expired'))
633
+ .catch(error => this.log(runtime.selfAid, error));
634
+ },
635
+ });
636
+ try {
637
+ if (this.canDeliver(runtime, delivery.interactionId, delivery.ownerAid) !== 'allow') {
638
+ await this.invalidate(runtime, record.reviewId, delivery, 'owner_changed');
639
+ return;
640
+ }
641
+ const envelope = withEnvelopeReplyContext(this.envelope(runtime, delivery.ownerAid, delivery.operationId, interaction.sessionId), {
642
+ delivery: { chatType: 'private' }, attention: 'required',
643
+ metadata: { encrypted: true, source: 'daemon', chatmode: 'interactive', operationId: delivery.operationId },
644
+ });
645
+ const receipt = await sendInteractionPayload(runtime.adapter, envelope, interaction, renderActionAsText(interaction), envelope.replyContext);
646
+ if (!receipt || typeof receipt === 'string' || (receipt.status !== 'sent' && receipt.status !== 'queued')) {
647
+ throw new Error('群邀请卡片投递失败');
648
+ }
649
+ this.store.update(runtime.selfAid, record.reviewId, item => {
650
+ const saved = item.ownerDeliveries.find(d => d.interactionId === delivery.interactionId);
651
+ if (receipt.status === 'sent') {
652
+ saved.messageIds = [...new Set([...saved.messageIds, ...receipt.messages.map(message => message.messageId)])];
653
+ if (saved.status !== 'invalidated')
654
+ saved.status = 'sent';
655
+ }
656
+ else if (saved.status !== 'sent' && saved.status !== 'invalidated') {
657
+ saved.status = 'queued';
658
+ saved.outboxId = receipt.outboxId;
659
+ }
660
+ });
661
+ }
662
+ catch (error) {
663
+ await this.invalidate(runtime, record.reviewId, delivery, 'delivery_failed');
664
+ this.store.update(runtime.selfAid, record.reviewId, item => {
665
+ const saved = item.ownerDeliveries.find(d => d.interactionId === delivery.interactionId);
666
+ saved.status = 'failed';
667
+ saved.nextRetryAt = Date.now() + 60_000;
668
+ });
669
+ this.log(runtime.selfAid, error);
670
+ await this.notice(runtime, delivery.ownerAid, `fallback:${delivery.interactionId}`, renderActionAsText(interaction));
671
+ }
672
+ }
673
+ envelope(runtime, owner, operationId, sessionId) {
674
+ return buildEnvelope({ operationId, taskId: operationId, sessionId, channel: runtime.channelName,
675
+ channelId: owner, agentName: runtime.agentName, chatmode: 'interactive', delivery: { chatType: 'private' },
676
+ replyContext: { delivery: { chatType: 'private' }, metadata: { operationId } } });
677
+ }
678
+ async notice(runtime, owner, operation, text) {
679
+ if (!this.active(runtime) || !this.owners(runtime.selfAid).includes(owner))
680
+ return false;
681
+ try {
682
+ const operationId = `group-invite-notice:${operation}:${owner}`;
683
+ const envelope = withEnvelopeReplyContext(this.envelope(runtime, owner, operationId), {
684
+ delivery: { chatType: 'private' }, attention: 'required',
685
+ metadata: { encrypted: true, source: 'daemon', chatmode: 'interactive', operationId },
686
+ });
687
+ const receipt = await runtime.adapter.send(envelope, { kind: 'command.result', text });
688
+ return !!receipt && (receipt.status === 'sent' || receipt.status === 'queued');
689
+ }
690
+ catch (error) {
691
+ this.log(runtime.selfAid, error);
692
+ return false;
693
+ }
694
+ }
695
+ async finish(runtime, record, status) {
696
+ const updated = this.store.update(runtime.selfAid, record.reviewId, item => {
697
+ item.status = status;
698
+ item.resolvedAt ??= Date.now();
699
+ item.outcomeOwners = [...new Set([...item.ownerDeliveries.map(d => d.ownerAid), ...(item.actorAid ? [item.actorAid] : [])])];
700
+ item.outcomeDelivered ??= [];
701
+ });
702
+ logger.info(`[GroupInviteReview] outcome self=${runtime.selfAid} review=${record.reviewId} status=${status}`);
703
+ for (const delivery of updated.ownerDeliveries)
704
+ await this.invalidate(runtime, record.reviewId, delivery, status);
705
+ if (status === 'accepted')
706
+ runtime.channel.refreshGroupInviteMembership?.(record.groupId);
707
+ await this.notifyOutcome(runtime, this.get(runtime.selfAid, record.reviewId));
708
+ }
709
+ async notifyOutcome(runtime, record) {
710
+ const text = record.status === 'accepted' ? `已确认加入群聊 ${record.groupId}。`
711
+ : record.status === 'rejected' ? `已拒绝群聊 ${record.groupId} 的邀请。`
712
+ : `群聊 ${record.groupId} 的这轮邀请已不再待处理,可能已撤回、过期或由其它客户端处理。`;
713
+ for (const owner of record.outcomeOwners ?? [...new Set([...record.ownerDeliveries.map(d => d.ownerAid), ...(record.actorAid ? [record.actorAid] : [])])]) {
714
+ if (record.outcomeDelivered?.includes(owner))
715
+ continue;
716
+ const revoked = !this.owners(runtime.selfAid).includes(owner);
717
+ if (revoked || await this.notice(runtime, owner, `outcome:${record.reviewId}`, text)) {
718
+ this.store.update(runtime.selfAid, record.reviewId, item => {
719
+ item.outcomeDelivered = [...new Set([...(item.outcomeDelivered ?? []), owner])];
720
+ });
721
+ }
722
+ }
723
+ }
724
+ claimDecision(runtime, id, owner, action, interactionId) {
725
+ const record = this.get(runtime.selfAid, id);
726
+ if (!this.active(runtime) || !this.owners(runtime.selfAid).includes(owner))
727
+ return { accepted: false, text: '只有当前 Agent Owner 可以审核。' };
728
+ if (record.status !== 'pending')
729
+ return { accepted: false, text: OPEN_INVITE_STATES.includes(record.status)
730
+ ? '该邀请正在核实处理结果,暂不接受其它决定。' : '这轮邀请已经处理或失效。' };
731
+ if (interactionId && this.canDeliver(runtime, interactionId, owner) !== 'allow')
732
+ return { accepted: false, text: '卡片已失效,请用 /invites 重新打开。' };
733
+ if (!runtime.ready)
734
+ return { accepted: false, text: '邀请状态尚未完成核实,请稍后重试。' };
735
+ const decision = this.store.update(runtime.selfAid, id, item => {
736
+ item.status = 'processing';
737
+ item.decision = action;
738
+ item.actorAid = owner;
739
+ item.selectedInviteId = item.inviteIds[0];
740
+ item.attemptId = randomUUID();
741
+ item.attempts++;
742
+ item.submittedAt = Date.now();
743
+ item.nextAttemptAt = Date.now() + 30_000;
744
+ });
745
+ runtime.nextSync = Math.min(runtime.nextSync, Date.now() + 30_000);
746
+ logger.info(`[GroupInviteReview] decision self=${runtime.selfAid} review=${id} actor=${owner} action=${action}`);
747
+ return { accepted: true, record: decision };
748
+ }
749
+ async runClaimedDecision(runtime, decision) {
750
+ try {
751
+ const client = this.client(runtime);
752
+ const current = this.get(runtime.selfAid, decision.reviewId);
753
+ if (current.status !== 'processing' || current.attemptId !== decision.attemptId)
754
+ return;
755
+ const pending = (await listPendingGroupInvites(client, runtime.selfAid)).find(item => item.groupId === decision.groupId);
756
+ this.assertCurrent(runtime, client);
757
+ const selected = pending?.inviteIds.find(invite => decision.inviteIds.includes(invite));
758
+ if (!selected) {
759
+ if (current.decision === 'accept') {
760
+ const joined = await isInvitedGroupMember(client, runtime.selfAid, current.groupId);
761
+ this.assertCurrent(runtime, client);
762
+ if (joined) {
763
+ await this.finish(runtime, current, 'accepted');
764
+ return;
765
+ }
766
+ }
767
+ this.store.update(runtime.selfAid, current.reviewId, item => {
768
+ item.status = 'stale';
769
+ item.resolvedAt = Date.now();
770
+ });
771
+ await this.finish(runtime, this.get(runtime.selfAid, current.reviewId), 'stale');
772
+ return;
773
+ }
774
+ if (selected !== current.selectedInviteId) {
775
+ this.store.update(runtime.selfAid, decision.reviewId, item => { item.selectedInviteId = selected; });
776
+ }
777
+ await this.executeDecision(runtime, this.get(runtime.selfAid, decision.reviewId), client);
778
+ }
779
+ catch (error) {
780
+ if (this.bindings.get(runtime.selfAid) !== runtime)
781
+ return;
782
+ const current = this.get(runtime.selfAid, decision.reviewId);
783
+ if (OPEN_INVITE_STATES.includes(current.status)) {
784
+ this.store.update(runtime.selfAid, decision.reviewId, item => {
785
+ item.status = 'reconciling';
786
+ item.error = errorText(error);
787
+ item.nextAttemptAt = Date.now() + 30_000;
788
+ });
789
+ await this.notice(runtime, decision.actorAid, `uncertain:${decision.attemptId}`, '群邀请决定已记录,服务端结果暂未确认。系统会继续核实,此时不接受相反决定。');
790
+ }
791
+ this.log(runtime.selfAid, error);
792
+ }
793
+ }
794
+ async decide(runtime, id, owner, action, interactionId) {
795
+ const claimed = this.claimDecision(runtime, id, owner, action, interactionId);
796
+ if (!claimed.accepted)
797
+ return claimed;
798
+ await this.runClaimedDecision(runtime, claimed.record);
799
+ const result = this.get(runtime.selfAid, id);
800
+ return { accepted: true, groupId: result.groupId, status: result.status,
801
+ text: result.status === 'accepted' ? `已确认加入 ${result.groupId}。` : result.status === 'rejected'
802
+ ? `已拒绝 ${result.groupId} 的邀请。` : result.status === 'stale' ? '这轮邀请已不再待处理,未执行新的入群操作。' : '决定已记录,正在核实服务端结果;请稍后查看 /invites。' };
803
+ }
804
+ async executeDecision(runtime, record, client) {
805
+ try {
806
+ this.assertCurrent(runtime, client);
807
+ if (!this.owners(runtime.selfAid).includes(record.actorAid))
808
+ throw new Error('Owner 权限已变化,暂停重试');
809
+ await decideGroupInvite(client, record.decision, record.selectedInviteId);
810
+ }
811
+ catch (error) {
812
+ if (this.bindings.get(runtime.selfAid) !== runtime)
813
+ return;
814
+ const current = this.get(runtime.selfAid, record.reviewId);
815
+ if (!OPEN_INVITE_STATES.includes(current.status)) {
816
+ this.log(runtime.selfAid, error);
817
+ return;
818
+ }
819
+ this.store.update(runtime.selfAid, record.reviewId, item => {
820
+ item.status = 'reconciling';
821
+ item.error = errorText(error);
822
+ item.nextAttemptAt = Date.now() + Math.min(900_000, 30_000 * 2 ** Math.min(item.attempts - 1, 5));
823
+ });
824
+ this.log(runtime.selfAid, error);
825
+ await this.notice(runtime, record.actorAid, `uncertain:${record.attemptId}`, '群邀请决定已记录,服务端结果暂未确认。系统会继续核实,此时不接受相反决定。');
826
+ return;
827
+ }
828
+ // Persist the authoritative RPC outcome before best-effort invalidation and notifications.
829
+ const status = record.decision === 'accept' ? 'accepted' : 'rejected';
830
+ try {
831
+ await this.finish(runtime, record, status);
832
+ }
833
+ catch (error) {
834
+ this.log(runtime.selfAid, error);
835
+ }
836
+ }
837
+ async recoverDecision(runtime, record, pending) {
838
+ const client = this.client(runtime);
839
+ const selected = pending?.inviteIds.find(id => record.inviteIds.includes(id));
840
+ if (!selected) {
841
+ if (record.decision === 'accept') {
842
+ // Failure to read membership is unknown, not proof of non-membership.
843
+ const joined = await isInvitedGroupMember(client, runtime.selfAid, record.groupId);
844
+ this.assertCurrent(runtime, client);
845
+ if (joined) {
846
+ await this.finish(runtime, record, 'accepted');
847
+ return;
848
+ }
849
+ }
850
+ await this.finish(runtime, record, 'stale');
851
+ return;
852
+ }
853
+ if (selected !== record.selectedInviteId) {
854
+ this.store.update(runtime.selfAid, record.reviewId, item => { item.selectedInviteId = selected; });
855
+ record.selectedInviteId = selected;
856
+ }
857
+ if (record.nextAttemptAt && record.nextAttemptAt > Date.now())
858
+ return;
859
+ // Revocation stops future writes; never unlock the opposite action while an old request may have committed.
860
+ if (!this.owners(runtime.selfAid).includes(record.actorAid))
861
+ return;
862
+ const updated = this.store.update(runtime.selfAid, record.reviewId, item => {
863
+ item.status = 'reconciling';
864
+ item.attempts++;
865
+ item.submittedAt = Date.now();
866
+ });
867
+ await this.executeDecision(runtime, updated, client);
868
+ }
869
+ async decideManaged(selfAid, owner, inviteId, action) {
870
+ const runtime = this.bindings.get(selfAid);
871
+ if (!runtime || !this.owners(selfAid).includes(owner))
872
+ return { accepted: false, text: 'Agent Owner 审核通道不可用。' };
873
+ await this.reconcile(selfAid);
874
+ return this.serial(selfAid, async () => {
875
+ const record = this.store.list(selfAid, OPEN_INVITE_STATES).find(item => item.inviteIds.includes(inviteId));
876
+ return record ? this.decide(runtime, record.reviewId, owner, action) : { accepted: false, text: '邀请已失效或不存在。' };
877
+ });
878
+ }
879
+ async handleCommand(request) {
880
+ if (!/^\s*\/invites(?:\s|$)/i.test(request.content))
881
+ return { handled: false };
882
+ const runtime = this.bindings.get(request.selfAid);
883
+ const answer = (text) => ({ handled: true, text });
884
+ if (request.channelType !== 'aun' || request.chatType !== 'private' || !runtime
885
+ || request.channelKey !== runtime.channelKey || !this.owners(request.selfAid).includes(request.ownerAid)) {
886
+ return answer('请在当前 Agent Owner 的 AUN 私聊中查看或审核群邀请。');
887
+ }
888
+ const args = request.content.trim().split(/\s+/).slice(1);
889
+ try {
890
+ if (args.length === 2 && ['accept', 'reject'].includes(args[1])) {
891
+ const result = await this.serial(request.selfAid, () => this.decide(runtime, args[0], request.ownerAid, args[1]));
892
+ return answer(result.text);
893
+ }
894
+ if (args.length === 2 && args[1] === 'open') {
895
+ await this.reconcile(request.selfAid);
896
+ await this.serial(request.selfAid, async () => {
897
+ const record = this.get(request.selfAid, args[0]);
898
+ if (record.status !== 'pending')
899
+ throw new Error('邀请已处理或正在核实');
900
+ await this.ensureCards(runtime, record, 1, request.ownerAid);
901
+ });
902
+ return answer('已提交卡片投递;也可使用 /invites <审核编号> accept|reject。');
903
+ }
904
+ if (args.length > 1 || (args.length === 1 && !/^[1-9]\d{0,5}$/.test(args[0]))) {
905
+ return answer('用法:/invites [页码];/invites <审核编号> open|accept|reject。');
906
+ }
907
+ await this.reconcile(request.selfAid);
908
+ const records = this.store.list(request.selfAid, OPEN_INVITE_STATES);
909
+ if (!records.length)
910
+ return answer('当前没有待处理的群邀请。');
911
+ const page = Math.min(Number(args[0] ?? 1), Math.ceil(records.length / PAGE_SIZE));
912
+ const rows = records.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
913
+ const lines = rows.map(item => `${item.reviewId}\n群聊:${item.groupId};${item.status === 'pending' ? '待审核' : '正在核实结果'}`);
914
+ return answer(`待处理邀请 ${records.length} 项,第 ${page}/${Math.ceil(records.length / PAGE_SIZE)} 页\n\n${lines.join('\n\n')}\n\n/invites <审核编号> open|accept|reject`);
915
+ }
916
+ catch (error) {
917
+ this.log(request.selfAid, error);
918
+ return answer(`暂时无法处理邀请:${errorText(error)}。待处理记录已保留。`);
919
+ }
920
+ }
921
+ log(selfAid, error) {
922
+ logger.warn(`[GroupInviteReview] self=${selfAid} ${errorText(error)}`);
923
+ }
924
+ }