@zhin.js/adapter-milky 1.0.0 → 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 (100) hide show
  1. package/CHANGELOG.md +962 -0
  2. package/README.md +58 -117
  3. package/adapters/milky.js +51 -0
  4. package/adapters/milky.ts +74 -0
  5. package/agent/PERMITS.md +14 -0
  6. package/{skills/milky/SKILL.md → agent/skills/milky.md} +1 -1
  7. package/commands/endpoint/add/[id].js +3 -0
  8. package/commands/endpoint/add/[id].ts +3 -0
  9. package/commands/endpoint/list.js +3 -0
  10. package/commands/endpoint/list.ts +3 -0
  11. package/commands/endpoint/remove/[id].js +3 -0
  12. package/commands/endpoint/remove/[id].ts +3 -0
  13. package/lib/client.d.ts +21 -0
  14. package/lib/client.js +33 -0
  15. package/lib/endpoint-management.d.ts +17 -0
  16. package/lib/endpoint-management.js +47 -0
  17. package/lib/endpoint.d.ts +7 -0
  18. package/lib/endpoint.js +6 -0
  19. package/lib/index.d.ts +6 -20
  20. package/lib/index.js +4 -33
  21. package/lib/milky-auth.d.ts +2 -0
  22. package/lib/milky-auth.js +16 -0
  23. package/lib/milky-endpoint-commands.d.ts +1 -0
  24. package/lib/milky-endpoint-commands.js +18 -0
  25. package/lib/milky-runtime-state.d.ts +1 -0
  26. package/lib/milky-runtime-state.js +6 -0
  27. package/lib/protocol.d.ts +174 -0
  28. package/lib/protocol.js +494 -0
  29. package/lib/sse-client.d.ts +20 -0
  30. package/lib/sse-client.js +85 -0
  31. package/lib/sse-endpoint.d.ts +32 -0
  32. package/lib/sse-endpoint.js +207 -0
  33. package/lib/webhook-endpoint.d.ts +22 -0
  34. package/lib/webhook-endpoint.js +149 -0
  35. package/lib/ws-endpoint.d.ts +23 -0
  36. package/lib/ws-endpoint.js +233 -0
  37. package/lib/ws-types.d.ts +11 -0
  38. package/lib/ws-types.js +1 -0
  39. package/lib/wss-endpoint.d.ts +25 -0
  40. package/lib/wss-endpoint.js +184 -0
  41. package/package.json +73 -19
  42. package/plugin.js +14 -0
  43. package/schema.json +100 -0
  44. package/src/client.ts +80 -0
  45. package/src/endpoint-management.ts +74 -0
  46. package/src/endpoint.ts +26 -0
  47. package/src/index.ts +63 -45
  48. package/src/milky-auth.ts +14 -0
  49. package/src/milky-endpoint-commands.ts +19 -0
  50. package/src/milky-runtime-state.ts +7 -0
  51. package/src/protocol.ts +647 -0
  52. package/src/sse-client.ts +106 -0
  53. package/src/sse-endpoint.ts +258 -0
  54. package/src/webhook-endpoint.ts +196 -0
  55. package/src/ws-endpoint.ts +291 -0
  56. package/src/ws-types.ts +12 -0
  57. package/src/wss-endpoint.ts +228 -0
  58. package/lib/adapter.d.ts +0 -28
  59. package/lib/adapter.d.ts.map +0 -1
  60. package/lib/adapter.js +0 -90
  61. package/lib/adapter.js.map +0 -1
  62. package/lib/api.d.ts +0 -10
  63. package/lib/api.d.ts.map +0 -1
  64. package/lib/api.js +0 -48
  65. package/lib/api.js.map +0 -1
  66. package/lib/bot-sse.d.ts +0 -31
  67. package/lib/bot-sse.d.ts.map +0 -1
  68. package/lib/bot-sse.js +0 -195
  69. package/lib/bot-sse.js.map +0 -1
  70. package/lib/bot-webhook.d.ts +0 -34
  71. package/lib/bot-webhook.d.ts.map +0 -1
  72. package/lib/bot-webhook.js +0 -187
  73. package/lib/bot-webhook.js.map +0 -1
  74. package/lib/bot-ws.d.ts +0 -35
  75. package/lib/bot-ws.d.ts.map +0 -1
  76. package/lib/bot-ws.js +0 -262
  77. package/lib/bot-ws.js.map +0 -1
  78. package/lib/bot-wss.d.ts +0 -34
  79. package/lib/bot-wss.d.ts.map +0 -1
  80. package/lib/bot-wss.js +0 -225
  81. package/lib/bot-wss.js.map +0 -1
  82. package/lib/index.d.ts.map +0 -1
  83. package/lib/index.js.map +0 -1
  84. package/lib/types.d.ts +0 -75
  85. package/lib/types.d.ts.map +0 -1
  86. package/lib/types.js +0 -5
  87. package/lib/types.js.map +0 -1
  88. package/lib/utils.d.ts +0 -35
  89. package/lib/utils.d.ts.map +0 -1
  90. package/lib/utils.js +0 -157
  91. package/lib/utils.js.map +0 -1
  92. package/plugin.yml +0 -3
  93. package/src/adapter.ts +0 -101
  94. package/src/api.ts +0 -63
  95. package/src/bot-sse.ts +0 -228
  96. package/src/bot-webhook.ts +0 -221
  97. package/src/bot-ws.ts +0 -296
  98. package/src/bot-wss.ts +0 -268
  99. package/src/types.ts +0 -73
  100. package/src/utils.ts +0 -174
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Minimal fetch-based SSE client (no EventSource dependency).
3
+ * Parses `text/event-stream` frames and invokes onMessage for each `data:` payload.
4
+ */
5
+ export interface SseClientOptions {
6
+ readonly url: string;
7
+ readonly headers?: Record<string, string>;
8
+ readonly fetch?: typeof globalThis.fetch;
9
+ readonly signal?: AbortSignal;
10
+ readonly onMessage: (data: string) => void;
11
+ readonly onError?: (error: Error) => void;
12
+ readonly onOpen?: () => void;
13
+ }
14
+
15
+ export interface SseClientHandle {
16
+ readonly closed: Promise<void>;
17
+ close(): void;
18
+ }
19
+
20
+ export function openSseStream(options: SseClientOptions): SseClientHandle {
21
+ const controller = new AbortController();
22
+ const fetchImpl = options.fetch ?? globalThis.fetch;
23
+ let settle!: () => void;
24
+ const closed = new Promise<void>((resolve) => { settle = resolve; });
25
+
26
+ const run = async (): Promise<void> => {
27
+ try {
28
+ const response = await fetchImpl(options.url, {
29
+ method: 'GET',
30
+ headers: {
31
+ Accept: 'text/event-stream',
32
+ ...(options.headers ?? {}),
33
+ },
34
+ signal: anySignal([controller.signal, options.signal]),
35
+ });
36
+ if (!response.ok) {
37
+ throw new Error(`SSE HTTP ${response.status}`);
38
+ }
39
+ if (!response.body) {
40
+ throw new Error('SSE response has no body');
41
+ }
42
+ options.onOpen?.();
43
+ const reader = response.body.getReader();
44
+ const decoder = new TextDecoder();
45
+ let buffer = '';
46
+ while (true) {
47
+ const { done, value } = await reader.read();
48
+ if (done) break;
49
+ buffer += decoder.decode(value, { stream: true });
50
+ buffer = consumeSseBuffer(buffer, options.onMessage);
51
+ }
52
+ buffer += decoder.decode();
53
+ consumeSseBuffer(`${buffer}\n\n`, options.onMessage);
54
+ } catch (error) {
55
+ if (!controller.signal.aborted) {
56
+ const err = error instanceof Error ? error : new Error(String(error));
57
+ options.onError?.(err);
58
+ }
59
+ } finally {
60
+ settle();
61
+ }
62
+ };
63
+
64
+ void run();
65
+
66
+ return {
67
+ closed,
68
+ close() {
69
+ controller.abort();
70
+ },
71
+ };
72
+ }
73
+
74
+ /** Exported for unit tests. */
75
+ export function consumeSseBuffer(
76
+ buffer: string,
77
+ onMessage: (data: string) => void,
78
+ ): string {
79
+ let rest = buffer;
80
+ while (true) {
81
+ const sep = rest.indexOf('\n\n');
82
+ if (sep < 0) return rest;
83
+ const frame = rest.slice(0, sep);
84
+ rest = rest.slice(sep + 2);
85
+ const dataLines: string[] = [];
86
+ for (const line of frame.split('\n')) {
87
+ if (line.startsWith('data:')) {
88
+ dataLines.push(line.slice(5).replace(/^ /, ''));
89
+ }
90
+ }
91
+ if (dataLines.length > 0) onMessage(dataLines.join('\n'));
92
+ }
93
+ }
94
+
95
+ function anySignal(signals: Array<AbortSignal | undefined>): AbortSignal {
96
+ const controller = new AbortController();
97
+ for (const signal of signals) {
98
+ if (!signal) continue;
99
+ if (signal.aborted) {
100
+ controller.abort();
101
+ return controller.signal;
102
+ }
103
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
104
+ }
105
+ return controller.signal;
106
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Milky SSE client endpoint — GET text/event-stream on /event.
3
+ */
4
+ import {
5
+ ClientEndpoint,
6
+ createRecallEndpointControl,
7
+ createEndpointLifecycle,
8
+ type EndpointConnectHandle,
9
+ type EndpointControl,
10
+ type EndpointLifecycle,
11
+ type EndpointManagement,
12
+ type EndpointSendRequest,
13
+ } from 'zhin.js/adapter';
14
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
15
+ import type { CapabilityId } from 'zhin.js';
16
+ import { createMilkyEndpointManagement } from './endpoint-management.js';
17
+ import { callMilkyClient, createMilkyEndpointClient, forwardMilkyClientEvents, type MilkyClient } from './client.js';
18
+ import {
19
+ buildSendAction,
20
+ buildSseConnectOptions,
21
+ callApi,
22
+ extractInboundAudioUrl,
23
+ formatInboundContent,
24
+ formatInboundMessageId,
25
+ formatInboundSegments,
26
+ formatOutboundMessageId,
27
+ formatOutboundSegments,
28
+ isMentioned,
29
+ milkyInboundConversation,
30
+ parseMessageReceiveData,
31
+ parseMilkyMessageId,
32
+ senderNickname,
33
+ type MilkyEvent,
34
+ type MilkyIncomingMessage,
35
+ type MilkySseConfig,
36
+ } from './protocol.js';
37
+ import { openSseStream, type SseClientHandle } from './sse-client.js';
38
+
39
+ export type CreateMilkySseStream = (options: {
40
+ readonly url: string;
41
+ readonly headers: Record<string, string>;
42
+ readonly onMessage: (data: string) => void;
43
+ readonly onError?: (error: Error) => void;
44
+ readonly onOpen?: () => void;
45
+ }) => SseClientHandle;
46
+
47
+ export interface MilkySseEndpointOptions {
48
+ readonly id: CapabilityId;
49
+ readonly config: MilkySseConfig;
50
+ readonly createSseStream?: CreateMilkySseStream;
51
+ readonly callApi?: typeof callApi;
52
+ }
53
+
54
+ export class MilkySseEndpoint extends ClientEndpoint<MilkyClient> {
55
+ readonly client: MilkyClient;
56
+ readonly #logger!: ReturnType<typeof getAdapterLogger>;
57
+
58
+ readonly #options: MilkySseEndpointOptions;
59
+ readonly #callApi: typeof callApi;
60
+ readonly management: EndpointManagement;
61
+ readonly control: EndpointControl = createRecallEndpointControl((id) => this.recallMessage(id));
62
+ readonly #lifecycle: EndpointLifecycle;
63
+ #stream?: SseClientHandle;
64
+
65
+ constructor(options: MilkySseEndpointOptions) {
66
+ super();
67
+ this.#logger = getAdapterLogger('milky', options.config.id);
68
+ this.#options = options;
69
+ this.#callApi = options.callApi ?? callApi;
70
+ this.client = createMilkyEndpointClient(options.config, this.#callApi);
71
+ this.management = createMilkyEndpointManagement({
72
+ callApi: (action, params) => callMilkyClient(this.client, action, params),
73
+ });
74
+ this.bindClientEvents(
75
+ (receive) => forwardMilkyClientEvents(this.client, receive),
76
+ (name, payload) => {
77
+ if (name === 'event') this.#admitRaw(payload as MilkyEvent);
78
+ },
79
+ (_name, error) => this.#warnPlatformEvent(error),
80
+ );
81
+ this.#lifecycle = createEndpointLifecycle({
82
+ name: options.config.id,
83
+ // reconnect_interval 旧语义为固定间隔:multiplier 1 + 无 jitter + 不封顶
84
+ reconnect: {
85
+ initialIntervalMs: options.config.reconnect_interval,
86
+ multiplier: 1,
87
+ maxIntervalMs: Number.MAX_SAFE_INTEGER,
88
+ jitterMs: 0,
89
+ },
90
+ });
91
+ }
92
+
93
+ async start(): Promise<void> {
94
+ if (this.#lifecycle.started) return;
95
+ try {
96
+ await this.#lifecycle.start((handle) => this.#connect(handle));
97
+ } catch (err) {
98
+ // 与 WS 对齐:start 失败复位由基座保证;agent 反注册留在适配器侧,允许重试
99
+ throw err;
100
+ }
101
+ }
102
+
103
+ async stop(): Promise<void> {
104
+ this.close();
105
+ await this.#lifecycle.stop();
106
+ this.#stream?.close();
107
+ this.#stream = undefined;
108
+ }
109
+
110
+ async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
111
+ const message = formatOutboundSegments(payload);
112
+ const { action, params } = buildSendAction(conversation, message);
113
+ const data = await callMilkyClient<{ message_seq?: number }>(this.client, action, params);
114
+ const messageId = formatOutboundMessageId(conversation, data?.message_seq);
115
+ this.#logger.debug(formatCompact({
116
+ op: 'milky_send',
117
+ endpoint: this.#options.config.id,
118
+ target: `${conversation.kind}:${conversation.id}`,
119
+ messageId,
120
+ mode: 'sse',
121
+ }));
122
+ return messageId;
123
+ }
124
+
125
+ async recallMessage(id: string): Promise<void> {
126
+ const parsed = parseMilkyMessageId(id);
127
+ if (!parsed) throw new Error(`Invalid message id: ${id}`);
128
+ if (parsed.message_scene === 'group') {
129
+ await callMilkyClient(this.client, 'recall_group_message', {
130
+ group_id: parsed.peer_id,
131
+ message_seq: parsed.message_seq,
132
+ });
133
+ } else {
134
+ await callMilkyClient(this.client, 'recall_private_message', {
135
+ user_id: parsed.peer_id,
136
+ message_seq: parsed.message_seq,
137
+ });
138
+ }
139
+ }
140
+
141
+ #admitRaw(event: MilkyEvent): void {
142
+ const data = parseMessageReceiveData(event);
143
+ if (!data) return;
144
+ this.#admitMessage(data, event);
145
+ }
146
+
147
+ #warnPlatformEvent(error: unknown): void {
148
+ this.#logger.warn(formatCompact({ op: 'milky_platform_event_failed', error: error instanceof Error ? error.message : String(error) }));
149
+ }
150
+
151
+ #admitMessage(data: MilkyIncomingMessage, event: MilkyEvent): void {
152
+ const conversation = milkyInboundConversation(String(this.#options.id), data);
153
+ const target = `${conversation.kind}:${conversation.id}`;
154
+ const content = formatInboundContent(data);
155
+ const segments = formatInboundSegments(data);
156
+ const audioUrl = extractInboundAudioUrl(data);
157
+ const nickname = senderNickname(data);
158
+ const mentioned = isMentioned(data, event.self_id);
159
+ void this.emit('message.receive', {
160
+ conversation,
161
+ message: { conversation, id: formatInboundMessageId(data) },
162
+ content,
163
+ segments,
164
+ sender: { id: String(data.sender_id), name: nickname },
165
+ endpointId: this.#options.config.id,
166
+ ...(mentioned ? { mentioned: true } : {}),
167
+ metadata: Object.freeze({
168
+ message_scene: data.message_scene,
169
+ peer_id: String(data.peer_id),
170
+ sender_id: String(data.sender_id),
171
+ message_seq: data.message_seq,
172
+ time: data.time ?? event.time,
173
+ self_id: event.self_id != null ? String(event.self_id) : undefined,
174
+ ...(nickname ? { nickname } : {}),
175
+ }),
176
+ }).catch((err) => {
177
+ this.#logger.warn(formatCompact({
178
+ op: 'milky_gateway_receive_failed',
179
+ target,
180
+ error: err instanceof Error ? err.message : String(err),
181
+ }));
182
+ });
183
+ }
184
+
185
+ async #connect(handle: EndpointConnectHandle): Promise<void> {
186
+ const { url, headers, safeUrl } = buildSseConnectOptions(this.#options.config);
187
+ const create = this.#options.createSseStream ?? ((opts) => openSseStream(opts));
188
+
189
+ await new Promise<void>((resolve, reject) => {
190
+ let settled = false;
191
+ const stream = create({
192
+ url,
193
+ headers,
194
+ onOpen: () => {
195
+ if (settled) return;
196
+ settled = true;
197
+ this.#logger.debug(formatCompact({
198
+ endpoint: this.#options.config.id,
199
+ mode: 'sse',
200
+ url: safeUrl,
201
+ }));
202
+ resolve();
203
+ },
204
+ onMessage: (data) => this.#onMessage(data),
205
+ onError: (error) => {
206
+ this.#logger.warn(formatCompact({
207
+ op: 'sse_error',
208
+ endpoint: this.#options.config.id,
209
+ ok: false,
210
+ error: error.message,
211
+ }));
212
+ if (!settled) {
213
+ settled = true;
214
+ reject(error);
215
+ }
216
+ },
217
+ });
218
+ this.#stream = stream;
219
+ handle.onForceClose(() => {
220
+ try {
221
+ stream.close();
222
+ } catch {
223
+ /* ignore */
224
+ }
225
+ });
226
+ void stream.closed.then(() => {
227
+ // stop-during-connect 竞态由基座静默 settle(主动停止不算失败),此处仅对运行期断开告警
228
+ if (this.#lifecycle.state !== 'stopped') {
229
+ this.#logger.warn(formatCompact({
230
+ op: 'disconnect',
231
+ mode: 'sse',
232
+ reconnect_ms: this.#options.config.reconnect_interval,
233
+ }));
234
+ }
235
+ // 基座语义:仅曾 open 的连接才武装重连;初始连接失败由 start() 的 catch 复位
236
+ handle.notifyClosed(new Error('Milky SSE closed'));
237
+ if (!settled) {
238
+ settled = true;
239
+ reject(new Error('Milky SSE closed before open'));
240
+ }
241
+ });
242
+ });
243
+ }
244
+
245
+ #onMessage(data: string): void {
246
+ try {
247
+ const event = JSON.parse(data) as MilkyEvent;
248
+ this.client.ingest(event as Parameters<MilkyClient['ingest']>[0]);
249
+ } catch (error) {
250
+ this.#logger.warn(formatCompact({
251
+ op: 'milky_parse_failed',
252
+ endpoint: this.#options.config.id,
253
+ mode: 'sse',
254
+ error: error instanceof Error ? error.message : String(error),
255
+ }));
256
+ }
257
+ }
258
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Milky webhook endpoint — httpHostToken POST inbound + baseUrl HTTP API outbound.
3
+ */
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import {
6
+ ClientEndpoint,
7
+ createRecallEndpointControl,
8
+ type EndpointControl,
9
+ type EndpointManagement,
10
+ type EndpointSendRequest,
11
+ } from 'zhin.js/adapter';
12
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
13
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
14
+ import type { CapabilityId } from 'zhin.js';
15
+ import { verifyMilkyAccessToken } from './milky-auth.js';
16
+ import { createMilkyEndpointManagement } from './endpoint-management.js';
17
+ import { callMilkyClient, createMilkyEndpointClient, forwardMilkyClientEvents, type MilkyClient } from './client.js';
18
+ import {
19
+ buildSendAction,
20
+ callApi,
21
+ extractInboundAudioUrl,
22
+ formatInboundContent,
23
+ formatInboundMessageId,
24
+ formatInboundSegments,
25
+ formatOutboundMessageId,
26
+ formatOutboundSegments,
27
+ isMentioned,
28
+ milkyInboundConversation,
29
+ parseMessageReceiveData,
30
+ parseMilkyMessageId,
31
+ senderNickname,
32
+ type MilkyEvent,
33
+ type MilkyIncomingMessage,
34
+ type MilkyWebhookConfig,
35
+ } from './protocol.js';
36
+
37
+ export interface MilkyWebhookEndpointOptions {
38
+ readonly id: CapabilityId;
39
+ readonly http: HttpHost;
40
+ readonly config: MilkyWebhookConfig;
41
+ readonly callApi?: typeof callApi;
42
+ }
43
+
44
+ export class MilkyWebhookEndpoint extends ClientEndpoint<MilkyClient> {
45
+ readonly client: MilkyClient;
46
+ readonly #logger!: ReturnType<typeof getAdapterLogger>;
47
+
48
+ readonly #options: MilkyWebhookEndpointOptions;
49
+ readonly #callApi: typeof callApi;
50
+ readonly management: EndpointManagement;
51
+ readonly control: EndpointControl = createRecallEndpointControl((id) => this.recallMessage(id));
52
+ #routeReleases: HttpRouteRegistration[] = [];
53
+ #started = false;
54
+
55
+ constructor(options: MilkyWebhookEndpointOptions) {
56
+ super();
57
+ this.#logger = getAdapterLogger('milky', options.config.id);
58
+ this.#options = options;
59
+ this.#callApi = options.callApi ?? callApi;
60
+ this.client = createMilkyEndpointClient(options.config, this.#callApi);
61
+ this.management = createMilkyEndpointManagement({
62
+ callApi: (action, params) => callMilkyClient(this.client, action, params),
63
+ });
64
+ this.bindClientEvents(
65
+ (receive) => forwardMilkyClientEvents(this.client, receive),
66
+ (name, payload) => {
67
+ if (name === 'event') this.#admitRaw(payload as MilkyEvent);
68
+ },
69
+ (_name, error) => this.#warnPlatformEvent(error),
70
+ );
71
+ }
72
+
73
+ async start(): Promise<void> {
74
+ if (this.#started) return;
75
+ this.#started = true;
76
+ this.#setupRoutes();
77
+ this.#logger.info(formatCompact({
78
+ op: 'listen',
79
+ endpoint: this.#options.config.id,
80
+ mode: 'webhook',
81
+ path: this.#options.config.path,
82
+ }));
83
+ }
84
+
85
+ async stop(): Promise<void> {
86
+ this.close();
87
+ for (const release of this.#routeReleases.splice(0)) release();
88
+ this.#started = false;
89
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
90
+ }
91
+
92
+ async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
93
+ const message = formatOutboundSegments(payload);
94
+ const { action, params } = buildSendAction(conversation, message);
95
+ const data = await callMilkyClient<{ message_seq?: number }>(this.client, action, params);
96
+ const messageId = formatOutboundMessageId(conversation, data?.message_seq);
97
+ this.#logger.debug(formatCompact({
98
+ op: 'milky_send',
99
+ endpoint: this.#options.config.id,
100
+ target: `${conversation.kind}:${conversation.id}`,
101
+ messageId,
102
+ }));
103
+ return messageId;
104
+ }
105
+
106
+ async recallMessage(id: string): Promise<void> {
107
+ const parsed = parseMilkyMessageId(id);
108
+ if (!parsed) throw new Error(`Invalid message id: ${id}`);
109
+ if (parsed.message_scene === 'group') {
110
+ await callMilkyClient(this.client, 'recall_group_message', {
111
+ group_id: parsed.peer_id,
112
+ message_seq: parsed.message_seq,
113
+ });
114
+ } else {
115
+ await callMilkyClient(this.client, 'recall_private_message', {
116
+ user_id: parsed.peer_id,
117
+ message_seq: parsed.message_seq,
118
+ });
119
+ }
120
+ }
121
+
122
+ #admitRaw(event: MilkyEvent): void {
123
+ const data = parseMessageReceiveData(event);
124
+ if (!data) return;
125
+ this.#admitMessage(data, event);
126
+ }
127
+
128
+ #warnPlatformEvent(error: unknown): void {
129
+ this.#logger.warn(formatCompact({ op: 'milky_platform_event_failed', error: error instanceof Error ? error.message : String(error) }));
130
+ }
131
+
132
+ #admitMessage(data: MilkyIncomingMessage, event: MilkyEvent): void {
133
+ const conversation = milkyInboundConversation(String(this.#options.id), data);
134
+ const target = `${conversation.kind}:${conversation.id}`;
135
+ const content = formatInboundContent(data);
136
+ const segments = formatInboundSegments(data);
137
+ const audioUrl = extractInboundAudioUrl(data);
138
+ const nickname = senderNickname(data);
139
+ const mentioned = isMentioned(data, event.self_id);
140
+ void this.emit('message.receive', {
141
+ conversation,
142
+ message: { conversation, id: formatInboundMessageId(data) },
143
+ content,
144
+ segments,
145
+ sender: { id: String(data.sender_id), name: nickname },
146
+ endpointId: this.#options.config.id,
147
+ ...(mentioned ? { mentioned: true } : {}),
148
+ metadata: Object.freeze({
149
+ message_scene: data.message_scene,
150
+ peer_id: String(data.peer_id),
151
+ sender_id: String(data.sender_id),
152
+ message_seq: data.message_seq,
153
+ time: data.time ?? event.time,
154
+ self_id: event.self_id != null ? String(event.self_id) : undefined,
155
+ ...(nickname ? { nickname } : {}),
156
+ }),
157
+ }).catch((err) => {
158
+ this.#logger.warn(formatCompact({
159
+ op: 'milky_gateway_receive_failed',
160
+ target,
161
+ error: err instanceof Error ? err.message : String(err),
162
+ }));
163
+ });
164
+ }
165
+
166
+ #setupRoutes(): void {
167
+ const path = this.#options.config.path;
168
+ this.#routeReleases.push(
169
+ this.#options.http.route('POST', path, async (request, response) => {
170
+ await this.#handleWebhook(request, response);
171
+ }, { summary: 'Milky webhook callback', tags: ['milky'] }),
172
+ );
173
+ }
174
+
175
+ async #handleWebhook(request: IncomingMessage, response: ServerResponse): Promise<void> {
176
+ try {
177
+ if (!verifyMilkyAccessToken(this.#options.config.access_token, request)) {
178
+ response.writeHead(403, { 'Content-Type': 'application/json' });
179
+ response.end(JSON.stringify({ message: 'Unauthorized' }));
180
+ return;
181
+ }
182
+ if (!this.clientEventsOpen) {
183
+ response.writeHead(200, { 'Content-Type': 'application/json' });
184
+ response.end(JSON.stringify({ status: 'ok' }));
185
+ return;
186
+ }
187
+ await this.client.acceptHttp(request, response);
188
+ } catch (error) {
189
+ this.#logger.error('Milky webhook error:', error);
190
+ if (!response.headersSent) {
191
+ response.writeHead(500, { 'Content-Type': 'application/json' });
192
+ response.end(JSON.stringify({ message: 'Internal Server Error' }));
193
+ }
194
+ }
195
+ }
196
+ }