@zhin.js/host-http 1.0.0

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.
@@ -0,0 +1,696 @@
1
+ /**
2
+ * Extended Console RPC: cron/schedule + endpoint social/inbox surface.
3
+ *
4
+ * 挂在 `dispatchRuntimeConsoleRpc`(console-rpc.ts)之后的第二级 dispatch:
5
+ * 返回 `undefined` 表示不识别该 type,由调用方继续走默认分支;
6
+ * 识别后返回 `{ data }` 或 `{ error }`(requestId 由调用方补齐)。
7
+ *
8
+ * 数据源约定(只读真实数据源,不做内存假数据):
9
+ * - cron:`ctx.scheduleHost`(Plugin Runtime ScheduleHost,仅 register/list,
10
+ * 无持久化引擎,故 cron:add/remove/pause/resume 报"未接线")。
11
+ * - inbox:`ctx.databaseHost.models`(unified_inbox_message/request/notice 三张表,
12
+ * 表不存在或查询失败 → 空数组 + inboxEnabled:false)。
13
+ * - 社交/群管:`ctx.resolveEndpoint` 拿 live endpoint 实例,
14
+ * 实例上探测到对应方法才调用,否则报"该平台不支持"。
15
+ */
16
+
17
+ /** 新 Runtime 统一收件箱表名(与 packages/im/zhin/src/setup/register-inbox.ts 一致)。 */
18
+ const TABLE_MESSAGE = 'unified_inbox_message';
19
+ const TABLE_REQUEST = 'unified_inbox_request';
20
+ const TABLE_NOTICE = 'unified_inbox_notice';
21
+
22
+ export interface ConsoleRpcExtendedCtx {
23
+ /** full scope 才允许写操作;demo scope 只放行只读 RPC。 */
24
+ fullScope: boolean;
25
+ projectRoot: string;
26
+ /** Plugin Runtime ScheduleHost(basic/cli schedule-host-installer 提供)。 */
27
+ scheduleHost?: unknown;
28
+ /** 经 ImRuntime / AdapterEndpointIndex 解析 live endpoint 实例。 */
29
+ resolveEndpoint?: (adapter: string, endpointId: string) => unknown;
30
+ /** Plugin Runtime DatabaseHost 的 models 视图。 */
31
+ databaseHost?: { models: { get(name: string): unknown } };
32
+ }
33
+
34
+ export type ExtendedRpcResult = { data: unknown } | { error: string };
35
+
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
+ ]);
51
+
52
+ const CRON_NOT_WIRED =
53
+ '持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
54
+ '不支持 add/remove/pause/resume(需要 @zhin.js/agent 持久化调度引擎)';
55
+
56
+ const CONSUMED_NOT_WIRED =
57
+ '收件箱已读标记未接线:新 Runtime 尚未提供 request/notice 的 consumed 写路径';
58
+
59
+ interface ScheduleJobRow {
60
+ id: string;
61
+ cron: string;
62
+ description?: string;
63
+ }
64
+
65
+ interface InboxModel {
66
+ select(): {
67
+ where(query: Record<string, unknown>): Promise<Record<string, unknown>[]> | Record<string, unknown>[];
68
+ };
69
+ }
70
+
71
+ type EndpointAny = Record<string, unknown>;
72
+
73
+ export async function dispatchExtendedConsoleRpc(
74
+ type: string,
75
+ data: Record<string, unknown> | undefined,
76
+ ctx: ConsoleRpcExtendedCtx,
77
+ ): Promise<ExtendedRpcResult | undefined> {
78
+ const d = data ?? {};
79
+
80
+ if (WRITE_TYPES.has(type) && !ctx.fullScope) {
81
+ return { error: `Demo scope: RPC "${type}" is forbidden` };
82
+ }
83
+
84
+ switch (type) {
85
+ case 'schedule:list':
86
+ case 'cron:list':
87
+ return listSchedule(ctx);
88
+
89
+ case 'cron:add':
90
+ case 'cron:remove':
91
+ case 'cron:pause':
92
+ case 'cron:resume':
93
+ return { error: CRON_NOT_WIRED };
94
+
95
+ case 'endpoint:requests':
96
+ return listPendingRequests(d, ctx);
97
+ case 'endpoint:inboxRequests':
98
+ return listInbox(d, ctx, TABLE_REQUEST, 'requests', mapRequestRow);
99
+ case 'endpoint:inboxNotices':
100
+ return listInbox(d, ctx, TABLE_NOTICE, 'notices', mapNoticeRow);
101
+ case 'endpoint:inboxMessages':
102
+ return listInboxMessages(d, ctx);
103
+
104
+ case 'endpoint:requestApprove':
105
+ case 'endpoint:requestReject':
106
+ return actOnRequest(type, d, ctx);
107
+
108
+ case 'endpoint:requestConsumed':
109
+ case 'endpoint:noticeConsumed':
110
+ return { error: CONSUMED_NOT_WIRED };
111
+
112
+ case 'endpoint:friends':
113
+ return listFriends(d, ctx);
114
+ case 'endpoint:groups':
115
+ return listGroups(d, ctx);
116
+ case 'endpoint:channels':
117
+ return listChannels(d, ctx);
118
+ case 'endpoint:groupMembers':
119
+ return listGroupMembers(d, ctx);
120
+
121
+ case 'endpoint:groupKick':
122
+ return groupWriteOp(d, ctx, {
123
+ methods: ['removeMember', 'kickMember', 'setGroupKick'],
124
+ buildArgs: (gid, uid) => [gid, uid],
125
+ requireUser: true,
126
+ unsupported: '踢出群成员',
127
+ });
128
+ case 'endpoint:groupMute':
129
+ return groupWriteOp(d, ctx, {
130
+ methods: ['muteMember', 'setGroupBan', 'banMember'],
131
+ buildArgs: (gid, uid, extra) => [gid, uid, extra.duration ?? 600],
132
+ requireUser: true,
133
+ unsupported: '禁言群成员',
134
+ });
135
+ case 'endpoint:groupAdmin':
136
+ return groupWriteOp(d, ctx, {
137
+ methods: ['setModerator', 'setGroupAdmin', 'setAdmin'],
138
+ buildArgs: (gid, uid, extra) => [gid, uid, extra.enable !== false],
139
+ requireUser: true,
140
+ unsupported: '设置群管理员',
141
+ });
142
+ case 'endpoint:deleteFriend':
143
+ return deleteFriend(d, ctx);
144
+
145
+ default:
146
+ return undefined;
147
+ }
148
+ }
149
+
150
+ // ---------------------------------------------------------------- cron
151
+
152
+ function listSchedule(ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
153
+ const host = ctx.scheduleHost as { list?: () => unknown } | undefined;
154
+ if (!host || typeof host.list !== 'function') {
155
+ return { error: '调度服务未配置(scheduleHost 未挂载)' };
156
+ }
157
+ try {
158
+ const raw = host.list();
159
+ const memory: ScheduleJobRow[] = (Array.isArray(raw) ? raw : [])
160
+ .filter((job): job is ScheduleJobRow =>
161
+ !!job && typeof job === 'object'
162
+ && typeof (job as ScheduleJobRow).id === 'string'
163
+ && typeof (job as ScheduleJobRow).cron === 'string')
164
+ .map((job) => ({
165
+ id: job.id,
166
+ cron: job.cron,
167
+ ...(typeof job.description === 'string' ? { description: job.description } : {}),
168
+ }));
169
+ // persistent 引擎未接入新 Runtime,返回空数组保持 legacy 形状。
170
+ return { data: { memory, persistent: [] } };
171
+ } catch (error) {
172
+ return { error: errorMessage(error) };
173
+ }
174
+ }
175
+
176
+ // ---------------------------------------------------------------- inbox
177
+
178
+ function getInboxModel(ctx: ConsoleRpcExtendedCtx, table: string): InboxModel | undefined {
179
+ const model = ctx.databaseHost?.models?.get(table) as InboxModel | undefined;
180
+ if (!model || typeof model.select !== 'function') return undefined;
181
+ return model;
182
+ }
183
+
184
+ async function readInboxRows(
185
+ ctx: ConsoleRpcExtendedCtx,
186
+ table: string,
187
+ where: Record<string, unknown>,
188
+ ): Promise<{ rows: Record<string, unknown>[]; enabled: boolean }> {
189
+ const model = getInboxModel(ctx, table);
190
+ if (!model) return { rows: [], enabled: false };
191
+ try {
192
+ const rows = await model.select().where(where);
193
+ return { rows: Array.isArray(rows) ? rows : [], enabled: true };
194
+ } catch {
195
+ // 表未创建 / 方言未启动等场景降级为空,不向上抛。
196
+ return { rows: [], enabled: false };
197
+ }
198
+ }
199
+
200
+ async function listInbox(
201
+ d: Record<string, unknown>,
202
+ ctx: ConsoleRpcExtendedCtx,
203
+ table: string,
204
+ key: 'requests' | 'notices',
205
+ mapRow: (row: Record<string, unknown>) => Record<string, unknown>,
206
+ ): Promise<ExtendedRpcResult> {
207
+ const adapter = strField(d, '$adapter', 'adapter');
208
+ const endpointId = strField(d, '$endpoint', 'endpointId', 'endpoint');
209
+ if (!adapter || !endpointId) return { error: '$adapter and $endpoint required' };
210
+ const limit = Math.min(numField(d, 30, '$limit', 'limit'), 100);
211
+ const offset = Math.max(0, numField(d, 0, '$offset', 'offset'));
212
+ const { rows, enabled } = await readInboxRows(ctx, table, {
213
+ adapter,
214
+ endpoint_id: endpointId,
215
+ });
216
+ const sorted = rows
217
+ .slice()
218
+ .sort((a, b) => Number(b.created_at ?? 0) - Number(a.created_at ?? 0))
219
+ .slice(offset, offset + limit)
220
+ .map(mapRow);
221
+ return { data: { [key]: sorted, inboxEnabled: enabled } };
222
+ }
223
+
224
+ /** endpoint:requests —— 未处理的好友/群请求(unified_inbox_request 中 resolved=0)。 */
225
+ async function listPendingRequests(
226
+ d: Record<string, unknown>,
227
+ ctx: ConsoleRpcExtendedCtx,
228
+ ): Promise<ExtendedRpcResult> {
229
+ const adapter = strField(d, '$adapter', 'adapter');
230
+ const endpointId = strField(d, '$endpoint', 'endpointId', 'endpoint');
231
+ if (!adapter || !endpointId) return { error: '$adapter and $endpoint required' };
232
+ const { rows, enabled } = await readInboxRows(ctx, TABLE_REQUEST, {
233
+ adapter,
234
+ endpoint_id: endpointId,
235
+ });
236
+ const requests = rows
237
+ .filter((row) => Number(row.resolved ?? 0) === 0)
238
+ .sort((a, b) => Number(a.created_at ?? 0) - Number(b.created_at ?? 0))
239
+ .map(mapRequestRow);
240
+ return { data: { requests, inboxEnabled: enabled } };
241
+ }
242
+
243
+ async function listInboxMessages(
244
+ d: Record<string, unknown>,
245
+ ctx: ConsoleRpcExtendedCtx,
246
+ ): Promise<ExtendedRpcResult> {
247
+ const adapter = strField(d, '$adapter', 'adapter');
248
+ const endpointId = strField(d, '$endpoint', 'endpointId', 'endpoint');
249
+ const channelId = strField(d, '$channel_id', 'channelId', 'channel_id');
250
+ const channelType = strField(d, '$channel_type', 'channelType', 'channel_type');
251
+ if (!adapter || !endpointId || !channelId || !channelType) {
252
+ return { error: '$adapter, $endpoint, $channel_id, $channel_type required' };
253
+ }
254
+ const limit = Math.min(numField(d, 50, '$limit', 'limit'), 100);
255
+ const beforeTs = optionalNum(d, '$before_ts', 'beforeTs', 'before_ts');
256
+ const beforeId = optionalNum(d, '$before_id', 'beforeId', 'before_id');
257
+ const parent = normalizeParent(d.$parent ?? d.parent);
258
+
259
+ const where: Record<string, unknown> = {
260
+ adapter,
261
+ endpoint_id: endpointId,
262
+ channel_id: channelId,
263
+ channel_type: channelType,
264
+ };
265
+ if (parent) {
266
+ where.channel_parent_type = parent.type;
267
+ where.channel_parent_id = parent.id;
268
+ }
269
+ const { rows, enabled } = await readInboxRows(ctx, TABLE_MESSAGE, where);
270
+ const messages = rows
271
+ .filter((row) => (beforeTs == null || Number(row.created_at ?? 0) < beforeTs)
272
+ && (beforeId == null || Number(row.id ?? 0) < beforeId))
273
+ .sort((a, b) => Number(b.created_at ?? 0) - Number(a.created_at ?? 0))
274
+ .slice(0, limit)
275
+ .map((row) => ({
276
+ id: row.id,
277
+ platform_message_id: row.platform_message_id,
278
+ sender_id: row.sender_id,
279
+ sender_name: row.sender_name,
280
+ content: row.content,
281
+ raw: row.raw,
282
+ created_at: row.created_at,
283
+ channel: channelFromStoredRow(row),
284
+ parent: parentFromStoredRow(row),
285
+ }));
286
+ return { data: { messages, inboxEnabled: enabled } };
287
+ }
288
+
289
+ function mapRequestRow(row: Record<string, unknown>): Record<string, unknown> {
290
+ return {
291
+ id: row.id,
292
+ platform_request_id: row.platform_request_id,
293
+ type: row.type,
294
+ scene_type: row.scene_type ?? undefined,
295
+ scene_id: row.scene_id,
296
+ sub_type: row.sub_type ?? undefined,
297
+ actor: { id: row.actor_id, name: row.actor_name ?? undefined },
298
+ comment: row.comment ?? undefined,
299
+ created_at: row.created_at,
300
+ resolved: row.resolved,
301
+ resolved_at: row.resolved_at ?? undefined,
302
+ };
303
+ }
304
+
305
+ function mapNoticeRow(row: Record<string, unknown>): Record<string, unknown> {
306
+ return {
307
+ id: row.id,
308
+ platform_notice_id: row.platform_notice_id,
309
+ type: row.type,
310
+ scene_type: row.scene_type ?? undefined,
311
+ scene_id: row.scene_id,
312
+ sub_type: row.sub_type ?? undefined,
313
+ actor_id: row.actor_id ?? undefined,
314
+ actor_name: row.actor_name ?? undefined,
315
+ target_id: row.target_id ?? undefined,
316
+ target_name: row.target_name ?? undefined,
317
+ payload: row.payload,
318
+ created_at: row.created_at,
319
+ };
320
+ }
321
+
322
+ // ---------------------------------------------------------------- 请求审批
323
+
324
+ async function actOnRequest(
325
+ type: 'endpoint:requestApprove' | 'endpoint:requestReject',
326
+ d: Record<string, unknown>,
327
+ ctx: ConsoleRpcExtendedCtx,
328
+ ): Promise<ExtendedRpcResult> {
329
+ const adapter = strField(d, '$adapter', 'adapter');
330
+ const endpointId = strField(d, '$endpoint', 'endpointId', 'endpoint');
331
+ const requestId = strField(d, '$id', 'id', 'platformRequestId', 'platform_request_id');
332
+ if (!adapter || !endpointId || !requestId) {
333
+ return { error: '$adapter, $endpoint, $id required' };
334
+ }
335
+ const resolved = resolveLiveEndpoint(ctx, adapter, endpointId);
336
+ 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);
343
+ if (!method) {
344
+ return {
345
+ error: `请求审批未接线:当前平台(${adapter})endpoint 不支持 ${methodNames[0]},` +
346
+ '且新 Runtime 尚未挂载 pending request 注册表',
347
+ };
348
+ }
349
+ try {
350
+ const extra = approve
351
+ ? strField(d, '$remark', 'remark')
352
+ : strField(d, '$reason', 'reason');
353
+ await method.call(endpoint, requestId, extra || undefined);
354
+ return { data: { success: true } };
355
+ } catch (error) {
356
+ return { error: errorMessage(error) };
357
+ }
358
+ }
359
+
360
+ // ---------------------------------------------------------------- 社交读取
361
+
362
+ async function listFriends(
363
+ d: Record<string, unknown>,
364
+ ctx: ConsoleRpcExtendedCtx,
365
+ ): Promise<ExtendedRpcResult> {
366
+ const resolved = requireEndpoint(d, ctx);
367
+ if ('error' in resolved) return resolved;
368
+ const { endpoint, adapter } = resolved;
369
+ 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
+ }));
387
+ return { data: { friends, count: friends.length } };
388
+ }
389
+ return { error: `当前适配器(${adapter})不支持好友列表` };
390
+ } catch (error) {
391
+ return { error: errorMessage(error) };
392
+ }
393
+ }
394
+
395
+ async function listGroups(
396
+ d: Record<string, unknown>,
397
+ ctx: ConsoleRpcExtendedCtx,
398
+ ): Promise<ExtendedRpcResult> {
399
+ const resolved = requireEndpoint(d, ctx);
400
+ if ('error' in resolved) return resolved;
401
+ const { endpoint, adapter } = resolved;
402
+ 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
+ }));
418
+ return { data: { groups, count: groups.length } };
419
+ }
420
+ return { error: `当前适配器(${adapter})不支持群列表` };
421
+ } catch (error) {
422
+ return { error: errorMessage(error) };
423
+ }
424
+ }
425
+
426
+ async function listChannels(
427
+ d: Record<string, unknown>,
428
+ ctx: ConsoleRpcExtendedCtx,
429
+ ): Promise<ExtendedRpcResult> {
430
+ const resolved = requireEndpoint(d, ctx);
431
+ if ('error' in resolved) return resolved;
432
+ const { endpoint, adapter } = resolved;
433
+ 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
+ }
469
+ return { data: { channels, count: channels.length } };
470
+ }
471
+ return { error: `当前适配器(${adapter})不支持频道列表` };
472
+ } catch (error) {
473
+ return { error: errorMessage(error) };
474
+ }
475
+ }
476
+
477
+ async function listGroupMembers(
478
+ d: Record<string, unknown>,
479
+ ctx: ConsoleRpcExtendedCtx,
480
+ ): Promise<ExtendedRpcResult> {
481
+ const adapter = strField(d, '$adapter', 'adapter');
482
+ const endpointId = strField(d, '$endpoint', 'endpointId', 'endpoint');
483
+ const groupId = strField(d, '$group_id', 'groupId', 'group_id');
484
+ if (!adapter || !endpointId || !groupId) {
485
+ return { error: '$adapter, $endpoint, $group_id required' };
486
+ }
487
+ const resolved = resolveLiveEndpoint(ctx, adapter, endpointId);
488
+ if ('error' in resolved) return resolved;
489
+ const { endpoint } = resolved;
490
+ try {
491
+ const method = pickMethod(endpoint, ['getGroupMemberList', 'listMembers', 'getMemberList']);
492
+ if (!method) {
493
+ return { error: `当前适配器(${adapter})不支持群成员列表` };
494
+ }
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 } };
505
+ } catch (error) {
506
+ return { error: errorMessage(error) };
507
+ }
508
+ }
509
+
510
+ // ---------------------------------------------------------------- 群管/好友写操作
511
+
512
+ interface GroupWriteSpec {
513
+ methods: string[];
514
+ buildArgs(groupId: string, userId: string, extra: { duration?: number; enable?: boolean }): unknown[];
515
+ requireUser: boolean;
516
+ /** 中文操作名,用于"该平台不支持 xxx"。 */
517
+ unsupported: string;
518
+ }
519
+
520
+ async function groupWriteOp(
521
+ d: Record<string, unknown>,
522
+ ctx: ConsoleRpcExtendedCtx,
523
+ spec: GroupWriteSpec,
524
+ ): Promise<ExtendedRpcResult> {
525
+ const adapter = strField(d, '$adapter', 'adapter');
526
+ const endpointId = strField(d, '$endpoint', 'endpointId', 'endpoint');
527
+ const groupId = strField(d, '$group_id', 'groupId', 'group_id');
528
+ const userId = strField(d, '$user_id', 'userId', 'user_id');
529
+ if (!adapter || !endpointId || !groupId) {
530
+ return { error: '$adapter, $endpoint, $group_id required' };
531
+ }
532
+ if (spec.requireUser && !userId) {
533
+ return { error: '$user_id required' };
534
+ }
535
+ const resolved = resolveLiveEndpoint(ctx, adapter, endpointId);
536
+ if ('error' in resolved) return resolved;
537
+ const { endpoint } = resolved;
538
+ const method = pickMethod(endpoint, spec.methods);
539
+ if (!method) {
540
+ return { error: `当前适配器(${adapter})不支持${spec.unsupported}` };
541
+ }
542
+ try {
543
+ const durationRaw = d.$duration ?? d.duration;
544
+ const enableRaw = d.$enable ?? d.enable;
545
+ const args = spec.buildArgs(groupId, userId, {
546
+ duration: typeof durationRaw === 'number' && Number.isFinite(durationRaw)
547
+ ? durationRaw
548
+ : undefined,
549
+ enable: typeof enableRaw === 'boolean' ? enableRaw : undefined,
550
+ });
551
+ await method.call(endpoint, ...args);
552
+ return { data: { success: true } };
553
+ } catch (error) {
554
+ return { error: errorMessage(error) };
555
+ }
556
+ }
557
+
558
+ async function deleteFriend(
559
+ d: Record<string, unknown>,
560
+ ctx: ConsoleRpcExtendedCtx,
561
+ ): Promise<ExtendedRpcResult> {
562
+ const adapter = strField(d, '$adapter', 'adapter');
563
+ const endpointId = strField(d, '$endpoint', 'endpointId', 'endpoint');
564
+ const userId = strField(d, '$user_id', 'userId', 'user_id');
565
+ if (!adapter || !endpointId || !userId) {
566
+ return { error: '$adapter, $endpoint, $user_id required' };
567
+ }
568
+ const resolved = resolveLiveEndpoint(ctx, adapter, endpointId);
569
+ if ('error' in resolved) return resolved;
570
+ const { endpoint } = resolved;
571
+ const method = pickMethod(endpoint, ['deleteFriend', 'delete_friend']);
572
+ if (!method) {
573
+ return { error: '当前适配器暂不支持删除好友' };
574
+ }
575
+ try {
576
+ const numeric = Number(userId);
577
+ await method.call(endpoint, Number.isFinite(numeric) && userId.trim() !== '' ? numeric : userId);
578
+ return { data: { success: true } };
579
+ } catch (error) {
580
+ return { error: errorMessage(error) };
581
+ }
582
+ }
583
+
584
+ // ---------------------------------------------------------------- helpers
585
+
586
+ function strField(d: Record<string, unknown>, ...keys: string[]): string {
587
+ for (const key of keys) {
588
+ const value = d[key];
589
+ if (typeof value === 'string' && value.trim()) return value.trim();
590
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
591
+ }
592
+ return '';
593
+ }
594
+
595
+ function numField(d: Record<string, unknown>, fallback: number, ...keys: string[]): number {
596
+ for (const key of keys) {
597
+ const parsed = Number(d[key]);
598
+ if (d[key] != null && Number.isFinite(parsed)) return parsed;
599
+ }
600
+ return fallback;
601
+ }
602
+
603
+ function optionalNum(d: Record<string, unknown>, ...keys: string[]): number | undefined {
604
+ for (const key of keys) {
605
+ if (d[key] == null) continue;
606
+ const parsed = Number(d[key]);
607
+ if (Number.isFinite(parsed)) return parsed;
608
+ }
609
+ return undefined;
610
+ }
611
+
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;
619
+ }
620
+ return undefined;
621
+ }
622
+
623
+ type ResolveOk = { endpoint: EndpointAny; adapter: string; endpointId: string };
624
+
625
+ function resolveLiveEndpoint(
626
+ ctx: ConsoleRpcExtendedCtx,
627
+ adapter: string,
628
+ endpointId: string,
629
+ ): ResolveOk | { error: string } {
630
+ if (!ctx.resolveEndpoint) {
631
+ return { error: 'Endpoint registry is not configured' };
632
+ }
633
+ const endpoint = ctx.resolveEndpoint(adapter, endpointId);
634
+ if (!endpoint || typeof endpoint !== 'object') {
635
+ return { error: 'endpoint not found' };
636
+ }
637
+ return { endpoint: endpoint as EndpointAny, adapter, endpointId };
638
+ }
639
+
640
+ function requireEndpoint(
641
+ d: Record<string, unknown>,
642
+ ctx: ConsoleRpcExtendedCtx,
643
+ ): ResolveOk | { error: string } {
644
+ const adapter = strField(d, '$adapter', 'adapter');
645
+ const endpointId = strField(d, '$endpoint', 'endpointId', 'endpoint');
646
+ if (!adapter || !endpointId) return { error: '$adapter and $endpoint required' };
647
+ return resolveLiveEndpoint(ctx, adapter, endpointId);
648
+ }
649
+
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
+ function normalizeParent(raw: unknown): { type: 'group' | 'guild'; id: string; name?: string } | undefined {
663
+ if (!raw || typeof raw !== 'object') return undefined;
664
+ const parent = raw as { type?: unknown; id?: unknown; name?: unknown };
665
+ const id = typeof parent.id === 'string' && parent.id.trim() ? parent.id.trim() : undefined;
666
+ if (!id) return undefined;
667
+ let type: 'group' | 'guild' | undefined;
668
+ if (parent.type === 'group' || parent.type === 'guild') type = parent.type;
669
+ else if (parent.type === 'channel') type = 'guild';
670
+ if (!type) return undefined;
671
+ const name = typeof parent.name === 'string' && parent.name.trim() ? parent.name.trim() : undefined;
672
+ return name ? { type, id, name } : { type, id };
673
+ }
674
+
675
+ function channelFromStoredRow(row: Record<string, unknown>): Record<string, unknown> {
676
+ const parent = parentFromStoredRow(row);
677
+ return {
678
+ id: String(row.channel_id ?? ''),
679
+ type: String(row.channel_type ?? ''),
680
+ ...(row.channel_name != null ? { name: String(row.channel_name) } : {}),
681
+ ...(parent ? { parent } : {}),
682
+ };
683
+ }
684
+
685
+ function parentFromStoredRow(
686
+ row: Record<string, unknown>,
687
+ ): { type: 'group' | 'guild'; id: string; name?: string } | undefined {
688
+ return normalizeParent({
689
+ type: row.channel_parent_type,
690
+ id: row.channel_parent_id,
691
+ });
692
+ }
693
+
694
+ function errorMessage(error: unknown): string {
695
+ return error instanceof Error ? error.message : String(error);
696
+ }