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