@zhin.js/host-http 1.0.1 → 1.0.3

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.
@@ -9,12 +9,18 @@
9
9
  * - cron:`ctx.scheduleHost`(Plugin Runtime ScheduleHost,仅 register/list,
10
10
  * 无持久化引擎,故 cron:add/remove/pause/resume 报"未接线")。
11
11
  * - inbox:`ctx.databaseHost.models`(unified_inbox_message/request/notice 三张表,
12
- * 表不存在或查询失败 → 空数组 + inboxEnabled:false)。
13
- * - 社交/群管:`ctx.resolveEndpoint` 拿 live endpoint 实例,
14
- * 实例上探测到对应方法才调用,否则报"该平台不支持"。
12
+ * 表不存在或查询失败 → 空数组 + inboxEnabled:false;requestConsumed/noticeConsumed
13
+ * 按行 id update consumed=1,表/model 缺失时报"未接线")。
14
+ * - 社交/群管:`ctx.resolveEndpoint` 拿 live endpoint 实例,只消费其
15
+ * `management` 语义端口;平台 SDK 兼容与字段归一化由 Adapter 负责。
15
16
  */
16
17
 
17
- /** 新 Runtime 统一收件箱表名(与 packages/im/zhin/src/setup/register-inbox.ts 一致)。 */
18
+ /** 新 Runtime 统一收件箱表名(SSOT:@zhin.js/plugin-runtime inbox.js)。 */
19
+ import {
20
+ assertDemoConsoleRpcAllowed,
21
+ normalizeConsoleRpcType,
22
+ } from '@zhin.js/console-protocol';
23
+
18
24
  const TABLE_MESSAGE = 'unified_inbox_message';
19
25
  const TABLE_REQUEST = 'unified_inbox_request';
20
26
  const TABLE_NOTICE = 'unified_inbox_notice';
@@ -25,36 +31,35 @@ export interface ConsoleRpcExtendedCtx {
25
31
  projectRoot: string;
26
32
  /** Plugin Runtime ScheduleHost(basic/cli schedule-host-installer 提供)。 */
27
33
  scheduleHost?: unknown;
28
- /** 经 ImRuntime / AdapterEndpointIndex 解析 live endpoint 实例。 */
34
+ /** 经 ImRuntime / AdapterEndpointIndex 解析管理语义端口。 */
35
+ resolveEndpointManagement?: (
36
+ adapter: string,
37
+ endpointId: string,
38
+ ) => EndpointManagementPort | null | undefined;
39
+ /** @deprecated Use `resolveEndpointManagement`; accepted for one compatibility cycle. */
29
40
  resolveEndpoint?: (adapter: string, endpointId: string) => unknown;
30
41
  /** Plugin Runtime DatabaseHost 的 models 视图。 */
31
42
  databaseHost?: { models: { get(name: string): unknown } };
43
+ /** Agent Host 持久化调度引擎(@zhin.js/agent getAssistantRuntime().engine),未 init 时返回 null。 */
44
+ resolveScheduleEngine?: () => ConsoleScheduleEngine | null | undefined;
32
45
  }
33
46
 
34
- export type ExtendedRpcResult = { data: unknown } | { error: string };
47
+ export interface ConsoleScheduleEngine {
48
+ addJob(job: Record<string, unknown>): Promise<Record<string, unknown>>;
49
+ removeJob(id: string): Promise<boolean>;
50
+ pauseJob(id: string): Promise<boolean>;
51
+ resumeJob(id: string): Promise<boolean>;
52
+ listJobs(): Promise<Record<string, unknown>[]>;
53
+ }
35
54
 
36
- /** demo scope 只读放行清单之外的写操作 type。 */
37
- const WRITE_TYPES = new Set([
38
- 'cron:add',
39
- 'cron:remove',
40
- 'cron:pause',
41
- 'cron:resume',
42
- 'endpoint:requestApprove',
43
- 'endpoint:requestReject',
44
- 'endpoint:requestConsumed',
45
- 'endpoint:noticeConsumed',
46
- 'endpoint:groupKick',
47
- 'endpoint:groupMute',
48
- 'endpoint:groupAdmin',
49
- 'endpoint:deleteFriend',
50
- ]);
55
+ export type ExtendedRpcResult = { data: unknown } | { error: string };
51
56
 
52
57
  const CRON_NOT_WIRED =
53
58
  '持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
54
59
  '不支持 add/remove/pause/resume(需要 @zhin.js/agent 持久化调度引擎)';
55
60
 
56
61
  const CONSUMED_NOT_WIRED =
57
- '收件箱已读标记未接线:新 Runtime 尚未提供 request/notice 的 consumed 写路径';
62
+ '收件箱已读标记未接线:unified_inbox_request/notice 表未注册或 DatabaseHost 未启动';
58
63
 
59
64
  interface ScheduleJobRow {
60
65
  id: string;
@@ -62,13 +67,41 @@ interface ScheduleJobRow {
62
67
  description?: string;
63
68
  }
64
69
 
70
+ interface InboxSelection {
71
+ where(query: Record<string, unknown>): InboxSelection;
72
+ /** 可选:DB 侧排序下推(wrapModel 链具备该能力,轻量 fake 可缺失)。 */
73
+ orderBy?(field: string, direction?: 'ASC' | 'DESC'): InboxSelection;
74
+ /** 可选:DB 侧 limit 下推。 */
75
+ limit?(count: number): InboxSelection;
76
+ then<TResult1 = Record<string, unknown>[], TResult2 = never>(
77
+ onfulfilled?: ((value: Record<string, unknown>[]) => TResult1 | PromiseLike<TResult1>) | null,
78
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
79
+ ): Promise<TResult1 | TResult2>;
80
+ }
81
+
65
82
  interface InboxModel {
66
- select(): {
67
- where(query: Record<string, unknown>): Promise<Record<string, unknown>[]> | Record<string, unknown>[];
83
+ select(): InboxSelection;
84
+ update?(patch: Record<string, unknown>): {
85
+ where(query: Record<string, unknown>): Promise<unknown> | unknown;
68
86
  };
69
87
  }
70
88
 
71
- type EndpointAny = Record<string, unknown>;
89
+ interface EndpointManagementPort {
90
+ listFriends?(): Promise<readonly { user_id: number | string; nickname: string; remark: string }[]>;
91
+ listGroups?(): Promise<readonly { group_id: number | string; name: string }[]>;
92
+ listChannels?(): Promise<readonly {
93
+ id: string;
94
+ name?: string;
95
+ parent?: { type: string; id: string; name?: string };
96
+ }[]>;
97
+ listGroupMembers?(groupId: string): Promise<readonly unknown[]>;
98
+ approveRequest?(requestId: string, remark?: string): Promise<void>;
99
+ rejectRequest?(requestId: string, reason?: string): Promise<void>;
100
+ kickGroupMember?(groupId: string, userId: string): Promise<void>;
101
+ muteGroupMember?(groupId: string, userId: string, durationSeconds: number): Promise<void>;
102
+ setGroupAdmin?(groupId: string, userId: string, enabled: boolean): Promise<void>;
103
+ deleteFriend?(userId: string): Promise<void>;
104
+ }
72
105
 
73
106
  export async function dispatchExtendedConsoleRpc(
74
107
  type: string,
@@ -76,9 +109,11 @@ export async function dispatchExtendedConsoleRpc(
76
109
  ctx: ConsoleRpcExtendedCtx,
77
110
  ): Promise<ExtendedRpcResult | undefined> {
78
111
  const d = data ?? {};
112
+ type = normalizeConsoleRpcType(type);
79
113
 
80
- if (WRITE_TYPES.has(type) && !ctx.fullScope) {
81
- return { error: `Demo scope: RPC "${type}" is forbidden` };
114
+ if (!ctx.fullScope) {
115
+ const denied = assertDemoConsoleRpcAllowed(type);
116
+ if (denied) return { error: denied };
82
117
  }
83
118
 
84
119
  switch (type) {
@@ -87,59 +122,61 @@ export async function dispatchExtendedConsoleRpc(
87
122
  return listSchedule(ctx);
88
123
 
89
124
  case 'cron:add':
125
+ return addCron(d, ctx);
90
126
  case 'cron:remove':
91
127
  case 'cron:pause':
92
128
  case 'cron:resume':
93
- return { error: CRON_NOT_WIRED };
129
+ return mutateCron(type, d, ctx);
94
130
 
95
- case 'endpoint:requests':
131
+ case 'request.list':
96
132
  return listPendingRequests(d, ctx);
97
- case 'endpoint:inboxRequests':
133
+ case 'inbox.requests':
98
134
  return listInbox(d, ctx, TABLE_REQUEST, 'requests', mapRequestRow);
99
- case 'endpoint:inboxNotices':
135
+ case 'inbox.notices':
100
136
  return listInbox(d, ctx, TABLE_NOTICE, 'notices', mapNoticeRow);
101
- case 'endpoint:inboxMessages':
137
+ case 'inbox.messages':
102
138
  return listInboxMessages(d, ctx);
103
139
 
104
- case 'endpoint:requestApprove':
105
- case 'endpoint:requestReject':
140
+ case 'request.approve':
141
+ case 'request.reject':
106
142
  return actOnRequest(type, d, ctx);
107
143
 
108
- case 'endpoint:requestConsumed':
109
- case 'endpoint:noticeConsumed':
110
- return { error: CONSUMED_NOT_WIRED };
144
+ case 'request.consumed':
145
+ return markInboxConsumed(d, ctx, TABLE_REQUEST);
146
+ case 'notice.consumed':
147
+ return markInboxConsumed(d, ctx, TABLE_NOTICE);
111
148
 
112
- case 'endpoint:friends':
149
+ case 'endpoint.friends':
113
150
  return listFriends(d, ctx);
114
- case 'endpoint:groups':
151
+ case 'endpoint.groups':
115
152
  return listGroups(d, ctx);
116
- case 'endpoint:channels':
153
+ case 'endpoint.channels':
117
154
  return listChannels(d, ctx);
118
- case 'endpoint:groupMembers':
155
+ case 'endpoint.group_members':
119
156
  return listGroupMembers(d, ctx);
120
157
 
121
- case 'endpoint:groupKick':
158
+ case 'endpoint.group_kick':
122
159
  return groupWriteOp(d, ctx, {
123
- methods: ['removeMember', 'kickMember', 'setGroupKick'],
160
+ method: 'kickGroupMember',
124
161
  buildArgs: (gid, uid) => [gid, uid],
125
162
  requireUser: true,
126
163
  unsupported: '踢出群成员',
127
164
  });
128
- case 'endpoint:groupMute':
165
+ case 'endpoint.group_mute':
129
166
  return groupWriteOp(d, ctx, {
130
- methods: ['muteMember', 'setGroupBan', 'banMember'],
167
+ method: 'muteGroupMember',
131
168
  buildArgs: (gid, uid, extra) => [gid, uid, extra.duration ?? 600],
132
169
  requireUser: true,
133
170
  unsupported: '禁言群成员',
134
171
  });
135
- case 'endpoint:groupAdmin':
172
+ case 'endpoint.group_admin':
136
173
  return groupWriteOp(d, ctx, {
137
- methods: ['setModerator', 'setGroupAdmin', 'setAdmin'],
174
+ method: 'setGroupAdmin',
138
175
  buildArgs: (gid, uid, extra) => [gid, uid, extra.enable !== false],
139
176
  requireUser: true,
140
177
  unsupported: '设置群管理员',
141
178
  });
142
- case 'endpoint:deleteFriend':
179
+ case 'endpoint.delete_friend':
143
180
  return deleteFriend(d, ctx);
144
181
 
145
182
  default:
@@ -149,7 +186,7 @@ export async function dispatchExtendedConsoleRpc(
149
186
 
150
187
  // ---------------------------------------------------------------- cron
151
188
 
152
- function listSchedule(ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
189
+ function listSchedule(ctx: ConsoleScheduleListCtx): Promise<ExtendedRpcResult> | ExtendedRpcResult {
153
190
  const host = ctx.scheduleHost as { list?: () => unknown } | undefined;
154
191
  if (!host || typeof host.list !== 'function') {
155
192
  return { error: '调度服务未配置(scheduleHost 未挂载)' };
@@ -165,9 +202,94 @@ function listSchedule(ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
165
202
  id: job.id,
166
203
  cron: job.cron,
167
204
  ...(typeof job.description === 'string' ? { description: job.description } : {}),
205
+ // console cron 页渲染字段(由 schedule host 提供时透传)
206
+ expression: (job as { expression?: string }).expression ?? job.cron,
207
+ running: (job as { running?: boolean }).running ?? true,
208
+ ...((job as { plugin?: string }).plugin ? { plugin: (job as { plugin?: string }).plugin } : {}),
209
+ ...((job as { nextExecution?: number }).nextExecution
210
+ ? { nextExecution: (job as { nextExecution?: number }).nextExecution } : {}),
168
211
  }));
169
- // persistent 引擎未接入新 Runtime,返回空数组保持 legacy 形状。
170
- return { data: { memory, persistent: [] } };
212
+ return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent } }));
213
+ } catch (error) {
214
+ return { error: errorMessage(error) };
215
+ }
216
+ }
217
+
218
+ type ConsoleScheduleListCtx = ConsoleRpcExtendedCtx;
219
+
220
+ /** 持久化任务(Agent Host ScheduleJobEngine)→ 前端 PersistentCron 形状。 */
221
+ async function listPersistentJobs(ctx: ConsoleRpcExtendedCtx): Promise<Record<string, unknown>[]> {
222
+ const engine = ctx.resolveScheduleEngine?.();
223
+ if (!engine) return [];
224
+ try {
225
+ const jobs = await engine.listJobs();
226
+ return jobs.map((job) => ({
227
+ id: job.id,
228
+ label: job.label ?? job.id,
229
+ cronExpression: (job.schedule as { cron?: string } | undefined)?.cron ?? '',
230
+ prompt: (job.action as { prompt?: string } | undefined)?.prompt ?? '',
231
+ enabled: job.enabled !== false,
232
+ source: job.source ?? 'manual',
233
+ createdAt: (job as { createdAt?: number }).createdAt,
234
+ nextExecution: (job.state as { nextRunAtMs?: number } | undefined)?.nextRunAtMs,
235
+ ...((job.notify as { target?: unknown } | undefined)?.target
236
+ ? { context: { target: (job.notify as { target?: unknown }).target } } : {}),
237
+ lastExecutedAt: (job.state as { lastExecutedAt?: number } | undefined)?.lastExecutedAt,
238
+ lastStatus: (job.state as { lastStatus?: string } | undefined)?.lastStatus,
239
+ }));
240
+ } catch {
241
+ return [];
242
+ }
243
+ }
244
+
245
+ async function addCron(
246
+ d: Record<string, unknown>,
247
+ ctx: ConsoleRpcExtendedCtx,
248
+ ): Promise<ExtendedRpcResult> {
249
+ const engine = ctx.resolveScheduleEngine?.();
250
+ if (!engine) return { error: CRON_NOT_WIRED };
251
+ const cronExpression = String(d.cronExpression ?? '').trim();
252
+ const prompt = String(d.prompt ?? '').trim();
253
+ if (!cronExpression) return { error: 'cronExpression is required' };
254
+ if (!prompt) return { error: 'prompt is required' };
255
+ const label = typeof d.label === 'string' && d.label.trim() ? d.label.trim() : undefined;
256
+ const context = (d.context ?? {}) as Record<string, unknown>;
257
+ const target = context.target ?? context.channel;
258
+ const job = {
259
+ id: `console-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
260
+ ...(label ? { label } : {}),
261
+ enabled: true,
262
+ schedule: { kind: 'solar', cron: cronExpression },
263
+ action: { kind: 'agent', prompt },
264
+ notify: target
265
+ ? { channel: 'im', target }
266
+ : { channel: 'silent' },
267
+ source: 'manual',
268
+ };
269
+ try {
270
+ const created = await engine.addJob(job);
271
+ return { data: { job: created, success: true } };
272
+ } catch (error) {
273
+ return { error: errorMessage(error) };
274
+ }
275
+ }
276
+
277
+ async function mutateCron(
278
+ type: 'cron:remove' | 'cron:pause' | 'cron:resume',
279
+ d: Record<string, unknown>,
280
+ ctx: ConsoleRpcExtendedCtx,
281
+ ): Promise<ExtendedRpcResult> {
282
+ const engine = ctx.resolveScheduleEngine?.();
283
+ if (!engine) return { error: CRON_NOT_WIRED };
284
+ const id = String(d.id ?? '').trim();
285
+ if (!id) return { error: 'id is required' };
286
+ try {
287
+ const ok = type === 'cron:remove'
288
+ ? await engine.removeJob(id)
289
+ : type === 'cron:pause'
290
+ ? await engine.pauseJob(id)
291
+ : await engine.resumeJob(id);
292
+ return ok ? { data: { success: true } } : { error: `任务不存在: ${id}` };
171
293
  } catch (error) {
172
294
  return { error: errorMessage(error) };
173
295
  }
@@ -185,11 +307,24 @@ async function readInboxRows(
185
307
  ctx: ConsoleRpcExtendedCtx,
186
308
  table: string,
187
309
  where: Record<string, unknown>,
310
+ options?: {
311
+ /** DB 侧排序下推(模型不支持时由调用方内存排序兜底)。 */
312
+ orderBy?: { field: string; direction?: 'ASC' | 'DESC' };
313
+ /** DB 侧 limit 下推(模型不支持时由调用方内存分页兜底)。 */
314
+ limit?: number;
315
+ },
188
316
  ): Promise<{ rows: Record<string, unknown>[]; enabled: boolean }> {
189
317
  const model = getInboxModel(ctx, table);
190
318
  if (!model) return { rows: [], enabled: false };
191
319
  try {
192
- const rows = await model.select().where(where);
320
+ let selection: InboxSelection = model.select().where(where);
321
+ if (options?.orderBy && typeof selection.orderBy === 'function') {
322
+ selection = selection.orderBy(options.orderBy.field, options.orderBy.direction ?? 'DESC');
323
+ }
324
+ if (options?.limit != null && typeof selection.limit === 'function') {
325
+ selection = selection.limit(options.limit);
326
+ }
327
+ const rows = await selection;
193
328
  return { rows: Array.isArray(rows) ? rows : [], enabled: true };
194
329
  } catch {
195
330
  // 表未创建 / 方言未启动等场景降级为空,不向上抛。
@@ -212,6 +347,10 @@ async function listInbox(
212
347
  const { rows, enabled } = await readInboxRows(ctx, table, {
213
348
  adapter,
214
349
  endpoint_id: endpointId,
350
+ }, {
351
+ // sort/limit 下推到 DB 侧(内存 sort/slice 保留,作为无下推能力模型的兜底)
352
+ orderBy: { field: 'created_at', direction: 'DESC' },
353
+ limit: offset + limit,
215
354
  });
216
355
  const sorted = rows
217
356
  .slice()
@@ -221,7 +360,7 @@ async function listInbox(
221
360
  return { data: { [key]: sorted, inboxEnabled: enabled } };
222
361
  }
223
362
 
224
- /** endpoint:requests —— 未处理的好友/群请求(unified_inbox_request 中 resolved=0)。 */
363
+ /** request.list —— 未处理的好友/群请求(unified_inbox_request 中 resolved=0)。 */
225
364
  async function listPendingRequests(
226
365
  d: Record<string, unknown>,
227
366
  ctx: ConsoleRpcExtendedCtx,
@@ -232,6 +371,8 @@ async function listPendingRequests(
232
371
  const { rows, enabled } = await readInboxRows(ctx, TABLE_REQUEST, {
233
372
  adapter,
234
373
  endpoint_id: endpointId,
374
+ }, {
375
+ orderBy: { field: 'created_at', direction: 'ASC' },
235
376
  });
236
377
  const requests = rows
237
378
  .filter((row) => Number(row.resolved ?? 0) === 0)
@@ -266,7 +407,10 @@ async function listInboxMessages(
266
407
  where.channel_parent_type = parent.type;
267
408
  where.channel_parent_id = parent.id;
268
409
  }
269
- const { rows, enabled } = await readInboxRows(ctx, TABLE_MESSAGE, where);
410
+ const { rows, enabled } = await readInboxRows(ctx, TABLE_MESSAGE, where, {
411
+ // before_ts/before_id 过滤保留在内存(涉及双字段比较),排序下推 DB 侧
412
+ orderBy: { field: 'created_at', direction: 'DESC' },
413
+ });
270
414
  const messages = rows
271
415
  .filter((row) => (beforeTs == null || Number(row.created_at ?? 0) < beforeTs)
272
416
  && (beforeId == null || Number(row.id ?? 0) < beforeId))
@@ -287,62 +431,113 @@ async function listInboxMessages(
287
431
  }
288
432
 
289
433
  function mapRequestRow(row: Record<string, unknown>): Record<string, unknown> {
434
+ const actorId = row.actor_id;
435
+ const actorName = row.actor_name ?? undefined;
436
+ const sceneId = row.scene_id;
437
+ const sceneType = row.scene_type ?? undefined;
290
438
  return {
291
439
  id: row.id,
292
440
  platform_request_id: row.platform_request_id,
441
+ // console endpoint-detail 期望的 camelCase/扁平别名(SSE 推送路径同款形状)
442
+ platformRequestId: row.platform_request_id,
293
443
  type: row.type,
294
- scene_type: row.scene_type ?? undefined,
295
- scene_id: row.scene_id,
444
+ scene_type: sceneType,
445
+ scene_id: sceneId,
446
+ channel_id: sceneId,
447
+ channel_type: sceneType,
448
+ channel: { id: sceneId, type: sceneType },
296
449
  sub_type: row.sub_type ?? undefined,
297
- actor: { id: row.actor_id, name: row.actor_name ?? undefined },
450
+ actor: { id: actorId, name: actorName },
451
+ sender: { id: actorId, name: actorName },
452
+ sender_id: actorId,
453
+ sender_name: actorName,
298
454
  comment: row.comment ?? undefined,
299
455
  created_at: row.created_at,
456
+ timestamp: row.created_at,
300
457
  resolved: row.resolved,
301
458
  resolved_at: row.resolved_at ?? undefined,
302
459
  };
303
460
  }
304
461
 
305
462
  function mapNoticeRow(row: Record<string, unknown>): Record<string, unknown> {
463
+ const actorId = row.actor_id ?? undefined;
464
+ const actorName = row.actor_name ?? undefined;
465
+ const sceneId = row.scene_id;
466
+ const sceneType = row.scene_type ?? undefined;
306
467
  return {
307
468
  id: row.id,
308
469
  platform_notice_id: row.platform_notice_id,
309
470
  type: row.type,
310
- scene_type: row.scene_type ?? undefined,
311
- scene_id: row.scene_id,
471
+ scene_type: sceneType,
472
+ scene_id: sceneId,
473
+ channel_id: sceneId,
474
+ channel_type: sceneType,
312
475
  sub_type: row.sub_type ?? undefined,
313
- actor_id: row.actor_id ?? undefined,
314
- actor_name: row.actor_name ?? undefined,
476
+ actor_id: actorId,
477
+ actor_name: actorName,
478
+ // console endpoint-detail 期望的操作人字段别名
479
+ operator_id: actorId,
480
+ operator_name: actorName,
315
481
  target_id: row.target_id ?? undefined,
316
482
  target_name: row.target_name ?? undefined,
317
483
  payload: row.payload,
318
484
  created_at: row.created_at,
485
+ timestamp: row.created_at,
319
486
  };
320
487
  }
321
488
 
489
+ // ---------------------------------------------------------------- consumed 标记
490
+
491
+ /**
492
+ * request.consumed / notice.consumed —— 按行 id 置 consumed=1。
493
+ * 表未注册(或 model 无 update)时保持"未接线"报错。
494
+ */
495
+ async function markInboxConsumed(
496
+ d: Record<string, unknown>,
497
+ ctx: ConsoleRpcExtendedCtx,
498
+ table: string,
499
+ ): Promise<ExtendedRpcResult> {
500
+ const ids = numArrayField(d, '$row_ids', 'row_ids', 'rowIds', '$ids', 'ids');
501
+ // console UI 单条已读发 data:{id},归一为数组
502
+ if (ids.length === 0) {
503
+ const single = Number(d.id ?? d.$id);
504
+ if (Number.isFinite(single) && single > 0) ids.push(single);
505
+ }
506
+ if (ids.length === 0) return { error: '$row_ids required' };
507
+ const model = getInboxModel(ctx, table);
508
+ if (!model || typeof model.update !== 'function') return { error: CONSUMED_NOT_WIRED };
509
+ const now = Date.now();
510
+ try {
511
+ for (const id of ids) {
512
+ await model.update({ consumed: 1, consumed_at: now }).where({ id });
513
+ }
514
+ return { data: { success: true, updated: ids.length } };
515
+ } catch (error) {
516
+ return { error: errorMessage(error) };
517
+ }
518
+ }
519
+
322
520
  // ---------------------------------------------------------------- 请求审批
323
521
 
324
522
  async function actOnRequest(
325
- type: 'endpoint:requestApprove' | 'endpoint:requestReject',
523
+ type: 'request.approve' | 'request.reject',
326
524
  d: Record<string, unknown>,
327
525
  ctx: ConsoleRpcExtendedCtx,
328
526
  ): Promise<ExtendedRpcResult> {
329
527
  const adapter = strField(d, '$adapter', 'adapter');
330
528
  const endpointId = strField(d, '$endpoint', 'endpointId', 'endpoint');
331
- const requestId = strField(d, '$id', 'id', 'platformRequestId', 'platform_request_id');
529
+ const requestId = strField(d, '$id', 'id', 'platformRequestId', 'platform_request_id', 'requestId');
332
530
  if (!adapter || !endpointId || !requestId) {
333
531
  return { error: '$adapter, $endpoint, $id required' };
334
532
  }
335
533
  const resolved = resolveLiveEndpoint(ctx, adapter, endpointId);
336
534
  if ('error' in resolved) return resolved;
337
- const endpoint = resolved.endpoint;
338
- const approve = type === 'endpoint:requestApprove';
339
- const methodNames = approve
340
- ? ['approveRequest', 'approve_request', '$approveRequest']
341
- : ['rejectRequest', 'reject_request', '$rejectRequest'];
342
- const method = pickMethod(endpoint, methodNames);
535
+ const management = resolved.management;
536
+ const approve = type === 'request.approve';
537
+ const method = approve ? management.approveRequest : management.rejectRequest;
343
538
  if (!method) {
344
539
  return {
345
- error: `请求审批未接线:当前平台(${adapter})endpoint 不支持 ${methodNames[0]},` +
540
+ error: `请求审批未接线:当前平台(${adapter})endpoint 不支持 ${approve ? 'approveRequest' : 'rejectRequest'},` +
346
541
  '且新 Runtime 尚未挂载 pending request 注册表',
347
542
  };
348
543
  }
@@ -350,7 +545,7 @@ async function actOnRequest(
350
545
  const extra = approve
351
546
  ? strField(d, '$remark', 'remark')
352
547
  : strField(d, '$reason', 'reason');
353
- await method.call(endpoint, requestId, extra || undefined);
548
+ await method.call(management, requestId, extra || undefined);
354
549
  return { data: { success: true } };
355
550
  } catch (error) {
356
551
  return { error: errorMessage(error) };
@@ -365,25 +560,10 @@ async function listFriends(
365
560
  ): Promise<ExtendedRpcResult> {
366
561
  const resolved = requireEndpoint(d, ctx);
367
562
  if ('error' in resolved) return resolved;
368
- const { endpoint, adapter } = resolved;
563
+ const { management, adapter } = resolved;
369
564
  try {
370
- const map = (endpoint.friends ?? endpoint.fl) as Map<unknown, Record<string, unknown>> | undefined;
371
- if (map && typeof map.values === 'function') {
372
- const friends = Array.from(map.values()).map((f) => ({
373
- user_id: Number(f.user_id),
374
- nickname: String(f.nickname ?? ''),
375
- remark: String(f.remark ?? ''),
376
- }));
377
- return { data: { friends, count: friends.length } };
378
- }
379
- const getFriendList = pickMethod(endpoint, ['getFriendList', 'getFriends', 'listFriends']);
380
- if (getFriendList) {
381
- const raw = await getFriendList.call(endpoint);
382
- const friends = unwrapList(raw).map((row) => ({
383
- user_id: Number(row.user_id ?? row.userId ?? row.id ?? 0),
384
- nickname: String(row.nickname ?? row.name ?? row.user_id ?? ''),
385
- remark: String(row.remark ?? ''),
386
- }));
565
+ if (management.listFriends) {
566
+ const friends = [...await management.listFriends()];
387
567
  return { data: { friends, count: friends.length } };
388
568
  }
389
569
  return { error: `当前适配器(${adapter})不支持好友列表` };
@@ -398,23 +578,10 @@ async function listGroups(
398
578
  ): Promise<ExtendedRpcResult> {
399
579
  const resolved = requireEndpoint(d, ctx);
400
580
  if ('error' in resolved) return resolved;
401
- const { endpoint, adapter } = resolved;
581
+ const { management, adapter } = resolved;
402
582
  try {
403
- const map = (endpoint.groups ?? endpoint.gl) as Map<unknown, Record<string, unknown>> | undefined;
404
- if (map && typeof map.values === 'function') {
405
- const groups = Array.from(map.values()).map((g) => ({
406
- group_id: Number(g.group_id),
407
- name: String(g.group_name ?? g.name ?? g.group_id ?? ''),
408
- }));
409
- return { data: { groups, count: groups.length } };
410
- }
411
- const getGroupList = pickMethod(endpoint, ['getGroupList', 'getGroups', 'listGroups']);
412
- if (getGroupList) {
413
- const raw = await getGroupList.call(endpoint);
414
- const groups = unwrapList(raw).map((row) => ({
415
- group_id: Number(row.group_id ?? row.groupId ?? row.id ?? 0),
416
- name: String(row.name ?? row.group_name ?? row.group_id ?? ''),
417
- }));
583
+ if (management.listGroups) {
584
+ const groups = [...await management.listGroups()];
418
585
  return { data: { groups, count: groups.length } };
419
586
  }
420
587
  return { error: `当前适配器(${adapter})不支持群列表` };
@@ -429,43 +596,10 @@ async function listChannels(
429
596
  ): Promise<ExtendedRpcResult> {
430
597
  const resolved = requireEndpoint(d, ctx);
431
598
  if ('error' in resolved) return resolved;
432
- const { endpoint, adapter } = resolved;
599
+ const { management, adapter } = resolved;
433
600
  try {
434
- const channels: Array<{
435
- id: string;
436
- name?: string;
437
- parent?: { type: string; id: string; name?: string };
438
- }> = [];
439
- const getGuildChannelList = pickMethod(endpoint, ['getGuildChannelList']);
440
- if (getGuildChannelList) {
441
- const list = await getGuildChannelList.call(endpoint) as Array<Record<string, unknown>>;
442
- for (const item of Array.isArray(list) ? list : []) {
443
- const parent = normalizeParent(item.parent);
444
- channels.push({
445
- id: String(item.id ?? ''),
446
- ...(item.name != null ? { name: String(item.name) } : {}),
447
- ...(parent ? { parent } : {}),
448
- });
449
- }
450
- return { data: { channels, count: channels.length } };
451
- }
452
- const getGuilds = pickMethod(endpoint, ['getGuilds', 'listGuilds']);
453
- const getChannels = pickMethod(endpoint, ['getChannels', 'listChannels']);
454
- if (getGuilds && getChannels) {
455
- const guilds = unwrapList(await getGuilds.call(endpoint));
456
- for (const g of guilds) {
457
- const gid = String(g.id ?? g.guild_id ?? '');
458
- if (!gid) continue;
459
- const guildName = String(g.name ?? g.guild_name ?? gid);
460
- const chs = unwrapList(await getChannels.call(endpoint, gid));
461
- for (const c of chs) {
462
- channels.push({
463
- id: String(c.id ?? c.channel_id ?? ''),
464
- name: String(c.name ?? c.channel_name ?? c.id ?? ''),
465
- parent: { type: 'guild', id: gid, name: guildName },
466
- });
467
- }
468
- }
601
+ if (management.listChannels) {
602
+ const channels = [...await management.listChannels()];
469
603
  return { data: { channels, count: channels.length } };
470
604
  }
471
605
  return { error: `当前适配器(${adapter})不支持频道列表` };
@@ -486,22 +620,14 @@ async function listGroupMembers(
486
620
  }
487
621
  const resolved = resolveLiveEndpoint(ctx, adapter, endpointId);
488
622
  if ('error' in resolved) return resolved;
489
- const { endpoint } = resolved;
623
+ const { management } = resolved;
490
624
  try {
491
- const method = pickMethod(endpoint, ['getGroupMemberList', 'listMembers', 'getMemberList']);
625
+ const method = management.listGroupMembers;
492
626
  if (!method) {
493
627
  return { error: `当前适配器(${adapter})不支持群成员列表` };
494
628
  }
495
- const raw = await method.call(endpoint, groupId);
496
- if (Array.isArray(raw)) {
497
- return { data: { members: raw, count: raw.length } };
498
- }
499
- // Map(icqq 风格 gml)→ values 数组
500
- if (raw && typeof (raw as Map<unknown, unknown>).values === 'function') {
501
- const members = Array.from((raw as Map<unknown, unknown>).values());
502
- return { data: { members, count: members.length } };
503
- }
504
- return { data: raw ?? { members: [], count: 0 } };
629
+ const members = [...await method(groupId)];
630
+ return { data: { members, count: members.length } };
505
631
  } catch (error) {
506
632
  return { error: errorMessage(error) };
507
633
  }
@@ -510,7 +636,7 @@ async function listGroupMembers(
510
636
  // ---------------------------------------------------------------- 群管/好友写操作
511
637
 
512
638
  interface GroupWriteSpec {
513
- methods: string[];
639
+ method: 'kickGroupMember' | 'muteGroupMember' | 'setGroupAdmin';
514
640
  buildArgs(groupId: string, userId: string, extra: { duration?: number; enable?: boolean }): unknown[];
515
641
  requireUser: boolean;
516
642
  /** 中文操作名,用于"该平台不支持 xxx"。 */
@@ -534,8 +660,8 @@ async function groupWriteOp(
534
660
  }
535
661
  const resolved = resolveLiveEndpoint(ctx, adapter, endpointId);
536
662
  if ('error' in resolved) return resolved;
537
- const { endpoint } = resolved;
538
- const method = pickMethod(endpoint, spec.methods);
663
+ const { management } = resolved;
664
+ const method = management[spec.method];
539
665
  if (!method) {
540
666
  return { error: `当前适配器(${adapter})不支持${spec.unsupported}` };
541
667
  }
@@ -548,7 +674,7 @@ async function groupWriteOp(
548
674
  : undefined,
549
675
  enable: typeof enableRaw === 'boolean' ? enableRaw : undefined,
550
676
  });
551
- await method.call(endpoint, ...args);
677
+ await (method as (...args: unknown[]) => Promise<void>).call(management, ...args);
552
678
  return { data: { success: true } };
553
679
  } catch (error) {
554
680
  return { error: errorMessage(error) };
@@ -567,14 +693,13 @@ async function deleteFriend(
567
693
  }
568
694
  const resolved = resolveLiveEndpoint(ctx, adapter, endpointId);
569
695
  if ('error' in resolved) return resolved;
570
- const { endpoint } = resolved;
571
- const method = pickMethod(endpoint, ['deleteFriend', 'delete_friend']);
696
+ const { management } = resolved;
697
+ const method = management.deleteFriend;
572
698
  if (!method) {
573
699
  return { error: '当前适配器暂不支持删除好友' };
574
700
  }
575
701
  try {
576
- const numeric = Number(userId);
577
- await method.call(endpoint, Number.isFinite(numeric) && userId.trim() !== '' ? numeric : userId);
702
+ await method(userId);
578
703
  return { data: { success: true } };
579
704
  } catch (error) {
580
705
  return { error: errorMessage(error) };
@@ -609,24 +734,34 @@ function optionalNum(d: Record<string, unknown>, ...keys: string[]): number | un
609
734
  return undefined;
610
735
  }
611
736
 
612
- function pickMethod(
613
- endpoint: EndpointAny,
614
- names: readonly string[],
615
- ): ((...args: unknown[]) => unknown) | undefined {
616
- for (const name of names) {
617
- const fn = endpoint[name];
618
- if (typeof fn === 'function') return fn as (...args: unknown[]) => unknown;
737
+ /** 数字数组字段($row_ids 等):取首个数组值,收敛为有限数字。 */
738
+ function numArrayField(d: Record<string, unknown>, ...keys: string[]): number[] {
739
+ for (const key of keys) {
740
+ const value = d[key];
741
+ if (!Array.isArray(value)) continue;
742
+ return value
743
+ .map((item) => Number(item))
744
+ .filter((item) => Number.isFinite(item));
619
745
  }
620
- return undefined;
746
+ return [];
621
747
  }
622
748
 
623
- type ResolveOk = { endpoint: EndpointAny; adapter: string; endpointId: string };
749
+ type ResolveOk = {
750
+ management: EndpointManagementPort;
751
+ adapter: string;
752
+ endpointId: string;
753
+ };
624
754
 
625
755
  function resolveLiveEndpoint(
626
756
  ctx: ConsoleRpcExtendedCtx,
627
757
  adapter: string,
628
758
  endpointId: string,
629
759
  ): ResolveOk | { error: string } {
760
+ if (ctx.resolveEndpointManagement) {
761
+ const management = ctx.resolveEndpointManagement(adapter, endpointId);
762
+ if (!management) return { error: 'endpoint not found' };
763
+ return { management, adapter, endpointId };
764
+ }
630
765
  if (!ctx.resolveEndpoint) {
631
766
  return { error: 'Endpoint registry is not configured' };
632
767
  }
@@ -634,7 +769,14 @@ function resolveLiveEndpoint(
634
769
  if (!endpoint || typeof endpoint !== 'object') {
635
770
  return { error: 'endpoint not found' };
636
771
  }
637
- return { endpoint: endpoint as EndpointAny, adapter, endpointId };
772
+ const management = (endpoint as { management?: unknown }).management;
773
+ return {
774
+ management: management && typeof management === 'object'
775
+ ? management as EndpointManagementPort
776
+ : {},
777
+ adapter,
778
+ endpointId,
779
+ };
638
780
  }
639
781
 
640
782
  function requireEndpoint(
@@ -647,18 +789,6 @@ function requireEndpoint(
647
789
  return resolveLiveEndpoint(ctx, adapter, endpointId);
648
790
  }
649
791
 
650
- /** 兼容数组 / `{ data: [...] }` / `{ list: [...] }` 的列表返回。 */
651
- function unwrapList(raw: unknown): Record<string, unknown>[] {
652
- const arr = Array.isArray(raw)
653
- ? raw
654
- : raw && typeof raw === 'object'
655
- ? ((raw as Record<string, unknown>).data ?? (raw as Record<string, unknown>).list)
656
- : undefined;
657
- return (Array.isArray(arr) ? arr : []).filter(
658
- (item): item is Record<string, unknown> => !!item && typeof item === 'object',
659
- );
660
- }
661
-
662
792
  function normalizeParent(raw: unknown): { type: 'group' | 'guild'; id: string; name?: string } | undefined {
663
793
  if (!raw || typeof raw !== 'object') return undefined;
664
794
  const parent = raw as { type?: unknown; id?: unknown; name?: unknown };