@zhin.js/adapter-milky 3.0.1 → 4.0.1

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 (89) hide show
  1. package/CHANGELOG.md +443 -0
  2. package/README.md +36 -117
  3. package/adapters/milky.ts +68 -0
  4. package/lib/endpoint.d.ts +7 -0
  5. package/lib/endpoint.js +6 -0
  6. package/lib/index.d.ts +3 -20
  7. package/lib/index.js +3 -35
  8. package/lib/milky-agent-deps.d.ts +24 -0
  9. package/lib/milky-agent-deps.js +30 -0
  10. package/lib/milky-auth.d.ts +3 -0
  11. package/lib/milky-auth.js +30 -0
  12. package/lib/protocol.d.ts +162 -0
  13. package/lib/protocol.js +337 -0
  14. package/lib/sse-client.d.ts +20 -0
  15. package/lib/sse-client.js +85 -0
  16. package/lib/sse-endpoint.d.ts +50 -0
  17. package/lib/sse-endpoint.js +261 -0
  18. package/lib/webhook-endpoint.d.ts +40 -0
  19. package/lib/webhook-endpoint.js +212 -0
  20. package/lib/ws-endpoint.d.ts +42 -0
  21. package/lib/ws-endpoint.js +320 -0
  22. package/lib/ws-types.d.ts +11 -0
  23. package/lib/ws-types.js +1 -0
  24. package/lib/wss-endpoint.d.ts +40 -0
  25. package/lib/wss-endpoint.js +263 -0
  26. package/package.json +52 -19
  27. package/plugin.ts +8 -0
  28. package/schema.json +36 -0
  29. package/src/endpoint.ts +26 -0
  30. package/src/index.ts +59 -48
  31. package/src/milky-agent-deps.ts +53 -0
  32. package/src/milky-auth.ts +29 -0
  33. package/src/protocol.ts +491 -0
  34. package/src/sse-client.ts +106 -0
  35. package/src/sse-endpoint.ts +318 -0
  36. package/src/webhook-endpoint.ts +262 -0
  37. package/src/ws-endpoint.ts +373 -0
  38. package/src/ws-types.ts +12 -0
  39. package/src/wss-endpoint.ts +305 -0
  40. package/lib/adapter.d.ts +0 -37
  41. package/lib/adapter.d.ts.map +0 -1
  42. package/lib/adapter.js +0 -99
  43. package/lib/adapter.js.map +0 -1
  44. package/lib/api.d.ts +0 -10
  45. package/lib/api.d.ts.map +0 -1
  46. package/lib/api.js +0 -48
  47. package/lib/api.js.map +0 -1
  48. package/lib/endpoint-sse.d.ts +0 -31
  49. package/lib/endpoint-sse.d.ts.map +0 -1
  50. package/lib/endpoint-sse.js +0 -197
  51. package/lib/endpoint-sse.js.map +0 -1
  52. package/lib/endpoint-webhook.d.ts +0 -34
  53. package/lib/endpoint-webhook.d.ts.map +0 -1
  54. package/lib/endpoint-webhook.js +0 -189
  55. package/lib/endpoint-webhook.js.map +0 -1
  56. package/lib/endpoint-ws.d.ts +0 -35
  57. package/lib/endpoint-ws.d.ts.map +0 -1
  58. package/lib/endpoint-ws.js +0 -264
  59. package/lib/endpoint-ws.js.map +0 -1
  60. package/lib/endpoint-wss.d.ts +0 -34
  61. package/lib/endpoint-wss.d.ts.map +0 -1
  62. package/lib/endpoint-wss.js +0 -227
  63. package/lib/endpoint-wss.js.map +0 -1
  64. package/lib/index.d.ts.map +0 -1
  65. package/lib/index.js.map +0 -1
  66. package/lib/segment-mapper.d.ts +0 -2
  67. package/lib/segment-mapper.d.ts.map +0 -1
  68. package/lib/segment-mapper.js +0 -2
  69. package/lib/segment-mapper.js.map +0 -1
  70. package/lib/types.d.ts +0 -75
  71. package/lib/types.d.ts.map +0 -1
  72. package/lib/types.js +0 -5
  73. package/lib/types.js.map +0 -1
  74. package/lib/utils.d.ts +0 -35
  75. package/lib/utils.d.ts.map +0 -1
  76. package/lib/utils.js +0 -166
  77. package/lib/utils.js.map +0 -1
  78. package/plugin.yml +0 -3
  79. package/src/adapter.ts +0 -111
  80. package/src/api.ts +0 -63
  81. package/src/endpoint-sse.ts +0 -232
  82. package/src/endpoint-webhook.ts +0 -225
  83. package/src/endpoint-ws.ts +0 -300
  84. package/src/endpoint-wss.ts +0 -272
  85. package/src/segment-mapper.ts +0 -1
  86. package/src/types.ts +0 -73
  87. package/src/utils.ts +0 -182
  88. /package/{skills/milky → agent}/PERMITS.md +0 -0
  89. /package/{skills/milky/SKILL.md → agent/skills/milky.md} +0 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Milky SSE client endpoint — GET text/event-stream on /event.
3
+ */
4
+ import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import type { MessageGateway } from '@zhin.js/core/runtime';
6
+ import { formatCompact, getLogger } from '@zhin.js/logger';
7
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
8
+ import { registerMilkyAgentEndpoint } from './milky-agent-deps.js';
9
+ import {
10
+ buildSendAction,
11
+ buildSseConnectOptions,
12
+ callApi,
13
+ extractInboundAudioUrl,
14
+ formatInboundContent,
15
+ formatInboundMessageId,
16
+ formatInboundTarget,
17
+ formatOutboundMessageId,
18
+ formatOutboundSegments,
19
+ isMentioned,
20
+ parseMessageReceiveData,
21
+ parseMilkyMessageId,
22
+ senderNickname,
23
+ type MilkyEvent,
24
+ type MilkyIncomingMessage,
25
+ type MilkySseConfig,
26
+ } from './protocol.js';
27
+ import { openSseStream, type SseClientHandle } from './sse-client.js';
28
+
29
+ const logger = getLogger('milky');
30
+
31
+ export type CreateMilkySseStream = (options: {
32
+ readonly url: string;
33
+ readonly headers: Record<string, string>;
34
+ readonly onMessage: (data: string) => void;
35
+ readonly onError?: (error: Error) => void;
36
+ readonly onOpen?: () => void;
37
+ }) => SseClientHandle;
38
+
39
+ export interface MilkySseEndpointOptions {
40
+ readonly id: CapabilityId;
41
+ readonly gateway: MessageGateway;
42
+ readonly config: MilkySseConfig;
43
+ readonly createSseStream?: CreateMilkySseStream;
44
+ readonly callApi?: typeof callApi;
45
+ }
46
+
47
+ export class MilkySseEndpoint implements EndpointInstance {
48
+ readonly #options: MilkySseEndpointOptions;
49
+ readonly #callApi: typeof callApi;
50
+ #stream?: SseClientHandle;
51
+ #reconnectTimer?: NodeJS.Timeout;
52
+ #open = false;
53
+ #started = false;
54
+ #stopping = false;
55
+ #unregisterAgent?: () => void;
56
+
57
+ constructor(options: MilkySseEndpointOptions) {
58
+ this.#options = options;
59
+ this.#callApi = options.callApi ?? callApi;
60
+ }
61
+
62
+ async start(): Promise<void> {
63
+ if (this.#started) return;
64
+ this.#started = true;
65
+ this.#stopping = false;
66
+ this.#unregisterAgent = registerMilkyAgentEndpoint(this.#options.config.name, this);
67
+ await this.#connect();
68
+ }
69
+
70
+ open(): void {
71
+ this.#open = true;
72
+ }
73
+
74
+ close(): void {
75
+ this.#open = false;
76
+ }
77
+
78
+ async stop(): Promise<void> {
79
+ this.#open = false;
80
+ this.#stopping = true;
81
+ this.#started = false;
82
+ this.#unregisterAgent?.();
83
+ this.#unregisterAgent = undefined;
84
+ if (this.#reconnectTimer) {
85
+ clearTimeout(this.#reconnectTimer);
86
+ this.#reconnectTimer = undefined;
87
+ }
88
+ this.#stream?.close();
89
+ this.#stream = undefined;
90
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name, mode: 'sse' }));
91
+ }
92
+
93
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
94
+ const message = formatOutboundSegments(payload);
95
+ const { action, params } = buildSendAction(target, message);
96
+ const data = await this.callApi(action, params) as { message_seq?: number } | undefined;
97
+ const messageId = formatOutboundMessageId(target, data?.message_seq);
98
+ logger.debug(formatCompact({
99
+ op: 'milky_send',
100
+ endpoint: this.#options.config.name,
101
+ target,
102
+ messageId,
103
+ mode: 'sse',
104
+ }));
105
+ return messageId;
106
+ }
107
+
108
+ callApi(action: string, params: Record<string, unknown> = {}): Promise<unknown> {
109
+ return this.#callApi(this.apiOptions(), action, params);
110
+ }
111
+
112
+ async recallMessage(id: string): Promise<void> {
113
+ const parsed = parseMilkyMessageId(id);
114
+ if (!parsed) throw new Error(`Invalid message id: ${id}`);
115
+ if (parsed.message_scene === 'group') {
116
+ await this.callApi('recall_group_message', {
117
+ group_id: parsed.peer_id,
118
+ message_seq: parsed.message_seq,
119
+ });
120
+ } else {
121
+ await this.callApi('recall_private_message', {
122
+ user_id: parsed.peer_id,
123
+ message_seq: parsed.message_seq,
124
+ });
125
+ }
126
+ }
127
+
128
+ async kickMember(groupId: number, userId: number, rejectAddRequest = false): Promise<boolean> {
129
+ await this.callApi('kick_group_member', {
130
+ group_id: groupId,
131
+ user_id: userId,
132
+ reject_add_request: rejectAddRequest,
133
+ });
134
+ return true;
135
+ }
136
+
137
+ async muteMember(groupId: number, userId: number, duration = 600): Promise<boolean> {
138
+ await this.callApi('set_group_member_mute', {
139
+ group_id: groupId,
140
+ user_id: userId,
141
+ duration,
142
+ });
143
+ return true;
144
+ }
145
+
146
+ async muteAll(groupId: number, enable = true): Promise<boolean> {
147
+ await this.callApi('set_group_whole_mute', { group_id: groupId, is_mute: enable });
148
+ return true;
149
+ }
150
+
151
+ async setAdmin(groupId: number, userId: number, enable = true): Promise<boolean> {
152
+ await this.callApi('set_group_member_admin', {
153
+ group_id: groupId,
154
+ user_id: userId,
155
+ is_set: enable,
156
+ });
157
+ return true;
158
+ }
159
+
160
+ async setCard(groupId: number, userId: number, card: string): Promise<boolean> {
161
+ await this.callApi('set_group_member_card', {
162
+ group_id: groupId,
163
+ user_id: userId,
164
+ card,
165
+ });
166
+ return true;
167
+ }
168
+
169
+ async setTitle(groupId: number, userId: number, title: string): Promise<boolean> {
170
+ await this.callApi('set_group_member_special_title', {
171
+ group_id: groupId,
172
+ user_id: userId,
173
+ special_title: title,
174
+ });
175
+ return true;
176
+ }
177
+
178
+ async setGroupName(groupId: number, name: string): Promise<boolean> {
179
+ await this.callApi('set_group_name', { group_id: groupId, new_group_name: name });
180
+ return true;
181
+ }
182
+
183
+ async getMemberList(groupId: number): Promise<unknown[]> {
184
+ return this.callApi('get_group_member_list', { group_id: groupId }) as Promise<unknown[]>;
185
+ }
186
+
187
+ async getGroupInfo(groupId: number): Promise<unknown> {
188
+ return this.callApi('get_group_info', { group_id: groupId });
189
+ }
190
+
191
+ admit(event: MilkyEvent): void {
192
+ const data = parseMessageReceiveData(event);
193
+ if (!this.#open || !data) return;
194
+ this.#admitMessage(data, event);
195
+ }
196
+
197
+ apiOptions(): { baseUrl: string; access_token?: string } {
198
+ return {
199
+ baseUrl: this.#options.config.baseUrl,
200
+ access_token: this.#options.config.access_token,
201
+ };
202
+ }
203
+
204
+ #admitMessage(data: MilkyIncomingMessage, event: MilkyEvent): void {
205
+ const target = formatInboundTarget(data);
206
+ const content = formatInboundContent(data);
207
+ const audioUrl = extractInboundAudioUrl(data);
208
+ const nickname = senderNickname(data);
209
+ const mentioned = isMentioned(data, event.self_id);
210
+ void this.#options.gateway.receive({
211
+ adapter: this.#options.id,
212
+ target,
213
+ content,
214
+ sender: String(data.sender_id),
215
+ id: formatInboundMessageId(data),
216
+ metadata: Object.freeze({
217
+ message_scene: data.message_scene,
218
+ peer_id: String(data.peer_id),
219
+ sender_id: String(data.sender_id),
220
+ message_seq: data.message_seq,
221
+ endpoint: this.#options.config.name,
222
+ time: data.time ?? event.time,
223
+ self_id: event.self_id != null ? String(event.self_id) : undefined,
224
+ ...(nickname ? { nickname } : {}),
225
+ ...(mentioned ? { mentioned: true } : {}),
226
+ ...(audioUrl ? { audio_url: audioUrl } : {}),
227
+ }),
228
+ }).catch((err) => {
229
+ logger.warn(formatCompact({
230
+ op: 'milky_gateway_receive_failed',
231
+ target,
232
+ error: err instanceof Error ? err.message : String(err),
233
+ }));
234
+ });
235
+ }
236
+
237
+ async #connect(): Promise<void> {
238
+ const { url, headers, safeUrl } = buildSseConnectOptions(this.#options.config);
239
+ const create = this.#options.createSseStream ?? ((opts) => openSseStream(opts));
240
+
241
+ await new Promise<void>((resolve, reject) => {
242
+ let settled = false;
243
+ const stream = create({
244
+ url,
245
+ headers,
246
+ onOpen: () => {
247
+ if (settled) return;
248
+ settled = true;
249
+ logger.debug(formatCompact({
250
+ endpoint: this.#options.config.name,
251
+ mode: 'sse',
252
+ url: safeUrl,
253
+ }));
254
+ resolve();
255
+ },
256
+ onMessage: (data) => this.#onMessage(data),
257
+ onError: (error) => {
258
+ logger.warn(formatCompact({
259
+ op: 'sse_error',
260
+ endpoint: this.#options.config.name,
261
+ ok: false,
262
+ error: error.message,
263
+ }));
264
+ if (!settled) {
265
+ settled = true;
266
+ reject(error);
267
+ }
268
+ },
269
+ });
270
+ this.#stream = stream;
271
+ void stream.closed.then(() => {
272
+ if (this.#stopping) return;
273
+ logger.warn(formatCompact({
274
+ op: 'disconnect',
275
+ endpoint: this.#options.config.name,
276
+ mode: 'sse',
277
+ reconnect_ms: this.#options.config.reconnect_interval,
278
+ }));
279
+ if (!settled) {
280
+ settled = true;
281
+ reject(new Error('Milky SSE closed before open'));
282
+ }
283
+ this.#scheduleReconnect();
284
+ });
285
+ });
286
+ }
287
+
288
+ #onMessage(data: string): void {
289
+ try {
290
+ const event = JSON.parse(data) as MilkyEvent;
291
+ this.admit(event);
292
+ } catch (error) {
293
+ logger.warn(formatCompact({
294
+ op: 'milky_parse_failed',
295
+ endpoint: this.#options.config.name,
296
+ mode: 'sse',
297
+ error: error instanceof Error ? error.message : String(error),
298
+ }));
299
+ }
300
+ }
301
+
302
+ #scheduleReconnect(): void {
303
+ if (this.#stopping || !this.#started || this.#reconnectTimer) return;
304
+ const delay = this.#options.config.reconnect_interval;
305
+ this.#reconnectTimer = setTimeout(() => {
306
+ this.#reconnectTimer = undefined;
307
+ void this.#connect().catch((err) => {
308
+ logger.warn(formatCompact({
309
+ op: 'reconnect',
310
+ endpoint: this.#options.config.name,
311
+ mode: 'sse',
312
+ ok: false,
313
+ error: err instanceof Error ? err.message : String(err),
314
+ }));
315
+ });
316
+ }, delay);
317
+ }
318
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Milky webhook endpoint — httpHostToken POST inbound + baseUrl HTTP API outbound.
3
+ */
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import type { EndpointInstance } from '@zhin.js/adapter';
6
+ import type { MessageGateway } from '@zhin.js/core/runtime';
7
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
8
+ import { formatCompact, getLogger } from '@zhin.js/logger';
9
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
10
+ import { readRequestBody, verifyMilkyAccessToken } from './milky-auth.js';
11
+ import { registerMilkyAgentEndpoint } from './milky-agent-deps.js';
12
+ import {
13
+ buildSendAction,
14
+ callApi,
15
+ extractInboundAudioUrl,
16
+ formatInboundContent,
17
+ formatInboundMessageId,
18
+ formatInboundTarget,
19
+ formatOutboundMessageId,
20
+ formatOutboundSegments,
21
+ isMentioned,
22
+ parseMessageReceiveData,
23
+ parseMilkyMessageId,
24
+ senderNickname,
25
+ type MilkyEvent,
26
+ type MilkyIncomingMessage,
27
+ type MilkyWebhookConfig,
28
+ } from './protocol.js';
29
+
30
+ const logger = getLogger('milky');
31
+
32
+ export interface MilkyWebhookEndpointOptions {
33
+ readonly id: CapabilityId;
34
+ readonly gateway: MessageGateway;
35
+ readonly http: HttpHost;
36
+ readonly config: MilkyWebhookConfig;
37
+ readonly callApi?: typeof callApi;
38
+ }
39
+
40
+ export class MilkyWebhookEndpoint implements EndpointInstance {
41
+ readonly #options: MilkyWebhookEndpointOptions;
42
+ readonly #callApi: typeof callApi;
43
+ #routeReleases: HttpRouteRegistration[] = [];
44
+ #open = false;
45
+ #started = false;
46
+ #unregisterAgent?: () => void;
47
+
48
+ constructor(options: MilkyWebhookEndpointOptions) {
49
+ this.#options = options;
50
+ this.#callApi = options.callApi ?? callApi;
51
+ }
52
+
53
+ async start(): Promise<void> {
54
+ if (this.#started) return;
55
+ this.#started = true;
56
+ this.#unregisterAgent = registerMilkyAgentEndpoint(this.#options.config.name, this);
57
+ this.#setupRoutes();
58
+ logger.info(formatCompact({
59
+ op: 'listen',
60
+ endpoint: this.#options.config.name,
61
+ mode: 'webhook',
62
+ path: this.#options.config.path,
63
+ }));
64
+ }
65
+
66
+ open(): void {
67
+ this.#open = true;
68
+ }
69
+
70
+ close(): void {
71
+ this.#open = false;
72
+ }
73
+
74
+ async stop(): Promise<void> {
75
+ this.#open = false;
76
+ for (const release of this.#routeReleases.splice(0)) release();
77
+ this.#unregisterAgent?.();
78
+ this.#unregisterAgent = undefined;
79
+ this.#started = false;
80
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
81
+ }
82
+
83
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
84
+ const message = formatOutboundSegments(payload);
85
+ const { action, params } = buildSendAction(target, message);
86
+ const data = await this.callApi(action, params) as { message_seq?: number } | undefined;
87
+ const messageId = formatOutboundMessageId(target, data?.message_seq);
88
+ logger.debug(formatCompact({
89
+ op: 'milky_send',
90
+ endpoint: this.#options.config.name,
91
+ target,
92
+ messageId,
93
+ }));
94
+ return messageId;
95
+ }
96
+
97
+ callApi(action: string, params: Record<string, unknown> = {}): Promise<unknown> {
98
+ return this.#callApi(this.apiOptions(), action, params);
99
+ }
100
+
101
+ async recallMessage(id: string): Promise<void> {
102
+ const parsed = parseMilkyMessageId(id);
103
+ if (!parsed) throw new Error(`Invalid message id: ${id}`);
104
+ if (parsed.message_scene === 'group') {
105
+ await this.callApi('recall_group_message', {
106
+ group_id: parsed.peer_id,
107
+ message_seq: parsed.message_seq,
108
+ });
109
+ } else {
110
+ await this.callApi('recall_private_message', {
111
+ user_id: parsed.peer_id,
112
+ message_seq: parsed.message_seq,
113
+ });
114
+ }
115
+ }
116
+
117
+ async kickMember(groupId: number, userId: number, rejectAddRequest = false): Promise<boolean> {
118
+ await this.callApi('kick_group_member', {
119
+ group_id: groupId,
120
+ user_id: userId,
121
+ reject_add_request: rejectAddRequest,
122
+ });
123
+ return true;
124
+ }
125
+
126
+ async muteMember(groupId: number, userId: number, duration = 600): Promise<boolean> {
127
+ await this.callApi('set_group_member_mute', {
128
+ group_id: groupId,
129
+ user_id: userId,
130
+ duration,
131
+ });
132
+ return true;
133
+ }
134
+
135
+ async muteAll(groupId: number, enable = true): Promise<boolean> {
136
+ await this.callApi('set_group_whole_mute', { group_id: groupId, is_mute: enable });
137
+ return true;
138
+ }
139
+
140
+ async setAdmin(groupId: number, userId: number, enable = true): Promise<boolean> {
141
+ await this.callApi('set_group_member_admin', {
142
+ group_id: groupId,
143
+ user_id: userId,
144
+ is_set: enable,
145
+ });
146
+ return true;
147
+ }
148
+
149
+ async setCard(groupId: number, userId: number, card: string): Promise<boolean> {
150
+ await this.callApi('set_group_member_card', {
151
+ group_id: groupId,
152
+ user_id: userId,
153
+ card,
154
+ });
155
+ return true;
156
+ }
157
+
158
+ async setTitle(groupId: number, userId: number, title: string): Promise<boolean> {
159
+ await this.callApi('set_group_member_special_title', {
160
+ group_id: groupId,
161
+ user_id: userId,
162
+ special_title: title,
163
+ });
164
+ return true;
165
+ }
166
+
167
+ async setGroupName(groupId: number, name: string): Promise<boolean> {
168
+ await this.callApi('set_group_name', { group_id: groupId, new_group_name: name });
169
+ return true;
170
+ }
171
+
172
+ async getMemberList(groupId: number): Promise<unknown[]> {
173
+ return this.callApi('get_group_member_list', { group_id: groupId }) as Promise<unknown[]>;
174
+ }
175
+
176
+ async getGroupInfo(groupId: number): Promise<unknown> {
177
+ return this.callApi('get_group_info', { group_id: groupId });
178
+ }
179
+
180
+ admit(event: MilkyEvent): void {
181
+ const data = parseMessageReceiveData(event);
182
+ if (!this.#open || !data) return;
183
+ this.#admitMessage(data, event);
184
+ }
185
+
186
+ apiOptions(): { baseUrl: string; access_token?: string } {
187
+ return {
188
+ baseUrl: this.#options.config.baseUrl,
189
+ access_token: this.#options.config.access_token,
190
+ };
191
+ }
192
+
193
+ #admitMessage(data: MilkyIncomingMessage, event: MilkyEvent): void {
194
+ const target = formatInboundTarget(data);
195
+ const content = formatInboundContent(data);
196
+ const audioUrl = extractInboundAudioUrl(data);
197
+ const nickname = senderNickname(data);
198
+ const mentioned = isMentioned(data, event.self_id);
199
+ void this.#options.gateway.receive({
200
+ adapter: this.#options.id,
201
+ target,
202
+ content,
203
+ sender: String(data.sender_id),
204
+ id: formatInboundMessageId(data),
205
+ metadata: Object.freeze({
206
+ message_scene: data.message_scene,
207
+ peer_id: String(data.peer_id),
208
+ sender_id: String(data.sender_id),
209
+ message_seq: data.message_seq,
210
+ endpoint: this.#options.config.name,
211
+ time: data.time ?? event.time,
212
+ self_id: event.self_id != null ? String(event.self_id) : undefined,
213
+ ...(nickname ? { nickname } : {}),
214
+ ...(mentioned ? { mentioned: true } : {}),
215
+ ...(audioUrl ? { audio_url: audioUrl } : {}),
216
+ }),
217
+ }).catch((err) => {
218
+ logger.warn(formatCompact({
219
+ op: 'milky_gateway_receive_failed',
220
+ target,
221
+ error: err instanceof Error ? err.message : String(err),
222
+ }));
223
+ });
224
+ }
225
+
226
+ #setupRoutes(): void {
227
+ const path = this.#options.config.path;
228
+ this.#routeReleases.push(
229
+ this.#options.http.route('POST', path, async (request, response) => {
230
+ await this.#handleWebhook(request, response);
231
+ }, { summary: 'Milky webhook callback', tags: ['milky'] }),
232
+ );
233
+ }
234
+
235
+ async #handleWebhook(request: IncomingMessage, response: ServerResponse): Promise<void> {
236
+ try {
237
+ if (!verifyMilkyAccessToken(this.#options.config.access_token, request)) {
238
+ response.writeHead(403, { 'Content-Type': 'application/json' });
239
+ response.end(JSON.stringify({ message: 'Unauthorized' }));
240
+ return;
241
+ }
242
+ const raw = await readRequestBody(request);
243
+ let event: MilkyEvent;
244
+ try {
245
+ event = JSON.parse(raw) as MilkyEvent;
246
+ } catch {
247
+ response.writeHead(400, { 'Content-Type': 'application/json' });
248
+ response.end(JSON.stringify({ message: 'Invalid JSON' }));
249
+ return;
250
+ }
251
+ if (this.#open) this.admit(event);
252
+ response.writeHead(200, { 'Content-Type': 'application/json' });
253
+ response.end(JSON.stringify({ status: 'ok' }));
254
+ } catch (error) {
255
+ logger.error('Milky webhook error:', error);
256
+ if (!response.headersSent) {
257
+ response.writeHead(500, { 'Content-Type': 'application/json' });
258
+ response.end(JSON.stringify({ message: 'Internal Server Error' }));
259
+ }
260
+ }
261
+ }
262
+ }