@zhin.js/adapter-napcat 1.0.1 → 1.1.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.
Files changed (159) hide show
  1. package/CHANGELOG.md +661 -0
  2. package/README.md +60 -95
  3. package/adapters/napcat.js +47 -0
  4. package/adapters/napcat.ts +64 -0
  5. package/{skills/napcat → agent}/PERMITS.md +3 -3
  6. package/{skills/napcat/SKILL.md → agent/skills/napcat.md} +2 -2
  7. package/agent/tools/ai_tts.ts +19 -0
  8. package/agent/tools/del_group_notice.ts +20 -0
  9. package/agent/tools/delete_essence_msg.ts +19 -0
  10. package/agent/tools/delete_friend.ts +18 -0
  11. package/agent/tools/download_file.ts +17 -0
  12. package/agent/tools/forward_single_msg.ts +28 -0
  13. package/agent/tools/get_ai_characters.ts +17 -0
  14. package/agent/tools/get_essence_list.ts +17 -0
  15. package/agent/tools/get_friend_msg_history.ts +18 -0
  16. package/agent/tools/get_group_file_url.ts +19 -0
  17. package/agent/tools/get_group_info_ex.ts +17 -0
  18. package/agent/tools/get_group_msg_history.ts +19 -0
  19. package/agent/tools/get_group_notice.ts +17 -0
  20. package/agent/tools/get_group_root_files.ts +17 -0
  21. package/agent/tools/get_group_shut_list.ts +17 -0
  22. package/agent/tools/get_mini_app_ark.ts +20 -0
  23. package/agent/tools/get_user_status.ts +16 -0
  24. package/agent/tools/group_sign.ts +18 -0
  25. package/agent/tools/mark_msg_as_read.ts +17 -0
  26. package/agent/tools/ocr_image.ts +16 -0
  27. package/agent/tools/send_forward_msg.ts +27 -0
  28. package/agent/tools/send_group_notice.ts +21 -0
  29. package/agent/tools/send_like.ts +18 -0
  30. package/agent/tools/send_poke.ts +18 -0
  31. package/agent/tools/set_avatar.ts +17 -0
  32. package/agent/tools/set_emoji_reaction.ts +18 -0
  33. package/agent/tools/set_essence_msg.ts +19 -0
  34. package/agent/tools/set_group_portrait.ts +20 -0
  35. package/agent/tools/set_online_status.ts +18 -0
  36. package/agent/tools/set_profile.ts +21 -0
  37. package/agent/tools/set_signature.ts +17 -0
  38. package/agent/tools/set_title.ts +21 -0
  39. package/agent/tools/translate.ts +16 -0
  40. package/agent/tools/upload_group_file.ts +21 -0
  41. package/commands/endpoint/add/[id].js +3 -0
  42. package/commands/endpoint/add/[id].ts +3 -0
  43. package/commands/endpoint/list.js +3 -0
  44. package/commands/endpoint/list.ts +3 -0
  45. package/commands/endpoint/remove/[id].js +3 -0
  46. package/commands/endpoint/remove/[id].ts +3 -0
  47. package/lib/client.d.ts +52 -0
  48. package/lib/client.js +62 -0
  49. package/lib/endpoint-management.d.ts +15 -0
  50. package/lib/endpoint-management.js +47 -0
  51. package/lib/http-endpoint.d.ts +27 -0
  52. package/lib/http-endpoint.js +177 -0
  53. package/lib/index.d.ts +11 -22
  54. package/lib/index.js +10 -59
  55. package/lib/napcat-endpoint-commands.d.ts +1 -0
  56. package/lib/napcat-endpoint-commands.js +28 -0
  57. package/lib/napcat-inbound.d.ts +12 -5
  58. package/lib/napcat-inbound.js +28 -13
  59. package/lib/napcat-runtime-state.d.ts +1 -0
  60. package/lib/napcat-runtime-state.js +6 -0
  61. package/lib/onebot-get-msg.d.ts +16 -3
  62. package/lib/onebot-get-msg.js +53 -1
  63. package/lib/protocol.d.ts +165 -0
  64. package/lib/protocol.js +305 -0
  65. package/lib/side-event-dispatch.d.ts +7 -0
  66. package/lib/side-event-dispatch.js +36 -0
  67. package/lib/webhook.d.ts +2 -0
  68. package/lib/webhook.js +14 -0
  69. package/lib/ws-endpoint.d.ts +27 -0
  70. package/lib/ws-endpoint.js +241 -0
  71. package/lib/ws-transport.d.ts +13 -0
  72. package/lib/ws-transport.js +75 -0
  73. package/lib/ws-types.d.ts +17 -0
  74. package/lib/ws-types.js +1 -0
  75. package/lib/wss-auth.d.ts +2 -0
  76. package/lib/wss-auth.js +16 -0
  77. package/lib/wss-endpoint.d.ts +26 -0
  78. package/lib/wss-endpoint.js +187 -0
  79. package/package.json +62 -32
  80. package/plugin.js +18 -0
  81. package/schema.json +110 -0
  82. package/src/client.ts +79 -0
  83. package/src/endpoint-management.ts +72 -0
  84. package/src/http-endpoint.ts +234 -0
  85. package/src/index.ts +63 -87
  86. package/src/napcat-endpoint-commands.ts +26 -0
  87. package/src/napcat-inbound.ts +34 -10
  88. package/src/napcat-runtime-state.ts +7 -0
  89. package/src/onebot-get-msg.ts +69 -5
  90. package/src/protocol.ts +468 -0
  91. package/src/side-event-dispatch.ts +48 -0
  92. package/src/webhook.ts +16 -0
  93. package/src/ws-endpoint.ts +301 -0
  94. package/src/ws-transport.ts +105 -0
  95. package/src/ws-types.ts +20 -0
  96. package/src/wss-auth.ts +17 -0
  97. package/src/wss-endpoint.ts +242 -0
  98. package/client/NapCatManagement.tsx +0 -113
  99. package/client/index.tsx +0 -11
  100. package/client/tsconfig.json +0 -7
  101. package/client/utils/api.ts +0 -30
  102. package/dist/index.js +0 -27
  103. package/lib/adapter.d.ts +0 -28
  104. package/lib/adapter.d.ts.map +0 -1
  105. package/lib/adapter.js +0 -94
  106. package/lib/adapter.js.map +0 -1
  107. package/lib/agent-prompt.d.ts +0 -3
  108. package/lib/agent-prompt.d.ts.map +0 -1
  109. package/lib/agent-prompt.js +0 -105
  110. package/lib/agent-prompt.js.map +0 -1
  111. package/lib/endpoint-base.d.ts +0 -143
  112. package/lib/endpoint-base.d.ts.map +0 -1
  113. package/lib/endpoint-base.js +0 -334
  114. package/lib/endpoint-base.js.map +0 -1
  115. package/lib/endpoint-http.d.ts +0 -18
  116. package/lib/endpoint-http.d.ts.map +0 -1
  117. package/lib/endpoint-http.js +0 -119
  118. package/lib/endpoint-http.js.map +0 -1
  119. package/lib/endpoint-ws-client.d.ts +0 -21
  120. package/lib/endpoint-ws-client.d.ts.map +0 -1
  121. package/lib/endpoint-ws-client.js +0 -165
  122. package/lib/endpoint-ws-client.js.map +0 -1
  123. package/lib/endpoint-ws-server.d.ts +0 -21
  124. package/lib/endpoint-ws-server.d.ts.map +0 -1
  125. package/lib/endpoint-ws-server.js +0 -150
  126. package/lib/endpoint-ws-server.js.map +0 -1
  127. package/lib/index.d.ts.map +0 -1
  128. package/lib/index.js.map +0 -1
  129. package/lib/napcat-inbound.d.ts.map +0 -1
  130. package/lib/napcat-inbound.js.map +0 -1
  131. package/lib/onebot-get-msg.d.ts.map +0 -1
  132. package/lib/onebot-get-msg.js.map +0 -1
  133. package/lib/routes.d.ts +0 -4
  134. package/lib/routes.d.ts.map +0 -1
  135. package/lib/routes.js +0 -61
  136. package/lib/routes.js.map +0 -1
  137. package/lib/tools.d.ts +0 -8
  138. package/lib/tools.d.ts.map +0 -1
  139. package/lib/tools.js +0 -605
  140. package/lib/tools.js.map +0 -1
  141. package/lib/types.d.ts +0 -293
  142. package/lib/types.d.ts.map +0 -1
  143. package/lib/types.js +0 -6
  144. package/lib/types.js.map +0 -1
  145. package/lib/typing-indicator.d.ts +0 -45
  146. package/lib/typing-indicator.d.ts.map +0 -1
  147. package/lib/typing-indicator.js +0 -132
  148. package/lib/typing-indicator.js.map +0 -1
  149. package/plugin.yml +0 -3
  150. package/src/adapter.ts +0 -101
  151. package/src/agent-prompt.ts +0 -114
  152. package/src/endpoint-base.ts +0 -376
  153. package/src/endpoint-http.ts +0 -109
  154. package/src/endpoint-ws-client.ts +0 -164
  155. package/src/endpoint-ws-server.ts +0 -150
  156. package/src/routes.ts +0 -61
  157. package/src/tools.ts +0 -632
  158. package/src/types.ts +0 -279
  159. package/src/typing-indicator.ts +0 -184
@@ -1,20 +1,26 @@
1
- import type { MessageSegment, QuotedMessagePayload } from 'zhin.js';
1
+ export interface NapcatMessageLookup {
2
+ messageId: string;
3
+ sender?: { id: string; name: string };
4
+ content: Array<{ type: string; data?: Record<string, unknown> }>;
5
+ raw?: string;
6
+ time?: number;
7
+ }
2
8
 
3
9
  export function parseOneBotGetMsgResponse(
4
10
  messageId: string,
5
11
  data: unknown,
6
- ): QuotedMessagePayload {
12
+ ): NapcatMessageLookup {
7
13
  const record =
8
14
  data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
9
- let content: QuotedMessagePayload['content'] = [];
15
+ let content: NapcatMessageLookup['content'] = [];
10
16
  if (Array.isArray(record.message)) {
11
- content = record.message as MessageSegment[];
17
+ content = record.message as NapcatMessageLookup['content'];
12
18
  } else if (typeof record.raw_message === 'string' && record.raw_message) {
13
19
  content = [{ type: 'text', data: { text: record.raw_message } }];
14
20
  }
15
21
 
16
22
  const senderRaw = record.sender;
17
- let sender: QuotedMessagePayload['sender'];
23
+ let sender: NapcatMessageLookup['sender'];
18
24
  if (senderRaw && typeof senderRaw === 'object') {
19
25
  const s = senderRaw as Record<string, unknown>;
20
26
  sender = {
@@ -31,3 +37,61 @@ export function parseOneBotGetMsgResponse(
31
37
  time: typeof record.time === 'number' ? record.time : undefined,
32
38
  };
33
39
  }
40
+
41
+ export function createNapCatContentPort(
42
+ callApi: (action: string, params?: Record<string, unknown>) => Promise<unknown>,
43
+ ): EndpointContentPort {
44
+ return Object.freeze({
45
+ async resolve(reference: ConversationReference, context: EndpointContentResolveContext) {
46
+ try {
47
+ context.signal.throwIfAborted();
48
+ if (reference.kind === 'media') {
49
+ return reference.media.kind === 'file'
50
+ ? Object.freeze({ status: 'unsupported' as const, code: 'napcat_opaque_media_requires_platform_file_metadata' })
51
+ : Object.freeze({ status: 'resolved' as const, reference, value: reference.media });
52
+ }
53
+ if (reference.kind === 'message') {
54
+ const parsed = parseOneBotGetMsgResponse(reference.message.id, await callApi('get_msg', { message_id: reference.message.id }));
55
+ context.signal.throwIfAborted();
56
+ const value: ConversationMessage = Object.freeze({
57
+ ref: reference.message,
58
+ ...(parsed.sender?.id ? { actor: Object.freeze({ id: parsed.sender.id, ...(parsed.sender.name ? { displayName: parsed.sender.name } : {}) }) } : {}),
59
+ segments: canonicalSegments(parsed.content),
60
+ timestamp: toMillis(parsed.time),
61
+ });
62
+ return Object.freeze({ status: 'resolved' as const, reference, value });
63
+ }
64
+ const raw = await callApi('get_forward_msg', { message_id: reference.forwardId, id: reference.forwardId }) as Record<string, unknown>;
65
+ context.signal.throwIfAborted();
66
+ const nodes = Array.isArray(raw.messages) ? raw.messages.slice(0, context.maxEntries) : [];
67
+ const value: readonly ForwardEntry[] = Object.freeze(nodes.map((node) => {
68
+ const row = node as Record<string, unknown>;
69
+ const sender = row.sender as Record<string, unknown> | undefined;
70
+ return Object.freeze({
71
+ ...(sender?.user_id != null ? { actor: Object.freeze({ id: String(sender.user_id), ...(sender.nickname ? { displayName: String(sender.nickname) } : {}) }) } : {}),
72
+ segments: canonicalSegments(Array.isArray(row.content) ? row.content as never[] : []),
73
+ timestamp: toMillis(row.time),
74
+ });
75
+ }));
76
+ return Object.freeze({ status: 'resolved' as const, reference, value });
77
+ } catch (error) {
78
+ if (context.signal.aborted) return Object.freeze({ status: 'expired' as const, code: 'turn_aborted' });
79
+ return Object.freeze({ status: 'failed' as const, code: 'napcat_content_resolution_failed', message: error instanceof Error ? error.message : String(error) });
80
+ }
81
+ },
82
+ });
83
+ }
84
+
85
+ function canonicalSegments(content: Array<{ type: string; data?: Record<string, unknown> }>) {
86
+ return Object.freeze(content.map((segment) => Object.freeze({
87
+ type: segment.type,
88
+ data: Object.freeze(segment.data ?? {}),
89
+ })));
90
+ }
91
+
92
+ function toMillis(value: unknown): number {
93
+ const time = Number(value);
94
+ return Number.isFinite(time) ? (time < 10_000_000_000 ? time * 1000 : time) : Date.now();
95
+ }
96
+ import type { EndpointContentPort, EndpointContentResolveContext } from 'zhin.js/adapter';
97
+ import type { ConversationMessage, ConversationReference, ForwardEntry } from '@zhin.js/im-contract';
@@ -0,0 +1,468 @@
1
+ /**
2
+ * NapCat protocol helpers (OneBot 11 + NapCat extensions).
3
+ * No legacy Adapter/Endpoint / segment-mapper.
4
+ * Canonicalization is owned by gateway/core before endpoint.send.
5
+ */
6
+ import { isMediaRef, type MediaRef } from '@zhin.js/core';
7
+ import type { ConversationRef } from '@zhin.js/im-contract';
8
+ import { formatCompact, getLogger } from '@zhin.js/logger';
9
+
10
+ const logger = getLogger('napcat');
11
+
12
+ /** Transitional legacy endpoint row (`endpoints[]` with `context: napcat`). */
13
+ export interface NapCatLegacyEndpointRow {
14
+ readonly context?: string;
15
+ readonly connection?: 'ws' | 'wss' | 'http';
16
+ readonly id?: string;
17
+ readonly access_token?: string;
18
+ readonly url?: string;
19
+ readonly path?: string;
20
+ readonly http_url?: string;
21
+ readonly post_path?: string;
22
+ readonly reconnect_interval?: number;
23
+ readonly heartbeat_interval?: number;
24
+ readonly poll_interval?: number;
25
+ }
26
+
27
+ /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
28
+ export interface NapCatAdapterConfig {
29
+ readonly connection?: 'ws' | 'wss' | 'http';
30
+ readonly id?: string;
31
+ readonly access_token?: string;
32
+ readonly url?: string;
33
+ readonly path?: string;
34
+ readonly http_url?: string;
35
+ readonly post_path?: string;
36
+ readonly reconnect_interval?: number;
37
+ readonly heartbeat_interval?: number;
38
+ readonly poll_interval?: number;
39
+ /** Transitional: legacy root `endpoints[]` with `context: napcat`. */
40
+ readonly endpoints?: ReadonlyArray<NapCatLegacyEndpointRow>;
41
+ }
42
+
43
+ export interface NapCatConfigBase {
44
+ readonly context: 'napcat';
45
+ readonly id: string;
46
+ readonly access_token?: string;
47
+ }
48
+
49
+ /** 正向 WebSocket:应用连 NapCat WS */
50
+ export interface NapCatWsConfig extends NapCatConfigBase {
51
+ readonly connection: 'ws';
52
+ readonly url: string;
53
+ readonly reconnect_interval: number;
54
+ readonly heartbeat_interval: number;
55
+ }
56
+
57
+ /** 反向 WebSocket:httpHostToken WS upgrade 入站/出站 */
58
+ export interface NapCatWssConfig extends NapCatConfigBase {
59
+ readonly connection: 'wss';
60
+ readonly path: string;
61
+ readonly heartbeat_interval: number;
62
+ }
63
+
64
+ /** HTTP API + POST 上报:httpHostToken POST 入站 + http_url/{action} 出站 */
65
+ export interface NapCatHttpConfig extends NapCatConfigBase {
66
+ readonly connection: 'http';
67
+ readonly http_url: string;
68
+ readonly post_path: string;
69
+ readonly poll_interval: number;
70
+ }
71
+
72
+ export type ResolvedNapCatConfig = NapCatWsConfig | NapCatWssConfig | NapCatHttpConfig;
73
+ export type NapCatEndpointConfig = ResolvedNapCatConfig;
74
+
75
+ export interface NapCatSender {
76
+ readonly role?: string;
77
+ readonly nickname?: string;
78
+ readonly card?: string;
79
+ readonly title?: string;
80
+ readonly user_id?: number;
81
+ }
82
+
83
+ export interface MessageSegment {
84
+ type: string;
85
+ data?: Record<string, unknown>;
86
+ }
87
+
88
+ export interface NapCatEvent {
89
+ post_type: string;
90
+ self_id?: number | string;
91
+ message_type?: string;
92
+ sub_type?: string;
93
+ message_id?: number | string;
94
+ user_id?: number | string;
95
+ group_id?: number | string;
96
+ sender?: NapCatSender;
97
+ message?: MessageSegment[] | string;
98
+ raw_message?: string;
99
+ time?: number;
100
+ notice_type?: string;
101
+ request_type?: string;
102
+ [key: string]: unknown;
103
+ }
104
+
105
+ export type NapCatMessageEvent = NapCatEvent & {
106
+ post_type: 'message' | 'message_sent';
107
+ message_id: number | string;
108
+ user_id: number | string;
109
+ };
110
+
111
+ export interface NapCatActionRequest {
112
+ action: string;
113
+ params: Record<string, unknown>;
114
+ echo?: string;
115
+ }
116
+
117
+ export interface NapCatActionResponse {
118
+ status: string;
119
+ retcode: number;
120
+ data?: unknown;
121
+ echo?: string;
122
+ message?: string;
123
+ wording?: string;
124
+ }
125
+
126
+ export interface NapCatWireSegment {
127
+ readonly type: string;
128
+ readonly data?: Record<string, unknown>;
129
+ }
130
+
131
+ export interface ParsedSendTarget {
132
+ readonly message_type: 'private' | 'group';
133
+ readonly id: string;
134
+ }
135
+
136
+ function normalizeConnection(
137
+ connection: string | undefined,
138
+ ): 'ws' | 'wss' | 'http' {
139
+ if (connection === 'wss' || connection === 'http') return connection;
140
+ return 'ws';
141
+ }
142
+
143
+ export function resolveNapCatConfig(config: NapCatAdapterConfig = {}): ResolvedNapCatConfig {
144
+ const entry = config.endpoints?.find((item) => item.context === 'napcat');
145
+ const connection = normalizeConnection(config.connection ?? entry?.connection);
146
+ const id = (typeof config.id === 'string' && config.id)
147
+ || (typeof entry?.id === 'string' && entry.id)
148
+ || process.env.NAPCAT_BOT_NAME
149
+ || 'napcat-bot';
150
+ const access_token = config.access_token ?? entry?.access_token;
151
+
152
+ if (connection === 'ws') {
153
+ const url = config.url ?? entry?.url;
154
+ if (!url) {
155
+ throw new TypeError(
156
+ 'NapCat connection:ws requires url (plugins.<key>.url or endpoints with context: napcat)',
157
+ );
158
+ }
159
+ return {
160
+ context: 'napcat',
161
+ connection: 'ws',
162
+ id,
163
+ access_token,
164
+ url,
165
+ reconnect_interval: config.reconnect_interval ?? entry?.reconnect_interval ?? 5000,
166
+ heartbeat_interval: config.heartbeat_interval ?? entry?.heartbeat_interval ?? 30_000,
167
+ };
168
+ }
169
+
170
+ if (connection === 'wss') {
171
+ const path = config.path ?? entry?.path;
172
+ if (!path) throw new TypeError('NapCat connection:wss requires path');
173
+ return {
174
+ context: 'napcat',
175
+ connection: 'wss',
176
+ id,
177
+ access_token,
178
+ path,
179
+ heartbeat_interval: config.heartbeat_interval ?? entry?.heartbeat_interval ?? 30_000,
180
+ };
181
+ }
182
+
183
+ if (connection === 'http') {
184
+ const http_url = config.http_url ?? entry?.http_url;
185
+ const post_path = config.post_path ?? entry?.post_path;
186
+ if (!http_url || !post_path) {
187
+ throw new TypeError('NapCat connection:http requires http_url and post_path');
188
+ }
189
+ return {
190
+ context: 'napcat',
191
+ connection: 'http',
192
+ id,
193
+ access_token,
194
+ http_url,
195
+ post_path,
196
+ poll_interval: config.poll_interval ?? entry?.poll_interval ?? 30_000,
197
+ };
198
+ }
199
+
200
+ throw new TypeError(`Unknown NapCat connection: ${String(connection)}`);
201
+ }
202
+
203
+ export function isMessageEvent(
204
+ ev: NapCatEvent,
205
+ ): ev is NapCatMessageEvent {
206
+ return (ev.post_type === 'message' || ev.post_type === 'message_sent')
207
+ && ev.message_id != null;
208
+ }
209
+
210
+ export function getChannelId(ev: NapCatEvent): string {
211
+ if (ev.message_type === 'group' && ev.group_id != null) return String(ev.group_id);
212
+ if (ev.group_id != null && ev.message_type !== 'private') return String(ev.group_id);
213
+ if (ev.user_id != null) return String(ev.user_id);
214
+ return '';
215
+ }
216
+
217
+ /**
218
+ * 入站归一化 → ConversationRef:群消息 → kind 'group';私聊临时会话
219
+ * (sub_type 'group')→ kind 'private' + 群容器进 `parent`;其余私聊 → 'private'。
220
+ */
221
+ export function napcatInboundConversation(endpointKey: string, ev: NapCatEvent): ConversationRef {
222
+ const endpoint = { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey };
223
+ const isGroup = ev.message_type === 'group' || (ev.group_id != null && ev.message_type !== 'private');
224
+ if (isGroup && ev.group_id != null) {
225
+ return { endpoint, kind: 'group', id: String(ev.group_id) };
226
+ }
227
+ if (ev.sub_type === 'group' && ev.group_id != null) {
228
+ return {
229
+ endpoint,
230
+ kind: 'private',
231
+ id: ev.user_id != null ? String(ev.user_id) : '',
232
+ parent: { kind: 'group', id: String(ev.group_id) },
233
+ };
234
+ }
235
+ return { endpoint, kind: 'private', id: ev.user_id != null ? String(ev.user_id) : '' };
236
+ }
237
+
238
+ /** 出站:ConversationRef → OneBot 私聊/群聊目标。 */
239
+ export function napcatOutboundTarget(conversation: ConversationRef): ParsedSendTarget {
240
+ return {
241
+ message_type: conversation.kind === 'group' ? 'group' : 'private',
242
+ id: conversation.id,
243
+ };
244
+ }
245
+
246
+ export function formatInboundContent(ev: NapCatEvent): string {
247
+ if (Array.isArray(ev.message)) {
248
+ return ev.message
249
+ .map((seg) => (seg.type === 'text' ? String(seg.data?.text ?? '') : ''))
250
+ .join('');
251
+ }
252
+ if (typeof ev.message === 'string') return ev.message.replace(/\[CQ:[^\]]+\]/g, '').trim();
253
+ return typeof ev.raw_message === 'string'
254
+ ? ev.raw_message.replace(/\[CQ:[^\]]+\]/g, '').trim()
255
+ : '';
256
+ }
257
+
258
+ /**
259
+ * 入站 sender 语义:必须是用户 ID(Runtime Message contract)。
260
+ * 显示名走 metadata.nickname,见 senderNickname。
261
+ */
262
+ export function senderUserId(ev: NapCatEvent): string {
263
+ return ev.user_id != null ? String(ev.user_id) : '';
264
+ }
265
+
266
+ /** 显示名(群名片 card 优先于 nickname),没有则 undefined。 */
267
+ export function senderNickname(ev: NapCatEvent): string | undefined {
268
+ const name = ev.sender?.card || ev.sender?.nickname;
269
+ return typeof name === 'string' && name ? name : undefined;
270
+ }
271
+
272
+ /**
273
+ * canonical MediaRef → OneBot `file` 参数:url 直传、base64 → `base64://`、
274
+ * 本地路径 → `file://`(路径在 OneBot 实现侧解析)、kind=file 不透明引用原样透传。
275
+ */
276
+ export function mediaRefToOneBotFile(media: MediaRef): string {
277
+ if (media.kind === 'base64') {
278
+ return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
279
+ }
280
+ if (media.kind === 'path') {
281
+ return media.value.startsWith('file://') ? media.value : `file://${media.value}`;
282
+ }
283
+ return media.value;
284
+ }
285
+
286
+ /** 媒体段 data 里的 canonical-only 字段,不进 OneBot wire。 */
287
+ const MEDIA_DATA_SKIP_KEYS = new Set(['media', 'alt', 'url', 'base64', 'file', 'mime_type']);
288
+
289
+ /**
290
+ * 媒体段 → OneBot wire:媒体来源唯一认 canonical `data.media`(MediaRef-only,
291
+ * 无 legacy 字段回读)。缺失或形状非法时 warn + 丢弃(返回 null)。
292
+ */
293
+ function oneBotMediaSegment(
294
+ type: 'image' | 'record' | 'video',
295
+ data: Record<string, unknown>,
296
+ ): MessageSegment | null {
297
+ if (!isMediaRef(data.media)) {
298
+ logger.warn(formatCompact({
299
+ op: 'napcat_outbound_media_dropped',
300
+ type,
301
+ reason: 'missing_media_ref',
302
+ }));
303
+ return null;
304
+ }
305
+ const extra: Record<string, unknown> = {};
306
+ for (const [key, value] of Object.entries(data)) {
307
+ if (!MEDIA_DATA_SKIP_KEYS.has(key)) extra[key] = value;
308
+ }
309
+ return { type, data: { ...extra, file: mediaRefToOneBotFile(data.media) } };
310
+ }
311
+
312
+ /**
313
+ * canonical Segment → OneBot 11 数组段:
314
+ * - mention → at(`qq: target`);
315
+ * - reply(`message_id`)→ reply(`id`);
316
+ * - image / audio→record / video 的 MediaRef → `file`(url / base64:// / file://),
317
+ * 无 canonical `data.media` 的媒体段丢弃(返回 null);
318
+ * - face 取 `id`;
319
+ * - 其余(NapCat 扩展段、已是 wire 形状的段)原样透传。
320
+ */
321
+ function canonicalToOneBotSegment(segment: NapCatWireSegment): MessageSegment | null {
322
+ const data = segment.data ?? {};
323
+ switch (segment.type) {
324
+ case 'mention': {
325
+ const target = data.target ?? data.qq ?? data.id;
326
+ if (target == null) return { type: segment.type, data };
327
+ return { type: 'at', data: { qq: String(target) } };
328
+ }
329
+ case 'reply': {
330
+ const messageId = data.message_id ?? data.id;
331
+ if (messageId == null) return { type: segment.type, data };
332
+ return { type: 'reply', data: { id: String(messageId) } };
333
+ }
334
+ case 'face': {
335
+ if (data.id == null) return { type: segment.type, data };
336
+ return { type: 'face', data: { id: data.id } };
337
+ }
338
+ case 'image':
339
+ case 'record':
340
+ case 'video':
341
+ return oneBotMediaSegment(segment.type, data);
342
+ case 'audio':
343
+ return oneBotMediaSegment('record', data);
344
+ default:
345
+ return { type: segment.type, data };
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Wire-encode an already-rendered outbound payload into OneBot message segments.
351
+ * 入参假定已经 core `normalizeOutboundPayload` 归一为 canonical Segment[];
352
+ * 媒体段(image/audio/video)仅认 canonical `data.media`,缺失则 warn + 丢弃;
353
+ * 非媒体的 wire 段(at 等)原样透传。
354
+ */
355
+ export function formatOutboundSegments(payload: unknown): MessageSegment[] {
356
+ if (typeof payload === 'string') {
357
+ return [{ type: 'text', data: { text: payload } }];
358
+ }
359
+
360
+ const items: Array<string | NapCatWireSegment> = Array.isArray(payload)
361
+ ? payload as Array<string | NapCatWireSegment>
362
+ : payload && typeof payload === 'object' && 'type' in (payload as object)
363
+ ? [payload as NapCatWireSegment]
364
+ : [];
365
+
366
+ if (items.length === 0) {
367
+ const text = payload == null
368
+ ? ''
369
+ : typeof payload === 'object'
370
+ ? JSON.stringify(payload)
371
+ : String(payload);
372
+ return [{ type: 'text', data: { text } }];
373
+ }
374
+
375
+ const segs: MessageSegment[] = [];
376
+ for (const item of items) {
377
+ if (typeof item === 'string') {
378
+ segs.push({ type: 'text', data: { text: item } });
379
+ continue;
380
+ }
381
+ const seg = canonicalToOneBotSegment(item);
382
+ if (seg) segs.push(seg);
383
+ }
384
+ return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
385
+ }
386
+
387
+ export function buildSendAction(
388
+ target: ParsedSendTarget,
389
+ message: MessageSegment[],
390
+ ): { action: string; params: Record<string, unknown> } {
391
+ if (target.message_type === 'group') {
392
+ return {
393
+ action: 'send_group_msg',
394
+ params: {
395
+ group_id: Number(target.id) || target.id,
396
+ message,
397
+ },
398
+ };
399
+ }
400
+ return {
401
+ action: 'send_private_msg',
402
+ params: {
403
+ user_id: Number(target.id) || target.id,
404
+ message,
405
+ },
406
+ };
407
+ }
408
+
409
+ /** Build WS connect URL + headers (access_token via Bearer + query). */
410
+ export function buildWsConnectOptions(config: NapCatWsConfig): {
411
+ readonly url: string;
412
+ readonly headers: Record<string, string>;
413
+ readonly safeUrl: string;
414
+ } {
415
+ const headers: Record<string, string> = {};
416
+ let connectUrl = config.url;
417
+ if (config.access_token) {
418
+ headers.Authorization = `Bearer ${config.access_token}`;
419
+ const url = new URL(config.url);
420
+ url.searchParams.set('access_token', config.access_token);
421
+ connectUrl = url.toString();
422
+ }
423
+ const safeUrl = new URL(connectUrl);
424
+ safeUrl.searchParams.delete('access_token');
425
+ return { url: connectUrl, headers, safeUrl: safeUrl.toString() };
426
+ }
427
+
428
+ export interface NapCatHttpOptions {
429
+ readonly http_url: string;
430
+ readonly access_token?: string;
431
+ }
432
+
433
+ /**
434
+ * 向 NapCat HTTP API 发送动作请求:POST {http_url}/{action}。
435
+ * 供 connection:http 出站与纯协议测试使用。
436
+ */
437
+ export async function callNapCatHttpAction(
438
+ options: NapCatHttpOptions,
439
+ action: string,
440
+ params: Record<string, unknown> = {},
441
+ ): Promise<NapCatActionResponse> {
442
+ const base = options.http_url.replace(/\/$/, '');
443
+ const url = `${base}/${action}`;
444
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
445
+ if (options.access_token) {
446
+ headers.Authorization = `Bearer ${options.access_token}`;
447
+ }
448
+ const res = await fetch(url, {
449
+ method: 'POST',
450
+ headers,
451
+ body: JSON.stringify(params),
452
+ });
453
+ const text = await res.text();
454
+ if (res.status === 401) throw new Error(`NapCat HTTP auth failed: ${text}`);
455
+ if (res.status !== 200) throw new Error(`NapCat HTTP ${res.status}: ${text}`);
456
+ let data: NapCatActionResponse;
457
+ try {
458
+ data = JSON.parse(text) as NapCatActionResponse;
459
+ } catch {
460
+ throw new Error(`NapCat HTTP invalid response: ${text.slice(0, 200)}`);
461
+ }
462
+ if (data.status !== 'ok' && data.retcode !== 0) {
463
+ throw new Error(
464
+ `NapCat HTTP action failed [${data.retcode}]: ${data.message || data.wording || 'unknown'}`,
465
+ );
466
+ }
467
+ return data;
468
+ }
@@ -0,0 +1,48 @@
1
+ import { receiveOneBotLikeSideEvent } from '@zhin.js/core';
2
+ import type { EndpointEventEmitter } from 'zhin.js/adapter';
3
+ import { formatCompact, type getAdapterLogger } from '@zhin.js/logger';
4
+ import type { NapCatEvent } from './protocol.js';
5
+
6
+ export interface NapCatSideEventCaller {
7
+ callApi(action: string, params?: Record<string, unknown>): Promise<unknown>;
8
+ }
9
+
10
+ export function receiveNapCatSideEvent(
11
+ emit: EndpointEventEmitter,
12
+ endpointKey: string,
13
+ caller: NapCatSideEventCaller,
14
+ raw: NapCatEvent,
15
+ logger: ReturnType<typeof getAdapterLogger>,
16
+ ): void {
17
+ if (!emit) return;
18
+ const record = raw as Record<string, unknown>;
19
+ const postType = String(record.post_type ?? '');
20
+ const requestType = String(record.request_type ?? '');
21
+ const isRequest = postType === 'request' || postType.startsWith('request.');
22
+ const isFriend = requestType === 'friend' || postType.includes('friend');
23
+ void receiveOneBotLikeSideEvent(emit, {
24
+ adapter: 'napcat',
25
+ endpointKey,
26
+ platform: 'napcat',
27
+ raw: record,
28
+ ...(isRequest ? {
29
+ approve: async (flag, remark) => {
30
+ await (isFriend
31
+ ? caller.callApi('set_friend_add_request', { flag, approve: true, remark })
32
+ : caller.callApi('set_group_add_request', { flag, approve: true, reason: remark }));
33
+ },
34
+ reject: async (flag, reason) => {
35
+ await (isFriend
36
+ ? caller.callApi('set_friend_add_request', { flag, approve: false })
37
+ : caller.callApi('set_group_add_request', { flag, approve: false, reason }));
38
+ },
39
+ } : {}),
40
+ }).catch((err) => {
41
+ logger.warn(formatCompact({
42
+ op: 'napcat_side_event_failed',
43
+ endpoint: endpointKey,
44
+ post_type: postType || undefined,
45
+ error: err instanceof Error ? err.message : String(err),
46
+ }));
47
+ });
48
+ }
package/src/webhook.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+
3
+ export async function readRequestBody(request: IncomingMessage): Promise<string> {
4
+ const chunks: Buffer[] = [];
5
+ let size = 0;
6
+ for await (const chunk of request) {
7
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8
+ size += buffer.length;
9
+ if (size > 1_048_576) {
10
+ request.destroy();
11
+ throw new Error('Request body exceeds 1MB');
12
+ }
13
+ chunks.push(buffer);
14
+ }
15
+ return Buffer.concat(chunks).toString('utf8');
16
+ }